rowboat/apps/x/plan.md
Gagancreates b9c4099c3b
Add plan for per-chat work directory fix
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.
2026-05-26 18:40:25 +00:00

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 / 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. RuntimeAgentState 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):
    workingDirectory: z.string().optional(),
    
  2. 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(),
    });
    
  3. Add WorkdirChangedEvent to the RunEvent union (line ~108-124).
  4. Add the derived field to the Run schema (line ~147-157):
    workingDirectory: z.string().optional(),
    
  5. Add it to CreateRunOptions (line ~169-175):
    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:
    ...(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:
    const 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 } : {}),
    
    (Import WorkdirChangedEvent from @x/shared/dist/runs.js.)
  3. runs/runs.tscreateRun() (line ~31) passes workingDirectory through to repo.create({ ... }):
    ...(opts.workingDirectory ? { workingDirectory: opts.workingDirectory } : {}),
    
  4. runs/runs.ts — add a new exported function to change the work dir mid-chat:
    export 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]);
    }
    
    (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):
    workingDirectory: string | null = null;
    
  2. 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;
      
  3. Replace the global-file read at the injection site. At runtime.ts:1125, change:
    const userWorkDir = loadUserWorkDir();
    
    to:
    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:
    '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):
    '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):
    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<Record<string, string | null>>({}).
  2. Pass to each <ChatInputWithMentions> (lines 5282 and 5347):
    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:
    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:
    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):

  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.