@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.
- package/CHANGELOG.md +26 -0
- package/README.md +137 -11
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- 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 +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +73 -30
- package/src/chat/Composer.tsx +41 -41
- 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 +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +8 -6
- package/src/core/conversation.ts +3 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- 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/msglist/CliMsgList.tsx +10 -10
- 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 +463 -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 +80 -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 +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +20 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- 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,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Priority, Status } from "./protocol/types";
|
|
4
|
+
import { Icon, AgentMark } from "./icons";
|
|
5
|
+
import { SCLS, SLABEL, AWAIT_LABEL } from "./types";
|
|
6
|
+
|
|
7
|
+
export function StatusDot({ status, running, awaiting, lg }: { status: Status; running?: boolean; awaiting?: boolean; lg?: boolean }) {
|
|
8
|
+
// Signal language (mission-control): "needs your input" is an alert coral, a
|
|
9
|
+
// *live* working session is blue (both pulse to draw the eye), and an idle
|
|
10
|
+
// status falls back to its base color. Awaiting wins over running — a turn
|
|
11
|
+
// parked on a question is technically live but it's really waiting on you.
|
|
12
|
+
const cls = awaiting ? "c" : running ? "b" : SCLS[status];
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
className={`sdot ${cls} ${lg ? "lg" : ""} ${awaiting || running ? "pulse" : ""}`}
|
|
16
|
+
title={awaiting ? AWAIT_LABEL : running ? "运行中" : SLABEL[status]}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function PriPill({ p }: { p: Priority }) {
|
|
22
|
+
const map: Record<Priority, string> = { hi: "高", med: "中", lo: "低" };
|
|
23
|
+
return <span className={`pri ${p}`}>{map[p]}</span>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SearchBar({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="search-bar">
|
|
29
|
+
<span className="search-ic">{Icon.search()}</span>
|
|
30
|
+
<input
|
|
31
|
+
className="search-input" value={value} placeholder={placeholder} spellCheck={false}
|
|
32
|
+
onChange={(e) => onChange(e.target.value)}
|
|
33
|
+
onKeyDown={(e) => { if (e.key === "Escape" && value) { e.stopPropagation(); onChange(""); } }}
|
|
34
|
+
/>
|
|
35
|
+
{value && <button className="search-clear" title="清除搜索" onClick={() => onChange("")}>{Icon.x()}</button>}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Chat avatar. The assistant side wears the brand mark of the agent the task
|
|
41
|
+
// actually runs on (Claude / Codex), so a transcript says at a glance who wrote
|
|
42
|
+
// it; an unknown or missing agent id falls back to the generic bolt.
|
|
43
|
+
export function Avatar({ who, agent }: { who: "user" | "cc"; agent?: string | null }) {
|
|
44
|
+
if (who === "user") return <span className="av you">A</span>;
|
|
45
|
+
const mark = agent ? AgentMark[agent] : undefined;
|
|
46
|
+
return <span className={`av cc${mark ? ` ${agent}` : ""}`}>{mark ? mark() : Icon.bolt()}</span>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Which agent driver a task runs under (Claude Code / Codex …). Hidden when only
|
|
50
|
+
// one agent is available (nothing to disambiguate) so single-agent workspaces
|
|
51
|
+
// stay clutter-free. `multi` is passed by the caller from the agents bundle.
|
|
52
|
+
export function AgentBadge({ label, multi }: { label: string; multi: boolean }) {
|
|
53
|
+
if (!multi) return null;
|
|
54
|
+
return <span className="agent-badge" title={`运行于 ${label}`}>{label}</span>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- async-state primitives (pair with the .spinner/.load-note/.skel/.err-note
|
|
58
|
+
// styles in globals.css) — every panel that fetches uses these, so loading and
|
|
59
|
+
// error presentation stays uniform across the app. ----
|
|
60
|
+
|
|
61
|
+
export function Spinner({ size }: { size?: number }) {
|
|
62
|
+
return <span className="spinner" role="status" aria-label="加载中" style={size ? { width: size, height: size } : undefined} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Standard "we're fetching" line: spinner + quiet text. Replaces bare "Loading…".
|
|
66
|
+
export function LoadNote({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
|
67
|
+
return <div className="load-note" style={style}><Spinner size={13} />{children}</div>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One shimmer bar. Compose a few for card/list/transcript skeletons.
|
|
71
|
+
export function Skel({ w, h = 10, r, style }: { w: number | string; h?: number; r?: number | string; style?: React.CSSProperties }) {
|
|
72
|
+
return <span className="skel" aria-hidden style={{ width: w, height: h, ...(r !== undefined ? { borderRadius: r } : null), ...style }} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Recoverable-error line: the message plus an inline Retry when the caller can
|
|
76
|
+
// simply refetch. Same warm-red voice as transcript system errors.
|
|
77
|
+
export function ErrNote({ children, onRetry, retryLabel = "重试", style }: {
|
|
78
|
+
children: React.ReactNode; onRetry?: () => void; retryLabel?: string; style?: React.CSSProperties;
|
|
79
|
+
}) {
|
|
80
|
+
return (
|
|
81
|
+
<div className="err-note" style={style}>
|
|
82
|
+
<span className="err-msg">⚠ {children}</span>
|
|
83
|
+
{onRetry && <button className="btn btn-line btn-sm" onClick={onRetry}>{Icon.restore()} {retryLabel}</button>}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One shared accessible popover for the full workspace and standalone controls.
|
|
89
|
+
export { Popover } from "../../ui/Popover";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Pure helpers behind the live transcript (useTaskStream + SessionView), kept
|
|
2
|
+
// free of React so they can be unit-tested. The rules they encode exist for
|
|
3
|
+
// long tasks: thousands of messages, megabytes of tool output.
|
|
4
|
+
|
|
5
|
+
import type { ToolData } from "./protocol/types";
|
|
6
|
+
import type { Msg } from "./types";
|
|
7
|
+
import { buildSessions } from "./format";
|
|
8
|
+
|
|
9
|
+
/** Messages the live view's snapshot carries (GET /messages?tail=N). */
|
|
10
|
+
export const SNAPSHOT_TAIL = 300;
|
|
11
|
+
/** Older messages fetched per page from /history. */
|
|
12
|
+
export const HISTORY_PAGE = 300;
|
|
13
|
+
/** Transcript rows kept in the DOM while following the newest output. */
|
|
14
|
+
export const RENDER_WINDOW = 120;
|
|
15
|
+
/** Rows revealed per step when scrolling back (or forward again). */
|
|
16
|
+
export const RENDER_STEP = 80;
|
|
17
|
+
/** Most rows ever in the DOM at once; the far end is dropped beyond this. */
|
|
18
|
+
export const RENDER_MAX = 360;
|
|
19
|
+
/**
|
|
20
|
+
* Transcripts one tab keeps in memory: the open task plus the most recently
|
|
21
|
+
* left ones (switching back to those shows at once). Each kept one is trimmed
|
|
22
|
+
* to a snapshot's size when left (trimToTail), so on real node data this is
|
|
23
|
+
* ~10 MB typical, well under 100 MB at worst. Per tab: several tabs on the
|
|
24
|
+
* same task each keep their own.
|
|
25
|
+
*/
|
|
26
|
+
export const KEEP_TRANSCRIPTS = 30;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A transcript the tab is leaving, brought back to what a fresh snapshot holds
|
|
30
|
+
* (the newest `tail` messages; `older` grows by what was dropped), so a task
|
|
31
|
+
* read far back doesn't keep thousands of messages around in the background.
|
|
32
|
+
* Returns the input untouched when it is already that small.
|
|
33
|
+
*/
|
|
34
|
+
export function trimToTail(msgs: Msg[], older: number, tail = SNAPSHOT_TAIL): { msgs: Msg[]; older: number } {
|
|
35
|
+
const queued = msgs.filter((m) => m.role === "queued");
|
|
36
|
+
const rest = msgs.filter((m) => m.role !== "queued");
|
|
37
|
+
if (rest.length <= tail) return { msgs, older };
|
|
38
|
+
return { msgs: [...rest.slice(rest.length - tail), ...queued], older: older + rest.length - tail };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The tab's kept-transcript order after opening `id`, and which tasks fall out. */
|
|
42
|
+
export function touchRecent(recent: string[], id: string, keep = KEEP_TRANSCRIPTS): { recent: string[]; evicted: string[] } {
|
|
43
|
+
const next = [id, ...recent.filter((t) => t !== id)];
|
|
44
|
+
return { recent: next.slice(0, keep), evicted: next.slice(keep) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parsed tool payloads, cached per message object. Message objects are replaced
|
|
48
|
+
// whenever their content changes, so a cached entry can never go stale, and the
|
|
49
|
+
// transcript stops re-parsing every tool row's JSON on each live event.
|
|
50
|
+
const toolCache = new WeakMap<Msg, ToolData>();
|
|
51
|
+
/** The tool card behind a "tool" message. Read-only: never mutate the result. */
|
|
52
|
+
export function toolDataOf(m: Msg): ToolData {
|
|
53
|
+
let data = toolCache.get(m);
|
|
54
|
+
if (!data) {
|
|
55
|
+
try { data = JSON.parse(m.content) as ToolData; } catch { data = { title: m.content }; }
|
|
56
|
+
toolCache.set(m, data);
|
|
57
|
+
}
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Index of the message with this id, searching from the newest end (where live updates land). */
|
|
62
|
+
export function indexFromEnd(arr: Msg[], id: string): number {
|
|
63
|
+
for (let i = arr.length - 1; i >= 0; i--) if (arr[i].id === id) return i;
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when an AskUserQuestion card near the end is still unanswered. */
|
|
68
|
+
export function hasOpenAsk(messages: Msg[], scan = 400): boolean {
|
|
69
|
+
for (let i = messages.length - 1, n = 0; i >= 0 && n < scan; i--, n++) {
|
|
70
|
+
const m = messages[i];
|
|
71
|
+
if (m.role !== "tool") continue;
|
|
72
|
+
const d = toolDataOf(m);
|
|
73
|
+
if (d.ask && !d.ask.answers) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Apply a (re)connect snapshot to what the tab already holds.
|
|
80
|
+
*
|
|
81
|
+
* - Unchanged messages keep their object identity, so memoized rows don't
|
|
82
|
+
* re-render (and re-parse their markdown) on every reconnect.
|
|
83
|
+
* - Older pages the user already scrolled in are kept when the snapshot's tail
|
|
84
|
+
* still overlaps them; `older` is reduced by what was kept.
|
|
85
|
+
*/
|
|
86
|
+
export function mergeSnapshot(prev: Msg[] | undefined, committed: Msg[], queued: Msg[], older: number): { msgs: Msg[]; older: number } {
|
|
87
|
+
const byId = new Map<string, Msg>();
|
|
88
|
+
for (const m of prev ?? []) byId.set(m.id, m);
|
|
89
|
+
const reuse = (m: Msg): Msg => {
|
|
90
|
+
const p = byId.get(m.id);
|
|
91
|
+
if (!p || p.role !== m.role || p.content !== m.content || p.generation !== m.generation) return m;
|
|
92
|
+
return p.ts === m.ts ? p : { ...p, ts: m.ts };
|
|
93
|
+
};
|
|
94
|
+
const fresh = committed.map(reuse);
|
|
95
|
+
let kept: Msg[] = [];
|
|
96
|
+
if (prev && fresh.length && older > 0) {
|
|
97
|
+
const at = prev.findIndex((m) => m.id === fresh[0].id);
|
|
98
|
+
// Only persisted rows (they carry ts) — local notices died with the old view.
|
|
99
|
+
if (at > 0) kept = prev.slice(0, at).filter((m) => m.role !== "queued" && m.ts != null);
|
|
100
|
+
}
|
|
101
|
+
return { msgs: [...kept, ...fresh, ...queued.map(reuse)], older: Math.max(0, older - kept.length) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type TranscriptItem =
|
|
105
|
+
| { kind: "label"; key: string; n: number; current: boolean }
|
|
106
|
+
| { kind: "break"; key: string; summary: string }
|
|
107
|
+
| { kind: "msg"; key: string; m: Msg; initial: boolean; hideWho: boolean };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The transcript as a flat row list (session labels, /clear breaks, messages),
|
|
111
|
+
* so the view can render any window of it. `hasOlder`: earlier messages aren't
|
|
112
|
+
* loaded yet, so the first loaded one is not necessarily its session's opener.
|
|
113
|
+
*/
|
|
114
|
+
export function buildTranscriptItems(messages: Msg[], hasOlder: boolean): TranscriptItem[] {
|
|
115
|
+
const sessions = buildSessions(messages);
|
|
116
|
+
const items: TranscriptItem[] = [];
|
|
117
|
+
sessions.forEach((s, si) => {
|
|
118
|
+
if (si > 0 && s.summaryBefore) items.push({ kind: "break", key: `b-${s.n}`, summary: s.summaryBefore });
|
|
119
|
+
items.push({ kind: "label", key: `s-${s.n}`, n: s.n, current: si === sessions.length - 1 });
|
|
120
|
+
s.messages.forEach((m, mi) => {
|
|
121
|
+
const prev = s.messages[mi - 1];
|
|
122
|
+
// collapse the repeated agent header across an assistant run (text → tool → text)
|
|
123
|
+
const hideWho = m.role === "assistant" && !!prev && (prev.role === "assistant" || prev.role === "tool");
|
|
124
|
+
const initial = mi === 0 && m.role === "user" && !(si === 0 && hasOlder);
|
|
125
|
+
items.push({ kind: "msg", key: m.id, m, initial, hideWho });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
return items;
|
|
129
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Client-side shapes + UI constants shared across the orchestrator modules.
|
|
2
|
+
// Pure data only (no React / no Icon) so any module can import freely.
|
|
3
|
+
import type { Priority, Status } from "./protocol/types";
|
|
4
|
+
import type { InternalUsageEstimate } from "./protocol/internalUsage";
|
|
5
|
+
export type { InternalUsageEstimate };
|
|
6
|
+
|
|
7
|
+
// ---------- client shapes ----------
|
|
8
|
+
export interface ProjectRow {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
icon: string;
|
|
12
|
+
sub: string;
|
|
13
|
+
color: string;
|
|
14
|
+
context: string;
|
|
15
|
+
repo_path: string;
|
|
16
|
+
branch: string;
|
|
17
|
+
dev_command: string;
|
|
18
|
+
setup_command: string;
|
|
19
|
+
test_command: string;
|
|
20
|
+
default_agent: string; // agent driver new tasks in this project default to (lib/agents/registry.ts)
|
|
21
|
+
send_context: number; // 1 = new tasks default to sending the saved project context to the agent
|
|
22
|
+
port: number;
|
|
23
|
+
deprecated: number;
|
|
24
|
+
seeded: number; // 1 = built-in "Welcome" tutorial project (coach marks + post-merge nudge)
|
|
25
|
+
task_count: number;
|
|
26
|
+
last_activity: number;
|
|
27
|
+
awaiting_count: number; // in-progress tasks waiting on the user (across this project)
|
|
28
|
+
cost_usd: number; // cumulative dollar spend across all this project's tasks
|
|
29
|
+
}
|
|
30
|
+
export interface TaskRow {
|
|
31
|
+
parent_task_id?: string|null; fork_commit?: string; fork_source_title?: string; fork_context?: string; fork_group_id?: string; work_branch?: string; created_at?: number;
|
|
32
|
+
id: string;
|
|
33
|
+
project_id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
description: string;
|
|
36
|
+
priority: Priority;
|
|
37
|
+
status: Status;
|
|
38
|
+
suggested: number;
|
|
39
|
+
agent: string; // agent driver this task's sessions run under (lib/agents/); fixed for the task's life
|
|
40
|
+
send_context: number; // 1 = sessions get the saved project context (seeded from the project setting)
|
|
41
|
+
model: string | null;
|
|
42
|
+
resolved_model: string | null;
|
|
43
|
+
reasoning: string | null; // thinking preset; null = inherit default
|
|
44
|
+
permission_mode: string | null; // run permission; null = bypassPermissions (default)
|
|
45
|
+
session_id: string | null;
|
|
46
|
+
worktree_path: string; // isolated git worktree this task runs in ("" = not created yet — appears on the first turn)
|
|
47
|
+
pr_url: string; // GitHub PR opened from this task's branch ("" = none yet)
|
|
48
|
+
generation: number;
|
|
49
|
+
started: number;
|
|
50
|
+
running: number;
|
|
51
|
+
awaiting_input: number;
|
|
52
|
+
updated_at: number;
|
|
53
|
+
cost_usd: number; // cumulative dollar spend across all turns of this task
|
|
54
|
+
total_tokens: number; // cumulative tokens (input+output+cache) across all turns
|
|
55
|
+
cache_read_tokens: number; // of that total, context re-read from the prompt cache (~10% of input price)
|
|
56
|
+
cache_creation_tokens: number; // of that total, context written INTO the cache (fresh work)
|
|
57
|
+
depends_on: string[]; // task ids this task is blocked by until they're done
|
|
58
|
+
auto_start: number; // 1 = start automatically when the last unfinished blocker is marked done
|
|
59
|
+
context_tokens: number; // latest turn's input-side tokens ≈ current context-window occupancy
|
|
60
|
+
context_pct: number; // context_tokens as a percent (0–100) of the model's window
|
|
61
|
+
}
|
|
62
|
+
// A single row in the titlebar "need you" dropdown: an awaiting task plus enough
|
|
63
|
+
// of its project to label and color it. Mirrors lib/store.ts listNeedsYou().
|
|
64
|
+
export interface NeedsYouRow {
|
|
65
|
+
id: string;
|
|
66
|
+
project_id: string;
|
|
67
|
+
title: string;
|
|
68
|
+
project_name: string;
|
|
69
|
+
project_color: string;
|
|
70
|
+
project_icon: string;
|
|
71
|
+
waiting_since: number;
|
|
72
|
+
}
|
|
73
|
+
// A row in the ⌘K palette's session search: any real task across the active
|
|
74
|
+
// projects plus enough of its project to label it. Mirrors lib/store.ts
|
|
75
|
+
// listAllTasksLite().
|
|
76
|
+
export interface PaletteTaskRow {
|
|
77
|
+
id: string;
|
|
78
|
+
project_id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
status: Status;
|
|
81
|
+
running: number;
|
|
82
|
+
awaiting_input: number;
|
|
83
|
+
updated_at: number;
|
|
84
|
+
project_name: string;
|
|
85
|
+
project_color: string;
|
|
86
|
+
project_icon: string;
|
|
87
|
+
}
|
|
88
|
+
export interface Msg {
|
|
89
|
+
id: string;
|
|
90
|
+
// "queued" is a client-only role: a follow-up the user typed mid-turn that's
|
|
91
|
+
// parked (in pending_messages) until the current turn ends. Not a persisted
|
|
92
|
+
// message role — it never lands in the `messages` table.
|
|
93
|
+
role: "user" | "assistant" | "tool" | "system" | "session_break" | "queued";
|
|
94
|
+
content: string;
|
|
95
|
+
generation: number;
|
|
96
|
+
toolId?: string; // tool_use id, for merging the tool_result that arrives later
|
|
97
|
+
ts?: number; // created_at of the persisted row (ms epoch); absent on synthetic ids
|
|
98
|
+
}
|
|
99
|
+
export interface ProjectSession {
|
|
100
|
+
id: string;
|
|
101
|
+
task_id: string;
|
|
102
|
+
task_title: string;
|
|
103
|
+
task_status: Status;
|
|
104
|
+
generation: number;
|
|
105
|
+
claude_session_id: string | null;
|
|
106
|
+
started_at: number;
|
|
107
|
+
ended_at: number | null;
|
|
108
|
+
message_count: number;
|
|
109
|
+
}
|
|
110
|
+
export interface RecapInfo {
|
|
111
|
+
recap: string | null;
|
|
112
|
+
recap_at: number;
|
|
113
|
+
hasHistory: boolean;
|
|
114
|
+
stale: boolean;
|
|
115
|
+
needsRecap: boolean;
|
|
116
|
+
generating: boolean;
|
|
117
|
+
lastActivity: number;
|
|
118
|
+
// Client-side only: set when the fetch/generate failed, so the landing pane
|
|
119
|
+
// can offer a retry instead of silently showing nothing.
|
|
120
|
+
error?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Divergence status for the reopened-task sync banner (GET /api/tasks/:id/sync).
|
|
124
|
+
export interface SyncStatusResp {
|
|
125
|
+
isolated: boolean;
|
|
126
|
+
baseBranch?: string;
|
|
127
|
+
behind?: number;
|
|
128
|
+
ahead?: number;
|
|
129
|
+
isDirty?: boolean;
|
|
130
|
+
canFastForward?: boolean;
|
|
131
|
+
clean?: boolean;
|
|
132
|
+
conflicts?: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type FsListing = { path: string; parent: string | null; home: string; entries: { name: string; path: string }[] };
|
|
136
|
+
|
|
137
|
+
// ---------- GitHub onboarding shapes ----------
|
|
138
|
+
export type GhStatusT = { installed: boolean; authenticated: boolean; login: string | null };
|
|
139
|
+
export type GhLoginT = { status: "idle" | "starting" | "awaiting" | "success" | "error"; code: string | null; url: string | null; user: string | null; error: string | null };
|
|
140
|
+
export type GhRepoT = { nameWithOwner: string; description: string; isPrivate: boolean; updatedAt: string };
|
|
141
|
+
|
|
142
|
+
// ---------- first-run onboarding wizard shapes ----------
|
|
143
|
+
export type OnbStep = "connect" | "verify";
|
|
144
|
+
export type OnboardingT = {
|
|
145
|
+
complete: boolean;
|
|
146
|
+
step: OnbStep;
|
|
147
|
+
method: "subscription" | "api_key" | null;
|
|
148
|
+
account: { email: string | null; plan: string | null } | null;
|
|
149
|
+
};
|
|
150
|
+
export type ClaudeLoginT = {
|
|
151
|
+
status: "idle" | "starting" | "awaiting" | "submitting" | "success" | "error";
|
|
152
|
+
url: string | null;
|
|
153
|
+
email: string | null;
|
|
154
|
+
plan: string | null;
|
|
155
|
+
error: string | null;
|
|
156
|
+
log: string;
|
|
157
|
+
};
|
|
158
|
+
export type ClaudeVerifyT = {
|
|
159
|
+
connected: boolean;
|
|
160
|
+
email: string | null;
|
|
161
|
+
plan: string | null;
|
|
162
|
+
method: string | null;
|
|
163
|
+
provider?: string | null;
|
|
164
|
+
error: string | null;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// ---------- multi-agent connect (GET /api/agents + /api/agents/[id]/*) ----------
|
|
168
|
+
// Mirrors the server's AgentCapabilities + per-agent connection state. Used by
|
|
169
|
+
// the Settings "Agents" surface to render connect cards and gray out agents that
|
|
170
|
+
// aren't wired up. Only the fields the client reads are typed here.
|
|
171
|
+
export type AgentCapabilitiesT = {
|
|
172
|
+
apiKeyHint: string | null;
|
|
173
|
+
loginStyle: "paste_code" | "device_code";
|
|
174
|
+
supportsBedrock?: boolean;
|
|
175
|
+
loginHint?: string;
|
|
176
|
+
supportsProviderAuth?: boolean;
|
|
177
|
+
};
|
|
178
|
+
export type AgentInfoT = {
|
|
179
|
+
id: string;
|
|
180
|
+
label: string;
|
|
181
|
+
provider?: string | null;
|
|
182
|
+
// The active provider's credentials can be refreshed from the UI (AWS SSO
|
|
183
|
+
// device-code flow) — shows the refresh button in the Bedrock connect card.
|
|
184
|
+
providerRefresh?: boolean;
|
|
185
|
+
capabilities: AgentCapabilitiesT;
|
|
186
|
+
connected: boolean;
|
|
187
|
+
/** The CLI exists on the server machine (GET /api/agents); absent = unknown. */
|
|
188
|
+
installed?: boolean;
|
|
189
|
+
account: { email: string | null; plan: string | null; method: "subscription" | "api_key" | "bedrock" | "compatible" } | null;
|
|
190
|
+
authBroken?: AgentAuthBrokenT | null;
|
|
191
|
+
};
|
|
192
|
+
// Connected, but its login stopped working mid-flight (see lib/authFailure.ts).
|
|
193
|
+
// `reason` is the provider's own error text; `at` is when it was first seen.
|
|
194
|
+
export type AgentAuthBrokenT = { at: number; reason: string };
|
|
195
|
+
export type AgentsResponseT = { default: string; agents: AgentInfoT[]; utility?: UtilityAgentT };
|
|
196
|
+
// Which agent actually runs the app's project-scoped internal jobs (recaps,
|
|
197
|
+
// context drafts), resolved connected-first on the server (lib/agents/oneshots).
|
|
198
|
+
// `id: null` = nothing connected; `fallback` = the configured agent isn't
|
|
199
|
+
// connected, so a different one is standing in.
|
|
200
|
+
export type UtilityAgentT = { id: string | null; configured: string; fallback: boolean };
|
|
201
|
+
export type AgentLoginT = ClaudeLoginT & { code?: string | null };
|
|
202
|
+
|
|
203
|
+
// ---------- status maps (DB status -> design's r/a/g classes + labels) ----------
|
|
204
|
+
export const SCLS: Record<Status, "r" | "a" | "g" | "h" | "x"> = { not_started: "r", in_progress: "a", on_hold: "h", done: "g", cancelled: "x" };
|
|
205
|
+
export const SLABEL: Record<Status, string> = { not_started: "未开始", in_progress: "进行中", on_hold: "已搁置", done: "已完成", cancelled: "已取消" };
|
|
206
|
+
export const AWAIT_LABEL = "需要你处理";
|
|
207
|
+
export const SSUB: Record<Status, string> = { not_started: "尚未开始会话", in_progress: "会话进行中或已暂停", on_hold: "已暂停 — 稍后继续", done: "工作完成 / 已合并", cancelled: "已放弃 — 不会再继续" };
|
|
208
|
+
export const STATUSES: Status[] = ["not_started", "in_progress", "on_hold", "done", "cancelled"];
|
|
209
|
+
export const PLABEL: Record<Priority, string> = { hi: "高", med: "中", lo: "低" };
|
|
210
|
+
export const PRIORITIES: Priority[] = ["hi", "med", "lo"];
|
|
211
|
+
|
|
212
|
+
// ---------- agent capability descriptors (mirrors lib/agents/types.ts) ----------
|
|
213
|
+
// The run controls are no longer hardcoded per agent: each driver ships a
|
|
214
|
+
// capability descriptor (models / reasoning / permission modes it supports, plus
|
|
215
|
+
// feature flags) served by GET /api/agents. The client renders every picker from
|
|
216
|
+
// this data, so a task's controls always match the agent it runs under.
|
|
217
|
+
export interface AgentModelOption { value: string; label: string; sub: string; contextWindow: number; group?: string }
|
|
218
|
+
export interface AgentPickerOption { value: string; label: string; sub: string }
|
|
219
|
+
export interface AgentCapabilities {
|
|
220
|
+
models: AgentModelOption[];
|
|
221
|
+
reasoningOptions: AgentPickerOption[];
|
|
222
|
+
permissionModes: AgentPickerOption[];
|
|
223
|
+
supportsAsks: boolean; // can surface interactive ask cards mid-turn
|
|
224
|
+
supportsMcpTools: boolean; // can mount the orchestrator MCP tools
|
|
225
|
+
reportsCostUsd: boolean; // usage carries a real dollar cost (not just tokens)
|
|
226
|
+
costIsEstimated: boolean; // cost is estimated from tokens × API prices — show with ~
|
|
227
|
+
supportsResume: boolean; // turns can resume a prior session/thread id
|
|
228
|
+
supportsCustomModels?: boolean;
|
|
229
|
+
supportsBedrock?: boolean;
|
|
230
|
+
}
|
|
231
|
+
// How this agent is signed in. "subscription" (a Max/Pro or ChatGPT login) means
|
|
232
|
+
// turns draw on plan quota and cost no marginal money, so a dollar figure is an
|
|
233
|
+
// API-PRICE EQUIVALENT rather than a charge; "api_key" means it really is billed.
|
|
234
|
+
// Mirrors lib/agents/connections.ts AgentConnection; null when not connected.
|
|
235
|
+
export interface AgentAccount { email: string | null; plan: string | null; method: "subscription" | "api_key" | "bedrock" | "compatible" }
|
|
236
|
+
// `installed` = this agent's CLI exists on the server machine. Independent of
|
|
237
|
+
// `authenticated` (credentials): an uninstalled agent can't run however well
|
|
238
|
+
// it's connected, and no Connect flow fixes it. Optional so an older payload
|
|
239
|
+
// (or a test fixture) reads as "unknown", which callers treat as installed.
|
|
240
|
+
export interface AgentInfo { defaults?: { model?: string|null; reasoning?: string|null; permission_mode?: string|null }; id: string; label: string; provider?: string | null; capabilities: AgentCapabilities; authenticated: boolean; installed?: boolean; account?: AgentAccount | null; authBroken?: AgentAuthBrokenT | null }
|
|
241
|
+
export interface AgentsBundle { default: string; agents: AgentInfo[]; utility?: UtilityAgentT }
|
|
242
|
+
export const EMPTY_AGENTS: AgentsBundle = { default: "claude", agents: [] };
|
|
243
|
+
|
|
244
|
+
// A picker option list. `value: null` is the synthetic "Default" head — it
|
|
245
|
+
// persists as null in tasks.model/reasoning/permission_mode, inheriting the
|
|
246
|
+
// app-level (agent-scoped) default, then the driver's built-in.
|
|
247
|
+
export type PickerOption = { value: string | null; label: string; sub: string; group?: string };
|
|
248
|
+
const DEFAULT_HEAD: PickerOption = { value: null, label: "默认", sub: "沿用智能体的默认设置" };
|
|
249
|
+
const withDefault = (opts: PickerOption[]): PickerOption[] => [DEFAULT_HEAD, ...opts];
|
|
250
|
+
// Build each picker's option list from a driver's capabilities. Undefined caps
|
|
251
|
+
// (agent metadata not loaded yet) yields just the Default head.
|
|
252
|
+
export const modelOptions = (caps?: AgentCapabilities): PickerOption[] => withDefault(caps?.models ?? []);
|
|
253
|
+
export const reasoningOptions = (caps?: AgentCapabilities): PickerOption[] => withDefault(caps?.reasoningOptions ?? []);
|
|
254
|
+
export const permissionOptions = (caps?: AgentCapabilities): PickerOption[] => withDefault(caps?.permissionModes ?? []);
|
|
255
|
+
|
|
256
|
+
// Lightweight filter box for the project & task lists — only worth showing once a
|
|
257
|
+
// list grows past SEARCH_MIN, so small workspaces stay clutter-free.
|
|
258
|
+
export const SEARCH_MIN = 6;
|
|
259
|
+
|
|
260
|
+
// How a project's tasks render: the grouped list (middle column + chat), or
|
|
261
|
+
// the full-workspace kanban board. Persisted alongside the other prefs.
|
|
262
|
+
export type TaskView = "list" | "board";
|
|
263
|
+
|
|
264
|
+
// Which surface fills the work area (the right two columns). "workspace" is the
|
|
265
|
+
// normal tasks+session view; "settings" replaces it with the app settings shell;
|
|
266
|
+
// "insights" with the usage/analytics dashboard. Mirrored into the URL
|
|
267
|
+
// (?view=settings / ?view=insights) so it's deep-linkable + refresh-stable,
|
|
268
|
+
// consistent with how project/task selection is persisted.
|
|
269
|
+
export type View = "workspace" | "settings" | "insights";
|
|
270
|
+
// Purely cosmetic, client-only look-and-feel prefs (the "Appearance" panel).
|
|
271
|
+
export interface Appearance { theme: "light" | "dark"; density: string; }
|
|
272
|
+
export const DEFAULT_APPEARANCE: Appearance = { theme: "dark", density: "1" };
|
|
273
|
+
|
|
274
|
+
// App-level preferences (distinct from Appearance, which is purely cosmetic). These
|
|
275
|
+
// are personal/client-only so they live in the same localStorage store as Appearance;
|
|
276
|
+
// if shared/server config is ever needed, a `settings` table in lib/db.ts keyed by
|
|
277
|
+
// name would be the place. Keep this a flat object with sensible defaults so new
|
|
278
|
+
// settings are a one-line addition here + a field in SettingsView.
|
|
279
|
+
export interface Settings {
|
|
280
|
+
// The app nudges you to /clear when a session's context window crosses EITHER
|
|
281
|
+
// of these — a percentage of the window, or an absolute token count. The paired
|
|
282
|
+
// "Recommend /clear when context is high" feature reads these.
|
|
283
|
+
clearThresholdPct: number; // 0–100, % of the context window
|
|
284
|
+
clearThresholdTokens: number; // absolute token count
|
|
285
|
+
}
|
|
286
|
+
export const DEFAULT_SETTINGS: Settings = { clearThresholdPct: 75, clearThresholdTokens: 150_000 };
|
|
287
|
+
|
|
288
|
+
// Persisted sidebar layout — column widths and collapsed (hidden) state, so the
|
|
289
|
+
// user can carve out more room for the chat and have it stick across reloads.
|
|
290
|
+
// Exception: the projects column always opens collapsed (a slim rail you click
|
|
291
|
+
// to expand) — usePrefs ignores its persisted state on load.
|
|
292
|
+
export interface Layout { projW: number; taskW: number; railW: number; projCollapsed: boolean; taskCollapsed: boolean; railCollapsed: boolean; }
|
|
293
|
+
export const DEFAULT_LAYOUT: Layout = { projW: 236, taskW: 352, railW: 430, projCollapsed: true, taskCollapsed: false, railCollapsed: false };
|
|
294
|
+
export const PROJ_W = { min: 170, max: 460 };
|
|
295
|
+
export const TASK_W = { min: 240, max: 620 };
|
|
296
|
+
export const RAIL_W = { min: 320, max: 760 };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type MutableRefObject } from "react";
|
|
4
|
+
import type { GlobalWireEvent } from "./protocol/events";
|
|
5
|
+
import { jget } from "./api";
|
|
6
|
+
import { openLive } from "./liveStream";
|
|
7
|
+
import type { ProjectRow, TaskRow } from "./types";
|
|
8
|
+
|
|
9
|
+
// One always-open EventSource on GET /api/events: coarse lifecycle events for
|
|
10
|
+
// EVERY task across EVERY project (turn started / awaiting input / answered /
|
|
11
|
+
// suggestion created / turn ended / mutation-route settles and deletions).
|
|
12
|
+
// This is what clears spinners and updates
|
|
13
|
+
// the "needs you" badges for tasks whose transcript stream isn't open — only
|
|
14
|
+
// the selected task has one (useTaskStream) — replacing the old 10s poll.
|
|
15
|
+
export function useGlobalEvents({ selProjRef, setTaskRunning, setTasks, setProjects, loadTasks, reconcileRunning, refreshAgents }: {
|
|
16
|
+
selProjRef: MutableRefObject<string | null>;
|
|
17
|
+
setTaskRunning: (id: string, on: boolean) => void;
|
|
18
|
+
setTasks: React.Dispatch<React.SetStateAction<TaskRow[]>>;
|
|
19
|
+
setProjects: React.Dispatch<React.SetStateAction<ProjectRow[]>>;
|
|
20
|
+
loadTasks: (projectId: string, selectFirst?: boolean) => Promise<void>;
|
|
21
|
+
reconcileRunning: () => Promise<void>;
|
|
22
|
+
refreshAgents: () => Promise<void>;
|
|
23
|
+
}) {
|
|
24
|
+
// Apply one lifecycle event. The payload is a fresh snapshot of the task
|
|
25
|
+
// row's running/awaiting_input/status (read after the runner persisted it),
|
|
26
|
+
// so applying it is idempotent — overlaps with the selected task's own
|
|
27
|
+
// stream, which fires for the same boundaries, are harmless.
|
|
28
|
+
const handle = (ev: GlobalWireEvent) => {
|
|
29
|
+
// An agent's login died or came back. Refetch the shared agents bundle
|
|
30
|
+
// rather than patching state locally, so the reconnect banner, the Settings
|
|
31
|
+
// cards, and the New-task picker all read one server-side truth. Rare event
|
|
32
|
+
// (once per outage), so the extra fetch costs nothing.
|
|
33
|
+
if (ev.type === "agent_auth") { void refreshAgents(); return; }
|
|
34
|
+
// A task was hard-deleted (possibly in another tab — in this one the local
|
|
35
|
+
// removal already happened, so the replay is a no-op). Drop it and adopt
|
|
36
|
+
// the recomputed project badge the event carries; there's no row left to
|
|
37
|
+
// re-fetch.
|
|
38
|
+
if (ev.type === "task_deleted") {
|
|
39
|
+
setTaskRunning(ev.taskId, false);
|
|
40
|
+
setTasks((prev) => prev.filter((t) => t.id !== ev.taskId));
|
|
41
|
+
setProjects((prev) => prev.map((p) => (p.id === ev.projectId ? { ...p, awaiting_count: ev.awaiting_count } : p)));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (ev.type !== "task") return;
|
|
45
|
+
setTaskRunning(ev.taskId, ev.running);
|
|
46
|
+
setTasks((prev) => prev.map((t) => {
|
|
47
|
+
if (t.id !== ev.taskId) return t;
|
|
48
|
+
// A launch publishes turn_started before the agent session opens, i.e.
|
|
49
|
+
// while the row's status is still "not_started" — don't regress a task
|
|
50
|
+
// the client already optimistically flipped to in_progress; the session-
|
|
51
|
+
// open event re-fires turn_started with the settled status moments later.
|
|
52
|
+
const status = ev.running && ev.status === "not_started" && t.status === "in_progress" ? t.status : ev.status;
|
|
53
|
+
return { ...t, running: ev.running ? 1 : 0, awaiting_input: ev.awaiting_input ? 1 : 0, status };
|
|
54
|
+
}));
|
|
55
|
+
// Project badge + titlebar pill: the event carries the project's fresh
|
|
56
|
+
// awaiting count, so no /api/projects refetch is needed.
|
|
57
|
+
setProjects((prev) => prev.map((p) => (p.id === ev.projectId ? { ...p, awaiting_count: ev.awaiting_count } : p)));
|
|
58
|
+
// A suggested task was created (by a turn in that project) — surface it in
|
|
59
|
+
// the Suggested tray right away if that project is on screen.
|
|
60
|
+
if ((ev.event === "suggested" || ev.event === "task_created") && selProjRef.current === ev.projectId) void loadTasks(ev.projectId, false);
|
|
61
|
+
};
|
|
62
|
+
// Route through a ref so the EventSource effect never re-subscribes.
|
|
63
|
+
const handleRef = useRef(handle);
|
|
64
|
+
useEffect(() => { handleRef.current = handle; });
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let opens = 0;
|
|
68
|
+
const onOpen = () => {
|
|
69
|
+
// This stream is a live tail with no snapshot: anything published while
|
|
70
|
+
// we were disconnected (laptop sleep, tunnel drop) is gone. On every
|
|
71
|
+
// REconnect, refetch the authoritative lists once to catch up. The first
|
|
72
|
+
// open is skipped — boot() and the project-selection effect already load
|
|
73
|
+
// them. reconcileRunning drains the running set fleet-wide: a turn_end
|
|
74
|
+
// missed while dark, on a task in a project we've navigated away from,
|
|
75
|
+
// is invisible to the two refetches below (they only cover the selected
|
|
76
|
+
// project's rows) and would leave that spinner stuck forever.
|
|
77
|
+
opens += 1;
|
|
78
|
+
if (opens === 1) return;
|
|
79
|
+
jget<ProjectRow[]>("/api/projects").then(setProjects).catch(() => {});
|
|
80
|
+
if (selProjRef.current) void loadTasks(selProjRef.current, false);
|
|
81
|
+
void reconcileRunning();
|
|
82
|
+
// An agent_auth event fired while we were dark would be lost (this stream
|
|
83
|
+
// is a live tail), leaving a stale banner — or none when the login is in
|
|
84
|
+
// fact dead. The bundle carries the persisted flag, so refetch it too.
|
|
85
|
+
void refreshAgents();
|
|
86
|
+
};
|
|
87
|
+
// Over the tab's shared live socket (./liveStream), not its own EventSource.
|
|
88
|
+
const sub = openLive("/api/events", {
|
|
89
|
+
onOpen,
|
|
90
|
+
onMessage: (data) => {
|
|
91
|
+
try { handleRef.current(JSON.parse(data) as GlobalWireEvent); } catch {}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return () => sub.close();
|
|
95
|
+
// All deps are stable (a ref, a setState, []-memoized callbacks).
|
|
96
|
+
}, [selProjRef, setProjects, loadTasks, reconcileRunning, refreshAgents]);
|
|
97
|
+
}
|