| 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 |