From e9453449267798ef5d2bd08c264460e5c7e653c8 Mon Sep 17 00:00:00 2001 From: nonlooped Date: Fri, 31 Jul 2026 11:34:54 +0300 Subject: [PATCH 1/2] feat: allow parallel project runs --- apps/desktop/PRODUCT.md | 2 +- apps/desktop/src/main/ipc.ts | 123 ++++++++++-------- apps/desktop/src/renderer/lib/store/chat.ts | 53 ++++---- .../src/renderer/lib/store/conversation.ts | 11 +- .../renderer/lib/store/eventRouting.test.ts | 18 +-- apps/desktop/src/renderer/lib/store/types.ts | 8 +- .../src/renderer/lib/store/workspace.ts | 18 ++- apps/desktop/src/shared/rpc-schema.ts | 6 +- 8 files changed, 130 insertions(+), 109 deletions(-) diff --git a/apps/desktop/PRODUCT.md b/apps/desktop/PRODUCT.md index 96610b2..a4518ff 100644 --- a/apps/desktop/PRODUCT.md +++ b/apps/desktop/PRODUCT.md @@ -30,7 +30,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains - NativePi is a Windows desktop application used alongside local code projects, Git repositories, and the existing Pi CLI ecosystem. Its workspace can be shared temporarily to browsers on the same local network. - Users pin project folders; create, discover, import, and manage Pi sessions; inspect streamed messages and tool activity; and review Git state and diffs. - Existing Pi credentials, configuration, sessions, packages, skills, prompts, and extensions remain in Pi's normal storage and remain usable by the Pi CLI. -- NativePi keeps at most one Pi process per project. Different projects may run concurrently. +- NativePi keeps one Pi process per active chat, so chats in the same project and different projects may run concurrently. - Conversations open directly from Pi session files without waiting for a Pi process to start. Drafts remain editable and are restored if a cold send fails. - Narrow windows move project navigation and project context into sheets so the conversation and composer remain usable. diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 2898140..22e859b 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -41,6 +41,7 @@ import { } from "./remoteAccess.ts"; import { checkForUpdate, downloadUpdate, installUpdate, startUpdates, updateState } from "./updates.ts"; +/** One Pi process per session: Pi already permits concurrent sessions in a project. */ const pis = new Map(); const starting = new Map>(); @@ -106,20 +107,21 @@ function markBusy(projectDir: string, until: number): void { busyUntil.set(projectDir, until); } -function forwardEvent(projectDir: string, event: PiMessage): void { - if (event["type"] === "agent_start") markBusy(projectDir, Number.POSITIVE_INFINITY); +function forwardEvent(projectDir: string, pi: PiProcess, event: PiMessage): void { + const sessionFile = pi.boundSessionFile ?? projectDir; + if (event["type"] === "agent_start") markBusy(sessionFile, Number.POSITIVE_INFINITY); // `agent_end` closes one low-level run, not the turn: Pi may still be waiting // out an auto-retry delay, compacting, or holding a queued follow-up, and none // of those emit anything while they wait. `agent_settled` is the event Pi // documents as "nothing will start again on its own", so it is the only one // that hands the project back. - else if (event["type"] === "agent_settled") markBusy(projectDir, Date.now() + SETTLE_GRACE_MS); + else if (event["type"] === "agent_settled") markBusy(sessionFile, Date.now() + SETTLE_GRACE_MS); // Any Pi message means Pi is alive and touching this project right now. - else if (busyUntil.get(projectDir) !== Number.POSITIVE_INFINITY) { - markBusy(projectDir, Date.now() + SETTLE_GRACE_MS); + else if (busyUntil.get(sessionFile) !== Number.POSITIVE_INFINITY) { + markBusy(sessionFile, Date.now() + SETTLE_GRACE_MS); } if (event["type"] === "message_end" || event["type"] === "agent_settled") notifySessionsChanged(projectDir); - push("piEvent", { projectDir, sessionFile: pis.get(projectDir)?.boundSessionFile, event }); + push("piEvent", { projectDir, sessionFile: pi.boundSessionFile, event }); } /** @@ -140,7 +142,9 @@ let quitConfirmed = false; export function quitBlocked(): boolean { if (quitConfirmed) return false; - const runs = [...pis.keys()].filter((projectDir) => busyUntil.get(projectDir) === Number.POSITIVE_INFINITY); + const runs = [...pis.entries()] + .filter(([sessionFile]) => busyUntil.get(sessionFile) === Number.POSITIVE_INFINITY) + .map(([, pi]) => pi.projectDir); const terminals = liveTerminalProjects(); const viewers = localServerStatus().clients.length; if (runs.length === 0 && terminals.length === 0 && viewers === 0) return false; @@ -176,62 +180,74 @@ function forwardPiFrame(projectDir: string, frame: TuiHostFrame): void { push("tuiFrame", { projectDir, frame }); } -function ensurePi(projectDir: string): Promise { - const existing = pis.get(projectDir); +function rememberPi(pi: PiProcess, sessionFile: string | undefined): void { + const previous = pi.boundSessionFile; + if (previous && pis.get(previous) === pi) pis.delete(previous); + pi.boundSessionFile = sessionFile; + if (sessionFile) pis.set(sessionFile, pi); +} + +function projectPi(projectDir: string): PiProcess | undefined { + return [...pis.values()].find((pi) => pi.projectDir === projectDir); +} + +function ensurePi(projectDir: string, sessionFile?: string): Promise { + const existing = sessionFile ? pis.get(sessionFile) : projectPi(projectDir); if (existing) return Promise.resolve(existing); - const inflight = starting.get(projectDir); + const key = sessionFile ?? `new:${projectDir}`; + const inflight = starting.get(key); if (inflight) return inflight; const startup = (async () => { status(projectDir, "starting", projectDir); - const pi = new PiProcess( + let pi!: PiProcess; + pi = new PiProcess( projectDir, - (msg) => forwardEvent(projectDir, msg), + (msg) => forwardEvent(projectDir, pi, msg), (code) => { // Panes belong to the process that drew them: a Pi that dies takes its // surfaces with it, and the window has to be told or they stay on screen // with nothing behind them. push("tuiFrame", { projectDir, frame: { type: "nativepi_tui_reset" } }); - pis.delete(projectDir); + if (pi.boundSessionFile && pis.get(pi.boundSessionFile) === pi) pis.delete(pi.boundSessionFile); // A Pi that dies mid-turn never reaches `agent_settled`, so drop the // marker with the process rather than leaving this project looking // permanently busy to the watcher. - busyUntil.delete(projectDir); + if (pi.boundSessionFile) busyUntil.delete(pi.boundSessionFile); status(projectDir, "exited", `exit ${code ?? "?"}`); }, (frame) => forwardPiFrame(projectDir, frame), ); - pis.set(projectDir, pi); try { const state = await pi.request({ type: "get_state" }); - pi.boundSessionFile = state.sessionFile; + if (sessionFile && state.sessionFile !== sessionFile) { + await pi.request({ type: "switch_session", sessionPath: sessionFile }); + rememberPi(pi, sessionFile); + } else { + rememberPi(pi, state.sessionFile); + } } catch { } status(projectDir, "ready"); - starting.delete(projectDir); + starting.delete(key); return pi; })(); - starting.set(projectDir, startup); + starting.set(key, startup); return startup; } async function bindPi(projectDir: string, sessionFile: string): Promise { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, sessionFile); // Everything reached through bindPi may write the session file (rename, fork, // clone, compact), so claim the write before it happens. - markBusy(projectDir, Date.now() + SETTLE_GRACE_MS); - if (pi.boundSessionFile !== sessionFile) { - const res = await pi.request<{ cancelled: boolean }>({ type: "switch_session", sessionPath: sessionFile }); - if (res.cancelled) throw new Error("The session is busy. Try again once the current run finishes."); - pi.boundSessionFile = sessionFile; - } + markBusy(sessionFile, Date.now() + SETTLE_GRACE_MS); return pi; } async function rebound(pi: PiProcess): Promise { const state = await pi.request({ type: "get_state" }); - pi.boundSessionFile = state.sessionFile; + rememberPi(pi, state.sessionFile); return state.sessionFile; } @@ -407,10 +423,9 @@ const handlers: HandlerMap = { }, restartPi: async ({ projectDir }) => { - const pi = pis.get(projectDir); - pis.delete(projectDir); - starting.delete(projectDir); - if (pi) await pi.stop(); + const all = [...new Set([...pis.values()].filter((pi) => pi.projectDir === projectDir))]; + for (const pi of all) if (pi.boundSessionFile) pis.delete(pi.boundSessionFile); + await Promise.all(all.map((pi) => pi.stop())); return { ok: true }; }, @@ -435,7 +450,7 @@ const handlers: HandlerMap = { const pi = await ensurePi(projectDir); await pi.request({ type: "new_session" }); const state = await pi.request({ type: "get_state" }); - pi.boundSessionFile = state.sessionFile; + rememberPi(pi, state.sessionFile); return { ok: true, sessionFile: state.sessionFile }; } catch (err) { return { ok: false, error: errorMessage(err) }; @@ -459,30 +474,26 @@ const handlers: HandlerMap = { // for a concurrent editor and a cold start counts as work in flight: the // renderer has already cleared the draft, and a close that slipped through // here would take the prompt with it. - markBusy(projectDir, Number.POSITIVE_INFINITY); + markBusy(sessionFile ?? projectDir, Number.POSITIVE_INFINITY); try { - const pi = await ensurePi(projectDir); - if (sessionFile) { - if (pi.boundSessionFile !== sessionFile) { - await pi.request({ type: "switch_session", sessionPath: sessionFile }); - pi.boundSessionFile = sessionFile; - } - } else { + const pi = await ensurePi(projectDir, sessionFile ?? undefined); + if (!sessionFile) { await pi.request({ type: "new_session" }); const state = await pi.request({ type: "get_state" }); - pi.boundSessionFile = state.sessionFile; + rememberPi(pi, state.sessionFile); sessionFile = state.sessionFile ?? null; } + if (sessionFile) markBusy(sessionFile, Number.POSITIVE_INFINITY); pi.send({ type: "prompt", message, images, streamingBehavior }); return { ok: true, sessionFile: sessionFile ?? undefined }; } catch (err) { - markBusy(projectDir, Date.now() + SETTLE_GRACE_MS); + markBusy(sessionFile ?? projectDir, Date.now() + SETTLE_GRACE_MS); return { ok: false, error: errorMessage(err) }; } }, - enqueue: ({ projectDir, behavior, message, images }) => { - const pi = pis.get(projectDir); + enqueue: ({ projectDir, sessionFile, behavior, message, images }) => { + const pi = pis.get(sessionFile); if (!pi) return { ok: false, error: "Pi is not running" }; pi.send({ type: behavior === "steer" ? "steer" : "follow_up", message, images }); return { ok: true }; @@ -497,8 +508,8 @@ const handlers: HandlerMap = { } }, - abort: ({ projectDir }) => { - pis.get(projectDir)?.send({ type: "abort" }); + abort: ({ sessionFile }) => { + pis.get(sessionFile)?.send({ type: "abort" }); return { ok: true }; }, @@ -586,10 +597,10 @@ const handlers: HandlerMap = { try { // Pi keeps the file open while bound to it; move it off this chat first so // the delete cannot race a write, and so Pi isn't left pointing at nothing. - const pi = pis.get(projectDir); + const pi = pis.get(sessionFile); if (pi?.boundSessionFile === sessionFile) { - await pi.request({ type: "new_session" }); - pi.boundSessionFile = (await pi.request({ type: "get_state" })).sessionFile; + if (pis.get(sessionFile) === pi) pis.delete(sessionFile); + await pi.stop(); } stopSessionWatch(sessionFile); await deleteSession(projectDir, sessionFile); @@ -614,7 +625,7 @@ const handlers: HandlerMap = { entry.stop = watchSessionFile(sessionFile, (mtimeMs) => { if (sessionWatches.get(sessionFile) !== entry || mtimeMs === entry.mtimeMs) return; entry.mtimeMs = mtimeMs; - if (Date.now() < (busyUntil.get(projectDir) ?? 0)) return; // Our own Pi wrote it. + if (Date.now() < (busyUntil.get(sessionFile) ?? 0)) return; // Our own Pi wrote it. push("sessionChangedExternally", { projectDir, sessionFile }); }); sessionWatches.set(sessionFile, entry); @@ -672,8 +683,8 @@ const handlers: HandlerMap = { } }, - abortRetry: ({ projectDir }) => { - pis.get(projectDir)?.send({ type: "abort_retry" }); + abortRetry: ({ sessionFile }) => { + pis.get(sessionFile)?.send({ type: "abort_retry" }); return { ok: true }; }, @@ -706,7 +717,7 @@ const handlers: HandlerMap = { }, authRespond: ({ projectDir, id, value, cancel }) => { - if (projectDir) pis.get(projectDir)?.respondAuthPrompt(id, { value, cancel }); + if (projectDir) projectPi(projectDir)?.respondAuthPrompt(id, { value, cancel }); else auth.respondPrompt(id, { value, cancel }); return { ok: true }; }, @@ -1039,17 +1050,17 @@ const handlers: HandlerMap = { } }, extensionRespond: ({ projectDir, response }) => { - pis.get(projectDir)?.sendRaw(response); + projectPi(projectDir)?.sendRaw(response); return { ok: true }; }, tuiSend: (params) => { const { projectDir, frame } = tuiSendParamsSchema.parse(params); - pis.get(projectDir)?.sendFrame(frame); + projectPi(projectDir)?.sendFrame(frame); return { ok: true }; }, tuiComplete: async (params) => { const { projectDir, lines, cursorLine, cursorCol } = tuiCompleteParamsSchema.parse(params); - const pi = pis.get(projectDir); + const pi = projectPi(projectDir); if (!pi) return { completions: null }; try { const reply = await pi.frameRequest((requestId) => ({ @@ -1068,7 +1079,7 @@ const handlers: HandlerMap = { }, tuiApply: async (params) => { const { projectDir, lines, cursorLine, cursorCol, item, prefix } = tuiApplyParamsSchema.parse(params); - const pi = pis.get(projectDir); + const pi = projectPi(projectDir); if (!pi) return { edit: null }; try { const reply = await pi.frameRequest((requestId) => ({ diff --git a/apps/desktop/src/renderer/lib/store/chat.ts b/apps/desktop/src/renderer/lib/store/chat.ts index c74ded3..60465a6 100644 --- a/apps/desktop/src/renderer/lib/store/chat.ts +++ b/apps/desktop/src/renderer/lib/store/chat.ts @@ -112,14 +112,14 @@ export const createChatSlice: SliceCreator = (set, get) => ({ // A run already streaming into this chat keeps its live state — this is // the path back into a project that kept working in the background, and // its conversation has been fed every event in the meantime. - const conv = get().conversations[projectPath]; - if (conv && conv.sessionFile === sessionFile && (conv.running || conv.error !== undefined)) return; - patchConversation(set, projectPath, () => ({ ...emptyConversation(), sessionFile })); + const conv = get().conversations[sessionFile]; + if (conv && (conv.running || conv.error !== undefined)) return; + patchConversation(set, projectPath, sessionFile, () => ({ ...emptyConversation(), projectDir: projectPath, sessionFile })); } const { entries } = await rpc.request.readSession({ sessionFile }); if (get().activeSessionFile !== sessionFile || get().activeProjectPath !== projectPath) return; if (projectPath) { - patchConversation(set, projectPath, { + patchConversation(set, projectPath, sessionFile, { entries: entries.filter((e): e is SessionEntry => e.type !== "session"), sessionName: sessionInfoName(entries), }); @@ -130,7 +130,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ const projectDir = get().activeProjectPath; if (projectDir) { void rpc.request.watchSession({ projectDir, sessionFile: null }); - patchConversation(set, projectDir, () => emptyConversation()); + patchConversation(set, projectDir, null, () => ({ ...emptyConversation(), projectDir })); } set({ activeSessionFile: null, isNewChat: true }); reportActiveDraft(get); @@ -142,7 +142,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ const res = await rpc.request.importSession({ projectDir, sourceFile }); if (res.canceled) return false; if (!res.ok || !res.sessionFile) { - patchConversation(set, projectDir, { error: res.error ?? "Failed to import chat" }); + patchConversation(set, projectDir, get().activeSessionFile, { error: res.error ?? "Failed to import chat" }); return false; } await get().refreshSessions(projectDir); @@ -246,7 +246,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ * whatever it does show up as arrives as events, a moment later. */ const pendingEntry: PendingMessage | null = text.startsWith("/") ? null : { id: pendingId++, text, images }; - patchConversation(set, projectDir, (c) => ({ + patchConversation(set, projectDir, s.activeSessionFile, (c) => ({ pending: pendingEntry ? [...c.pending, pendingEntry] : c.pending, error: undefined, errorRecovery: undefined, @@ -262,7 +262,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ images: images.map(toImageContent), }); if (!res.ok) { - patchConversation(set, projectDir, (c) => ({ + patchConversation(set, projectDir, s.activeSessionFile, (c) => ({ pending: c.pending.filter((p) => p.id !== pendingEntry?.id), error: res.error ?? "Failed to send", errorRecovery: "retrySend", @@ -277,7 +277,13 @@ export const createChatSlice: SliceCreator = (set, get) => ({ return; } if (res.sessionFile) { - patchConversation(set, projectDir, { sessionFile: res.sessionFile }); + patchConversation(set, projectDir, res.sessionFile, { projectDir, sessionFile: res.sessionFile }); + if (!s.activeSessionFile) { + set((state) => { + const { [projectDir]: draft, ...conversations } = state.conversations; + return { conversations: { ...conversations, [res.sessionFile!]: { ...(draft ?? emptyConversation()), projectDir, sessionFile: res.sessionFile! } } }; + }); + } setLastChat(projectDir, res.sessionFile); persist(get); if (get().activeProjectPath === projectDir && get().activeSessionFile !== res.sessionFile) { @@ -295,7 +301,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ // No optimistic entry: Pi echoes the queued message back via queue_update, // which is the source of truth for what's pending. - patchConversation(set, projectDir, { error: undefined }); + patchConversation(set, projectDir, get().activeSessionFile, { error: undefined }); get().setDraft(""); clearAttachments(set, key); @@ -316,9 +322,9 @@ export const createChatSlice: SliceCreator = (set, get) => ({ images: images.map(toImageContent), streamingBehavior: behavior, }) - : await rpc.request.enqueue({ projectDir, behavior, message: text, images: images.map(toImageContent) }); + : await rpc.request.enqueue({ projectDir, sessionFile: get().activeSessionFile!, behavior, message: text, images: images.map(toImageContent) }); if (!res.ok) { - patchConversation(set, projectDir, { + patchConversation(set, projectDir, get().activeSessionFile, { error: res.error ?? "Failed to queue message", errorRecovery: "retrySend", }); @@ -329,12 +335,14 @@ export const createChatSlice: SliceCreator = (set, get) => ({ abort: () => { const projectDir = get().activeProjectPath; - if (projectDir) void rpc.request.abort({ projectDir }); + const sessionFile = get().activeSessionFile; + if (projectDir && sessionFile) void rpc.request.abort({ projectDir, sessionFile }); }, abortRetry: () => { const projectDir = get().activeProjectPath; - if (projectDir) void rpc.request.abortRetry({ projectDir }); + const sessionFile = get().activeSessionFile; + if (projectDir && sessionFile) void rpc.request.abortRetry({ projectDir, sessionFile }); }, renameChat: async (sessionFile, name) => { @@ -344,7 +352,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ if (res.ok) { await get().refreshSessions(projectDir); if (get().activeSessionFile === sessionFile) { - patchConversation(set, projectDir, { sessionName: name }); + patchConversation(set, projectDir, sessionFile, { sessionName: name }); } } return res; @@ -409,14 +417,14 @@ export const createChatSlice: SliceCreator = (set, get) => ({ const sessionFile = get().activeSessionFile; const projectDir = get().activeProjectPath; if (!sessionFile) return; - if (projectDir) patchConversation(set, projectDir, { externalChange: null }); + if (projectDir) patchConversation(set, projectDir, sessionFile, { externalChange: null }); await get().selectChat(sessionFile); if (projectDir && get().activeProjectPath === projectDir) await get().refreshSessions(projectDir); }, clearError: () => { const projectDir = get().activeProjectPath; - if (projectDir) patchConversation(set, projectDir, { error: undefined, errorRecovery: undefined }); + if (projectDir) patchConversation(set, projectDir, get().activeSessionFile, { error: undefined, errorRecovery: undefined }); }, onEvent: ({ projectDir, sessionFile, event }) => { @@ -437,8 +445,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ // a run keeps its state — and its transcript — while another project is on // screen. Events for a chat other than the one this runtime belongs to are // still dropped, exactly as before. - const conv = conversationFor(s, projectDir); - if (sessionFile && conv.sessionFile && sessionFile !== conv.sessionFile) return; + const conv = conversationFor(s, projectDir, sessionFile ?? null); if (projectDir === s.activeProjectPath) { // Files change throughout a turn, not only at its end: refresh as messages // land, so the changes pane is live rather than stale for the whole @@ -447,13 +454,13 @@ export const createChatSlice: SliceCreator = (set, get) => ({ if (event.type === "agent_settled") void get().refreshGit(); else if (event.type === "message_end" && !gitRefreshedWithin(1000)) void get().refreshGit(); } - patchConversation(set, projectDir, reduce(conv, event)); + patchConversation(set, projectDir, sessionFile ?? null, { ...reduce(conv, event), projectDir, sessionFile: sessionFile ?? conv.sessionFile }); }, onPiError: (projectDir, message) => { // The draft was cleared by the submit that succeeded, so there is nothing // to re-send; restarting Pi is the recovery that actually applies. - patchConversation(set, projectDir, { + patchConversation(set, projectDir, get().activeProjectPath === projectDir ? get().activeSessionFile : null, { error: message, errorRecovery: "restartPi", running: false, @@ -462,9 +469,9 @@ export const createChatSlice: SliceCreator = (set, get) => ({ }, onSessionChangedExternally: ({ projectDir, sessionFile }) => { - const conv = get().conversations[projectDir]; + const conv = get().conversations[sessionFile]; if (!conv || sessionFile !== conv.sessionFile) return; if (conv.running || conv.externalChange) return; - patchConversation(set, projectDir, { externalChange: { sessionFile } }); + patchConversation(set, projectDir, sessionFile, { externalChange: { sessionFile } }); }, }); diff --git a/apps/desktop/src/renderer/lib/store/conversation.ts b/apps/desktop/src/renderer/lib/store/conversation.ts index 8a553bd..8d6ea0d 100644 --- a/apps/desktop/src/renderer/lib/store/conversation.ts +++ b/apps/desktop/src/renderer/lib/store/conversation.ts @@ -9,6 +9,7 @@ import type { AppState, Conversation, SetState } from "./types.ts"; */ export function emptyConversation(): Conversation { return { + projectDir: null, sessionFile: null, sessionName: undefined, entries: [], @@ -31,8 +32,8 @@ export function emptyConversation(): Conversation { */ const EMPTY_CONVERSATION: Conversation = Object.freeze(emptyConversation()); -export function conversationFor(s: AppState, projectPath: string | null): Conversation { - return (projectPath ? s.conversations[projectPath] : undefined) ?? EMPTY_CONVERSATION; +export function conversationFor(s: AppState, projectPath: string | null, sessionFile: string | null = s.activeSessionFile): Conversation { + return (projectPath ? s.conversations[sessionFile ?? projectPath] : undefined) ?? EMPTY_CONVERSATION; } /** The conversation the UI is looking at. Components select through this. */ @@ -44,11 +45,13 @@ export function activeConversation(s: AppState): Conversation { export function patchConversation( set: SetState, projectDir: string, + sessionFile: string | null, patch: Partial | ((c: Conversation) => Partial), ): void { set((s) => { - const current = s.conversations[projectDir] ?? emptyConversation(); + const key = sessionFile ?? projectDir; + const current = s.conversations[key] ?? emptyConversation(); const resolved = typeof patch === "function" ? patch(current) : patch; - return { conversations: { ...s.conversations, [projectDir]: { ...current, ...resolved } } }; + return { conversations: { ...s.conversations, [key]: { ...current, ...resolved } } }; }); } diff --git a/apps/desktop/src/renderer/lib/store/eventRouting.test.ts b/apps/desktop/src/renderer/lib/store/eventRouting.test.ts index 16b22a7..a69c302 100644 --- a/apps/desktop/src/renderer/lib/store/eventRouting.test.ts +++ b/apps/desktop/src/renderer/lib/store/eventRouting.test.ts @@ -40,7 +40,7 @@ test("a Pi error in an inactive project lands on that project's conversation", ( expect(conv?.errorRecovery).toBe("restartPi"); }); -test("events for a chat other than the conversation's own are still dropped", () => { +test("events from parallel chats keep their own runtime state", () => { useAppStore.setState({ activeProjectPath: "A:\\proj-a", conversations: {}, @@ -50,20 +50,14 @@ test("events for a chat other than the conversation's own are still dropped", () sessionFile: "one.jsonl", event: event("agent_start"), }); - useAppStore.setState((s) => ({ - conversations: { - ...s.conversations, - "A:\\proj-a": { ...s.conversations["A:\\proj-a"]!, sessionFile: "one.jsonl" }, - }, - })); - useAppStore.getState().onEvent({ projectDir: "A:\\proj-a", sessionFile: "other.jsonl", - event: event("agent_settled"), + event: event("agent_start"), }); - expect(useAppStore.getState().conversations["A:\\proj-a"]?.running).toBe(true); + expect(useAppStore.getState().conversations["one.jsonl"]?.running).toBe(true); + expect(useAppStore.getState().conversations["other.jsonl"]?.running).toBe(true); }); test("a new session is remembered when submit returns after switching projects", async () => { @@ -98,7 +92,7 @@ test("reopening the same session preserves a background failure", async () => { activeProjectPath: "A:\\failed-run", activeSessionFile: null, conversations: { - "A:\\failed-run": { + "failed.jsonl": { ...emptyConversation(), sessionFile: "failed.jsonl", error: "Pi crashed", @@ -109,7 +103,7 @@ test("reopening the same session preserves a background failure", async () => { await useAppStore.getState().selectChat("failed.jsonl"); - const conv = useAppStore.getState().conversations["A:\\failed-run"]; + const conv = useAppStore.getState().conversations["failed.jsonl"]; expect(conv?.error).toBe("Pi crashed"); expect(conv?.errorRecovery).toBe("restartPi"); }); diff --git a/apps/desktop/src/renderer/lib/store/types.ts b/apps/desktop/src/renderer/lib/store/types.ts index 946305b..3f426d7 100644 --- a/apps/desktop/src/renderer/lib/store/types.ts +++ b/apps/desktop/src/renderer/lib/store/types.ts @@ -104,11 +104,11 @@ export interface WorkspaceSlice { * One project's conversation runtime: the transcript being streamed, whether a * turn is running, its queue, retries, and errors. * - * Keyed per project in `ChatSlice.conversations` so a run in one project keeps - * receiving events — and keeps its state — while another project is on screen. - * `sessionFile` records which chat this runtime belongs to. + * Keyed per session file in `ChatSlice.conversations` so multiple chats in one + * project can keep receiving events while another chat or project is on screen. */ export interface Conversation { + projectDir: string | null; sessionFile: string | null; sessionName?: string; entries: SessionEntry[]; @@ -131,7 +131,7 @@ export interface ChatSlice { isNewChat: boolean; pinnedChats: string[]; - /** Conversation runtime per project path, active or not. */ + /** Conversation runtime per session file, active or not. */ conversations: Record; sendBehavior: "steer" | "followUp"; diff --git a/apps/desktop/src/renderer/lib/store/workspace.ts b/apps/desktop/src/renderer/lib/store/workspace.ts index 35f1830..37ac95d 100644 --- a/apps/desktop/src/renderer/lib/store/workspace.ts +++ b/apps/desktop/src/renderer/lib/store/workspace.ts @@ -61,13 +61,17 @@ export const createWorkspaceSlice: SliceCreator = (set, get) => removeProject: async (path) => { // Stop any turn still running in this project before its state goes away, // whether or not the project is the one on screen. - if (get().conversations[path]?.running) { - await rpc.request.abort({ projectDir: path }); + for (const conversation of Object.values(get().conversations)) { + if (conversation.projectDir === path && conversation.running && conversation.sessionFile) { + await rpc.request.abort({ projectDir: path, sessionFile: conversation.sessionFile }); + } } await rpc.request.terminalCloseProject({ projectDir: path }); await rpc.request.unwatchProjectSessions({ projectDir: path }); set((s) => { - const { [path]: _removed, ...conversations } = s.conversations; + const conversations = Object.fromEntries( + Object.entries(s.conversations).filter(([, conversation]) => conversation.projectDir !== path), + ); const terminalProjects = new Set(s.terminalProjects); terminalProjects.delete(path); return { projects: s.projects.filter((p) => p.path !== path), conversations, terminalProjects }; @@ -126,8 +130,10 @@ export const createWorkspaceSlice: SliceCreator = (set, get) => // A chat still running in this project wins over the last-opened one: the // user coming back mid-run should land on the run, not beside it. - const runningConv = get().conversations[path]; - const last = (runningConv?.running && runningConv.sessionFile) || getLastChat(path); + const runningConv = Object.values(get().conversations).find( + (conversation) => conversation.projectDir === path && conversation.running && conversation.sessionFile, + ); + const last = runningConv?.sessionFile || getLastChat(path); const sessions = get().sessionsByProject[path] ?? []; if (last && sessions.some((s) => s.path === last)) { await get().selectChat(last); @@ -163,7 +169,7 @@ export const createWorkspaceSlice: SliceCreator = (set, get) => restartPi: async () => { const path = get().activeProjectPath; if (!path) return; - patchConversation(set, path, { error: undefined, errorRecovery: undefined }); + patchConversation(set, path, get().activeSessionFile, { error: undefined, errorRecovery: undefined }); await rpc.request.restartPi({ projectDir: path }); if (get().activeProjectPath === path) warmProject(set, get, path); }, diff --git a/apps/desktop/src/shared/rpc-schema.ts b/apps/desktop/src/shared/rpc-schema.ts index 821d238..952490d 100644 --- a/apps/desktop/src/shared/rpc-schema.ts +++ b/apps/desktop/src/shared/rpc-schema.ts @@ -298,7 +298,7 @@ export type HostRequests = { response: { ok: boolean; sessionFile?: string; error?: string }; }; enqueue: { - params: { projectDir: string; behavior: "steer" | "followUp"; message: string; images?: ImageContent[] }; + params: { projectDir: string; sessionFile: string; behavior: "steer" | "followUp"; message: string; images?: ImageContent[] }; response: { ok: boolean; error?: string }; }; /** Resize dropped, pasted or picked images through Pi before they wait in the composer. */ @@ -306,7 +306,7 @@ export type HostRequests = { params: { files: { name: string; mimeType: string; data: string }[] }; response: { images: ImageAttachment[]; rejected: string[] }; }; - abort: { params: { projectDir: string }; response: { ok: boolean } }; + abort: { params: { projectDir: string; sessionFile: string }; response: { ok: boolean } }; getModels: { params: { projectDir: string }; response: { models: ModelInfo[]; error?: string } }; getState: { params: { projectDir: string }; response: { state?: RpcSessionState; error?: string } }; getThinkingLevels: { @@ -365,7 +365,7 @@ export type HostRequests = { params: { projectDir: string; sessionFile: string }; response: { ok: boolean; error?: string }; }; - abortRetry: { params: { projectDir: string }; response: { ok: boolean } }; + abortRetry: { params: { projectDir: string; sessionFile: string }; response: { ok: boolean } }; exportHtml: { params: { projectDir: string; sessionFile: string }; response: { ok: boolean; path?: string; error?: string }; From 5933361b921c7a7c378a728e5b77dc7999adfcb3 Mon Sep 17 00:00:00 2001 From: nonlooped Date: Fri, 31 Jul 2026 13:03:25 +0300 Subject: [PATCH 2/2] fix: route parallel sessions independently --- apps/desktop/src/main/ipc.ts | 79 +++++++++++-------- .../components/ComposerAutocomplete.tsx | 9 ++- .../src/renderer/components/ComposerInput.tsx | 5 +- .../src/renderer/components/Sidebar.tsx | 2 +- .../src/renderer/components/TuiSurface.tsx | 5 +- apps/desktop/src/renderer/lib/store/chat.ts | 18 +++-- .../src/renderer/lib/store/internals.ts | 13 +-- apps/desktop/src/renderer/lib/store/models.ts | 8 +- .../src/renderer/lib/store/projectContext.ts | 6 +- apps/desktop/src/renderer/lib/store/types.ts | 2 +- .../src/renderer/lib/store/workspace.ts | 13 ++- apps/desktop/src/shared/rpc-schema.ts | 21 ++--- 12 files changed, 105 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 22e859b..3df36b1 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -44,6 +44,7 @@ import { checkForUpdate, downloadUpdate, installUpdate, startUpdates, updateStat /** One Pi process per session: Pi already permits concurrent sessions in a project. */ const pis = new Map(); const starting = new Map>(); +const startingPis = new Map(); let mainWindow: BrowserWindow | null = null; type HostEventListener = (channel: K, payload: HostEvents[K]) => void; @@ -167,7 +168,7 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -function forwardPiFrame(projectDir: string, frame: TuiHostFrame): void { +function forwardPiFrame(projectDir: string, pi: PiProcess, frame: TuiHostFrame): void { if (frame.type === "nativepi_tui_auth_prompt") { push("authPrompt", { projectDir, id: frame.id, prompt: frame.prompt }); return; @@ -177,7 +178,7 @@ function forwardPiFrame(projectDir: string, frame: TuiHostFrame): void { if (frame.notice.kind === "auth_url") void shell.openExternal(frame.notice.url); return; } - push("tuiFrame", { projectDir, frame }); + push("tuiFrame", { projectDir, sessionFile: pi.boundSessionFile, frame }); } function rememberPi(pi: PiProcess, sessionFile: string | undefined): void { @@ -187,12 +188,13 @@ function rememberPi(pi: PiProcess, sessionFile: string | undefined): void { if (sessionFile) pis.set(sessionFile, pi); } -function projectPi(projectDir: string): PiProcess | undefined { +function projectPi(projectDir: string, sessionFile?: string | null): PiProcess | undefined { + if (sessionFile) return pis.get(sessionFile); return [...pis.values()].find((pi) => pi.projectDir === projectDir); } -function ensurePi(projectDir: string, sessionFile?: string): Promise { - const existing = sessionFile ? pis.get(sessionFile) : projectPi(projectDir); +function ensurePi(projectDir: string, sessionFile?: string, fresh = false): Promise { + const existing = sessionFile ? pis.get(sessionFile) : fresh ? undefined : projectPi(projectDir); if (existing) return Promise.resolve(existing); const key = sessionFile ?? `new:${projectDir}`; const inflight = starting.get(key); @@ -208,16 +210,20 @@ function ensurePi(projectDir: string, sessionFile?: string): Promise // Panes belong to the process that drew them: a Pi that dies takes its // surfaces with it, and the window has to be told or they stay on screen // with nothing behind them. - push("tuiFrame", { projectDir, frame: { type: "nativepi_tui_reset" } }); + push("tuiFrame", { projectDir, sessionFile: pi.boundSessionFile, frame: { type: "nativepi_tui_reset" } }); if (pi.boundSessionFile && pis.get(pi.boundSessionFile) === pi) pis.delete(pi.boundSessionFile); + if (startingPis.get(key) === pi) startingPis.delete(key); // A Pi that dies mid-turn never reaches `agent_settled`, so drop the // marker with the process rather than leaving this project looking // permanently busy to the watcher. if (pi.boundSessionFile) busyUntil.delete(pi.boundSessionFile); - status(projectDir, "exited", `exit ${code ?? "?"}`); + if (![...pis.values(), ...startingPis.values()].some((other) => other.projectDir === projectDir)) { + status(projectDir, "exited", `exit ${code ?? "?"}`); + } }, - (frame) => forwardPiFrame(projectDir, frame), + (frame) => forwardPiFrame(projectDir, pi, frame), ); + startingPis.set(key, pi); try { const state = await pi.request({ type: "get_state" }); if (sessionFile && state.sessionFile !== sessionFile) { @@ -228,6 +234,7 @@ function ensurePi(projectDir: string, sessionFile?: string): Promise } } catch { } + startingPis.delete(key); status(projectDir, "ready"); starting.delete(key); return pi; @@ -265,8 +272,8 @@ async function rebound(pi: PiProcess): Promise { * exactly as it would across a restart. */ function applyLive(patch: PiSettingsPatch): void { - for (const [projectDir, pi] of pis) { - const live = liveSettingsFor(projectDir) ?? patch; + for (const pi of pis.values()) { + const live = liveSettingsFor(pi.projectDir) ?? patch; if (patch.steeringMode !== undefined && live.steeringMode) { pi.send({ type: "set_steering_mode", mode: live.steeringMode }); } @@ -348,8 +355,9 @@ const prepareImagesParamsSchema = z.object({ * The frame schema already bounds the shapes; this is the same check applied to * what the window sends, so main is not the one place trusting either side. */ -const tuiSendParamsSchema = projectDirParamsSchema.extend({ frame: tuiClientFrameSchema }); -const tuiCompleteParamsSchema = projectDirParamsSchema.extend({ +const sessionPiParamsSchema = projectDirParamsSchema.extend({ sessionFile: z.string().min(1).nullable().optional() }); +const tuiSendParamsSchema = sessionPiParamsSchema.extend({ frame: tuiClientFrameSchema }); +const tuiCompleteParamsSchema = sessionPiParamsSchema.extend({ lines: z.array(z.string()).max(1000), cursorLine: z.number().int().min(0), cursorCol: z.number().int().min(0), @@ -423,7 +431,7 @@ const handlers: HandlerMap = { }, restartPi: async ({ projectDir }) => { - const all = [...new Set([...pis.values()].filter((pi) => pi.projectDir === projectDir))]; + const all = [...new Set([...pis.values(), ...startingPis.values()].filter((pi) => pi.projectDir === projectDir))]; for (const pi of all) if (pi.boundSessionFile) pis.delete(pi.boundSessionFile); await Promise.all(all.map((pi) => pi.stop())); return { ok: true }; @@ -438,16 +446,17 @@ const handlers: HandlerMap = { * settings the file now holds. */ restartAllPi: async () => { - const all = [...pis.values()]; + const all = [...new Set([...pis.values(), ...startingPis.values()])]; pis.clear(); starting.clear(); + startingPis.clear(); await Promise.all(all.map((pi) => pi.stop())); return { ok: true }; }, newChat: async ({ projectDir }) => { try { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, undefined, true); await pi.request({ type: "new_session" }); const state = await pi.request({ type: "get_state" }); rememberPi(pi, state.sessionFile); @@ -513,9 +522,9 @@ const handlers: HandlerMap = { return { ok: true }; }, - getModels: async ({ projectDir }) => { + getModels: async ({ projectDir, sessionFile }) => { try { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, sessionFile ?? undefined); const data = await pi.request<{ models: unknown[] }>({ type: "get_available_models" }); return { models: data.models.map(toModelInfo) }; } catch (err) { @@ -523,18 +532,18 @@ const handlers: HandlerMap = { } }, - getSessionProviders: async ({ projectDir }) => { + getSessionProviders: async ({ projectDir, sessionFile }) => { try { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, sessionFile ?? undefined); return { providers: await pi.getProviders() }; } catch (err) { return { providers: [], error: errorMessage(err) }; } }, - getState: async ({ projectDir }) => { + getState: async ({ projectDir, sessionFile }) => { try { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, sessionFile ?? undefined); const data = await pi.request({ type: "get_state" }); return { state: toSessionState(data) }; } catch (err) { @@ -542,9 +551,9 @@ const handlers: HandlerMap = { } }, - getThinkingLevels: async ({ projectDir }) => { + getThinkingLevels: async ({ projectDir, sessionFile }) => { try { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, sessionFile ?? undefined); const data = await pi.request<{ levels: unknown[] }>({ type: "get_available_thinking_levels" }); return { levels: data.levels.filter(isThinkingLevel) }; } catch (err) { @@ -552,9 +561,9 @@ const handlers: HandlerMap = { } }, - setModel: async ({ projectDir, provider, modelId }) => { + setModel: async ({ projectDir, sessionFile, provider, modelId }) => { try { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, sessionFile ?? undefined); await pi.request({ type: "set_model", provider, modelId }); return { ok: true }; } catch (err) { @@ -562,9 +571,9 @@ const handlers: HandlerMap = { } }, - setThinkingLevel: async ({ projectDir, level }) => { + setThinkingLevel: async ({ projectDir, sessionFile, level }) => { try { - const pi = await ensurePi(projectDir); + const pi = await ensurePi(projectDir, sessionFile ?? undefined); await pi.request({ type: "set_thinking_level", level }); return { ok: true }; } catch (err) { @@ -1049,18 +1058,18 @@ const handlers: HandlerMap = { return { extensions: [] }; } }, - extensionRespond: ({ projectDir, response }) => { - projectPi(projectDir)?.sendRaw(response); + extensionRespond: ({ projectDir, sessionFile, response }) => { + projectPi(projectDir, sessionFile)?.sendRaw(response); return { ok: true }; }, tuiSend: (params) => { - const { projectDir, frame } = tuiSendParamsSchema.parse(params); - projectPi(projectDir)?.sendFrame(frame); + const { projectDir, sessionFile, frame } = tuiSendParamsSchema.parse(params); + projectPi(projectDir, sessionFile)?.sendFrame(frame); return { ok: true }; }, tuiComplete: async (params) => { - const { projectDir, lines, cursorLine, cursorCol } = tuiCompleteParamsSchema.parse(params); - const pi = projectPi(projectDir); + const { projectDir, sessionFile, lines, cursorLine, cursorCol } = tuiCompleteParamsSchema.parse(params); + const pi = projectPi(projectDir, sessionFile); if (!pi) return { completions: null }; try { const reply = await pi.frameRequest((requestId) => ({ @@ -1078,8 +1087,8 @@ const handlers: HandlerMap = { } }, tuiApply: async (params) => { - const { projectDir, lines, cursorLine, cursorCol, item, prefix } = tuiApplyParamsSchema.parse(params); - const pi = projectPi(projectDir); + const { projectDir, sessionFile, lines, cursorLine, cursorCol, item, prefix } = tuiApplyParamsSchema.parse(params); + const pi = projectPi(projectDir, sessionFile); if (!pi) return { edit: null }; try { const reply = await pi.frameRequest((requestId) => ({ diff --git a/apps/desktop/src/renderer/components/ComposerAutocomplete.tsx b/apps/desktop/src/renderer/components/ComposerAutocomplete.tsx index fe3836e..2228b9d 100644 --- a/apps/desktop/src/renderer/components/ComposerAutocomplete.tsx +++ b/apps/desktop/src/renderer/components/ComposerAutocomplete.tsx @@ -62,9 +62,10 @@ export function useComposerAutocomplete( const [dismissed, setDismissed] = useState(null); // The characters an extension asked for, and the line it needs to answer about. const extensionTriggers = useAppStore((s) => s.extTriggers); + const sessionFile = useAppStore((s) => s.activeSessionFile); const [line, setLine] = useState<{ text: string; caret: number } | null>(null); const { commands, skills, files, loading } = useCompletionData(projectPath, trigger?.kind ?? null); - const extension = useExtensionCompletions(projectPath, trigger?.kind === "extension" ? line : null); + const extension = useExtensionCompletions(projectPath, sessionFile, trigger?.kind === "extension" ? line : null); const open = trigger !== null && dismissed !== trigger.start; const options = !open @@ -234,7 +235,7 @@ function useCompletionData(projectPath: string | null, kind: TriggerKind | null) * composer's own triggers carry on working, which is what happens in the * terminal too when a provider declines to answer. */ -function useExtensionCompletions(projectPath: string | null, line: { text: string; caret: number } | null) { +function useExtensionCompletions(projectPath: string | null, sessionFile: string | null, line: { text: string; caret: number } | null) { const [state, setState] = useState<{ options: ExtensionCompletionOption[]; loading: boolean }>(NO_EXTENSION_OPTIONS); useEffect(() => { @@ -247,7 +248,7 @@ function useExtensionCompletions(projectPath: string | null, line: { text: strin const timer = window.setTimeout(() => { const { lines, cursorLine, cursorCol } = linesAt(line.text, line.caret); void rpc.request - .tuiComplete({ projectDir: projectPath, lines, cursorLine, cursorCol }) + .tuiComplete({ projectDir: projectPath, sessionFile, lines, cursorLine, cursorCol }) .then(({ completions }) => { if (cancelled) return; setState({ @@ -271,7 +272,7 @@ function useExtensionCompletions(projectPath: string | null, line: { text: strin cancelled = true; window.clearTimeout(timer); }; - }, [line, projectPath]); + }, [line, projectPath, sessionFile]); return state; } diff --git a/apps/desktop/src/renderer/components/ComposerInput.tsx b/apps/desktop/src/renderer/components/ComposerInput.tsx index d597bd1..dfaae7d 100644 --- a/apps/desktop/src/renderer/components/ComposerInput.tsx +++ b/apps/desktop/src/renderer/components/ComposerInput.tsx @@ -15,6 +15,7 @@ import { registerComposerInserter } from "../lib/composerInsert.ts"; import { AutocompleteMenu, useComposerAutocomplete } from "./ComposerAutocomplete.tsx"; import { cn } from "@/lib/utils.ts"; import { rpc } from "@/lib/rpc.ts"; +import { useAppStore } from "../lib/store.ts"; /** * The message field: prose with skill and file chips in it. @@ -62,7 +63,7 @@ export default function ComposerInput({ // the text — that is the point of `applyCompletion` — so the edit is asked // for rather than assumed. Everything else is inserted here. if (option.kind === "extension") { - void applyExtensionCompletion(element, projectPath, option, emit); + void applyExtensionCompletion(element, projectPath, useAppStore.getState().activeSessionFile, option, emit); return; } // A command stays editable text — the user types its arguments next — where @@ -199,6 +200,7 @@ export default function ComposerInput({ async function applyExtensionCompletion( element: HTMLElement, projectPath: string | null, + sessionFile: string | null, option: ExtensionCompletionOption, emit: () => void, ): Promise { @@ -211,6 +213,7 @@ async function applyExtensionCompletion( const reply = await rpc.request .tuiApply({ projectDir: projectPath, + sessionFile, lines, cursorLine, cursorCol, diff --git a/apps/desktop/src/renderer/components/Sidebar.tsx b/apps/desktop/src/renderer/components/Sidebar.tsx index 8c0bdb5..cbe75af 100644 --- a/apps/desktop/src/renderer/components/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/Sidebar.tsx @@ -44,7 +44,7 @@ export default function Sidebar({ onClose, overlay = false }: { onClose: () => v const selectProject = useAppStore((s) => s.selectProject); const removeProject = useAppStore((s) => s.removeProject); const projectBusyStates = useAppStore( - useShallow((s) => s.projects.map((project) => s.conversations[project.path]?.running ?? false)), + useShallow((s) => s.projects.map((project) => Object.values(s.conversations).some((conversation) => conversation.projectDir === project.path && conversation.running))), ); const importSession = useAppStore((s) => s.importSession); const refreshSessions = useAppStore((s) => s.refreshSessions); diff --git a/apps/desktop/src/renderer/components/TuiSurface.tsx b/apps/desktop/src/renderer/components/TuiSurface.tsx index baff617..93eac58 100644 --- a/apps/desktop/src/renderer/components/TuiSurface.tsx +++ b/apps/desktop/src/renderer/components/TuiSurface.tsx @@ -32,6 +32,7 @@ function useSurfaceTerminal( ) { const containerRef = useRef(null); const fontSize = useAppStore((s) => s.preferences.terminalFontSize); + const sessionFile = useAppStore((s) => s.activeSessionFile); const { rows, focus } = options; useEffect(() => { @@ -73,6 +74,7 @@ function useSurfaceTerminal( if (!projectDir) return; void rpc.request.tuiSend({ projectDir, + sessionFile, frame: { type: "nativepi_tui_input", surfaceId: surface.id, data }, }); }); @@ -95,6 +97,7 @@ function useSurfaceTerminal( if (!projectDir) return; void rpc.request.tuiSend({ projectDir, + sessionFile, frame: { type: "nativepi_tui_resize", surfaceId: surface.id, @@ -117,7 +120,7 @@ function useSurfaceTerminal( offWrite(); terminal.dispose(); }; - }, [focus, fontSize, projectDir, rows, surface.id]); + }, [focus, fontSize, projectDir, rows, sessionFile, surface.id]); return containerRef; } diff --git a/apps/desktop/src/renderer/lib/store/chat.ts b/apps/desktop/src/renderer/lib/store/chat.ts index 60465a6..17734b6 100644 --- a/apps/desktop/src/renderer/lib/store/chat.ts +++ b/apps/desktop/src/renderer/lib/store/chat.ts @@ -31,7 +31,7 @@ const MAX_REMOTE_IMAGE_BATCH_BYTES = 48 * 1024 * 1024; * answered with the chat the user just left until they touched the new one. */ function reportActiveDraft(get: GetState): void { - reportDraft(get().activeProjectPath, get().drafts[draftKey(get)] ?? ""); + reportDraft(get().activeProjectPath, get().activeSessionFile, get().drafts[draftKey(get)] ?? ""); } /** @@ -139,10 +139,11 @@ export const createChatSlice: SliceCreator = (set, get) => ({ importSession: async (targetProjectDir, sourceFile) => { const projectDir = targetProjectDir ?? get().activeProjectPath; if (!projectDir) return false; + const targetSessionFile = get().activeProjectPath === projectDir ? get().activeSessionFile : null; const res = await rpc.request.importSession({ projectDir, sourceFile }); if (res.canceled) return false; if (!res.ok || !res.sessionFile) { - patchConversation(set, projectDir, get().activeSessionFile, { error: res.error ?? "Failed to import chat" }); + patchConversation(set, projectDir, targetSessionFile, { error: res.error ?? "Failed to import chat" }); return false; } await get().refreshSessions(projectDir); @@ -156,7 +157,7 @@ export const createChatSlice: SliceCreator = (set, get) => ({ const key = draftKey(get); set((s) => ({ drafts: { ...s.drafts, [key]: text } })); persist(get); - reportDraft(get().activeProjectPath, text); + reportDraft(get().activeProjectPath, get().activeSessionFile, text); }, insertIntoComposer: (text) => { @@ -297,11 +298,12 @@ export const createChatSlice: SliceCreator = (set, get) => ({ enqueue: async (behavior) => { const draft = currentDraft(get); if (!draft) return; - const { projectDir, key, text, images } = draft; + const { state: sendState, projectDir, key, text, images } = draft; + const sessionFile = sendState.activeSessionFile; // No optimistic entry: Pi echoes the queued message back via queue_update, // which is the source of truth for what's pending. - patchConversation(set, projectDir, get().activeSessionFile, { error: undefined }); + patchConversation(set, projectDir, sessionFile, { error: undefined }); get().setDraft(""); clearAttachments(set, key); @@ -317,14 +319,14 @@ export const createChatSlice: SliceCreator = (set, get) => ({ const res = text.startsWith("/") ? await rpc.request.submit({ projectDir, - sessionFile: get().activeSessionFile, + sessionFile, message: text, images: images.map(toImageContent), streamingBehavior: behavior, }) - : await rpc.request.enqueue({ projectDir, sessionFile: get().activeSessionFile!, behavior, message: text, images: images.map(toImageContent) }); + : await rpc.request.enqueue({ projectDir, sessionFile: sessionFile!, behavior, message: text, images: images.map(toImageContent) }); if (!res.ok) { - patchConversation(set, projectDir, get().activeSessionFile, { + patchConversation(set, projectDir, sessionFile, { error: res.error ?? "Failed to queue message", errorRecovery: "retrySend", }); diff --git a/apps/desktop/src/renderer/lib/store/internals.ts b/apps/desktop/src/renderer/lib/store/internals.ts index 7cc0eee..1fd30a8 100644 --- a/apps/desktop/src/renderer/lib/store/internals.ts +++ b/apps/desktop/src/renderer/lib/store/internals.ts @@ -42,12 +42,12 @@ export function replaceLastChats(map: Record): void { */ let draftReport: ReturnType | undefined; -export function reportDraft(projectDir: string | null, text: string): void { +export function reportDraft(projectDir: string | null, sessionFile: string | null, text: string): void { if (!projectDir) return; clearTimeout(draftReport); draftReport = setTimeout(() => { void rpc.request - .tuiSend({ projectDir, frame: { type: "nativepi_tui_editor", text } }) + .tuiSend({ projectDir, sessionFile, frame: { type: "nativepi_tui_editor", text } }) .catch(() => {}); }, 150); } @@ -113,9 +113,10 @@ export function draftKey(get: GetState): string { * overwriting a model the user picked while it was in flight. */ export function warmProject(set: SetState, get: GetState, path: string): void { + const sessionFile = get().activeSessionFile; void rpc.request.ensurePi({ projectDir: path }); - void rpc.request.getModels({ projectDir: path }).then((r) => { + void rpc.request.getModels({ projectDir: path, sessionFile }).then((r) => { if (get().activeProjectPath === path) set({ models: r.models }); }); @@ -123,14 +124,14 @@ export function warmProject(set: SetState, get: GetState, path: string): void { // that never runs extension `activate()`, so a provider an extension // registers (e.g. a custom-model bridge) is invisible until it's merged in // from this project's own session, which did run activation. - void rpc.request.getSessionProviders({ projectDir: path }).then((r) => { + void rpc.request.getSessionProviders({ projectDir: path, sessionFile }).then((r) => { if (get().activeProjectPath !== path || r.error) return; set({ providers: r.providers, providersLoaded: true }); }); const currentModel = get().model; const modelBeforeLoad = currentModel ? modelKey(currentModel) : undefined; - void rpc.request.getState({ projectDir: path }).then(async (r) => { + void rpc.request.getState({ projectDir: path, sessionFile }).then(async (r) => { const selectedModel = get().model; if ( get().activeProjectPath !== path || @@ -141,7 +142,7 @@ export function warmProject(set: SetState, get: GetState, path: string): void { } set({ model: r.state.model, thinkingLevel: r.state.thinkingLevel }); const loadedModel = r.state.model ? modelKey(r.state.model) : undefined; - const levels = await rpc.request.getThinkingLevels({ projectDir: path }); + const levels = await rpc.request.getThinkingLevels({ projectDir: path, sessionFile }); const activeModel = get().model; if ( get().activeProjectPath === path && diff --git a/apps/desktop/src/renderer/lib/store/models.ts b/apps/desktop/src/renderer/lib/store/models.ts index 1efc828..8e2149c 100644 --- a/apps/desktop/src/renderer/lib/store/models.ts +++ b/apps/desktop/src/renderer/lib/store/models.ts @@ -20,11 +20,12 @@ export const createModelSlice: SliceCreator = (set, get) => ({ setModel: async (model) => { const projectDir = get().activeProjectPath; if (!projectDir) return; + const sessionFile = get().activeSessionFile; // Levels are emptied first: the ones on screen belong to the old model, and // showing them against the new name would offer a level it may not support. set({ model, thinkingLevels: [] }); - await rpc.request.setModel({ projectDir, provider: model.provider, modelId: model.id }); - const result = await rpc.request.getThinkingLevels({ projectDir }); + await rpc.request.setModel({ projectDir, sessionFile, provider: model.provider, modelId: model.id }); + const result = await rpc.request.getThinkingLevels({ projectDir, sessionFile }); if ( get().activeProjectPath === projectDir && get().model?.provider === model.provider && @@ -50,8 +51,9 @@ export const createModelSlice: SliceCreator = (set, get) => ({ setThinkingLevel: async (level) => { const projectDir = get().activeProjectPath; if (!projectDir) return; + const sessionFile = get().activeSessionFile; set({ thinkingLevel: level }); - await rpc.request.setThinkingLevel({ projectDir, level }); + await rpc.request.setThinkingLevel({ projectDir, sessionFile, level }); }, cycleThinkingLevel: async () => { diff --git a/apps/desktop/src/renderer/lib/store/projectContext.ts b/apps/desktop/src/renderer/lib/store/projectContext.ts index 0181f18..b67c82d 100644 --- a/apps/desktop/src/renderer/lib/store/projectContext.ts +++ b/apps/desktop/src/renderer/lib/store/projectContext.ts @@ -56,7 +56,7 @@ export const createProjectContextSlice: SliceCreator = (set : current.method === "confirm" ? ({ type: "extension_ui_response", id: current.id, confirmed: confirmed ?? false } as const) : ({ type: "extension_ui_response", id: current.id, value: value ?? "" } as const); - void rpc.request.extensionRespond({ projectDir, response }); + void rpc.request.extensionRespond({ projectDir, sessionFile: s.activeSessionFile, response }); set({ extPrompts: s.extPrompts.slice(1) }); }, @@ -71,8 +71,8 @@ export const createProjectContextSlice: SliceCreator = (set * Only the project on screen is folded in, for the reason the extension prompts * are: a background project's footer has nothing to attach to. */ - onTuiFrame: ({ projectDir, frame }) => { - if (projectDir !== get().activeProjectPath) return; + onTuiFrame: ({ projectDir, sessionFile, frame }) => { + if (projectDir !== get().activeProjectPath || (sessionFile && sessionFile !== get().activeSessionFile)) return; switch (frame.type) { // Keyed by id rather than appended: a resync re-announces surfaces the // window may still be holding, and the same pane twice is not two panes. diff --git a/apps/desktop/src/renderer/lib/store/types.ts b/apps/desktop/src/renderer/lib/store/types.ts index 3f426d7..6076170 100644 --- a/apps/desktop/src/renderer/lib/store/types.ts +++ b/apps/desktop/src/renderer/lib/store/types.ts @@ -231,7 +231,7 @@ export interface ProjectContextSlice { switchBranch: (branch: string, create: boolean) => Promise<{ ok: boolean; error?: string }>; reloadExtensions: () => Promise; respondExtension: (value: { value?: string; confirmed?: boolean; cancel?: boolean }) => void; - onTuiFrame: (payload: { projectDir: string; frame: TuiHostFrame }) => void; + onTuiFrame: (payload: { projectDir: string; sessionFile?: string; frame: TuiHostFrame }) => void; } /** diff --git a/apps/desktop/src/renderer/lib/store/workspace.ts b/apps/desktop/src/renderer/lib/store/workspace.ts index 37ac95d..1c75436 100644 --- a/apps/desktop/src/renderer/lib/store/workspace.ts +++ b/apps/desktop/src/renderer/lib/store/workspace.ts @@ -1,7 +1,6 @@ import { rpc } from "../rpc.ts"; import { showHint } from "../toast.tsx"; import { dropAllSurfaces } from "../tuiSurfaces.ts"; -import { patchConversation } from "./conversation.ts"; import { getLastChat, persist, @@ -116,7 +115,7 @@ export const createWorkspaceSlice: SliceCreator = (set, get) => // A project whose Pi is not running yet has nothing to answer, and the // surfaces open normally when it starts. dropAllSurfaces(); - void rpc.request.tuiSend({ projectDir: path, frame: { type: "nativepi_tui_sync" } }).catch(() => {}); + void rpc.request.tuiSend({ projectDir: path, sessionFile: get().activeSessionFile, frame: { type: "nativepi_tui_sync" } }).catch(() => {}); persist(get); // The trust check does not depend on the session list, and a round trip @@ -169,7 +168,15 @@ export const createWorkspaceSlice: SliceCreator = (set, get) => restartPi: async () => { const path = get().activeProjectPath; if (!path) return; - patchConversation(set, path, get().activeSessionFile, { error: undefined, errorRecovery: undefined }); + set((s) => ({ + conversations: Object.fromEntries( + Object.entries(s.conversations).map(([key, conversation]) => + conversation.projectDir === path + ? [key, { ...conversation, running: false, runStartedAt: null, error: undefined, errorRecovery: undefined }] + : [key, conversation], + ), + ), + })); await rpc.request.restartPi({ projectDir: path }); if (get().activeProjectPath === path) warmProject(set, get, path); }, diff --git a/apps/desktop/src/shared/rpc-schema.ts b/apps/desktop/src/shared/rpc-schema.ts index 952490d..bb86bc9 100644 --- a/apps/desktop/src/shared/rpc-schema.ts +++ b/apps/desktop/src/shared/rpc-schema.ts @@ -307,18 +307,18 @@ export type HostRequests = { response: { images: ImageAttachment[]; rejected: string[] }; }; abort: { params: { projectDir: string; sessionFile: string }; response: { ok: boolean } }; - getModels: { params: { projectDir: string }; response: { models: ModelInfo[]; error?: string } }; - getState: { params: { projectDir: string }; response: { state?: RpcSessionState; error?: string } }; + getModels: { params: { projectDir: string; sessionFile?: string | null }; response: { models: ModelInfo[]; error?: string } }; + getState: { params: { projectDir: string; sessionFile?: string | null }; response: { state?: RpcSessionState; error?: string } }; getThinkingLevels: { - params: { projectDir: string }; + params: { projectDir: string; sessionFile?: string | null }; response: { levels: ThinkingLevel[]; error?: string }; }; setModel: { - params: { projectDir: string; provider: string; modelId: string }; + params: { projectDir: string; sessionFile?: string | null; provider: string; modelId: string }; response: { ok: boolean; error?: string }; }; setThinkingLevel: { - params: { projectDir: string; level: ThinkingLevel }; + params: { projectDir: string; sessionFile?: string | null; level: ThinkingLevel }; response: { ok: boolean; error?: string }; }; renameChat: { @@ -377,7 +377,7 @@ export type HostRequests = { * extension `activate()` and so the only one that knows about providers an * extension registered (e.g. `context.registerProvider()`). */ - getSessionProviders: { params: { projectDir: string }; response: { providers: AuthProviderInfo[]; error?: string } }; + getSessionProviders: { params: { projectDir: string; sessionFile?: string | null }; response: { providers: AuthProviderInfo[]; error?: string } }; login: { params: { projectDir?: string; providerId: string; type: "api_key" | "oauth" }; response: { ok: boolean; error?: string }; @@ -504,21 +504,22 @@ export type HostRequests = { response: { extensions: GraphicalExtension[] }; }; extensionRespond: { - params: { projectDir: string; response: ExtensionUiResponse }; + params: { projectDir: string; sessionFile?: string | null; response: ExtensionUiResponse }; response: { ok: boolean }; }; /** A keystroke, a size, or the composer state a pi-tui surface is waiting on. */ - tuiSend: { params: { projectDir: string; frame: TuiClientFrame }; response: { ok: boolean } }; + tuiSend: { params: { projectDir: string; sessionFile?: string | null; frame: TuiClientFrame }; response: { ok: boolean } }; /** What an extension's autocomplete provider offers for the text being typed. */ tuiComplete: { - params: { projectDir: string; lines: string[]; cursorLine: number; cursorCol: number }; + params: { projectDir: string; sessionFile?: string | null; lines: string[]; cursorLine: number; cursorCol: number }; response: { completions: TuiCompletions | null; error?: string }; }; /** What accepting one of those completions does to the text. */ tuiApply: { params: { projectDir: string; + sessionFile?: string | null; lines: string[]; cursorLine: number; cursorCol: number; @@ -544,7 +545,7 @@ export type HostEvents = { terminalData: { projectDir: string; terminalId: string; data: string; sequence: number }; terminalExit: { projectDir: string; terminalId: string; exitCode: number }; /** A pi-tui surface opening, drawing, closing, or reporting extension UI state. */ - tuiFrame: { projectDir: string; frame: TuiHostFrame }; + tuiFrame: { projectDir: string; sessionFile?: string; frame: TuiHostFrame }; }; export type HostRequestName = keyof HostRequests;