ui: CWD for agent (#26518)

* server : extend file_glob_search for UI pickers

* ui : add per-conversation working directory with picker

* ui : add path navigation and search scope to cwd picker

Treat path-like queries (starting with / or ~) as directory navigation
instead of glob-matching the whole query: search the parent for the last
segment, and descend into an exactly-typed directory by listing its
children. Show the effective search scope in the footer and auto-search
on open so the current directory and its siblings appear immediately.

Assisted-by: Claude

* db : persist per-call tool cwd on tool result messages

* ui : abbreviate tool paths under home with a tilde

* ui : show the per-call cwd on exec shell rows

* ui : clarify the synthetic cwd message for the model

* ui : reuse the trailing cwd row on a repeated pick

* ui : don't jump when a cwd row is injected mid-chat

* chore: Formatting

* refactor: Cleanup comments

* ui : unify working directory naming and add a synthetic-message flag

* ui : render synthetic cwd rows without a scroll jump

* ui : decouple the working directory picker into utils and sub-components

* ui : add get_info tool call block

* chore: Formatting

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

* fix: UI

* server : harden file_glob_search listing (kind enum, timeout, symlink guard, absolute base)

* ui : use persisted isSynthetic flag for cwd rows, drop legacy formats

* ui : cache picker search, fail visibly on native resolve

* ui : escape glob metacharacters in picker search glob

* ui : simplify auto-scroll pin

* chore: Format

* fix: Use `SvelteMap`

* refactor: Post-review fixes

* ui: accept Windows roots in the working directory picker

recognize a drive root (C:) and a UNC share (//host/share) as path
navigation, alongside the POSIX root and ~, so a query like D:\repos
lists that directory instead of glob-matching it under the home dir

split below the root, so a bare drive resolves to its root rather than
to a drive-relative prefix

rewrite backslashes into forward slashes only when the query carries a
Windows root, since a backslash is a legal POSIX filename character

paths keep travelling with forward slashes, which is what the server
returns and what Windows accepts

---------

Co-authored-by: Pascal <admin@serveurperso.com>
This commit is contained in:
Aleksander Grygier
2026-08-04 19:05:48 +02:00
committed by GitHub
co-authored by Pascal
parent 0713275082
commit 2f56fc3431
41 changed files with 1946 additions and 95 deletions
@@ -12,6 +12,7 @@
ChatMessageAssistant,
ChatMessageUser,
ChatMessageSystem,
ChatMessageSynthetic,
ChatMessageMcpPrompt
} from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
@@ -56,6 +57,10 @@
: message.content
);
// Synthetic cwd-change messages render with the folder-row UI instead
// of a user bubble. The persisted flag is the single source of truth.
let isSynthetic = $derived(Boolean(message.isSynthetic));
let rawEditContent = $derived.by(() => {
if (message.role !== MessageRole.ASSISTANT) return undefined;
@@ -344,7 +349,7 @@
}
</script>
<div class="chat-message">
<div class="chat-message" class:chat-message--synthetic={isSynthetic}>
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
@@ -375,6 +380,8 @@
{showDeleteDialog}
{siblingInfo}
/>
{:else if isSynthetic}
<ChatMessageSynthetic {message} class={className} />
{:else if message.role === MessageRole.USER}
<ChatMessageUser
class={className}
@@ -422,7 +429,17 @@
* once known; 500px sizes messages that have never been rendered.
*/
.chat-message {
--chat-message-intrinsic-size: 500px;
content-visibility: auto;
contain-intrinsic-size: auto 500px;
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
}
/*
* Synthetic rows (e.g. the working-directory change) are small, so an
* accurate placeholder keeps the injected row from inflating the
* auto-scroll offset; the 500px default is for ordinary bubbles.
*/
.chat-message--synthetic {
--chat-message-intrinsic-size: 40px;
}
</style>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Folder, FolderX } from '@lucide/svelte';
import { parseCwdMessage } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types';
interface Props {
class?: string;
message: DatabaseMessage;
}
let { class: className = '', message }: Props = $props();
// Parse the synthetic message content in the UI so the row reuses the
// exact same text the model saw, including any guidance suffix.
let info = $derived(parseCwdMessage(message.content));
</script>
{#if info}
<div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
{#if info.path === null}
<FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
{:else}
<Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Set working directory to&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
{info.display}
</span>
{/if}
</div>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import { parseCwdMessage } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types';
import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
interface Props {
class?: string;
message: DatabaseMessage;
}
let { class: className = '', message }: Props = $props();
// Synthetic messages render a dedicated UI, never a user bubble. The only
// kind today is the working-directory change; parse the content so the
// row reuses the exact synthetic text (and future kinds slot in here).
let isCwdChange = $derived(parseCwdMessage(message.content) !== null);
</script>
{#if isCwdChange}
<ChatMessageCwdChange {message} class={className} />
{:else}
<span class="text-muted-foreground block text-sm {className}">{message.content}</span>
{/if}
@@ -12,6 +12,7 @@
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
import ChatMessageToolCallBlockFileGlobSearch from './ChatMessageToolCallBlockFileGlobSearch.svelte';
import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
import ChatMessageToolCallBlockGetInfo from './ChatMessageToolCallBlockGetInfo.svelte';
import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
@@ -40,6 +41,8 @@
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.GET_DATETIME}
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
{:else if section.toolName === BuiltInTool.GET_INFO}
<ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
{:else if section.toolName === BuiltInTool.READ_FILE}
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.EDIT_FILE}
@@ -1,7 +1,8 @@
<script lang="ts">
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { computeLineDiff, prefixFor, type AgenticSection } from '$lib/utils';
import { computeLineDiff, prefixFor, abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseEditFileMeta } from './parsers/edit-file';
import ToolCallBlock from './ToolCallBlock.svelte';
@@ -15,6 +16,7 @@
let { section, open, isStreaming, onToggle }: Props = $props();
const editFileMeta = $derived(parseEditFileMeta(section));
const home = $derived(toolsStore.serverHome);
const editDiffs = $derived(
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
);
@@ -23,7 +25,9 @@
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Edit file </span>
<span class="font-mono">{editFileMeta?.filePath}</span>
<span class="font-mono" title={editFileMeta?.filePath}
>{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
>
{#if editFileMeta?.errorMessage}
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}
@@ -12,6 +12,7 @@
import { config } from '$lib/stores/settings.svelte';
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import {
abbreviateHome,
highlightCode,
isExitCodeSummaryLine,
parseExecShellCommandError,
@@ -21,6 +22,7 @@
type ExecShellExitStatus,
type ToolResultLine
} from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import type { DatabaseMessageExtra } from '$lib/types';
import ToolCallBlock from './ToolCallBlock.svelte';
@@ -75,6 +77,14 @@
execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
);
// The working directory the command ran with, persisted per call on the
// tool result message (it travels via the x-tool-cwd header, not the tool
// args). Reading it from the section keeps it accurate even if the
// conversation cwd changes later.
const cwd = $derived(section.toolCwd);
const home = $derived(toolsStore.serverHome);
const wdDisplay = $derived(abbreviateHome(cwd ?? '', home));
const exitBadgeClass = $derived(
execShellExitStatus?.timedOut
? 'exit-badge warning'
@@ -159,6 +169,11 @@
</script>
{#snippet execShellTitle()}
{#if cwd}
<span class="exec-wd" title={cwd}>{wdDisplay}</span>
<span class="exec-prompt">$</span>
{/if}
{#if highlightedCommandHtml}
<span class="font-mono">{@html highlightedCommandHtml}</span>
{:else}
@@ -232,6 +247,23 @@
</ToolCallBlock>
<style>
:root {
--exec-wd-margin: 0.4rem;
}
.exec-wd {
font-family: var(--font-mono);
color: var(--muted-foreground);
margin-right: var(--exec-wd-margin);
}
.exec-prompt {
font-family: var(--font-mono);
color: var(--muted-foreground);
opacity: 0.55;
margin-right: var(--exec-wd-margin);
}
.terminal-output {
overscroll-behavior: contain;
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { XCircle } from '@lucide/svelte';
import { type AgenticSection } from '$lib/utils';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
import ToolCallBlock from './ToolCallBlock.svelte';
@@ -14,6 +15,7 @@
let { section, open, isStreaming, onToggle }: Props = $props();
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
@@ -26,7 +28,9 @@
<span class="font-mono">{fileGlobMeta.include}</span>
{/if}
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono">{fileGlobMeta.path}</span>
<span class="font-mono" title={fileGlobMeta.path}
>{abbreviateHome(fileGlobMeta.path, home)}</span
>
{/if}
{/snippet}
@@ -0,0 +1,69 @@
<script lang="ts">
import { Info, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
interface Props {
section: AgenticSection;
isStreaming?: boolean;
}
let { section, isStreaming = false }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
type GetInfoMeta = {
os?: string;
cwd?: string;
errorMessage?: string;
};
function parseGetInfoMeta(toolResultString: string | undefined): GetInfoMeta {
if (!toolResultString) return {};
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') return { errorMessage: obj.error };
return {
os: typeof obj.os === 'string' ? obj.os : undefined,
cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined
};
}
} catch {
// not JSON - nothing to show
}
return {};
}
const infoMeta = $derived(parseGetInfoMeta(section.toolResult));
const home = $derived(toolsStore.serverHome);
const cwdDisplay = $derived(abbreviateHome(infoMeta.cwd ?? '', home));
</script>
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
<Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if infoMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{infoMeta.errorMessage}</span
>
{:else if infoMeta.os || infoMeta.cwd}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
{#if infoMeta.os}
<span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
{/if}
{#if infoMeta.cwd}
<span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
{/if}
{:else}
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
{/if}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { XCircle } from '@lucide/svelte';
import { type AgenticSection } from '$lib/utils';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseGrepSearchMeta } from './parsers/grep-search';
import ToolCallBlock from './ToolCallBlock.svelte';
@@ -14,6 +15,7 @@
let { section, open, isStreaming, onToggle }: Props = $props();
const grepMeta = $derived(parseGrepSearchMeta(section));
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
@@ -22,7 +24,7 @@
<span class="text-muted-foreground">Search for&nbsp;</span>
<span class="font-mono">{grepMeta.pattern}</span>
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono">{grepMeta.path}</span>
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
{/if}
{/snippet}
@@ -2,7 +2,8 @@
import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { type AgenticSection } from '$lib/utils';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseWriteFileMeta } from './parsers/write-file';
import ToolCallBlock from './ToolCallBlock.svelte';
@@ -16,12 +17,15 @@
let { section, open, isStreaming, onToggle }: Props = $props();
const writeFileMeta = $derived(parseWriteFileMeta(section));
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Write file </span>
<span class="font-mono">{writeFileMeta?.filePath}</span>
<span class="font-mono" title={writeFileMeta?.filePath}
>{abbreviateHome(writeFileMeta?.filePath ?? '', home)}</span
>
{#if writeFileMeta?.errorMessage}
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}