@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  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 +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. 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
+ }