TypeScript · 32438 bytes Raw Blame History
1 import { create } from "zustand";
2
3 import {
4 cancelTurn as cancelTurnIpc,
5 closePty as closePtyIpc,
6 listProjects,
7 listPtys as listPtysIpc,
8 onChatEvent,
9 onPtyExit,
10 onSessionsChanged,
11 readSession,
12 rescan as rescanIpc,
13 startTurn as startTurnIpc,
14 type ChatEvent,
15 type PtyExitEvent,
16 type PtyInfo,
17 } from "@/lib/ipc/client";
18 import type {
19 Message,
20 PermissionMode,
21 Project,
22 SessionDetail,
23 SessionSummary,
24 } from "@/lib/ipc/types";
25
26 /** Terminal-vs-cards display mode per session. Used by ViewerPane
27 * to branch between the stream-json card timeline (v1.0) and the
28 * embedded xterm.js PTY (v1.1). */
29 export type ViewerMode = "cards" | "terminal";
30
31 interface LoadingFlags {
32 projects: boolean;
33 detail: boolean;
34 }
35
36 export interface InFlightTurn {
37 turnId: string;
38 /** Absolute cwd — what we sent to the backend. */
39 projectCwd: string;
40 /** Session this turn is writing into. For resume turns this is
41 * the existing session id; for new turns it's the client-generated
42 * UUID we passed via `--session-id`. `null` between spawn and the
43 * first backend event (should be brief). */
44 sessionId: string | null;
45 status:
46 | "spawning"
47 | "streaming"
48 | "completed"
49 | "failed"
50 | "cancelled";
51 startedAt: number;
52 error?: string;
53 /** `true` when this turn is creating a brand-new session (there is
54 * no pre-existing disk file until the subprocess writes one). */
55 isNewSession: boolean;
56 /** Last ~50 lines of stderr from the spawned subprocess, capped at
57 * [`STDERR_LINE_CAP`] total. Surfaced by the TurnStatusBanner on
58 * failures so the user can see why a turn died. */
59 stderrTail: string[];
60 /** How many real assistant events landed during this turn. Used to
61 * detect the "completed with zero output" failure mode where
62 * claude exits 0 but wrote nothing — currently the most confusing
63 * failure because nothing appears in the timeline. */
64 assistantEventCount: number;
65 /** Exit code from the subprocess, once known. `null` while still
66 * running, set on `turn_completed` / `turn_failed`. */
67 exitCode: number | null;
68 }
69
70 /** Hard cap on stderr lines retained per in-flight turn. Matches
71 * the 8 KB byte cap in the Rust driver so we never grow unbounded
72 * in memory. */
73 const STDERR_LINE_CAP = 50;
74
75 /** How many recently-viewed SessionDetail objects to keep in the
76 * LRU cache. Each detail can be 1-2 MB for a long session, so
77 * keep this small. 5 is enough to make "flip between A, B, C,
78 * back to A" feel instant without blowing the heap. */
79 const DETAIL_CACHE_CAP = 5;
80
81 /** Returns a new Map with `id → detail` marked most-recently-used.
82 * `pinnedId` (the currently-selected session) is never evicted
83 * even when it's the oldest entry — otherwise opening 5 other
84 * sessions would silently drop the one the user is looking at,
85 * and clicking back to it would be a cache miss. */
86 function touchDetailCache(
87 cache: Map<string, SessionDetail>,
88 id: string,
89 detail: SessionDetail,
90 pinnedId: string | null,
91 ): Map<string, SessionDetail> {
92 const next = new Map(cache);
93 next.delete(id);
94 next.set(id, detail);
95 while (next.size > DETAIL_CACHE_CAP) {
96 let evict: string | undefined;
97 for (const key of next.keys()) {
98 if (key !== pinnedId) {
99 evict = key;
100 break;
101 }
102 }
103 if (evict === undefined) break;
104 next.delete(evict);
105 }
106 return next;
107 }
108
109 interface SessionStore {
110 /** All projects with their sessions embedded, sorted by the backend. */
111 projects: Project[];
112
113 /** Which project rows are expanded in the sidebar tree. */
114 expandedProjectIds: Set<string>;
115
116 /** Currently selected session — highlighted in the tree, rendered
117 * in the viewer. */
118 selectedSessionId: string | null;
119 /** Full body for the selected session. */
120 detail: SessionDetail | null;
121 /** Summary of the session the user just clicked, set instantly on
122 * selectSession so the viewer header can show the real title
123 * while the backend is still streaming the body. Cleared once
124 * `detail` arrives. `null` when no load is in flight. */
125 pendingSummary: SessionSummary | null;
126 /** LRU cache of the last-viewed SessionDetail objects, keyed by
127 * session id. Hitting a cached entry on click lets us render
128 * the previous content immediately (no skeleton) while we
129 * kick off a background refresh for fresh data. Capped at
130 * `DETAIL_CACHE_CAP` to bound memory — each SessionDetail can
131 * be 1-2 MB for a long session, so keeping more than a handful
132 * is a big ask. */
133 detailCache: Map<string, SessionDetail>;
134
135 /** In-flight chat turns keyed by turnId. Multiple entries are
136 * allowed — the UI currently only surfaces one at a time, but
137 * the shape supports future concurrent-turn work. */
138 inFlightTurns: Map<string, InFlightTurn>;
139
140 /** Per-session display mode. Keyed on the session's id (including
141 * the `pending-` prefix for in-flight new sessions). Missing
142 * entries fall back to a source-dependent default inside
143 * `selectSession` / `beginNewSession`. */
144 viewerMode: Map<string, ViewerMode>;
145
146 /** Map from session id → live PTY id. **Entries survive session
147 * switches** — the whole point of the codex-style parallel-thread
148 * model. Removed only on explicit close_pty, pty:exit, or app
149 * shutdown. */
150 ptyIds: Map<string, string>;
151
152 /** Metadata for every live PTY, keyed on pty id. Populated by
153 * `registerPty` and `refreshPtyList`, drained by
154 * `subscribeToPtyEvents` on `pty:exit`. Used by the titlebar
155 * "N terminals" popover. */
156 ptyInfos: Map<string, PtyInfo>;
157
158 loading: LoadingFlags;
159 error: string | null;
160
161 loadProjects: () => Promise<void>;
162 toggleProject: (projectId: string) => void;
163 expandProject: (projectId: string) => void;
164 collapseProject: (projectId: string) => void;
165
166 /** Called when the user clicks a session row. Takes the full
167 * SessionSummary so we can thread `source` through to the
168 * `read_session` dispatcher. Only fetches the full session
169 * detail when the resolved view mode is `cards` — terminal
170 * mode skips the fetch entirely to avoid reparsing giant
171 * JSONL files on session switch. */
172 selectSession: (session: SessionSummary) => Promise<void>;
173 /** Trigger a lazy detail load for a session that the user is
174 * about to view in cards mode (typically after toggling from
175 * terminal mode). No-op when the detail is already loaded. */
176 ensureDetailFor: (summary: SessionSummary) => Promise<void>;
177
178 rescan: () => Promise<void>;
179 subscribeToChanges: () => Promise<void>;
180
181 // --- chat ---
182
183 /** Start a chat turn against the currently-selected session. If
184 * the selected session is a pending-new one (created via
185 * `beginNewSession`), this fires `start_turn` with `newSessionId`
186 * set. Otherwise it fires with `resumeSessionId`. Returns the
187 * client-generated turnId. */
188 startTurn: (prompt: string, permissionMode?: PermissionMode) => Promise<string>;
189 cancelTurn: (turnId: string) => Promise<void>;
190 /** Create a synthetic "pending" SessionDetail for a new session
191 * in the given project cwd. The real session file lands on disk
192 * once the first turn starts. */
193 beginNewSession: (projectCwd: string, displayName: string) => void;
194 subscribeToChatEvents: () => Promise<void>;
195 /** Re-read a completed turn's session from disk and replace the
196 * in-memory detail with it. Called by the watcher rescan path
197 * and by the 2-second safety-net timer. */
198 reconcileTurn: (turnId: string) => Promise<void>;
199
200 // --- pty ---
201
202 /** Flip this session's display mode. No-op for archive sessions
203 * (they're locked to cards). Does NOT affect any live PTY —
204 * toggling away from terminal mode unmounts the xterm component
205 * but keeps the backend subprocess alive. */
206 toggleViewerMode: (sessionId: string) => void;
207 /** Direct setter used by `beginNewSession` to pick the initial
208 * mode for a pending session. */
209 setViewerMode: (sessionId: string, mode: ViewerMode) => void;
210 /** Called by `TerminalPane` after `spawnPty` or during reattach
211 * to record the binding from sessionId → ptyId and cache the
212 * PtyInfo for the titlebar popover. */
213 registerPty: (sessionId: string, info: PtyInfo) => void;
214 /** Explicit teardown triggered by the "close terminal" ✕ button
215 * in the viewer header (or the sidebar hover-close). Fires
216 * `closePty` on the backend and removes both map entries. Flips
217 * the session's mode back to cards so the next render picks the
218 * card viewer up again. */
219 closeSessionPty: (sessionId: string) => Promise<void>;
220 /** Attach-once listener for `pty:exit` events. Cleans up the
221 * `ptyIds` / `ptyInfos` entries when a subprocess dies on its
222 * own (natural exit, `/exit` slash command, crash). */
223 subscribeToPtyEvents: () => Promise<void>;
224 /** Reconcile the store against the backend's `active_ptys` map.
225 * Called on mount alongside the other subscribe* actions. */
226 refreshPtyList: () => Promise<void>;
227 }
228
229 let watcherAttached = false;
230 let chatAttached = false;
231 let ptyAttached = false;
232 let pendingRescan: ReturnType<typeof setTimeout> | null = null;
233 /** Turns whose `turn_completed` event fired and are waiting for the
234 * file watcher to reconcile `detail`. Keyed by sessionId. Safety-net
235 * timers live here too. */
236 const pendingReconcile = new Map<string, ReturnType<typeof setTimeout>>();
237
238 export const useSessionStore = create<SessionStore>((set, get) => ({
239 projects: [],
240 expandedProjectIds: new Set<string>(),
241 selectedSessionId: null,
242 detail: null,
243 pendingSummary: null,
244 detailCache: new Map(),
245 inFlightTurns: new Map(),
246 viewerMode: new Map(),
247 ptyIds: new Map(),
248 ptyInfos: new Map(),
249 loading: { projects: false, detail: false },
250 error: null,
251
252 async loadProjects() {
253 set((s) => ({ loading: { ...s.loading, projects: true }, error: null }));
254 try {
255 const projects = await listProjects();
256 set((s) => ({
257 projects,
258 loading: { ...s.loading, projects: false },
259 expandedProjectIds:
260 s.expandedProjectIds.size === 0
261 ? autoExpandInitial(projects, s.expandedProjectIds)
262 : s.expandedProjectIds,
263 }));
264 } catch (err) {
265 set((s) => ({
266 loading: { ...s.loading, projects: false },
267 error: formatError(err),
268 }));
269 }
270 },
271
272 toggleProject(projectId) {
273 set((s) => {
274 const next = new Set(s.expandedProjectIds);
275 if (next.has(projectId)) next.delete(projectId);
276 else next.add(projectId);
277 return { expandedProjectIds: next };
278 });
279 },
280
281 expandProject(projectId) {
282 set((s) => {
283 if (s.expandedProjectIds.has(projectId)) return s;
284 const next = new Set(s.expandedProjectIds);
285 next.add(projectId);
286 return { expandedProjectIds: next };
287 });
288 },
289
290 collapseProject(projectId) {
291 set((s) => {
292 if (!s.expandedProjectIds.has(projectId)) return s;
293 const next = new Set(s.expandedProjectIds);
294 next.delete(projectId);
295 return { expandedProjectIds: next };
296 });
297 },
298
299 async selectSession(session) {
300 if (get().selectedSessionId === session.id) return;
301 // CRITICAL: we do NOT touch viewerMode or ptyIds for the
302 // *previous* session — background terminals must keep running
303 // across session switches (codex-parallel-threads).
304 const cached = get().detailCache.get(session.id) ?? null;
305 // Check the resolved view mode for this session. If the user is
306 // going to see it as a PTY-backed terminal we skip `readSession`
307 // entirely — parsing 171 MB of JSONL for a session that will be
308 // rendered as an xterm is pure waste and the largest source of
309 // session-switch jank we've measured.
310 const resolvedMode =
311 get().viewerMode.get(session.id) ??
312 (session.source === "archive" ? "cards" : "cards");
313 const needsDetail = resolvedMode === "cards";
314
315 set((s) => {
316 const nextMode = new Map(s.viewerMode);
317 if (!nextMode.has(session.id)) {
318 nextMode.set(session.id, "cards");
319 }
320 return {
321 selectedSessionId: session.id,
322 detail: cached,
323 pendingSummary: cached || !needsDetail ? null : session,
324 viewerMode: nextMode,
325 loading: { ...s.loading, detail: needsDetail && !cached },
326 error: null,
327 };
328 });
329
330 if (!needsDetail) return;
331
332 // Race guard: if the user clicks session B while session A's
333 // readSession is still in flight, A's `set` would otherwise
334 // overwrite B's state a moment later.
335 const targetId = session.id;
336 try {
337 const detail = await readSession(
338 session.projectId,
339 session.id,
340 session.source,
341 );
342 if (get().selectedSessionId !== targetId) return;
343 set((s) => ({
344 detail,
345 pendingSummary: null,
346 detailCache: touchDetailCache(
347 s.detailCache,
348 session.id,
349 detail,
350 s.selectedSessionId,
351 ),
352 loading: { ...s.loading, detail: false },
353 }));
354 } catch (err) {
355 if (get().selectedSessionId !== targetId) return;
356 set((s) => ({
357 pendingSummary: null,
358 loading: { ...s.loading, detail: false },
359 error: formatError(err),
360 }));
361 }
362 },
363
364 /** Lazy-load the full session detail on demand. Called by the
365 * ViewerPane when the user toggles to cards mode and we don't
366 * have the detail yet (because selectSession skipped it for a
367 * terminal-default session). Idempotent — bails if the detail
368 * is already loaded or a load is in flight. */
369 async ensureDetailFor(summary) {
370 const s = get();
371 if (s.detail?.summary.id === summary.id) return;
372 if (s.loading.detail && s.pendingSummary?.id === summary.id) return;
373 const cached = s.detailCache.get(summary.id);
374 if (cached) {
375 set((state) => ({
376 detail: cached,
377 detailCache: touchDetailCache(
378 state.detailCache,
379 summary.id,
380 cached,
381 state.selectedSessionId,
382 ),
383 }));
384 return;
385 }
386 set((state) => ({
387 pendingSummary: summary,
388 loading: { ...state.loading, detail: true },
389 }));
390 try {
391 const detail = await readSession(
392 summary.projectId,
393 summary.id,
394 summary.source,
395 );
396 if (get().selectedSessionId !== summary.id) return;
397 set((state) => ({
398 detail,
399 pendingSummary: null,
400 detailCache: touchDetailCache(
401 state.detailCache,
402 summary.id,
403 detail,
404 state.selectedSessionId,
405 ),
406 loading: { ...state.loading, detail: false },
407 }));
408 } catch (err) {
409 if (get().selectedSessionId !== summary.id) return;
410 set((state) => ({
411 pendingSummary: null,
412 loading: { ...state.loading, detail: false },
413 error: formatError(err),
414 }));
415 }
416 },
417
418 async rescan() {
419 set((s) => ({ loading: { ...s.loading, projects: true }, error: null }));
420 try {
421 const projects = await rescanIpc();
422 set((s) => ({
423 projects,
424 loading: { ...s.loading, projects: false },
425 }));
426 // If a completed turn was waiting to reconcile its detail,
427 // check now whether the session it belongs to still exists on
428 // disk and reload. We iterate inFlightTurns whose status is
429 // `completed` — they're the ones in the reconcile window.
430 const selectedId = get().selectedSessionId;
431 for (const [, turn] of get().inFlightTurns) {
432 if (turn.status !== "completed") continue;
433 if (turn.sessionId && turn.sessionId === selectedId) {
434 await get().reconcileTurn(turn.turnId);
435 }
436 }
437 } catch (err) {
438 set((s) => ({
439 loading: { ...s.loading, projects: false },
440 error: formatError(err),
441 }));
442 }
443 },
444
445 async subscribeToChanges() {
446 if (watcherAttached) return;
447 watcherAttached = true;
448 await onSessionsChanged((ev) => {
449 // ONLY rescan on structural changes (new session file appears,
450 // session file disappears). Message-count ticks from active
451 // writes fire as `modified` events and previously caused a
452 // periodic ~300ms main-thread stall every few seconds —
453 // Rust walks every session on disk, serializes a ~1-2 MB
454 // Project[] payload, the frontend deserializes on the main
455 // thread, and React reconciles the whole sidebar. That
456 // full storm ran every ~2s any time an external claude
457 // process (observer sessions, background runs) was writing
458 // to disk, producing the "periodic mouse-move beachball"
459 // signature. Added/Removed still trigger a rescan because
460 // the sidebar needs to show new sessions as they appear.
461 if (ev.kind === "modified") return;
462 if (pendingRescan) clearTimeout(pendingRescan);
463 pendingRescan = setTimeout(() => {
464 pendingRescan = null;
465 void get().rescan();
466 }, 2000);
467 });
468 },
469
470 // ------------------------- chat actions -------------------------
471
472 async startTurn(prompt, permissionMode = "auto") {
473 const { detail } = get();
474 if (!detail) {
475 throw new Error("no session selected");
476 }
477 if (!detail.summary.cwd) {
478 throw new Error("selected session has no cwd");
479 }
480 const turnId = crypto.randomUUID();
481 const isNewSession = detail.summary.source === "disk"
482 && detail.summary.id.startsWith("pending-");
483 // For new sessions the selected session id is the client-generated
484 // uuid. For resume turns it's the real disk session id.
485 const resumeSessionId = isNewSession ? null : detail.summary.id;
486 const newSessionId = isNewSession
487 ? detail.summary.id.replace(/^pending-/, "")
488 : null;
489
490 // Optimistically append the user's prompt to the detail so it
491 // appears instantly (the real `user` event from the subprocess
492 // will arrive shortly after and match by id).
493 const userMessage: Message = {
494 kind: "user",
495 id: `local-user-${turnId}`,
496 at: new Date().toISOString(),
497 text: prompt,
498 isMeta: false,
499 };
500 set((s) => ({
501 detail: s.detail
502 ? { ...s.detail, messages: [...s.detail.messages, userMessage] }
503 : s.detail,
504 }));
505
506 await startTurnIpc({
507 turnId,
508 cwd: detail.summary.cwd,
509 resumeSessionId,
510 newSessionId,
511 prompt,
512 permissionMode,
513 });
514 return turnId;
515 },
516
517 async cancelTurn(turnId) {
518 await cancelTurnIpc(turnId);
519 },
520
521 beginNewSession(projectCwd, displayName) {
522 const pendingId = `pending-${crypto.randomUUID()}`;
523 const now = new Date().toISOString();
524 const summary: SessionSummary = {
525 id: pendingId,
526 projectId: projectCwd,
527 title: `${displayName} (new session)`,
528 startedAt: now,
529 lastActivityAt: now,
530 model: null,
531 messageCount: 0,
532 promptCount: 0,
533 gitBranch: null,
534 version: null,
535 slug: null,
536 cwd: projectCwd,
537 customTitle: null,
538 entrypoint: null,
539 source: "disk",
540 };
541 const detail: SessionDetail = { summary, messages: [] };
542 set((s) => {
543 const nextMode = new Map(s.viewerMode);
544 nextMode.set(pendingId, "terminal");
545 return {
546 selectedSessionId: pendingId,
547 detail,
548 viewerMode: nextMode,
549 };
550 });
551 },
552
553 async subscribeToChatEvents() {
554 if (chatAttached) return;
555 chatAttached = true;
556 await onChatEvent((ev) => handleChatEvent(ev, get, set));
557 },
558
559 async reconcileTurn(turnId) {
560 const turn = get().inFlightTurns.get(turnId);
561 if (!turn || !turn.sessionId) return;
562 try {
563 const detail = await readSession(turn.projectCwd, turn.sessionId, "disk");
564 set((s) => {
565 if (s.selectedSessionId !== turn.sessionId) return s;
566 const next = new Map(s.inFlightTurns);
567 next.delete(turnId);
568 return { detail, inFlightTurns: next };
569 });
570 } catch {
571 // Silent — the watcher will retry on the next rescan tick.
572 }
573 },
574
575 // ------------------------- pty actions -------------------------
576
577 toggleViewerMode(sessionId) {
578 set((s) => {
579 // Archive sessions are locked to cards — there's nothing to
580 // resume in a terminal because their transcripts are gone.
581 if (s.detail?.summary.id === sessionId && s.detail.summary.source === "archive") {
582 return s;
583 }
584 const cur = s.viewerMode.get(sessionId) ?? "cards";
585 const next = new Map(s.viewerMode);
586 next.set(sessionId, cur === "cards" ? "terminal" : "cards");
587 return { viewerMode: next };
588 });
589 },
590
591 setViewerMode(sessionId, mode) {
592 set((s) => {
593 const next = new Map(s.viewerMode);
594 next.set(sessionId, mode);
595 return { viewerMode: next };
596 });
597 },
598
599 registerPty(sessionId, info) {
600 set((s) => {
601 const nextIds = new Map(s.ptyIds);
602 nextIds.set(sessionId, info.ptyId);
603 const nextInfos = new Map(s.ptyInfos);
604 nextInfos.set(info.ptyId, info);
605 return { ptyIds: nextIds, ptyInfos: nextInfos };
606 });
607 },
608
609 async closeSessionPty(sessionId) {
610 const ptyId = get().ptyIds.get(sessionId);
611 if (!ptyId) return;
612 try {
613 await closePtyIpc(ptyId);
614 } catch (err) {
615 console.warn("[pty] close_pty failed", err);
616 }
617 // Clear optimistically — the pty:exit listener will also fire and
618 // be a no-op by then. Flipping back to cards means the user's
619 // next glance at this session shows the familiar card viewer.
620 set((s) => {
621 const nextIds = new Map(s.ptyIds);
622 nextIds.delete(sessionId);
623 const nextInfos = new Map(s.ptyInfos);
624 nextInfos.delete(ptyId);
625 const nextMode = new Map(s.viewerMode);
626 nextMode.set(sessionId, "cards");
627 return { ptyIds: nextIds, ptyInfos: nextInfos, viewerMode: nextMode };
628 });
629 },
630
631 async subscribeToPtyEvents() {
632 if (ptyAttached) return;
633 ptyAttached = true;
634 await onPtyExit((ev: PtyExitEvent) => {
635 set((s) => {
636 // Find which sessionId (if any) was bound to this ptyId and
637 // remove both sides of the mapping. Don't flip viewerMode here
638 // — a natural exit should leave the terminal pane showing the
639 // final state; the user can toggle back to cards themselves.
640 const nextIds = new Map(s.ptyIds);
641 for (const [sid, pid] of nextIds) {
642 if (pid === ev.ptyId) {
643 nextIds.delete(sid);
644 break;
645 }
646 }
647 const nextInfos = new Map(s.ptyInfos);
648 nextInfos.delete(ev.ptyId);
649 return { ptyIds: nextIds, ptyInfos: nextInfos };
650 });
651 });
652 // Reconcile against the backend on mount — picks up any PTYs
653 // that survived a dev-server reload, for example.
654 void get().refreshPtyList();
655 },
656
657 async refreshPtyList() {
658 try {
659 const infos = await listPtysIpc();
660 set((s) => {
661 const nextIds = new Map<string, string>();
662 const nextInfos = new Map<string, PtyInfo>();
663 for (const info of infos) {
664 nextInfos.set(info.ptyId, info);
665 if (info.sessionId) {
666 nextIds.set(info.sessionId, info.ptyId);
667 }
668 }
669 // Preserve entries whose sessionId is still in our store but
670 // whose ptyId didn't come back from the backend — those are
671 // race-condition stragglers that the next pty:exit tick will
672 // resolve.
673 for (const [sid, pid] of s.ptyIds) {
674 if (!nextIds.has(sid) && nextInfos.has(pid)) {
675 nextIds.set(sid, pid);
676 }
677 }
678 return { ptyIds: nextIds, ptyInfos: nextInfos };
679 });
680 } catch (err) {
681 console.warn("[pty] list_ptys failed", err);
682 }
683 },
684 }));
685
686 type SetStore = (
687 updater:
688 | Partial<SessionStore>
689 | ((prev: SessionStore) => Partial<SessionStore>),
690 ) => void;
691
692 function handleChatEvent(
693 ev: ChatEvent,
694 get: () => SessionStore,
695 set: SetStore,
696 ) {
697 switch (ev.kind) {
698 case "turn_started": {
699 // eslint-disable-next-line no-console
700 console.info(
701 "[chat] turn_started",
702 {
703 turnId: ev.turnId,
704 resumeSessionId: ev.resumeSessionId,
705 newSessionId: ev.newSessionId,
706 cwd: get().detail?.summary.cwd,
707 },
708 );
709 set((s) => {
710 // Drop any terminal-state turns for this session — the user is
711 // starting a new turn so we can stop surfacing the previous
712 // failure banner.
713 const sid = ev.resumeSessionId ?? ev.newSessionId;
714 const next = new Map(s.inFlightTurns);
715 if (sid) {
716 for (const [key, t] of next) {
717 if (
718 t.sessionId === sid &&
719 (t.status === "completed" ||
720 t.status === "failed" ||
721 t.status === "cancelled")
722 ) {
723 next.delete(key);
724 }
725 }
726 }
727 next.set(ev.turnId, {
728 turnId: ev.turnId,
729 projectCwd: get().detail?.summary.cwd ?? "",
730 sessionId: ev.resumeSessionId ?? ev.newSessionId,
731 status: "spawning",
732 startedAt: Date.now(),
733 isNewSession: ev.newSessionId !== null,
734 stderrTail: [],
735 assistantEventCount: 0,
736 exitCode: null,
737 });
738 return { inFlightTurns: next };
739 });
740 return;
741 }
742 case "session_bound": {
743 set((s) => {
744 const cur = s.inFlightTurns.get(ev.turnId);
745 if (!cur) return s;
746 const next = new Map(s.inFlightTurns);
747 next.set(ev.turnId, { ...cur, sessionId: ev.sessionId, status: "streaming" });
748 // Promote a pending detail to the real session id.
749 if (s.detail && s.detail.summary.id.startsWith("pending-")) {
750 const pendingId = s.detail.summary.id;
751 // Migrate per-session maps from pendingId → real session id.
752 const nextMode = new Map(s.viewerMode);
753 const mode = nextMode.get(pendingId);
754 if (mode !== undefined) {
755 nextMode.delete(pendingId);
756 nextMode.set(ev.sessionId, mode);
757 }
758 const nextPtyIds = new Map(s.ptyIds);
759 const ptyId = nextPtyIds.get(pendingId);
760 if (ptyId !== undefined) {
761 nextPtyIds.delete(pendingId);
762 nextPtyIds.set(ev.sessionId, ptyId);
763 }
764 return {
765 inFlightTurns: next,
766 selectedSessionId: ev.sessionId,
767 detail: {
768 ...s.detail,
769 summary: { ...s.detail.summary, id: ev.sessionId },
770 },
771 viewerMode: nextMode,
772 ptyIds: nextPtyIds,
773 };
774 }
775 return { inFlightTurns: next };
776 });
777 return;
778 }
779 case "message": {
780 appendOrMergeMessage(ev.turnId, ev.message, get, set);
781 // Bump the assistant-event counter on the in-flight turn so we
782 // can detect the "completed with zero output" failure mode.
783 if (ev.message.kind === "assistant") {
784 set((s) => {
785 const cur = s.inFlightTurns.get(ev.turnId);
786 if (!cur) return s;
787 const next = new Map(s.inFlightTurns);
788 next.set(ev.turnId, {
789 ...cur,
790 assistantEventCount: cur.assistantEventCount + 1,
791 });
792 return { inFlightTurns: next };
793 });
794 }
795 return;
796 }
797 case "stderr": {
798 // eslint-disable-next-line no-console
799 console.warn("[claude stderr]", ev.line);
800 set((s) => {
801 const cur = s.inFlightTurns.get(ev.turnId);
802 if (!cur) return s;
803 const next = new Map(s.inFlightTurns);
804 const tail = [...cur.stderrTail, ev.line];
805 if (tail.length > STDERR_LINE_CAP) {
806 tail.splice(0, tail.length - STDERR_LINE_CAP);
807 }
808 next.set(ev.turnId, { ...cur, stderrTail: tail });
809 return { inFlightTurns: next };
810 });
811 return;
812 }
813 case "turn_completed": {
814 // eslint-disable-next-line no-console
815 console.info("[chat] turn_completed", {
816 turnId: ev.turnId,
817 exitCode: ev.exitCode,
818 });
819 set((s) => {
820 const cur = s.inFlightTurns.get(ev.turnId);
821 if (!cur) return s;
822 const next = new Map(s.inFlightTurns);
823 next.set(ev.turnId, {
824 ...cur,
825 status: "completed",
826 exitCode: ev.exitCode,
827 });
828 return { inFlightTurns: next };
829 });
830 // Only schedule a reconcile if the turn actually produced
831 // assistant output. If it didn't, there's nothing new on disk
832 // to re-read and we want to KEEP the in-flight entry so the
833 // TurnStatusBanner can surface the zero-output warning.
834 const turn = get().inFlightTurns.get(ev.turnId);
835 if (turn?.sessionId && turn.assistantEventCount > 0) {
836 const timer = setTimeout(() => {
837 pendingReconcile.delete(turn.sessionId!);
838 void get().reconcileTurn(ev.turnId);
839 }, 2000);
840 pendingReconcile.set(turn.sessionId, timer);
841 }
842 // Flip the trailing assistant message's status from streaming → complete.
843 flipLastAssistantStatus(get, set, "complete");
844 return;
845 }
846 case "turn_failed": {
847 // eslint-disable-next-line no-console
848 console.error("[chat] turn_failed", {
849 turnId: ev.turnId,
850 reason: ev.reason,
851 });
852 set((s) => {
853 const cur = s.inFlightTurns.get(ev.turnId);
854 if (!cur) return s;
855 const next = new Map(s.inFlightTurns);
856 next.set(ev.turnId, { ...cur, status: "failed", error: ev.reason });
857 return { inFlightTurns: next };
858 });
859 flipLastAssistantStatus(get, set, "error");
860 return;
861 }
862 case "turn_cancelled": {
863 // eslint-disable-next-line no-console
864 console.info("[chat] turn_cancelled", { turnId: ev.turnId });
865 set((s) => {
866 const cur = s.inFlightTurns.get(ev.turnId);
867 if (!cur) return s;
868 const next = new Map(s.inFlightTurns);
869 next.set(ev.turnId, { ...cur, status: "cancelled" });
870 return { inFlightTurns: next };
871 });
872 flipLastAssistantStatus(get, set, "error");
873 return;
874 }
875 }
876 }
877
878 function appendOrMergeMessage(
879 _turnId: string,
880 incoming: Message,
881 _get: () => SessionStore,
882 set: SetStore,
883 ) {
884 set((s) => {
885 if (!s.detail) return s;
886 const messages = [...s.detail.messages];
887 // Merge assistant partials by id — the CLI emits multiple events
888 // with the same message.id as text streams in.
889 if (incoming.kind === "assistant") {
890 const lastIdx = findLastAssistantIndex(messages, incoming.id);
891 if (lastIdx >= 0) {
892 messages[lastIdx] = incoming;
893 return { detail: { ...s.detail, messages } };
894 }
895 }
896 // User message — check if we already optimistically appended it
897 // locally and replace.
898 if (incoming.kind === "user") {
899 const localIdx = messages.findIndex(
900 (m) => m.kind === "user" && m.id.startsWith("local-user-"),
901 );
902 if (localIdx >= 0) {
903 messages[localIdx] = incoming;
904 return { detail: { ...s.detail, messages } };
905 }
906 }
907 messages.push(incoming);
908 return { detail: { ...s.detail, messages } };
909 });
910 }
911
912 function findLastAssistantIndex(messages: Message[], id: string): number {
913 for (let i = messages.length - 1; i >= 0; i--) {
914 const m = messages[i];
915 if (m.kind === "assistant" && m.id === id) return i;
916 }
917 return -1;
918 }
919
920 function flipLastAssistantStatus(
921 _get: () => SessionStore,
922 set: SetStore,
923 status: "complete" | "error",
924 ) {
925 set((s) => {
926 if (!s.detail) return s;
927 const messages = [...s.detail.messages];
928 for (let i = messages.length - 1; i >= 0; i--) {
929 const m = messages[i];
930 if (m.kind === "assistant") {
931 messages[i] = {
932 ...m,
933 status,
934 };
935 return { detail: { ...s.detail, messages } };
936 }
937 }
938 return s;
939 });
940 }
941
942 function autoExpandInitial(
943 projects: Project[],
944 prev: Set<string>,
945 ): Set<string> {
946 const firstRegular = projects.find((p) => p.category === "regular");
947 if (!firstRegular) return prev;
948 const next = new Set(prev);
949 next.add(firstRegular.id);
950 return next;
951 }
952
953 function formatError(err: unknown): string {
954 if (err instanceof Error) return err.message;
955 if (typeof err === "string") return err;
956 return JSON.stringify(err);
957 }
958