Documents the global-workdir bug and a step-by-step plan to make the work directory run-scoped (set at creation, changeable mid-chat via an appended event) instead of a single shared config file.
14 KiB
Plan: Make the chat "work directory" per-chat instead of global
1. Problem / current behavior
In the Electron app (apps/x), a chat can have a work directory — a folder the
agent treats as the default location for file operations. The user sets it from the
chat input's dropdown ("Set / Change / Clear work directory").
Bug: the work directory is global, not per-chat. Setting it in one chat changes it for every chat — new chats and previously-created chats alike. Opening a different chat shows (and uses) whatever directory was last set anywhere.
Desired behavior:
- The work directory belongs to a single chat (run). Setting it in chat A must not affect chat B.
- A brand-new chat starts with no work directory.
- The user can set, change, or clear the work directory at any point in a chat, and the change applies to that chat only and to its subsequent messages.
2. Root cause
The work directory lives in one shared file — config/workdir.json in the Rowboat
workspace — and there is no per-run storage for it.
| Concern | Location | What it does |
|---|---|---|
| Write (UI) | apps/renderer/src/components/chat-input-with-mentions.tsx:275-306 (handleSetWorkDir / handleClearWorkDir) |
Writes the chosen path to the global config/workdir.json. |
| Read for display (UI) | apps/renderer/src/components/chat-input-with-mentions.tsx:259-273 (loadWorkDir, called on isActive) |
Re-reads the same global file whenever any tab becomes active. |
| Read for the agent | packages/core/src/agents/runtime.ts:41-51 (loadUserWorkDir) injected at runtime.ts:1125-1146 |
On every message, reads the same global file and injects it into the prompt, regardless of which run the message belongs to. |
The data model has nowhere to store it per chat:
Run/StartEventschema:packages/shared/src/runs.ts:19-34and147-157— no work-dir field.CreateRunOptions:packages/shared/src/runs.ts:169-175— not accepted at creation.- The run is created in the renderer at
apps/renderer/src/App.tsx:2357(runs:create) and the work dir is never passed through.
3. Design
Make the work directory run-scoped metadata, captured at run creation and updatable mid-chat via an appended event (run logs are append-only JSONL, so "change" = append a new event; readers use the latest one).
Three moving parts:
- Schema — add an optional
workingDirectorytoStartEvent(initial value) and add a newWorkdirChangedEventto the run-event union (later changes). Derive aworkingDirectoryfield on theRunobject from the latest of these. - Runtime —
AgentStatetracksworkingDirectory(set by the start event, overwritten by eachworkdir-changedevent). The prompt injection usesstate.workingDirectoryinstead of reading the global file. - Renderer — the chat input reads the work dir from the active run (via
runs:fetch), not the global file. For a new chat (no run yet), the chosen dir is held in tab-local state and passed intoruns:create. Set/Change/Clear on an existing run calls a newruns:setWorkdirIPC that appends aworkdir-changedevent.
The global config/workdir.json is no longer read or written. New chats therefore start
empty, which is what fixes the reported bug. (No migration needed — old runs simply have no
stored work dir and start empty going forward. Optionally delete the stale file; see step 7.)
4. Execution steps
Step 1 — Schema (packages/shared/src/runs.ts)
- Add
workingDirectorytoStartEvent(aftersubUseCase, line ~33):workingDirectory: z.string().optional(), - Add a new event type near the other event definitions (e.g. after
RunStoppedEvent, line ~106):export const WorkdirChangedEvent = BaseRunEvent.extend({ type: z.literal("workdir-changed"), // empty string / undefined means "cleared" workingDirectory: z.string().optional(), }); - Add
WorkdirChangedEventto theRunEventunion (line ~108-124). - Add the derived field to the
Runschema (line ~147-157):workingDirectory: z.string().optional(), - Add it to
CreateRunOptions(line ~169-175):workingDirectory: z.string().optional(),
Note: the read-side
LegacyStartEventinpackages/core/src/runs/repo.ts:21-30extendsStartEvent, so the new optional field is picked up automatically. No legacy change needed.
Step 2 — Persist work dir at run creation (packages/core/src/runs/)
runs/repo.ts— extendCreateRunRepoOptions(line ~34) withworkingDirectory?: string;. Increate()(thestartevent object, ~wheresubUseCaseis spread), include:
Add the same spread to the returned...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}),Runobject literal.runs/repo.ts— infetch(), derive the current work dir as the lastworkdir-changedevent's value, falling back to the start event'sworkingDirectory, and include it on the returnedRun:
(Importconst lastWorkdirEvent = [...events].reverse() .find((e) => e.type === 'workdir-changed') as | z.infer<typeof WorkdirChangedEvent> | undefined; const workingDirectory = lastWorkdirEvent ? (lastWorkdirEvent.workingDirectory || undefined) : (start.workingDirectory || undefined); // ... ...(workingDirectory ? { workingDirectory } : {}),WorkdirChangedEventfrom@x/shared/dist/runs.js.)runs/runs.ts—createRun()(line ~31) passesworkingDirectorythrough torepo.create({ ... }):...(opts.workingDirectory ? { workingDirectory: opts.workingDirectory } : {}),runs/runs.ts— add a new exported function to change the work dir mid-chat:
(Noexport async function setWorkdir(runId: string, workingDirectory: string | null): Promise<void> { const repo = container.resolve<IRunsRepo>('runsRepo'); const event: z.infer<typeof WorkdirChangedEvent> = { runId, type: "workdir-changed", subflow: [], ...(workingDirectory ? { workingDirectory } : {}), }; await repo.appendEvents(runId, [event]); }runtime.trigger()— this only updates metadata; it'll be read on the next message.)
Step 3 — Runtime reads per-run work dir (packages/core/src/agents/runtime.ts)
- Add a field to
AgentState(class at line 668, nearrunSubUseCase, line ~675):workingDirectory: string | null = null; - In
AgentState.ingest()(line 773):- In the
case "start":block (line ~786-793) add:this.workingDirectory = event.workingDirectory ?? null; - Add a new case:
case "workdir-changed": this.workingDirectory = event.workingDirectory ?? null; break;
- In the
- Replace the global-file read at the injection site. At
runtime.ts:1125, change:
to:const userWorkDir = loadUserWorkDir();
Leave the rest of the prompt block (lines 1126-1146) unchanged.const userWorkDir = state.workingDirectory; - Delete the now-unused
loadUserWorkDirfunction (lines 41-51) and theWORKDIR_CONFIG_FILEconstant (line 39).
Step 4 — IPC surface (packages/shared/src/ipc.ts)
runs:createalready usesCreateRunOptionsas itsreq(line 176-179) — the new optional field flows through automatically once Step 1 lands. No change needed there.- Add a new channel
runs:setWorkdir:'runs:setWorkdir': { req: z.object({ runId: z.string(), workingDirectory: z.string().nullable(), }), res: z.object({ success: z.literal(true) }), }, - Wire the handler in
apps/main/src/ipc.ts(alongside the otherruns:*handlers, e.g. near line 509):
(Confirm the import name used for the runs core module — it's'runs:setWorkdir': async (_event, args) => { await runsCore.setWorkdir(args.runId, args.workingDirectory); return { success: true as const }; },runsCorein the existingruns:createMessagehandler.)
Step 5 — Renderer: chat input reads/writes per-run (apps/renderer/src/components/chat-input-with-mentions.tsx)
The component already has runId as a prop (lines 119/146/777/806) and already loads
run-scoped data via runs:fetch for model-locking (lines 178-192) — mirror that pattern.
- Load from the run, not the global file. Replace
loadWorkDir(lines 259-273) so it:- If
runIdis set:runs:fetchandsetWorkDir(run.workingDirectory ?? null). - If
runIdis null: do not read any global file. Instead reflect the tab-local pending value supplied by the parent (see new props below). Defaultnull. Remove theworkspace:readFilecall onconfig/workdir.json.
- If
- Set / change. In
handleSetWorkDir(lines 275-292), after the user picks a folder:- If
runIdis set:await window.ipc.invoke('runs:setWorkdir', { runId, workingDirectory: chosen }). - If
runIdis null: call a new proponPendingWorkDirChange(chosen)so the parent stores it for this tab until the run is created. - In both cases
setWorkDir(chosen). Remove theworkspace:writeFiletoconfig/workdir.json.
- If
- Clear. In
handleClearWorkDir(lines 294-306), same branching withworkingDirectory: null/onPendingWorkDirChange(null), thensetWorkDir(null). - Props. Add to
ChatInputInnerProps(line 111) andChatInputWithMentionsProps(line 766), and thread through the wrapper (line 795-833):
WhenpendingWorkDir?: string | null onPendingWorkDirChange?: (dir: string | null) => voidrunIdis null, drive the displayedworkDirfrompendingWorkDir.
Step 6 — Renderer: hold pending work dir per tab + pass to run creation (apps/renderer/src/App.tsx)
Mirror the existing per-tab model pattern (selectedModelByTabRef, line 972; cleared on
tab close at line 2668; set/read at 2356, 5298-5300, 5358-5360).
- Add a tab-keyed store for the pending (pre-run) work dir. A ref works, but since the UI
must re-render when it changes, prefer state keyed by tab id, e.g.
const [pendingWorkDirByTab, setPendingWorkDirByTab] = useState<Record<string, string | null>>({}). - Pass to each
<ChatInputWithMentions>(lines 5282 and 5347):pendingWorkDir={pendingWorkDirByTab[tab.id] ?? null} onPendingWorkDirChange={(dir) => setPendingWorkDirByTab((prev) => ({ ...prev, [tab.id]: dir }))} - In the submit handler where the run is created (lines 2355-2372), pass the pending dir
into
runs:create:
After the run is created, the work dir lives on the run; clear the pending entry for that tab (optional, keeps state tidy).const pendingWorkDir = pendingWorkDirByTab[submitTabId] ?? undefined const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), ...(pendingWorkDir ? { workingDirectory: pendingWorkDir } : {}), }) - On tab close (near line 2668) delete the tab's entry from
pendingWorkDirByTab.
Step 7 — Cleanup / backward compatibility
- No data migration required. Existing runs have no stored work dir → they show empty and behave correctly going forward.
- The global
config/workdir.jsonis no longer read or written. Optionally delete it once on startup so the stale value doesn't linger (low priority; safe to skip). - Grep to confirm no remaining readers/writers of
config/workdir.jsonoutside the files changed above:grep -rn "workdir.json\|loadUserWorkDir\|WORKDIR_CONFIG_FILE" apps/x
5. Build & verify
cd apps/x && npm run deps # rebuild shared -> core -> preload (schema + IPC changes)
cd apps/x && npm run lint
Manual checks in dev (cd apps/x && npm run dev):
- New chat A → set work dir to /pathA. Open a new chat B → it shows no work dir.
- In chat A, send a message → agent should treat /pathA as the work dir (check the
injected "User Work Directory" block /
loopLogger"injecting user work directory"). - Reopen chat B, set /pathB → chat A still shows /pathA (reopen A to confirm).
- In chat A, change to /pathC mid-conversation → next message uses /pathC; earlier behavior unaffected. Clear it → subsequent messages have no work dir injected.
- Reload the app and reopen chat A → it still shows its last work dir (persisted on the run
log via
workdir-changed/ start event).
6. Touched files summary
| File | Change |
|---|---|
packages/shared/src/runs.ts |
Add workingDirectory to StartEvent, Run, CreateRunOptions; add WorkdirChangedEvent + add to union. |
packages/shared/src/ipc.ts |
Add runs:setWorkdir channel. |
packages/core/src/runs/repo.ts |
Persist workingDirectory in create(); derive latest in fetch(). |
packages/core/src/runs/runs.ts |
Pass through in createRun(); add setWorkdir(). |
packages/core/src/agents/runtime.ts |
AgentState.workingDirectory; ingest start + workdir-changed; inject state.workingDirectory; remove loadUserWorkDir + WORKDIR_CONFIG_FILE. |
apps/main/src/ipc.ts |
Handler for runs:setWorkdir. |
apps/renderer/src/components/chat-input-with-mentions.tsx |
Read/write per-run (via runs:fetch / runs:setWorkdir); new pendingWorkDir + onPendingWorkDirChange props; drop global-file I/O. |
apps/renderer/src/App.tsx |
Per-tab pending work dir state; pass to runs:create; clear on tab close. |