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:
1 parent
0713275082
commit
2f56fc3431
41 files changed
+1946
-95
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user