From d981fa9206a21ac9aa83ae21ac4b1ede8eba0eb2 Mon Sep 17 00:00:00 2001 From: gagan Date: Wed, 27 May 2026 03:09:56 +0530 Subject: [PATCH] fix: scope chat work directory per-run instead of globally (#578) * fix: scope chat work directory per-run instead of globally * fix: refine work directory pill and search button controls * feat: animate web search button toggle --- apps/x/apps/renderer/src/App.tsx | 58 +++++++- .../components/chat-input-with-mentions.tsx | 132 ++++++++---------- .../renderer/src/components/chat-sidebar.tsx | 6 + apps/x/packages/core/src/agents/runtime.ts | 16 ++- 4 files changed, 133 insertions(+), 79 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 04359de5..a35fbca7 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1034,6 +1034,10 @@ function App() { const chatViewStateByTabRef = useRef(chatViewStateByTab) const chatDraftsRef = useRef(new Map()) const selectedModelByTabRef = useRef(new Map()) + // Work directory is per-chat. Keyed by tab id; null/absent means none set. + const [workDirByTab, setWorkDirByTab] = useState>({}) + const workDirByTabRef = useRef(workDirByTab) + workDirByTabRef.current = workDirByTab const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) @@ -1046,6 +1050,36 @@ function App() { chatDraftsRef.current.delete(tabId) } }, []) + // Persist a run's work directory to its per-run sidecar config file. The agent + // runtime reads this same file (config/workdir-.json) on each turn. + const persistRunWorkDir = useCallback(async (runId: string, value: string | null) => { + try { + await window.ipc.invoke('workspace:writeFile', { + path: `config/workdir-${runId}.json`, + data: JSON.stringify(value ? { path: value } : {}, null, 2), + }) + } catch (err) { + console.error('Failed to persist work directory for run', runId, err) + } + }, []) + // Read a run's persisted work directory (used when (re)opening a run into a tab). + const loadRunWorkDir = useCallback(async (runId: string): Promise => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: `config/workdir-${runId}.json` }) + const parsed = JSON.parse(result.data) + const value = typeof parsed?.path === 'string' ? parsed.path.trim() : '' + return value || null + } catch { + return null + } + }, []) + const setTabWorkDir = useCallback((tabId: string, value: string | null) => { + setWorkDirByTab((prev) => ({ ...prev, [tabId]: value })) + // If the tab is already bound to a run, persist immediately so the change + // applies to that chat's subsequent messages. + const runId = chatTabsRef.current.find((t) => t.id === tabId)?.runId + if (runId) void persistRunWorkDir(runId, value) + }, [persistRunWorkDir]) const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => { return toolOpenByTab[tabId]?.[toolId] ?? false }, [toolOpenByTab]) @@ -2023,10 +2057,16 @@ function App() { setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + + // Restore the run's per-chat work directory into the tab it was loaded into. + const tabId = activeChatTabIdRef.current + const wd = await loadRunWorkDir(id) + if (loadRunRequestIdRef.current !== requestId) return + setWorkDirByTab((prev) => ({ ...prev, [tabId]: wd })) } catch (err) { console.error('Failed to load run:', err) } - }, []) + }, [loadRunWorkDir]) const getStreamingBuffer = useCallback((id: string) => { const existing = streamingBuffersRef.current.get(id) @@ -2478,6 +2518,10 @@ function App() { ? { ...tab, runId: currentRunId } : tab ))) + // Flush this tab's pending work directory onto the freshly created run so + // the agent picks it up on the first turn. Done before createMessage below. + const pendingWorkDir = workDirByTabRef.current[submitTabId] ?? null + if (pendingWorkDir) await persistRunWorkDir(currentRunId, pendingWorkDir) isNewRun = true } @@ -2671,6 +2715,8 @@ function App() { ...prev, [activeChatTabIdRef.current]: createEmptyChatTabViewState(), })) + // A brand-new chat starts with no work directory. + setWorkDirByTab(prev => ({ ...prev, [activeChatTabIdRef.current]: null })) }, [setChatViewportAnchor]) // Chat tab operations @@ -2758,6 +2804,12 @@ function App() { chatDraftsRef.current.delete(tabId) selectedModelByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) + setWorkDirByTab((prev) => { + if (!(tabId in prev)) return prev + const next = { ...prev } + delete next[tabId] + return next + }) setToolOpenByTab((prev) => { if (!(tabId in prev)) return prev const next = { ...prev } @@ -5800,6 +5852,8 @@ function App() { selectedModelByTabRef.current.delete(tab.id) } }} + workDir={workDirByTab[tab.id] ?? null} + onWorkDirChange={(v) => setTabWorkDir(tab.id, v)} isRecording={isActive && isRecording} recordingText={isActive ? voice.interimText : undefined} recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} @@ -5869,6 +5923,8 @@ function App() { selectedModelByTabRef.current.delete(tabId) } }} + workDirByTab={workDirByTab} + onWorkDirChangeForTab={setTabWorkDir} pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 97386508..013a68ad 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -28,7 +28,6 @@ import { DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -133,6 +132,10 @@ interface ChatInputInnerProps { onTtsModeChange?: (mode: 'summary' | 'full') => void /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ onSelectedModelChange?: (model: SelectedModel | null) => void + /** Work directory for this chat (per-chat). Null when none is set. */ + workDir?: string | null + /** Fired when the user sets/changes/clears the work directory for this chat. */ + onWorkDirChange?: (value: string | null) => void } function ChatInputInner({ @@ -159,6 +162,8 @@ function ChatInputInner({ onToggleTts, onTtsModeChange, onSelectedModelChange, + workDir = null, + onWorkDirChange, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -173,7 +178,6 @@ function ChatInputInner({ const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) - const [workDir, setWorkDir] = useState(null) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -256,22 +260,8 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) - // Load currently configured work directory - const loadWorkDir = useCallback(async () => { - try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' }) - const parsed = JSON.parse(result.data) - const value = typeof parsed?.path === 'string' ? parsed.path.trim() : '' - setWorkDir(value || null) - } catch { - setWorkDir(null) - } - }, []) - - useEffect(() => { - loadWorkDir() - }, [isActive, loadWorkDir]) - + // Work directory is owned per-chat by the parent (App). This component only + // drives the picker dialog and reports changes up via onWorkDirChange. const handleSetWorkDir = useCallback(async () => { try { let defaultPath: string | undefined = workDir ?? undefined @@ -291,31 +281,18 @@ function ChatInputInner({ defaultPath, }) if (!chosen) return - await window.ipc.invoke('workspace:writeFile', { - path: 'config/workdir.json', - data: JSON.stringify({ path: chosen }, null, 2), - }) - setWorkDir(chosen) + onWorkDirChange?.(chosen) toast.success(`Work directory set: ${chosen}`) } catch (err) { console.error('Failed to set work directory', err) toast.error('Failed to set work directory') } - }, [workDir]) + }, [workDir, onWorkDirChange]) - const handleClearWorkDir = useCallback(async () => { - try { - await window.ipc.invoke('workspace:writeFile', { - path: 'config/workdir.json', - data: JSON.stringify({}, null, 2), - }) - setWorkDir(null) - toast.success('Work directory cleared') - } catch (err) { - console.error('Failed to clear work directory', err) - toast.error('Failed to clear work directory') - } - }, []) + const handleClearWorkDir = useCallback(() => { + onWorkDirChange?.(null) + toast.success('Work directory cleared') + }, [onWorkDirChange]) // Check search tool availability (exa or signed-in via gateway) useEffect(() => { @@ -569,28 +546,29 @@ function ChatInputInner({ {workDir ? 'Change work directory' : 'Set work directory'} - {workDir && ( - <> - - { void handleClearWorkDir() }}> - - Clear work directory - - - )} {workDir && ( - +
+ + +
Work directory: {workDir} @@ -598,26 +576,28 @@ function ChatInputInner({
)} {searchAvailable && ( - searchEnabled ? ( - - ) : ( - - ) + Search + + )}
{lockedModel ? ( @@ -802,6 +782,8 @@ export interface ChatInputWithMentionsProps { onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void onSelectedModelChange?: (model: SelectedModel | null) => void + workDir?: string | null + onWorkDirChange?: (value: string | null) => void } export function ChatInputWithMentions({ @@ -831,6 +813,8 @@ export function ChatInputWithMentions({ onToggleTts, onTtsModeChange, onSelectedModelChange, + workDir, + onWorkDirChange, }: ChatInputWithMentionsProps) { return ( @@ -858,6 +842,8 @@ export function ChatInputWithMentions({ onToggleTts={onToggleTts} onTtsModeChange={onTtsModeChange} onSelectedModelChange={onSelectedModelChange} + workDir={workDir} + onWorkDirChange={onWorkDirChange} /> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 0ff286e3..0c054bb7 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -143,6 +143,8 @@ interface ChatSidebarProps { getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void + workDirByTab?: Record + onWorkDirChangeForTab?: (tabId: string, value: string | null) => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] @@ -199,6 +201,8 @@ export function ChatSidebar({ getInitialDraft, onDraftChangeForTab, onSelectedModelChangeForTab, + workDirByTab = {}, + onWorkDirChangeForTab, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), @@ -690,6 +694,8 @@ export function ChatSidebar({ initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} + workDir={workDirByTab[tab.id] ?? null} + onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined} isRecording={isActive && isRecording} recordingText={isActive ? recordingText : undefined} recordingState={isActive ? recordingState : undefined} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 883e8a14..f0d867bd 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -38,7 +38,12 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent. import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); -const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json'); + +// Work directory is scoped per run (per chat). Each run gets its own sidecar +// config file so setting it in one chat does not leak into others. +function workDirConfigFile(runId: string): string { + return path.join(WorkDir, 'config', `workdir-${runId}.json`); +} type ToolPermissionMetadataValue = z.infer; @@ -165,10 +170,11 @@ async function getToolPermissionMetadata( }; } -function loadUserWorkDir(): string | null { +function loadUserWorkDir(runId: string): string | null { try { - if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null; - const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8'); + const file = workDirConfigFile(runId); + if (!fs.existsSync(file)) return null; + const raw = fs.readFileSync(file, 'utf-8'); const parsed = JSON.parse(raw) as { path?: unknown }; const value = typeof parsed.path === 'string' ? parsed.path.trim() : ''; return value || null; @@ -1259,7 +1265,7 @@ export async function* streamAgent({ if (agentNotesContext) { instructionsWithDateTime += `\n\n${agentNotesContext}`; } - const userWorkDir = loadUserWorkDir(); + const userWorkDir = loadUserWorkDir(runId); if (userWorkDir) { loopLogger.log('injecting user work directory', userWorkDir); instructionsWithDateTime += `\n\n# User Work Directory