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 GrygierandPascal authored and GitHub committed 2026-08-04 19:05:48 +02:00
1 parent 0713275082
commit 2f56fc3431
41 files changed
+1946 -95

No files matched your search

+6 -3
View File
@@ -22,6 +22,7 @@
import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
@@ -812,11 +813,12 @@ class AgenticStore {
updateToolResultMessage
) {
const args = this.parseToolArguments(toolCall.function.arguments);
const msg = await createToolResultMessage(toolCall.id, '');
const cwd = conversationsStore.activeConversation?.cwd;
const msg = await createToolResultMessage(toolCall.id, '', undefined, cwd);
createdToolResultMessageId = msg.id;
let accumulated = '';
for await (const ev of ToolsService.streamTool(toolName, args, signal)) {
for await (const ev of ToolsService.streamTool(toolName, args, signal, cwd)) {
if (ev.chunk !== null) {
accumulated += ev.chunk;
await updateToolResultMessage(msg.id, accumulated);
@@ -835,7 +837,8 @@ class AgenticStore {
result = accumulated;
} else if (toolSource === ToolSource.BUILTIN) {
const args = this.parseToolArguments(toolCall.function.arguments);
const executionResult = await ToolsService.executeTool(toolName, args, signal);
const cwd = conversationsStore.activeConversation?.cwd;
const executionResult = await ToolsService.executeTool(toolName, args, signal, cwd);
result = executionResult.content;
+58 -6
View File
@@ -35,9 +35,12 @@ import {
findDescendantMessages,
findLeafNode,
findMessageById,
formatCwdMessage,
isAbortError,
generateConversationTitle
generateConversationTitle,
CWD_CLEARED_TEXT
} from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { classifyContinueIntent } from '$lib/utils/agentic';
import {
MAX_INACTIVE_CONVERSATION_STATES,
@@ -870,7 +873,8 @@ class ChatStore {
content: string,
type: MessageType = MessageType.TEXT,
parent: string = '-1',
extras?: DatabaseMessageExtra[]
extras?: DatabaseMessageExtra[],
isSynthetic?: boolean
): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) throw new Error('No active conversation');
@@ -893,7 +897,8 @@ class ChatStore {
timestamp: Date.now(),
toolCalls: '',
children: [],
extra: extras
extra: extras,
isSynthetic
},
parentId
);
@@ -903,6 +908,33 @@ class ChatStore {
return message;
}
/**
* Record a working-directory change into chat history as a synthetic
* user message, so the model sees it on its next turn (the client
* sends the cwd itself via the x-tool-cwd header on tool calls).
* A plain user message is used because some chat templates reject
* tool messages without a preceding tool call.
*/
async recordCwdChange(cwd: string | null): Promise<void> {
const content = cwd
? formatCwdMessage(cwd, await toolsStore.resolveServerHome())
: CWD_CLEARED_TEXT;
// Reuse the trailing cwd row when it is already the last message, so
// repeated picks update it in place instead of stacking another row.
const last = conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
if (last && last.role === MessageRole.USER && last.isSynthetic === true) {
await DatabaseService.updateMessage(last.id, { content, isSynthetic: true });
conversationsStore.updateMessageAtIndex(conversationsStore.activeMessages.length - 1, {
content,
isSynthetic: true
});
return;
}
await this.addMessage(MessageRole.USER, content, MessageType.TEXT, '-1', undefined, true);
}
async addSystemPrompt(): Promise<void> {
let activeConv = conversationsStore.activeConversation;
if (!activeConv) {
@@ -1055,6 +1087,7 @@ class ChatStore {
const rootId = await DatabaseService.createRootMessage(currentConv.id);
const currentConfig = config();
const systemPrompt = currentConfig.systemMessage?.toString().trim();
let sysOrRootId = rootId;
if (systemPrompt) {
const systemMessage = await DatabaseService.createSystemMessage(
currentConv.id,
@@ -1062,8 +1095,25 @@ class ChatStore {
rootId
);
conversationsStore.addMessageToActive(systemMessage);
parentIdForUserMessage = systemMessage.id;
} else parentIdForUserMessage = rootId;
sysOrRootId = systemMessage.id;
}
// Reflect a working directory picked on the new-chat screen into
// chat history before the first user message, so the model sees
// it on its first turn. createConversation() has already threaded
// the pending pick onto the conversation.
if (currentConv.cwd) {
const cwdMessage = await this.addMessage(
MessageRole.USER,
formatCwdMessage(currentConv.cwd, await toolsStore.resolveServerHome()),
MessageType.TEXT,
sysOrRootId,
undefined,
true
);
parentIdForUserMessage = cwdMessage.id;
} else {
parentIdForUserMessage = sysOrRootId;
}
}
const userMessage = await this.addMessage(
MessageRole.USER,
@@ -1282,7 +1332,8 @@ class ChatStore {
createToolResultMessage: async (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
extras?: DatabaseMessageExtra[],
toolCwd?: string
) => {
const msg = await DatabaseService.createMessageBranch(
{
@@ -1291,6 +1342,7 @@ class ChatStore {
role: MessageRole.TOOL,
content,
toolCallId,
toolCwd,
timestamp: Date.now(),
toolCalls: '',
children: [],
@@ -86,6 +86,15 @@ class ConversationsStore {
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
/**
* Working directory picked on the empty new-chat screen, before any
* conversation exists. Consumed by `chatStore.sendMessage()`, which
* records it into chat history as a synthetic message on first send.
* Cleared by `loadConversation` and `clearActiveConversation` so a
* stale pick can't bleed onto an unrelated chat.
*/
pendingCwd = $state<string | null>(null);
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
private static loadReasoningEffortDefault(): ReasoningEffort {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
@@ -250,9 +259,13 @@ class ConversationsStore {
// No MCP override list is seeded: getAllMcpServerOverrides resolves
// servers without a per-conversation override to `mcpServers[i].enabled`,
// and only explicit toggles are stored on the conversation.
// Working directory picked on the new-chat screen gets threaded in
// here too, then cleared so it doesn't bleed onto subsequent new chats.
const conversation = await DatabaseService.createConversation(conversationName, {
reasoningEffort: this.pendingReasoningEffort
reasoningEffort: this.pendingReasoningEffort,
cwd: this.pendingCwd ?? undefined
});
this.pendingCwd = null;
this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
@@ -276,6 +289,10 @@ class ConversationsStore {
return false;
}
// Drop any cwd the user drafted on the empty new-chat screen -
// it doesn't belong to this conversation.
this.pendingCwd = null;
this.activeConversation = conversation;
if (conversation.currNode) {
@@ -306,6 +323,7 @@ class ConversationsStore {
this.activeMessages = [];
// reload defaults so new chats inherit persisted state
this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
this.pendingCwd = null;
}
/**
@@ -855,6 +873,42 @@ class ConversationsStore {
}
}
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
*
* On the empty new-chat screen (no active conversation yet), the value
* is buffered into `pendingCwd` so the user can pick before
* sending the first message; `createConversation()` consumes it.
*
* @param value - Absolute server-side path to the working directory, or null to clear
*/
async setCwd(value: string | null): Promise<void> {
const trimmed = value?.trim() || undefined;
// No chat yet - buffer for the first chat the user creates.
if (!this.activeConversation) {
this.pendingCwd = trimmed ?? null;
return;
}
this.activeConversation = {
...this.activeConversation,
cwd: trimmed
};
await DatabaseService.updateConversation(this.activeConversation.id, {
cwd: trimmed
});
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (convIndex !== -1) {
this.conversations[convIndex].cwd = trimmed;
this.conversations = [...this.conversations];
}
this.pendingCwd = null;
}
/**
* Forks a conversation at a specific message, creating a new conversation
* containing messages from root up to the target message, then navigates to it.
@@ -1169,6 +1223,7 @@ if (browser) {
export const conversations = () => conversationsStore.conversations;
export const activeConversation = () => conversationsStore.activeConversation;
export const activeMessages = () => conversationsStore.activeMessages;
export const pendingCwd = () => conversationsStore.pendingCwd;
export const isConversationsInitialized = () => conversationsStore.isInitialized;
/**
+37 -1
View File
@@ -1,11 +1,19 @@
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { ToolsService } from '$lib/services/tools.service';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
import {
BuiltInTool,
GlobSearchType,
HealthCheckStatus,
JsonSchemaType,
ToolCallType,
ToolSource
} from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import {
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
buildSandboxToolDefinition,
HOME_TILDE,
TOOL_GROUP_LABELS,
TOOL_SERVER_LABELS
} from '$lib/constants';
@@ -20,6 +28,7 @@ class ToolsStore {
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
constructor() {
try {
@@ -138,6 +147,10 @@ class ToolsStore {
return this._builtinTools;
}
get serverHome(): string | null {
return this._serverHome ?? null;
}
get mcpTools(): OpenAIToolDefinition[] {
return this.mcpEntries().map((e) => e.definition);
}
@@ -488,6 +501,29 @@ class ToolsStore {
this._loading = false;
}
}
/**
* Absolute home directory on the server, resolved once per session via
* file_glob_search's `base` field (the server expands `~`). Anchors the
* directory picker's search scope and the `~` abbreviation of cwd
* displays. Returns null when tools are unavailable.
*/
async resolveServerHome(): Promise<string | null> {
if (this._serverHome !== undefined) return this._serverHome;
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
path: HOME_TILDE,
type: GlobSearchType.DIR,
max_depth: 1,
limit: 1
});
this._serverHome = typeof res.base === 'string' ? res.base : null;
} catch {
// searches still work via a literal `~`, only `~` abbreviation degrades
this._serverHome = null;
}
return this._serverHome;
}
}
export const toolsStore = new ToolsStore();