diff --git a/apps/x/plan.md b/apps/x/plan.md new file mode 100644 index 00000000..db16ce53 --- /dev/null +++ b/apps/x/plan.md @@ -0,0 +1,280 @@ +# 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` / `StartEvent` schema: `packages/shared/src/runs.ts:19-34` and `147-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: + +1. **Schema** — add an optional `workingDirectory` to `StartEvent` (initial value) and add a + new `WorkdirChangedEvent` to the run-event union (later changes). Derive a + `workingDirectory` field on the `Run` object from the latest of these. +2. **Runtime** — `AgentState` tracks `workingDirectory` (set by the start event, overwritten + by each `workdir-changed` event). The prompt injection uses `state.workingDirectory` + instead of reading the global file. +3. **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 into `runs:create`. Set/Change/Clear on an existing run calls a new + `runs:setWorkdir` IPC that appends a `workdir-changed` event. + +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`) + +1. Add `workingDirectory` to `StartEvent` (after `subUseCase`, line ~33): + ```ts + workingDirectory: z.string().optional(), + ``` +2. Add a new event type near the other event definitions (e.g. after `RunStoppedEvent`, + line ~106): + ```ts + export const WorkdirChangedEvent = BaseRunEvent.extend({ + type: z.literal("workdir-changed"), + // empty string / undefined means "cleared" + workingDirectory: z.string().optional(), + }); + ``` +3. Add `WorkdirChangedEvent` to the `RunEvent` union (line ~108-124). +4. Add the derived field to the `Run` schema (line ~147-157): + ```ts + workingDirectory: z.string().optional(), + ``` +5. Add it to `CreateRunOptions` (line ~169-175): + ```ts + workingDirectory: z.string().optional(), + ``` + +> Note: the read-side `LegacyStartEvent` in `packages/core/src/runs/repo.ts:21-30` extends +> `StartEvent`, 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/`) + +1. `runs/repo.ts` — extend `CreateRunRepoOptions` (line ~34) with + `workingDirectory?: string;`. In `create()` (the `start` event object, ~where + `subUseCase` is spread), include: + ```ts + ...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}), + ``` + Add the same spread to the returned `Run` object literal. +2. `runs/repo.ts` — in `fetch()`, derive the current work dir as **the last + `workdir-changed` event's value, falling back to the start event's + `workingDirectory`**, and include it on the returned `Run`: + ```ts + const lastWorkdirEvent = [...events].reverse() + .find((e) => e.type === 'workdir-changed') as + | z.infer | undefined; + const workingDirectory = lastWorkdirEvent + ? (lastWorkdirEvent.workingDirectory || undefined) + : (start.workingDirectory || undefined); + // ... + ...(workingDirectory ? { workingDirectory } : {}), + ``` + (Import `WorkdirChangedEvent` from `@x/shared/dist/runs.js`.) +3. `runs/runs.ts` — `createRun()` (line ~31) passes `workingDirectory` through to + `repo.create({ ... })`: + ```ts + ...(opts.workingDirectory ? { workingDirectory: opts.workingDirectory } : {}), + ``` +4. `runs/runs.ts` — add a new exported function to change the work dir mid-chat: + ```ts + 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]); + } + ``` + (No `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`) + +1. Add a field to `AgentState` (class at line 668, near `runSubUseCase`, line ~675): + ```ts + workingDirectory: string | null = null; + ``` +2. In `AgentState.ingest()` (line 773): + - In the `case "start":` block (line ~786-793) add: + ```ts + this.workingDirectory = event.workingDirectory ?? null; + ``` + - Add a new case: + ```ts + case "workdir-changed": + this.workingDirectory = event.workingDirectory ?? null; + break; + ``` +3. Replace the global-file read at the injection site. At `runtime.ts:1125`, change: + ```ts + const userWorkDir = loadUserWorkDir(); + ``` + to: + ```ts + const userWorkDir = state.workingDirectory; + ``` + Leave the rest of the prompt block (lines 1126-1146) unchanged. +4. Delete the now-unused `loadUserWorkDir` function (lines 41-51) and the + `WORKDIR_CONFIG_FILE` constant (line 39). + +### Step 4 — IPC surface (`packages/shared/src/ipc.ts`) + +1. `runs:create` already uses `CreateRunOptions` as its `req` (line 176-179) — the new + optional field flows through automatically once Step 1 lands. No change needed there. +2. Add a new channel `runs:setWorkdir`: + ```ts + 'runs:setWorkdir': { + req: z.object({ + runId: z.string(), + workingDirectory: z.string().nullable(), + }), + res: z.object({ success: z.literal(true) }), + }, + ``` +3. Wire the handler in `apps/main/src/ipc.ts` (alongside the other `runs:*` handlers, + e.g. near line 509): + ```ts + 'runs:setWorkdir': async (_event, args) => { + await runsCore.setWorkdir(args.runId, args.workingDirectory); + return { success: true as const }; + }, + ``` + (Confirm the import name used for the runs core module — it's `runsCore` in the existing + `runs:createMessage` handler.) + +### 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. + +1. **Load from the run, not the global file.** Replace `loadWorkDir` (lines 259-273) so it: + - If `runId` is set: `runs:fetch` and `setWorkDir(run.workingDirectory ?? null)`. + - If `runId` is null: do **not** read any global file. Instead reflect the tab-local + pending value supplied by the parent (see new props below). Default `null`. + Remove the `workspace:readFile` call on `config/workdir.json`. +2. **Set / change.** In `handleSetWorkDir` (lines 275-292), after the user picks a folder: + - If `runId` is set: `await window.ipc.invoke('runs:setWorkdir', { runId, workingDirectory: chosen })`. + - If `runId` is null: call a new prop `onPendingWorkDirChange(chosen)` so the parent stores + it for this tab until the run is created. + - In both cases `setWorkDir(chosen)`. + Remove the `workspace:writeFile` to `config/workdir.json`. +3. **Clear.** In `handleClearWorkDir` (lines 294-306), same branching with + `workingDirectory: null` / `onPendingWorkDirChange(null)`, then `setWorkDir(null)`. +4. **Props.** Add to `ChatInputInnerProps` (line 111) and `ChatInputWithMentionsProps` + (line 766), and thread through the wrapper (line 795-833): + ```ts + pendingWorkDir?: string | null + onPendingWorkDirChange?: (dir: string | null) => void + ``` + When `runId` is null, drive the displayed `workDir` from `pendingWorkDir`. + +### 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). + +1. 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>({})`. +2. Pass to each `` (lines 5282 and 5347): + ```tsx + pendingWorkDir={pendingWorkDirByTab[tab.id] ?? null} + onPendingWorkDirChange={(dir) => + setPendingWorkDirByTab((prev) => ({ ...prev, [tab.id]: dir }))} + ``` +3. In the submit handler where the run is created (lines 2355-2372), pass the pending dir + into `runs:create`: + ```ts + const pendingWorkDir = pendingWorkDirByTab[submitTabId] ?? undefined + const run = await window.ipc.invoke('runs:create', { + agentId, + ...(selected ? { model: selected.model, provider: selected.provider } : {}), + ...(pendingWorkDir ? { workingDirectory: pendingWorkDir } : {}), + }) + ``` + After the run is created, the work dir lives on the run; clear the pending entry for that + tab (optional, keeps state tidy). +4. 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.json` is 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.json` outside the files + changed above: + ```bash + grep -rn "workdir.json\|loadUserWorkDir\|WORKDIR_CONFIG_FILE" apps/x + ``` + +## 5. Build & verify + +```bash +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`): +1. New chat A → set work dir to /pathA. Open a new chat B → it shows **no** work dir. +2. 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"). +3. Reopen chat B, set /pathB → chat A still shows /pathA (reopen A to confirm). +4. In chat A, change to /pathC mid-conversation → next message uses /pathC; earlier behavior + unaffected. Clear it → subsequent messages have no work dir injected. +5. 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. |