@spzxg/cli-ui 0.6.2 → 0.6.6
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.
- package/CHANGELOG.md +49 -0
- package/README.md +165 -19
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +17 -10
- package/src/board/BrowserPane.tsx +346 -115
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +54 -31
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +94 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/frameQuote.ts +2 -2
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +9 -7
- package/src/board/workspace.ts +53 -14
- package/src/chat/CliChat.tsx +93 -40
- package/src/chat/Composer.tsx +44 -42
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +17 -6
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +103 -19
- package/src/core/conversation.ts +4 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +31 -14
- package/src/core/store.ts +151 -53
- package/src/core/types.ts +64 -9
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +19 -13
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +468 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +82 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +280 -0
- package/src/operator.base.css +1806 -0
- package/src/operator.css +194 -16
- package/src/styles.css +49 -1
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +82 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +17 -10
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- 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,280 @@
|
|
|
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
|
+
const reported = ev.awaiting_input;
|
|
153
|
+
const awaiting = reported === 0 || reported === 1 ? reported : open?.size ? 1 : 0;
|
|
154
|
+
setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: awaiting } : x)));
|
|
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 reported = ev.awaiting_input;
|
|
160
|
+
const waiting = ev.input.status === "pending";
|
|
161
|
+
setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: reported === 0 || reported === 1 ? reported : waiting ? 1 : x.awaiting_input && openAsksRef.current[taskId]?.size ? 1 : 0 } : x)));
|
|
162
|
+
} else if (ev.type === "usage") {
|
|
163
|
+
// Live cumulative spend: add this turn's totals to the task's figure.
|
|
164
|
+
// Context occupancy, by contrast, is the latest turn's INPUT-side
|
|
165
|
+
// tokens (not a sum) — it tracks how full the window is right now.
|
|
166
|
+
const u = ev.usage;
|
|
167
|
+
const turnTokens = u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_creation_tokens;
|
|
168
|
+
const ctxTokens = u.input_tokens + u.cache_read_tokens + u.cache_creation_tokens;
|
|
169
|
+
setTasks((prev) => prev.map((x) => (x.id === taskId
|
|
170
|
+
? { ...x, cost_usd: (x.cost_usd ?? 0) + u.cost_usd, total_tokens: (x.total_tokens ?? 0) + turnTokens,
|
|
171
|
+
// Cache buckets accumulate alongside the total so the usage chip's
|
|
172
|
+
// fresh-vs-cached split stays right mid-turn, not just after a reload.
|
|
173
|
+
cache_read_tokens: (x.cache_read_tokens ?? 0) + u.cache_read_tokens,
|
|
174
|
+
cache_creation_tokens: (x.cache_creation_tokens ?? 0) + u.cache_creation_tokens,
|
|
175
|
+
context_tokens: ctxTokens, context_pct: contextPct(ctxTokens, x.model, capsFor(agentsRef.current, x.agent)) }
|
|
176
|
+
: x)));
|
|
177
|
+
} else if (ev.type === "notice") upsertMsg(taskId, { id: ev.msgId ?? `n-${Date.now()}`, role: "system", content: ev.content, generation: gen });
|
|
178
|
+
else if (ev.type === "error") upsertMsg(taskId, { id: ev.msgId ?? `e-${Date.now()}`, role: "system", content: ev.content, generation: gen });
|
|
179
|
+
else if (ev.type === "suggested") { if (selProjRef.current) loadTasks(selProjRef.current, false); }
|
|
180
|
+
else if (ev.type === "turn_end") {
|
|
181
|
+
setTaskRunning(taskId, false);
|
|
182
|
+
// Any still-parked asks died with the turn (Stop rejects them server-side);
|
|
183
|
+
// the refetch below restores the authoritative awaiting_input flag.
|
|
184
|
+
delete openAsksRef.current[taskId];
|
|
185
|
+
void (async () => {
|
|
186
|
+
try { const fresh = await jget<TaskRow>(`/api/tasks/${taskId}`); setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, ...fresh } : x))); } catch {}
|
|
187
|
+
// Refresh the project list so the cross-project "Needs you" counts/badges update.
|
|
188
|
+
try { setProjects(await jget<ProjectRow[]>("/api/projects")); } catch {}
|
|
189
|
+
})();
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
// The handler reads lots of component state; keep the EventSource effect's
|
|
193
|
+
// dependency list down to the task id by routing through a ref.
|
|
194
|
+
const handleStreamEventRef = useRef(handleStreamEvent);
|
|
195
|
+
useEffect(() => { handleStreamEventRef.current = handleStreamEvent; });
|
|
196
|
+
|
|
197
|
+
// One live stream per selected task: the server replays a snapshot of the
|
|
198
|
+
// persisted transcript, then tails live turn events. Opening a task,
|
|
199
|
+
// reloading mid-turn, and waking from laptop sleep all converge on the same
|
|
200
|
+
// catch-up-then-tail path — EventSource reconnects re-snapshot automatically.
|
|
201
|
+
//
|
|
202
|
+
// Carried over the tab's shared live socket (./liveStream) rather than its own
|
|
203
|
+
// EventSource, and asking for only the newest SNAPSHOT_TAIL messages.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!selTask) return;
|
|
206
|
+
const id = selTask;
|
|
207
|
+
// A tab can stay open for days and visit hundreds of tasks: keep the most
|
|
208
|
+
// recent KEEP_TRANSCRIPTS (switching back to those is instant), each
|
|
209
|
+
// trimmed to a snapshot's size once left; older ones are dropped and simply
|
|
210
|
+
// re-snapshot if opened again. The task just left no longer streams (its
|
|
211
|
+
// subscription closed above), so trimming it races nothing.
|
|
212
|
+
const left = recentRef.current[0];
|
|
213
|
+
if (left && left !== id && msgsRef.current[left]) {
|
|
214
|
+
const t = trimToTail(msgsRef.current[left], olderRef.current[left] ?? 0);
|
|
215
|
+
if (t.msgs !== msgsRef.current[left]) {
|
|
216
|
+
msgsRef.current = { ...msgsRef.current, [left]: t.msgs };
|
|
217
|
+
setMsgsByTask((prev) => ({ ...prev, [left]: t.msgs }));
|
|
218
|
+
setOlderByTask((prev) => ({ ...prev, [left]: t.older }));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const { recent, evicted } = touchRecent(recentRef.current, id);
|
|
222
|
+
recentRef.current = recent;
|
|
223
|
+
if (evicted.length) {
|
|
224
|
+
const drop = <T,>(rec: Record<string, T>) => {
|
|
225
|
+
if (!evicted.some((t) => t in rec)) return rec;
|
|
226
|
+
const next = { ...rec };
|
|
227
|
+
for (const t of evicted) delete next[t];
|
|
228
|
+
return next;
|
|
229
|
+
};
|
|
230
|
+
msgsRef.current = drop(msgsRef.current);
|
|
231
|
+
setMsgsByTask(drop);
|
|
232
|
+
setOlderByTask(drop);
|
|
233
|
+
for (const t of evicted) { delete openAsksRef.current[t]; delete loadingOlderRef.current[t]; }
|
|
234
|
+
}
|
|
235
|
+
const sub = openLive(`/api/tasks/${id}/messages?tail=${SNAPSHOT_TAIL}`, {
|
|
236
|
+
onMessage: (data) => {
|
|
237
|
+
try { handleStreamEventRef.current(id, JSON.parse(data) as TaskStreamEvent); } catch {}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
return () => sub.close();
|
|
241
|
+
}, [selTask]);
|
|
242
|
+
|
|
243
|
+
// Page in the next batch of earlier messages for a task (the transcript calls
|
|
244
|
+
// this when the user scrolls to the top of what's loaded). Resolves true when
|
|
245
|
+
// something was added.
|
|
246
|
+
// `beforeApply` runs right before the page goes into state — the transcript
|
|
247
|
+
// measures its scroll anchor there, before the DOM changes.
|
|
248
|
+
const loadOlder = useCallback(async (taskId: string, beforeApply?: () => void): Promise<boolean> => {
|
|
249
|
+
if (loadingOlderRef.current[taskId] || !(olderRef.current[taskId] > 0)) return false;
|
|
250
|
+
const first = (msgsRef.current[taskId] ?? []).find((m) => m.role !== "queued" && m.ts != null);
|
|
251
|
+
if (!first) return false;
|
|
252
|
+
loadingOlderRef.current[taskId] = true;
|
|
253
|
+
try {
|
|
254
|
+
const page = await jget<{ messages: Message[]; older: number }>(`/api/tasks/${taskId}/history?before=${encodeURIComponent(first.id)}&limit=${HISTORY_PAGE}`);
|
|
255
|
+
// A reconnect replaced the list meanwhile: this page no longer joins up.
|
|
256
|
+
const joins = (list: Msg[] | undefined) => (list ?? []).find((m) => m.role !== "queued" && m.ts != null)?.id === first.id;
|
|
257
|
+
if (!joins(msgsRef.current[taskId])) return false;
|
|
258
|
+
beforeApply?.();
|
|
259
|
+
setMsgsByTask((prev) => {
|
|
260
|
+
const cur = prev[taskId] ?? [];
|
|
261
|
+
if (!joins(cur)) return prev;
|
|
262
|
+
const have = new Set(cur.map((m) => m.id));
|
|
263
|
+
const add = page.messages.map(toMsg).filter((m) => !have.has(m.id));
|
|
264
|
+
return { ...prev, [taskId]: [...add, ...cur] };
|
|
265
|
+
});
|
|
266
|
+
setOlderByTask((prev) => ({ ...prev, [taskId]: page.older }));
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
} finally {
|
|
271
|
+
loadingOlderRef.current[taskId] = false;
|
|
272
|
+
}
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
return { msgsByTask, olderByTask, loadOlder, appendMsg, setAnswerOnMsg };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toMsg(m: Message): Msg {
|
|
279
|
+
return { id: m.id, role: m.role, content: m.content, generation: m.generation, ts: m.created_at };
|
|
280
|
+
}
|