@spzxg/cli-ui 0.6.2 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState, type MutableRefObject } from "react";
4
+ import { LS, loadPersist } from "./persist";
5
+ import { reconcileHistory, closeOneLevel, type NavSel } from "./navHistory";
6
+ import {
7
+ DEFAULT_APPEARANCE, DEFAULT_SETTINGS, DEFAULT_LAYOUT,
8
+ type Appearance, type Settings, type Layout, type View, type TaskView,
9
+ } from "./types";
10
+
11
+ // Mirror of Orchestrator's mobile breakpoint — the Back-button trap only arms on
12
+ // mobile (single-pane), since on desktop every column is visible and Back should
13
+ // not be hijacked to close a panel.
14
+ const MOBILE_QUERY = "(max-width: 760px)";
15
+
16
+ // Owns the cosmetic/client-only preferences (appearance, settings, layout) and the
17
+ // active work-area view, plus the hydrate-once + persist/URL-sync effects. The
18
+ // open project/task are passed in so they get mirrored into localStorage + URL
19
+ // alongside the prefs (URL keeps a refresh landing where you were). The setters
20
+ // are passed in so the Back button (popstate) can close one pane level — on
21
+ // mobile this is the only way to step session → tasks → projects.
22
+ export function usePrefs({ selProj, selTask, urlSelRef, setSelProj, setSelTask }: {
23
+ selProj: string | null;
24
+ selTask: string | null;
25
+ urlSelRef: MutableRefObject<{ project?: string; task?: string; view?: string } | null>;
26
+ setSelProj: (id: string | null) => void;
27
+ setSelTask: (id: string | null) => void;
28
+ }) {
29
+ const [view, setView] = useState<View>("workspace");
30
+ const [taskView, setTaskView] = useState<TaskView>("list");
31
+ const [appearance, setAppearance] = useState<Appearance>(DEFAULT_APPEARANCE);
32
+ const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
33
+ const [layout, setLayout] = useState<Layout>(DEFAULT_LAYOUT);
34
+ const [hydrated, setHydrated] = useState(false);
35
+
36
+ // Latest selection, read by the once-attached popstate handler without
37
+ // re-subscribing. Updated every render (cheap, and refs are render-safe here).
38
+ const selRef = useRef<NavSel>({ proj: selProj, task: selTask, view });
39
+ selRef.current = { proj: selProj, task: selTask, view };
40
+
41
+ // Apply persisted preferences before the first hydrated paint.
42
+ useLayoutEffect(() => {
43
+ const p = loadPersist();
44
+ if (p.appearance) setAppearance({ ...DEFAULT_APPEARANCE, ...p.appearance });
45
+ if (p.settings) setSettings({ ...DEFAULT_SETTINGS, ...p.settings });
46
+ // The projects column starts hidden on every load, whatever was saved.
47
+ if (p.layout) setLayout({ ...DEFAULT_LAYOUT, ...p.layout, projCollapsed: DEFAULT_LAYOUT.projCollapsed });
48
+ if (p.taskView === "board") setTaskView("board");
49
+ const urlView = urlSelRef.current?.view;
50
+ if (urlView === "settings" || urlView === "insights") setView(urlView);
51
+ setHydrated(true);
52
+ }, [urlSelRef]);
53
+
54
+ // Apply the whole palette before paint. Hover transitions must not interpolate
55
+ // old dark backgrounds underneath the new light-theme text (or vice versa).
56
+ useLayoutEffect(() => {
57
+ if (!hydrated) return;
58
+ const root = document.documentElement;
59
+ if (root.dataset.theme === appearance.theme) return;
60
+ root.setAttribute("data-theme-changing", "");
61
+ root.setAttribute("data-theme", appearance.theme);
62
+ // Keep the guard for one painted frame, then restore normal hover feedback.
63
+ let frame = requestAnimationFrame(() => {
64
+ frame = requestAnimationFrame(() => root.removeAttribute("data-theme-changing"));
65
+ });
66
+ return () => { cancelAnimationFrame(frame); root.removeAttribute("data-theme-changing"); };
67
+ }, [appearance.theme, hydrated]);
68
+
69
+ // persist + apply appearance
70
+ useEffect(() => {
71
+ if (!hydrated) return;
72
+ document.documentElement.style.setProperty("--density", appearance.density);
73
+ try { localStorage.setItem(LS, JSON.stringify({ selProj, selTask, appearance, settings, layout, taskView })); } catch { /* Session remains usable when storage is unavailable. */ }
74
+
75
+ // Mirror the open project/task + active view into the URL (refresh-restore)
76
+ // and, on mobile, keep a single Back-trap entry on top while a pane is open
77
+ // so the device Back button steps session → tasks → projects. (See navHistory.)
78
+ const armTrap = window.matchMedia(MOBILE_QUERY).matches;
79
+ reconcileHistory(window.history, window.location.pathname, { proj: selProj, task: selTask, view }, armTrap);
80
+ }, [appearance, settings, layout, taskView, selProj, selTask, view, hydrated]);
81
+
82
+ // Resizing/rotating can reveal mobile Back buttons without changing selection.
83
+ // Arm the same-document history entry immediately at that breakpoint; waiting
84
+ // for a preference/selection change lets the first Back leave the workspace.
85
+ useLayoutEffect(() => {
86
+ if (!hydrated) return;
87
+ const mq = window.matchMedia(MOBILE_QUERY);
88
+ const sync = () => reconcileHistory(window.history, window.location.pathname, selRef.current, mq.matches);
89
+ sync();
90
+ mq.addEventListener("change", sync);
91
+ return () => mq.removeEventListener("change", sync);
92
+ }, [hydrated]);
93
+
94
+ // Back button: consume the trap and close exactly one pane level. The setState
95
+ // calls re-run the persist effect, which re-arms the trap if a pane is still
96
+ // open (pushState fires no popstate, so no loop). Driving off the live
97
+ // selection — not the popped URL — makes this immune to the task list churning
98
+ // selTask, which would otherwise leave stale duplicate history entries.
99
+ useEffect(() => {
100
+ const onPop = () => {
101
+ const next = closeOneLevel(selRef.current);
102
+ setSelProj(next.proj);
103
+ setSelTask(next.task);
104
+ setView(next.view);
105
+ };
106
+ window.addEventListener("popstate", onPop);
107
+ return () => window.removeEventListener("popstate", onPop);
108
+ }, [setSelProj, setSelTask]);
109
+
110
+ const setAppearanceKey = (k: keyof Appearance, v: string) => setAppearance((a) => ({ ...a, [k]: v }));
111
+ const setSetting = <K extends keyof Settings>(k: K, v: Settings[K]) => setSettings((s) => ({ ...s, [k]: v }));
112
+
113
+ return { view, setView, taskView, setTaskView, appearance, setAppearance: setAppearanceKey, settings, setSetting, setSettings, layout, setLayout, hydrated };
114
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState, type MutableRefObject } from "react";
4
+ import { jget, jsend } from "./api";
5
+ import type { RecapInfo, TaskRow } from "./types";
6
+
7
+ // Owns project "where you left off" recaps plus the selection-on-landing logic:
8
+ // fetch/generate a recap when entering a project, sweep stale projects on an
9
+ // interval, and decide whether to land on the recap or auto-pick the first task.
10
+ export function useRecaps({ selProj, selTask, tasks, setSelTask, selProjRef, settingsReady, backgroundJobs, recapMode }: {
11
+ selProj: string | null;
12
+ selTask: string | null;
13
+ tasks: TaskRow[];
14
+ setSelTask: (id: string | null) => void;
15
+ selProjRef: MutableRefObject<string | null>;
16
+ settingsReady: boolean;
17
+ backgroundJobs: boolean;
18
+ recapMode: "automatic" | "on_open" | "off";
19
+ }) {
20
+ const [recaps, setRecaps] = useState<Record<string, RecapInfo>>({});
21
+
22
+ // Fetch a project's recap status; if it's gone stale with new activity and
23
+ // none is ready yet, generate one on demand (so it's there the moment you land).
24
+ const fetchRecap = useCallback(async (pid: string, regen = false) => {
25
+ try {
26
+ let info = await jget<RecapInfo>(`/api/projects/${pid}/recap`);
27
+ setRecaps((m) => ({ ...m, [pid]: info }));
28
+ const unattended = !regen;
29
+ const canGenerate = regen || (settingsReady && backgroundJobs && recapMode !== "off");
30
+ if (canGenerate && (regen || (info.needsRecap && !info.recap && !info.generating))) {
31
+ setRecaps((m) => ({ ...m, [pid]: { ...info, generating: true } }));
32
+ const g = await jsend<{ recap: string; recap_at: number }>(`/api/projects/${pid}/recap`, "POST", { unattended });
33
+ info = { ...info, recap: g.recap, recap_at: g.recap_at, needsRecap: false, generating: false, hasHistory: true };
34
+ setRecaps((m) => ({ ...m, [pid]: info }));
35
+ }
36
+ } catch (e) {
37
+ // Keep whatever we had (an older recap is still useful) and record the
38
+ // failure so the landing pane can offer a retry.
39
+ const msg = e instanceof Error ? e.message : String(e);
40
+ setRecaps((m) => ({ ...m, [pid]: { ...(m[pid] as RecapInfo), generating: false, error: msg } }));
41
+ }
42
+ }, [backgroundJobs, recapMode, settingsReady]);
43
+
44
+ useEffect(() => { if (selProj && settingsReady) fetchRecap(selProj); }, [selProj, settingsReady, fetchRecap]);
45
+
46
+ // Landing decision: once tasks + recap status are in, auto-select the first
47
+ // task ONLY when there's no recap to show. If you're returning to a project
48
+ // with a recap (or one brewing), land on the recap so you can reorient first.
49
+ //
50
+ // Never on mobile: there the task list is its own pane, so auto-selecting a
51
+ // task forces you into the session view and you can never see the list — in
52
+ // particular it would instantly undo a Back press that cleared the task,
53
+ // making it impossible to switch tasks. On mobile, no task selected = show the
54
+ // task list, which is exactly what we want.
55
+ useEffect(() => {
56
+ if (!selProj || selTask) return;
57
+ if (window.matchMedia("(max-width: 760px)").matches) return;
58
+ const info = recaps[selProj];
59
+ if (info === undefined) return; // wait for recap status
60
+ const showRecap = !!info.recap || info.needsRecap || info.generating;
61
+ if (!showRecap) {
62
+ const first = tasks.find((t) => !t.suggested);
63
+ if (first) setSelTask(first.id);
64
+ }
65
+ }, [selProj, selTask, recaps, tasks, setSelTask]);
66
+
67
+ // A task restored from the URL may have been deleted since. Once the current
68
+ // project's tasks are loaded, drop a selTask that isn't among them so the
69
+ // landing decision can pick a valid target. The project_id gate avoids firing
70
+ // during the window where `tasks` still holds the previous project's list.
71
+ useEffect(() => {
72
+ if (!selTask || tasks.length === 0 || tasks[0].project_id !== selProj) return;
73
+ if (!tasks.some((t) => t.id === selTask)) setSelTask(null);
74
+ }, [selTask, tasks, selProj, setSelTask]);
75
+
76
+ // Proactively sweep for stale projects on load and on an interval, then
77
+ // refresh the currently-open project's recap so a freshly-baked one appears.
78
+ useEffect(() => {
79
+ if (!settingsReady || !backgroundJobs || recapMode !== "automatic") return;
80
+ const sweep = () => {
81
+ jsend("/api/recaps/sweep", "POST")
82
+ .then(() => { if (selProjRef.current) fetchRecap(selProjRef.current); })
83
+ .catch(() => {});
84
+ };
85
+ sweep();
86
+ const t = setInterval(sweep, 5 * 60 * 1000);
87
+ return () => clearInterval(t);
88
+ }, [backgroundJobs, fetchRecap, recapMode, selProjRef, settingsReady]);
89
+
90
+ return { recaps, fetchRecap };
91
+ }
@@ -0,0 +1,279 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
4
+ import type { TaskStreamEvent, ToolData, AskAnswers, Message } from "./protocol/types";
5
+ import { jget } from "./api";
6
+ import { openLive } from "./liveStream";
7
+ import { HISTORY_PAGE, SNAPSHOT_TAIL, indexFromEnd, mergeSnapshot, toolDataOf, touchRecent, trimToTail } from "./transcriptModel";
8
+ import { contextPct } from "./format";
9
+ import { capsFor } from "./agents";
10
+ import type { AgentsBundle, Msg, ProjectRow, TaskRow } from "./types";
11
+
12
+ // Owns the per-task transcript state (msgsByTask) plus the live stream: the
13
+ // snapshot-then-tail subscription and the message mutators that apply each
14
+ // server event. The turn itself runs server-side, detached from any connection.
15
+ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning, setTasks, setProjects, loadTasks }: {
16
+ selTask: string | null;
17
+ selProjRef: MutableRefObject<string | null>;
18
+ agentsRef: MutableRefObject<AgentsBundle>;
19
+ setTaskRunning: (id: string, on: boolean) => void;
20
+ setTasks: React.Dispatch<React.SetStateAction<TaskRow[]>>;
21
+ setProjects: React.Dispatch<React.SetStateAction<ProjectRow[]>>;
22
+ loadTasks: (projectId: string, selectFirst?: boolean) => Promise<void>;
23
+ }) {
24
+ const [msgsByTask, setMsgsByTask] = useState<Record<string, Msg[]>>({});
25
+ // How many earlier messages each task has that aren't loaded yet — the live
26
+ // view opens with only the newest SNAPSHOT_TAIL and pages the rest in on
27
+ // demand (loadOlder), so a huge transcript never lands in the tab at once.
28
+ const [olderByTask, setOlderByTask] = useState<Record<string, number>>({});
29
+ const msgsRef = useRef(msgsByTask);
30
+ msgsRef.current = msgsByTask;
31
+ const olderRef = useRef(olderByTask);
32
+ olderRef.current = olderByTask;
33
+ const loadingOlderRef = useRef<Record<string, boolean>>({});
34
+ // Tasks whose transcript this tab keeps, most recently opened first.
35
+ const recentRef = useRef<string[]>([]);
36
+
37
+ // Asks still awaiting an answer, per task. One assistant message can park
38
+ // several AskUserQuestion cards at once, and the "Needs your input" flag must
39
+ // stay up until the last one is answered — mirrors openAsks in lib/runner.ts.
40
+ const openAsksRef = useRef<Record<string, Set<string>>>({});
41
+
42
+ const appendMsg = (taskId: string, m: Msg) =>
43
+ setMsgsByTask((prev) => ({ ...prev, [taskId]: [...(prev[taskId] ?? []), m] }));
44
+
45
+ // Mark an AskUserQuestion message (matched by tool id) as answered. Matches on
46
+ // the id embedded in the content too, so it works for reloaded messages whose
47
+ // in-memory toolId was lost.
48
+ const setAnswerOnMsg = (taskId: string, askId: string, answers: AskAnswers) =>
49
+ setMsgsByTask((prev) => {
50
+ const arr = prev[taskId] ?? [];
51
+ return {
52
+ ...prev,
53
+ [taskId]: arr.map((m) => {
54
+ if (m.role !== "tool") return m;
55
+ try {
56
+ const d = JSON.parse(m.content) as ToolData;
57
+ if (m.toolId !== askId && d.ask?.id !== askId) return m;
58
+ d.ask = { id: d.ask?.id ?? askId, questions: d.ask?.questions ?? [], answers };
59
+ return { ...m, content: JSON.stringify(d) };
60
+ } catch {
61
+ return m;
62
+ }
63
+ }),
64
+ };
65
+ });
66
+
67
+ // Insert-or-replace a message by id. Live events carry their DB message id,
68
+ // so replays after a reconnect (snapshot + tail overlap) stay idempotent.
69
+ // Searched from the newest end, where live updates land.
70
+ const upsertMsg = (taskId: string, m: Msg) =>
71
+ setMsgsByTask((prev) => {
72
+ const arr = prev[taskId] ?? [];
73
+ const i = indexFromEnd(arr, m.id);
74
+ if (i === -1) return { ...prev, [taskId]: [...arr, m] };
75
+ const next = arr.slice();
76
+ next[i] = { ...arr[i], ...m };
77
+ return { ...prev, [taskId]: next };
78
+ });
79
+
80
+ // Drop a message by id — used when a queued follow-up is dequeued (about to
81
+ // run, or cancelled). If it's about to run, the matching `user` event re-adds
82
+ // it as a committed message.
83
+ const removeMsg = (taskId: string, msgId: string) =>
84
+ setMsgsByTask((prev) => ({ ...prev, [taskId]: (prev[taskId] ?? []).filter((m) => m.id !== msgId) }));
85
+
86
+ // Apply one server event to local state. Used by the per-task live
87
+ // subscription below; the turn itself runs server-side, detached from any
88
+ // connection.
89
+ const handleStreamEvent = (taskId: string, ev: TaskStreamEvent) => {
90
+ if (ev.type === "snapshot") {
91
+ // Authoritative catch-up: the full persisted transcript, then any parked
92
+ // follow-ups as "queued" bubbles (so a reload mid-run re-renders them),
93
+ // plus whether a turn is live right now (true after a reload mid-turn).
94
+ // The snapshot is only the newest slice (?tail=N); mergeSnapshot keeps
95
+ // older pages this tab already loaded and reuses unchanged message
96
+ // objects, so a reconnect doesn't re-render the whole transcript.
97
+ const committed: Msg[] = ev.messages.map(toMsg);
98
+ const queued: Msg[] = ev.pending.map((p) => ({ id: p.id, role: "queued" as const, content: p.content, generation: p.generation, ts: p.created_at }));
99
+ const merged = mergeSnapshot(msgsRef.current[taskId], committed, queued, ev.older ?? 0);
100
+ msgsRef.current = { ...msgsRef.current, [taskId]: merged.msgs };
101
+ setMsgsByTask((prev) => ({ ...prev, [taskId]: merged.msgs }));
102
+ setOlderByTask((prev) => ({ ...prev, [taskId]: merged.older }));
103
+ // Rebuild the open-ask set from the persisted transcript so a reload
104
+ // mid-turn (with asks still parked) counts them correctly.
105
+ const open = new Set<string>();
106
+ for (const m of committed) {
107
+ if (m.role !== "tool") continue;
108
+ const d = toolDataOf(m);
109
+ if (d.ask && !d.ask.answers) open.add(d.ask.id);
110
+ }
111
+ openAsksRef.current[taskId] = open;
112
+ setTaskRunning(taskId, ev.running);
113
+ return;
114
+ }
115
+ const gen = ("generation" in ev ? ev.generation : undefined) ?? 1;
116
+ if (ev.type === "user") upsertMsg(taskId, { id: ev.msgId, role: "user", content: ev.content, generation: gen, ts: ev.ts });
117
+ else if (ev.type === "queued") upsertMsg(taskId, { id: ev.msgId, role: "queued", content: ev.content, generation: gen, ts: ev.ts });
118
+ else if (ev.type === "dequeued") removeMsg(taskId, ev.msgId);
119
+ else if (ev.type === "model") setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, resolved_model: ev.model } : x)));
120
+ else if (ev.type === "assistant") upsertMsg(taskId, { id: ev.msgId ?? `a-${Date.now()}-${Math.random()}`, role: "assistant", content: ev.content, generation: gen, ts: ev.ts });
121
+ else if (ev.type === "tool") {
122
+ const data: ToolData = { title: ev.title, detail: ev.detail, peek: ev.peek, diff: ev.diff };
123
+ upsertMsg(taskId, { id: ev.msgId ?? `t-${Date.now()}-${Math.random()}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id });
124
+ } else if (ev.type === "tool_result") {
125
+ // Match by DB message id (works on snapshot-loaded messages too), falling
126
+ // back to the in-memory tool_use id. Newest first: results land near the end.
127
+ setMsgsByTask((prev) => {
128
+ const arr = prev[taskId] ?? [];
129
+ let i = arr.length - 1;
130
+ while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== ev.msgId && arr[i].toolId !== ev.id))) i--;
131
+ if (i < 0) return prev;
132
+ const m = arr[i];
133
+ let content: string;
134
+ try { const d = JSON.parse(m.content) as ToolData; d.result = ev.content; d.isError = ev.isError; if (ev.peek) d.peek = ev.peek; content = JSON.stringify(d); } catch { return prev; }
135
+ const next = arr.slice();
136
+ next[i] = { ...m, content };
137
+ return { ...prev, [taskId]: next };
138
+ });
139
+ } else if (ev.type === "ask") {
140
+ const data: ToolData = { title: "有问题需要你回答", ask: { id: ev.id, questions: ev.questions } };
141
+ upsertMsg(taskId, { id: ev.msgId ?? `ask-${ev.id}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id });
142
+ (openAsksRef.current[taskId] ??= new Set()).add(ev.id);
143
+ // Parked on a question: flag the row so it jumps to "Needs your input"
144
+ // (and triggers a notification) while the turn is still live. Mirrors the
145
+ // server's updateTask in lib/runner.ts.
146
+ setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: 1 } : x)));
147
+ } else if (ev.type === "ask_answered") {
148
+ setAnswerOnMsg(taskId, ev.id, ev.answers);
149
+ // Only drop the flag once every parked ask on this task is answered.
150
+ const open = openAsksRef.current[taskId];
151
+ open?.delete(ev.id);
152
+ if (!open || open.size === 0) {
153
+ setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: 0 } : x)));
154
+ }
155
+ } else if (ev.type === "input") {
156
+ // browser_ask_user card raised or settled: the event carries the whole card.
157
+ const data: ToolData = { title: "需要你提供信息", input: ev.input };
158
+ upsertMsg(taskId, { id: ev.msgId ?? `in-${ev.input.id}`, role: "tool", content: JSON.stringify(data), generation: gen });
159
+ const waiting = ev.input.status === "pending";
160
+ setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: waiting ? 1 : x.awaiting_input && openAsksRef.current[taskId]?.size ? 1 : 0 } : x)));
161
+ } else if (ev.type === "usage") {
162
+ // Live cumulative spend: add this turn's totals to the task's figure.
163
+ // Context occupancy, by contrast, is the latest turn's INPUT-side
164
+ // tokens (not a sum) — it tracks how full the window is right now.
165
+ const u = ev.usage;
166
+ const turnTokens = u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_creation_tokens;
167
+ const ctxTokens = u.input_tokens + u.cache_read_tokens + u.cache_creation_tokens;
168
+ setTasks((prev) => prev.map((x) => (x.id === taskId
169
+ ? { ...x, cost_usd: (x.cost_usd ?? 0) + u.cost_usd, total_tokens: (x.total_tokens ?? 0) + turnTokens,
170
+ // Cache buckets accumulate alongside the total so the usage chip's
171
+ // fresh-vs-cached split stays right mid-turn, not just after a reload.
172
+ cache_read_tokens: (x.cache_read_tokens ?? 0) + u.cache_read_tokens,
173
+ cache_creation_tokens: (x.cache_creation_tokens ?? 0) + u.cache_creation_tokens,
174
+ context_tokens: ctxTokens, context_pct: contextPct(ctxTokens, x.model, capsFor(agentsRef.current, x.agent)) }
175
+ : x)));
176
+ } else if (ev.type === "notice") upsertMsg(taskId, { id: ev.msgId ?? `n-${Date.now()}`, role: "system", content: ev.content, generation: gen });
177
+ else if (ev.type === "error") upsertMsg(taskId, { id: ev.msgId ?? `e-${Date.now()}`, role: "system", content: ev.content, generation: gen });
178
+ else if (ev.type === "suggested") { if (selProjRef.current) loadTasks(selProjRef.current, false); }
179
+ else if (ev.type === "turn_end") {
180
+ setTaskRunning(taskId, false);
181
+ // Any still-parked asks died with the turn (Stop rejects them server-side);
182
+ // the refetch below restores the authoritative awaiting_input flag.
183
+ delete openAsksRef.current[taskId];
184
+ void (async () => {
185
+ try { const fresh = await jget<TaskRow>(`/api/tasks/${taskId}`); setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, ...fresh } : x))); } catch {}
186
+ // Refresh the project list so the cross-project "Needs you" counts/badges update.
187
+ try { setProjects(await jget<ProjectRow[]>("/api/projects")); } catch {}
188
+ })();
189
+ }
190
+ };
191
+ // The handler reads lots of component state; keep the EventSource effect's
192
+ // dependency list down to the task id by routing through a ref.
193
+ const handleStreamEventRef = useRef(handleStreamEvent);
194
+ useEffect(() => { handleStreamEventRef.current = handleStreamEvent; });
195
+
196
+ // One live stream per selected task: the server replays a snapshot of the
197
+ // persisted transcript, then tails live turn events. Opening a task,
198
+ // reloading mid-turn, and waking from laptop sleep all converge on the same
199
+ // catch-up-then-tail path — EventSource reconnects re-snapshot automatically.
200
+ //
201
+ // Carried over the tab's shared live socket (./liveStream) rather than its own
202
+ // EventSource, and asking for only the newest SNAPSHOT_TAIL messages.
203
+ useEffect(() => {
204
+ if (!selTask) return;
205
+ const id = selTask;
206
+ // A tab can stay open for days and visit hundreds of tasks: keep the most
207
+ // recent KEEP_TRANSCRIPTS (switching back to those is instant), each
208
+ // trimmed to a snapshot's size once left; older ones are dropped and simply
209
+ // re-snapshot if opened again. The task just left no longer streams (its
210
+ // subscription closed above), so trimming it races nothing.
211
+ const left = recentRef.current[0];
212
+ if (left && left !== id && msgsRef.current[left]) {
213
+ const t = trimToTail(msgsRef.current[left], olderRef.current[left] ?? 0);
214
+ if (t.msgs !== msgsRef.current[left]) {
215
+ msgsRef.current = { ...msgsRef.current, [left]: t.msgs };
216
+ setMsgsByTask((prev) => ({ ...prev, [left]: t.msgs }));
217
+ setOlderByTask((prev) => ({ ...prev, [left]: t.older }));
218
+ }
219
+ }
220
+ const { recent, evicted } = touchRecent(recentRef.current, id);
221
+ recentRef.current = recent;
222
+ if (evicted.length) {
223
+ const drop = <T,>(rec: Record<string, T>) => {
224
+ if (!evicted.some((t) => t in rec)) return rec;
225
+ const next = { ...rec };
226
+ for (const t of evicted) delete next[t];
227
+ return next;
228
+ };
229
+ msgsRef.current = drop(msgsRef.current);
230
+ setMsgsByTask(drop);
231
+ setOlderByTask(drop);
232
+ for (const t of evicted) { delete openAsksRef.current[t]; delete loadingOlderRef.current[t]; }
233
+ }
234
+ const sub = openLive(`/api/tasks/${id}/messages?tail=${SNAPSHOT_TAIL}`, {
235
+ onMessage: (data) => {
236
+ try { handleStreamEventRef.current(id, JSON.parse(data) as TaskStreamEvent); } catch {}
237
+ },
238
+ });
239
+ return () => sub.close();
240
+ }, [selTask]);
241
+
242
+ // Page in the next batch of earlier messages for a task (the transcript calls
243
+ // this when the user scrolls to the top of what's loaded). Resolves true when
244
+ // something was added.
245
+ // `beforeApply` runs right before the page goes into state — the transcript
246
+ // measures its scroll anchor there, before the DOM changes.
247
+ const loadOlder = useCallback(async (taskId: string, beforeApply?: () => void): Promise<boolean> => {
248
+ if (loadingOlderRef.current[taskId] || !(olderRef.current[taskId] > 0)) return false;
249
+ const first = (msgsRef.current[taskId] ?? []).find((m) => m.role !== "queued" && m.ts != null);
250
+ if (!first) return false;
251
+ loadingOlderRef.current[taskId] = true;
252
+ try {
253
+ const page = await jget<{ messages: Message[]; older: number }>(`/api/tasks/${taskId}/history?before=${encodeURIComponent(first.id)}&limit=${HISTORY_PAGE}`);
254
+ // A reconnect replaced the list meanwhile: this page no longer joins up.
255
+ const joins = (list: Msg[] | undefined) => (list ?? []).find((m) => m.role !== "queued" && m.ts != null)?.id === first.id;
256
+ if (!joins(msgsRef.current[taskId])) return false;
257
+ beforeApply?.();
258
+ setMsgsByTask((prev) => {
259
+ const cur = prev[taskId] ?? [];
260
+ if (!joins(cur)) return prev;
261
+ const have = new Set(cur.map((m) => m.id));
262
+ const add = page.messages.map(toMsg).filter((m) => !have.has(m.id));
263
+ return { ...prev, [taskId]: [...add, ...cur] };
264
+ });
265
+ setOlderByTask((prev) => ({ ...prev, [taskId]: page.older }));
266
+ return true;
267
+ } catch {
268
+ return false;
269
+ } finally {
270
+ loadingOlderRef.current[taskId] = false;
271
+ }
272
+ }, []);
273
+
274
+ return { msgsByTask, olderByTask, loadOlder, appendMsg, setAnswerOnMsg };
275
+ }
276
+
277
+ function toMsg(m: Message): Msg {
278
+ return { id: m.id, role: m.role, content: m.content, generation: m.generation, ts: m.created_at };
279
+ }