From ce1913d4e07dfa90ef3b9a4ec3c39756ac583323 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Tue, 26 May 2026 18:23:32 +0000 Subject: [PATCH] Make chat work directory per-run instead of global The work directory was stored in a single shared config file, so setting it in one chat changed it for every chat and new chats inherited a stale value. Store it as run-scoped metadata: captured on the start event at creation and updatable mid-chat via an appended workdir-changed event. The runtime reads the per-run value from AgentState, and the chat input reads/writes it against the run (pending chats pass it into runs:create). --- apps/x/apps/main/src/ipc.ts | 4 ++ apps/x/apps/renderer/src/App.tsx | 19 ++++++ .../components/chat-input-with-mentions.tsx | 58 +++++++++++-------- .../renderer/src/components/chat-sidebar.tsx | 3 + apps/x/packages/core/src/agents/runtime.ts | 20 ++----- apps/x/packages/core/src/runs/repo.ts | 14 ++++- apps/x/packages/core/src/runs/runs.ts | 14 ++++- apps/x/packages/shared/src/ipc.ts | 9 +++ apps/x/packages/shared/src/runs.ts | 12 ++++ 9 files changed, 113 insertions(+), 40 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 78f8b55e..ae6b111f 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -524,6 +524,10 @@ export function setupIpcHandlers() { 'runs:fetch': async (_event, args) => { return runsCore.fetchRun(args.runId); }, + 'runs:setWorkdir': async (_event, args) => { + await runsCore.setWorkdir(args.runId, args.workingDirectory); + return { success: true as const }; + }, 'runs:list': async (_event, args) => { return runsCore.listRuns(args.cursor); }, diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e6c050b3..b9ed3b91 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -970,6 +970,7 @@ function App() { const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` const chatDraftsRef = useRef(new Map()) const selectedModelByTabRef = useRef(new Map()) + const pendingWorkDirByTabRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) @@ -2354,10 +2355,13 @@ function App() { let newRunCreatedAt: string | null = null if (!currentRunId) { const selected = selectedModelByTabRef.current.get(submitTabId) + const pendingWorkDir = pendingWorkDirByTabRef.current.get(submitTabId) const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + ...(pendingWorkDir ? { workingDirectory: pendingWorkDir } : {}), }) + pendingWorkDirByTabRef.current.delete(submitTabId) currentRunId = run.id newRunCreatedAt = run.createdAt setRunId(currentRunId) @@ -2666,6 +2670,7 @@ function App() { }) chatDraftsRef.current.delete(tabId) selectedModelByTabRef.current.delete(tabId) + pendingWorkDirByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) setToolOpenByTab((prev) => { if (!(tabId in prev)) return prev @@ -5300,6 +5305,13 @@ function App() { selectedModelByTabRef.current.delete(tab.id) } }} + onWorkDirChange={(dir) => { + if (dir) { + pendingWorkDirByTabRef.current.set(tab.id, dir) + } else { + pendingWorkDirByTabRef.current.delete(tab.id) + } + }} isRecording={isActive && isRecording} recordingText={isActive ? voice.interimText : undefined} recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} @@ -5360,6 +5372,13 @@ function App() { selectedModelByTabRef.current.delete(tabId) } }} + onWorkDirChangeForTab={(tabId, dir) => { + if (dir) { + pendingWorkDirByTabRef.current.set(tabId, dir) + } else { + pendingWorkDirByTabRef.current.delete(tabId) + } + }} 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 ccd96805..c5e48a41 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 @@ -133,6 +133,9 @@ 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 + /** Fired when the user sets/clears the work directory before a run exists, so the + * parent can pass it into runs:create. Once a run exists, changes go straight to the run. */ + onWorkDirChange?: (dir: string | null) => void } function ChatInputInner({ @@ -159,6 +162,7 @@ function ChatInputInner({ onToggleTts, onTtsModeChange, onSelectedModelChange, + onWorkDirChange, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -256,21 +260,22 @@ 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) - } - }, []) - + // The work directory is run-scoped. For an existing run, load it from the run. + // For a new chat (no run yet) the chosen directory lives in local state until + // the run is created, and is reported up via onWorkDirChange so it can be + // passed into runs:create. useEffect(() => { - loadWorkDir() - }, [isActive, loadWorkDir]) + if (!runId) { + setWorkDir(null) + return + } + let cancelled = false + window.ipc.invoke('runs:fetch', { runId }).then((run) => { + if (cancelled) return + setWorkDir(run.workingDirectory ?? null) + }).catch(() => { /* legacy run or fetch failure — leave as-is */ }) + return () => { cancelled = true } + }, [runId]) const handleSetWorkDir = useCallback(async () => { try { @@ -279,31 +284,33 @@ function ChatInputInner({ defaultPath: workDir ?? undefined, }) if (!chosen) return - await window.ipc.invoke('workspace:writeFile', { - path: 'config/workdir.json', - data: JSON.stringify({ path: chosen }, null, 2), - }) + if (runId) { + await window.ipc.invoke('runs:setWorkdir', { runId, workingDirectory: chosen }) + } else { + onWorkDirChange?.(chosen) + } setWorkDir(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, runId, onWorkDirChange]) const handleClearWorkDir = useCallback(async () => { try { - await window.ipc.invoke('workspace:writeFile', { - path: 'config/workdir.json', - data: JSON.stringify({}, null, 2), - }) + if (runId) { + await window.ipc.invoke('runs:setWorkdir', { runId, workingDirectory: null }) + } else { + onWorkDirChange?.(null) + } setWorkDir(null) toast.success('Work directory cleared') } catch (err) { console.error('Failed to clear work directory', err) toast.error('Failed to clear work directory') } - }, []) + }, [runId, onWorkDirChange]) // Check search tool availability (exa or signed-in via gateway) useEffect(() => { @@ -790,6 +797,7 @@ export interface ChatInputWithMentionsProps { onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void onSelectedModelChange?: (model: SelectedModel | null) => void + onWorkDirChange?: (dir: string | null) => void } export function ChatInputWithMentions({ @@ -819,6 +827,7 @@ export function ChatInputWithMentions({ onToggleTts, onTtsModeChange, onSelectedModelChange, + onWorkDirChange, }: ChatInputWithMentionsProps) { return ( @@ -846,6 +855,7 @@ export function ChatInputWithMentions({ onToggleTts={onToggleTts} onTtsModeChange={onTtsModeChange} onSelectedModelChange={onSelectedModelChange} + 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 06680652..8b6c7131 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -195,6 +195,7 @@ interface ChatSidebarProps { getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void + onWorkDirChangeForTab?: (tabId: string, dir: string | null) => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] @@ -250,6 +251,7 @@ export function ChatSidebar({ getInitialDraft, onDraftChangeForTab, onSelectedModelChangeForTab, + onWorkDirChangeForTab, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), @@ -742,6 +744,7 @@ export function ChatSidebar({ initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} + onWorkDirChange={onWorkDirChangeForTab ? (dir) => onWorkDirChangeForTab(tab.id, dir) : 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 809fb996..2eb784b1 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -36,19 +36,6 @@ 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'); - -function loadUserWorkDir(): string | null { - try { - if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null; - const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8'); - const parsed = JSON.parse(raw) as { path?: unknown }; - const value = typeof parsed.path === 'string' ? parsed.path.trim() : ''; - return value || null; - } catch { - return null; - } -} function loadAgentNotesContext(): string | null { const sections: string[] = []; @@ -673,6 +660,7 @@ export class AgentState { runProvider: string | null = null; runUseCase: UseCase | null = null; runSubUseCase: string | null = null; + workingDirectory: string | null = null; messages: z.infer = []; lastAssistantMsg: z.infer | null = null; subflowStates: Record = {}; @@ -790,6 +778,10 @@ export class AgentState { this.runProvider = event.provider; this.runUseCase = event.useCase ?? null; this.runSubUseCase = event.subUseCase ?? null; + this.workingDirectory = event.workingDirectory ?? null; + break; + case "workdir-changed": + this.workingDirectory = event.workingDirectory ?? null; break; case "spawn-subflow": // Seed the subflow state with its agent so downstream loadAgent works. @@ -1122,7 +1114,7 @@ export async function* streamAgent({ if (agentNotesContext) { instructionsWithDateTime += `\n\n${agentNotesContext}`; } - const userWorkDir = loadUserWorkDir(); + const userWorkDir = state.workingDirectory; if (userWorkDir) { loopLogger.log('injecting user work directory', userWorkDir); instructionsWithDateTime += `\n\n# User Work Directory diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index addb4f35..f63d3015 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -5,7 +5,7 @@ import path from "path"; import fsp from "fs/promises"; import fs from "fs"; import readline from "readline"; -import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase } from "@x/shared/dist/runs.js"; +import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase, WorkdirChangedEvent } from "@x/shared/dist/runs.js"; import { getDefaultModelAndProvider } from "../models/defaults.js"; /** @@ -37,6 +37,7 @@ export type CreateRunRepoOptions = { provider: string; useCase: z.infer; subUseCase?: string; + workingDirectory?: string; }; function runLogPath(runId: string): string { @@ -206,6 +207,7 @@ export class FSRunsRepo implements IRunsRepo { provider: options.provider, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), + ...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}), subflow: [], ts, }; @@ -218,6 +220,7 @@ export class FSRunsRepo implements IRunsRepo { provider: options.provider, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), + ...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}), log: [start], }; } @@ -244,6 +247,14 @@ export class FSRunsRepo implements IRunsRepo { }; const events: z.infer[] = [start, ...rawEvents.slice(1) as z.infer[]]; const title = this.extractTitle(events); + // The current work directory is the start event's value, overridden by the + // most recent workdir-changed event (append-only log — last write wins). + let workingDirectory: string | undefined = start.workingDirectory || undefined; + for (const event of events) { + if (event.type === 'workdir-changed') { + workingDirectory = (event as z.infer).workingDirectory || undefined; + } + } return { id, title, @@ -253,6 +264,7 @@ export class FSRunsRepo implements IRunsRepo { provider: start.provider, ...(start.useCase ? { useCase: start.useCase } : {}), ...(start.subUseCase ? { subUseCase: start.subUseCase } : {}), + ...(workingDirectory ? { workingDirectory } : {}), log: events, }; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 841d13a8..15b3691e 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -1,7 +1,7 @@ import z from "zod"; import container from "../di/container.js"; import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js"; -import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js"; +import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload, WorkdirChangedEvent } from "@x/shared/dist/runs.js"; import { IRunsRepo } from "./repo.js"; import { IAgentRuntime } from "../agents/runtime.js"; import { IBus } from "../application/lib/bus.js"; @@ -34,11 +34,23 @@ export async function createRun(opts: z.infer): Promise provider, useCase, ...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}), + ...(opts.workingDirectory ? { workingDirectory: opts.workingDirectory } : {}), }); await bus.publish(run.log[0]); return run; } +export async function setWorkdir(runId: string, workingDirectory: string | null): Promise { + const repo = container.resolve('runsRepo'); + const event: z.infer = { + runId, + type: "workdir-changed", + subflow: [], + ...(workingDirectory ? { workingDirectory } : {}), + }; + await repo.appendEvents(runId, [event]); +} + export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise { const queue = container.resolve('messageQueue'); const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 37cf41e7..01d4450b 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -234,6 +234,15 @@ const ipcSchemas = { }), res: Run, }, + 'runs:setWorkdir': { + req: z.object({ + runId: z.string(), + workingDirectory: z.string().nullable(), + }), + res: z.object({ + success: z.literal(true), + }), + }, 'runs:list': { req: z.object({ cursor: z.string().optional(), diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index b30c0b47..0bfa06c9 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -31,6 +31,9 @@ export const StartEvent = BaseRunEvent.extend({ "knowledge_sync", ]).optional(), subUseCase: z.string().optional(), + // Per-run work directory chosen at creation. Optional: a run may have none, + // and it can be changed later via WorkdirChangedEvent. + workingDirectory: z.string().optional(), }); export const SpawnSubFlowEvent = BaseRunEvent.extend({ @@ -105,6 +108,12 @@ export const RunStoppedEvent = BaseRunEvent.extend({ reason: z.enum(["user-requested", "force-stopped"]).optional(), }); +export const WorkdirChangedEvent = BaseRunEvent.extend({ + type: z.literal("workdir-changed"), + // Absent/empty means the work directory was cleared. + workingDirectory: z.string().optional(), +}); + export const RunEvent = z.union([ RunProcessingStartEvent, RunProcessingEndEvent, @@ -121,6 +130,7 @@ export const RunEvent = z.union([ ToolPermissionResponseEvent, RunErrorEvent, RunStoppedEvent, + WorkdirChangedEvent, ]); export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({ @@ -153,6 +163,7 @@ export const Run = z.object({ provider: z.string(), useCase: UseCase.optional(), subUseCase: z.string().optional(), + workingDirectory: z.string().optional(), log: z.array(RunEvent), }); @@ -172,4 +183,5 @@ export const CreateRunOptions = z.object({ provider: z.string().optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), + workingDirectory: z.string().optional(), });