@steerable/agent-shell-web 0.6.15

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 (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,104 @@
1
+ import type { OrchestrationTaskStatus } from '@steerable/agent-ui/cards';
2
+ import type { OrchestrationPlanPayload } from '@steerable/agent-protocol';
3
+
4
+ /**
5
+ * Pure model half of OrchestrationChildrenCard — no React and no runtime
6
+ * framework imports (type-only, erased at build), so the desktop's node
7
+ * vitest suite can exercise the mapping without the agent-ui dist build.
8
+ *
9
+ * The sidecar emits `agent.child` lifecycle notifications for orchestration
10
+ * children AND for `delegate_subagent` delegations (delegate-on-pool); the
11
+ * router forwards them as `orchestration_child` SSE events and AgentPage
12
+ * accumulates them into the `ChildInfo` list below.
13
+ */
14
+
15
+ export interface ChildInfo {
16
+ childId: string;
17
+ /** Spawn task text (only `child_spawned` carries it). */
18
+ task?: string;
19
+ depth?: number;
20
+ /** Resolved subagent profile (delegate children; `general-purpose` when
21
+ * the delegation named no subagent_type). Rendered as the task's agent. */
22
+ profile?: string;
23
+ /** running | completed | failed | cancelled | interrupted */
24
+ status: string;
25
+ }
26
+
27
+ const STATUS_MAP: Record<string, OrchestrationTaskStatus> = {
28
+ running: 'running',
29
+ completed: 'ok',
30
+ failed: 'failed',
31
+ cancelled: 'skipped',
32
+ // Interrupted = paused-but-resumable; renders as pending (not terminal).
33
+ interrupted: 'pending',
34
+ };
35
+
36
+ /**
37
+ * Pure projection of the live child list onto the card model — the card
38
+ * itself is click-tested in `@steerable/agent-ui`.
39
+ */
40
+ export function childrenToCardModel(children: ChildInfo[]): {
41
+ payload: OrchestrationPlanPayload;
42
+ taskStatuses: Record<string, OrchestrationTaskStatus>;
43
+ } {
44
+ return {
45
+ payload: {
46
+ mode: 'parallel',
47
+ tasks: children.map((c) => ({
48
+ id: c.childId,
49
+ // 委派子代理显示其 profile 名(如 researcher / general-purpose);
50
+ // 编排六件套的子代理没有 profile,退回 lineage childId。
51
+ agentId: c.profile ?? c.childId,
52
+ prompt: c.task ?? '',
53
+ })),
54
+ },
55
+ taskStatuses: Object.fromEntries(
56
+ children.map((c) => [c.childId, STATUS_MAP[c.status] ?? 'pending']),
57
+ ),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Fold the accumulated raw sidecar `agent.child` lifecycle events (as
63
+ * forwarded by the backend's `orchestration_child` SSE payload) back into a
64
+ * `ChildInfo[]`. Used to rebuild the in-flight child list when re-attaching
65
+ * to a running turn (where the original event stream was consumed by a now
66
+ * unmounted view).
67
+ */
68
+ export function foldOrchestrationChildEvents(
69
+ events: ReadonlyArray<Record<string, unknown>>,
70
+ ): ChildInfo[] {
71
+ const list: ChildInfo[] = [];
72
+ for (const ev of events) {
73
+ if (!ev || typeof ev !== 'object') continue;
74
+ const kind = typeof ev.kind === 'string' ? ev.kind : '';
75
+ const childId = typeof ev.childId === 'string' ? ev.childId : '';
76
+ if (!kind || !childId) continue;
77
+ const idx = list.findIndex((c) => c.childId === childId);
78
+ if (kind === 'child_spawned') {
79
+ if (idx >= 0) continue;
80
+ list.push({
81
+ childId,
82
+ task: typeof ev.task === 'string' ? ev.task : undefined,
83
+ depth: typeof ev.depth === 'number' ? ev.depth : undefined,
84
+ status: 'running',
85
+ });
86
+ continue;
87
+ }
88
+ if (idx < 0) continue;
89
+ const status =
90
+ kind === 'child_completed'
91
+ ? 'completed'
92
+ : kind === 'child_failed'
93
+ ? 'failed'
94
+ : kind === 'child_cancelled'
95
+ ? 'cancelled'
96
+ : kind === 'child_interrupted'
97
+ ? 'interrupted'
98
+ : kind === 'child_resumed'
99
+ ? 'running'
100
+ : list[idx].status;
101
+ list[idx] = { ...list[idx], status };
102
+ }
103
+ return list;
104
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Friendly timestamp formatter shared by `UserMessage` / `AssistantMessage`.
3
+ * Today → `HH:MM`, other days → `MM-DD HH:MM`.
4
+ *
5
+ * Direct port of the helper from
6
+ * `deeppath/apps/web/src/app/goals/desktop/components/ChatPanel/MessageList/UserMessage.tsx`.
7
+ */
8
+ export function getFriendlyDate(date: Date): string {
9
+ const now = new Date();
10
+ const isToday = now.toDateString() === date.toDateString();
11
+ if (isToday) {
12
+ return date.toLocaleTimeString([], {
13
+ hour: '2-digit',
14
+ minute: '2-digit',
15
+ });
16
+ }
17
+ const month = String(date.getMonth() + 1).padStart(2, '0');
18
+ const day = String(date.getDate()).padStart(2, '0');
19
+ const time = date.toLocaleTimeString([], {
20
+ hour: '2-digit',
21
+ minute: '2-digit',
22
+ });
23
+ return `${month}-${day} ${time}`;
24
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ExecutedAction } from './ExecutedActionsCard';
3
+ import {
4
+ appendDelta,
5
+ countProcessTools,
6
+ fallbackTimeline,
7
+ parseTurnBlocks,
8
+ processHasReasoning,
9
+ splitTurnProcess,
10
+ syncTools,
11
+ } from './turn-timeline';
12
+
13
+ const tool = (name: string, result?: unknown): ExecutedAction => ({
14
+ tool: name,
15
+ arguments: { path: `/${name}` },
16
+ ...(result === undefined ? {} : { result }),
17
+ });
18
+
19
+ describe('turn-timeline', () => {
20
+ it('merges consecutive deltas of the same kind and splits on kind change', () => {
21
+ let blocks = appendDelta([], 'reasoning', '想');
22
+ blocks = appendDelta(blocks, 'reasoning', '一下');
23
+ blocks = appendDelta(blocks, 'text', '先读文件');
24
+ blocks = appendDelta(blocks, 'text', '。');
25
+ expect(blocks).toEqual([
26
+ { type: 'reasoning', content: '想一下' },
27
+ { type: 'text', content: '先读文件。' },
28
+ ]);
29
+ });
30
+
31
+ it('places tools after the text that preceded them, then more text', () => {
32
+ let blocks = appendDelta([], 'text', '开始');
33
+ blocks = syncTools(blocks, [tool('read')]);
34
+ blocks = appendDelta(blocks, 'text', '接着写');
35
+ blocks = syncTools(blocks, [tool('read'), tool('write')]);
36
+ expect(blocks.map((b) => b.type)).toEqual(['text', 'tools', 'text', 'tools']);
37
+ expect(blocks[1]).toEqual({ type: 'tools', actions: [tool('read')] });
38
+ expect(blocks[3]).toEqual({ type: 'tools', actions: [tool('write')] });
39
+ });
40
+
41
+ it('groups consecutive tools and updates in-place results', () => {
42
+ const running = tool('read');
43
+ const done = tool('read', { success: true });
44
+ let blocks = syncTools([], [running]);
45
+ blocks = syncTools(blocks, [done]);
46
+ blocks = syncTools(blocks, [done, tool('write')]);
47
+ expect(blocks).toHaveLength(1);
48
+ expect(blocks[0]).toEqual({ type: 'tools', actions: [done, tool('write')] });
49
+ });
50
+
51
+ it('ignores empty deltas', () => {
52
+ expect(appendDelta([], 'text', '')).toEqual([]);
53
+ });
54
+
55
+ it('falls back to tools-then-text for history without a timeline', () => {
56
+ expect(fallbackTimeline('答', [tool('read', { success: true })])).toEqual([
57
+ { type: 'tools', actions: [tool('read', { success: true })] },
58
+ { type: 'text', content: '答' },
59
+ ]);
60
+ expect(fallbackTimeline('', undefined)).toEqual([]);
61
+ });
62
+
63
+ it('splits trailing summary text from the think→act process', () => {
64
+ const blocks = [
65
+ { type: 'reasoning' as const, content: '想' },
66
+ { type: 'tools' as const, actions: [tool('read')] },
67
+ { type: 'text' as const, content: '中间说明' },
68
+ { type: 'tools' as const, actions: [tool('write')] },
69
+ { type: 'text' as const, content: '总结' },
70
+ ];
71
+ expect(splitTurnProcess(blocks)).toEqual({
72
+ process: blocks.slice(0, 4),
73
+ answer: [blocks[4]],
74
+ });
75
+ expect(splitTurnProcess([{ type: 'text', content: '只回答' }])).toEqual({
76
+ process: [],
77
+ answer: [{ type: 'text', content: '只回答' }],
78
+ });
79
+ expect(splitTurnProcess([{ type: 'tools', actions: [tool('read')] }])).toEqual({
80
+ process: [{ type: 'tools', actions: [tool('read')] }],
81
+ answer: [],
82
+ });
83
+ expect(countProcessTools(blocks.slice(0, 4))).toBe(2);
84
+ expect(processHasReasoning(blocks.slice(0, 4))).toBe(true);
85
+ });
86
+
87
+ it('parses persisted timeline JSON and rejects unknown kinds', () => {
88
+ const parsed = parseTurnBlocks([
89
+ { type: 'reasoning', content: 'hmm' },
90
+ { type: 'tools', actions: [tool('read')] },
91
+ { type: 'text', content: 'ok' },
92
+ ]);
93
+ expect(parsed).toHaveLength(3);
94
+ expect(parseTurnBlocks([{ type: 'image' }])).toBeNull();
95
+ expect(parseTurnBlocks([])).toBeNull();
96
+ });
97
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Assistant-turn display timeline: reasoning, tool cards, and reply text
3
+ * in call order (think → act → observe → reply), not as two stacked
4
+ * groups.
5
+ *
6
+ * Live streams reconstruct this from SSE order. Persisted assistant
7
+ * metadata may carry the same `timeline` array so history reloads keep
8
+ * the interleaving. Messages without it fall back to tools-then-text.
9
+ */
10
+
11
+ import type { ExecutedAction } from './ExecutedActionsCard';
12
+
13
+ export type TurnBlock =
14
+ | { type: 'reasoning'; content: string }
15
+ | { type: 'text'; content: string }
16
+ | { type: 'tools'; actions: ExecutedAction[] };
17
+
18
+ export function appendDelta(
19
+ blocks: TurnBlock[],
20
+ type: 'text' | 'reasoning',
21
+ delta: string,
22
+ ): TurnBlock[] {
23
+ if (!delta) return blocks;
24
+ const last = blocks[blocks.length - 1];
25
+ if (last && last.type === type) {
26
+ const next = blocks.slice();
27
+ next[next.length - 1] = { type, content: last.content + delta };
28
+ return next;
29
+ }
30
+ return [...blocks, { type, content: delta }];
31
+ }
32
+
33
+ export function syncTools(
34
+ blocks: TurnBlock[],
35
+ actions: ExecutedAction[],
36
+ ): TurnBlock[] {
37
+ let placed = 0;
38
+ for (const block of blocks) {
39
+ if (block.type === 'tools') placed += block.actions.length;
40
+ }
41
+
42
+ const next: TurnBlock[] = blocks.map((block) =>
43
+ block.type === 'tools' ? { type: 'tools', actions: [...block.actions] } : block,
44
+ );
45
+
46
+ let idx = 0;
47
+ for (const block of next) {
48
+ if (block.type !== 'tools') continue;
49
+ for (let i = 0; i < block.actions.length; i += 1) {
50
+ if (idx < actions.length) {
51
+ block.actions[i] = actions[idx];
52
+ idx += 1;
53
+ }
54
+ }
55
+ }
56
+
57
+ const added = actions.slice(placed);
58
+ if (added.length === 0) return next;
59
+
60
+ const last = next[next.length - 1];
61
+ if (last && last.type === 'tools') {
62
+ next[next.length - 1] = { type: 'tools', actions: [...last.actions, ...added] };
63
+ } else {
64
+ next.push({ type: 'tools', actions: added });
65
+ }
66
+ return next;
67
+ }
68
+
69
+ /** Legacy history: all tools, then the concatenated reply. */
70
+ export function fallbackTimeline(
71
+ content: string,
72
+ actions: ExecutedAction[] | undefined,
73
+ ): TurnBlock[] {
74
+ const blocks: TurnBlock[] = [];
75
+ if (actions && actions.length > 0) {
76
+ blocks.push({ type: 'tools', actions });
77
+ }
78
+ if (content) {
79
+ blocks.push({ type: 'text', content });
80
+ }
81
+ return blocks;
82
+ }
83
+
84
+ export function parseTurnBlocks(raw: unknown): TurnBlock[] | null {
85
+ if (!Array.isArray(raw) || raw.length === 0) return null;
86
+ const blocks: TurnBlock[] = [];
87
+ for (const item of raw) {
88
+ if (!item || typeof item !== 'object') return null;
89
+ const rec = item as Record<string, unknown>;
90
+ if (rec.type === 'text' || rec.type === 'reasoning') {
91
+ if (typeof rec.content !== 'string') return null;
92
+ blocks.push({ type: rec.type, content: rec.content });
93
+ continue;
94
+ }
95
+ if (rec.type === 'tools') {
96
+ if (!Array.isArray(rec.actions)) return null;
97
+ blocks.push({ type: 'tools', actions: rec.actions as ExecutedAction[] });
98
+ continue;
99
+ }
100
+ return null;
101
+ }
102
+ return blocks;
103
+ }
104
+
105
+ /**
106
+ * Trailing text is the final summary. Reasoning, tools, and any narration
107
+ * that happened before that last answer stay in the foldable process group
108
+ * (Codex / DeepSeek turn-process).
109
+ */
110
+ export function splitTurnProcess(blocks: TurnBlock[]): {
111
+ process: TurnBlock[];
112
+ answer: Extract<TurnBlock, { type: 'text' }>[];
113
+ } {
114
+ let split = blocks.length;
115
+ while (split > 0 && blocks[split - 1].type === 'text') split -= 1;
116
+ return {
117
+ process: blocks.slice(0, split),
118
+ answer: blocks.slice(split) as Extract<TurnBlock, { type: 'text' }>[],
119
+ };
120
+ }
121
+
122
+ export function countProcessTools(process: TurnBlock[]): number {
123
+ let n = 0;
124
+ for (const block of process) {
125
+ if (block.type === 'tools') n += block.actions.length;
126
+ }
127
+ return n;
128
+ }
129
+
130
+ export function processHasReasoning(process: TurnBlock[]): boolean {
131
+ return process.some((block) => block.type === 'reasoning' && block.content.trim().length > 0);
132
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * useChatTasks 契约:
3
+ * - 拉 GET /chats/:id/tasks,`task-updated` 推送后重拉;
4
+ * - `finished` 只收本次挂载期间跑到终态的任务——首屏已经终态的历史任务
5
+ * 不算「刚跑完」,否则每次打开对话都会重弹一遍;
6
+ * - 两次刷新之间从建到完的任务算刚跑完(用户同样没看见它运行过);
7
+ * - `dismissFinished` 只隐藏通知,不影响 `tasks` 与 `summary`。
8
+ */
9
+ import { act, renderHook, waitFor } from '@testing-library/react';
10
+ import { afterEach, describe, expect, it, vi } from 'vitest';
11
+ import type { LocalTask } from '@/lib/local-api';
12
+ import { useChatTasks } from './useChatTasks';
13
+
14
+ afterEach(() => {
15
+ delete (window as { electron?: unknown }).electron;
16
+ });
17
+
18
+ function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
19
+ return {
20
+ id: 'task-1',
21
+ chatId: 'chat_1',
22
+ task: '跑测试',
23
+ status: 'running',
24
+ answer: null,
25
+ error: null,
26
+ worktreePath: null,
27
+ worktreeBranch: null,
28
+ worktreeState: null,
29
+ createdAt: '2026-09-13T01:00:00.000Z',
30
+ updatedAt: '2026-09-13T01:01:00.000Z',
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function installBridge(initial: LocalTask[]) {
36
+ let current = initial;
37
+ const listeners: Array<(p: { chatId: string; taskId: string; status: string }) => void> = [];
38
+ (window as { electron?: unknown }).electron = {
39
+ localBackend: {
40
+ request: vi.fn((input: { method: string; path: string }) => {
41
+ if (input.method === 'GET' && input.path.endsWith('/tasks')) {
42
+ return Promise.resolve({ tasks: current });
43
+ }
44
+ return Promise.reject(new Error(`unexpected request: ${input.path}`));
45
+ }),
46
+ },
47
+ onTaskUpdated: (cb: (p: { chatId: string; taskId: string; status: string }) => void) => {
48
+ listeners.push(cb);
49
+ return () => listeners.splice(listeners.indexOf(cb), 1);
50
+ },
51
+ };
52
+ return {
53
+ async push(next: LocalTask[]) {
54
+ current = next;
55
+ await act(async () => {
56
+ for (const cb of listeners) cb({ chatId: 'chat_1', taskId: 'task-1', status: 'x' });
57
+ });
58
+ },
59
+ };
60
+ }
61
+
62
+ describe('useChatTasks', () => {
63
+ it('首屏已终态的历史任务不算刚跑完', async () => {
64
+ installBridge([makeTask({ id: 'a', status: 'completed' })]);
65
+ const { result } = renderHook(() => useChatTasks('chat_1'));
66
+
67
+ await waitFor(() => expect(result.current.tasks).toHaveLength(1));
68
+ expect(result.current.finished).toEqual([]);
69
+ expect(result.current.summary.total).toBe(1);
70
+ });
71
+
72
+ it('运行中的任务跑到终态时进 finished', async () => {
73
+ const bridge = installBridge([makeTask({ id: 'a', status: 'running' })]);
74
+ const { result } = renderHook(() => useChatTasks('chat_1'));
75
+
76
+ await waitFor(() => expect(result.current.tasks).toHaveLength(1));
77
+ expect(result.current.finished).toEqual([]);
78
+
79
+ const done = makeTask({ id: 'a', status: 'completed', answer: '42 行' });
80
+ await bridge.push([done]);
81
+
82
+ await waitFor(() => expect(result.current.finished).toEqual([done]));
83
+ });
84
+
85
+ it('两次刷新之间从建到完的任务也算刚跑完', async () => {
86
+ const bridge = installBridge([]);
87
+ const { result } = renderHook(() => useChatTasks('chat_1'));
88
+
89
+ await waitFor(() => expect(result.current.tasks).toEqual([]));
90
+
91
+ const flash = makeTask({ id: 'a', status: 'failed', error: '炸了' });
92
+ await bridge.push([flash]);
93
+
94
+ await waitFor(() => expect(result.current.finished).toEqual([flash]));
95
+ });
96
+
97
+ it('同一个终态不会重复进 finished', async () => {
98
+ const bridge = installBridge([makeTask({ id: 'a', status: 'running' })]);
99
+ const { result } = renderHook(() => useChatTasks('chat_1'));
100
+
101
+ await waitFor(() => expect(result.current.tasks).toHaveLength(1));
102
+
103
+ const done = makeTask({ id: 'a', status: 'completed' });
104
+ await bridge.push([done]);
105
+ await waitFor(() => expect(result.current.finished).toHaveLength(1));
106
+
107
+ await bridge.push([done]);
108
+ expect(result.current.finished).toHaveLength(1);
109
+ });
110
+
111
+ it('dismissFinished 只隐藏通知,任务本身还在', async () => {
112
+ const bridge = installBridge([makeTask({ id: 'a', status: 'running' })]);
113
+ const { result } = renderHook(() => useChatTasks('chat_1'));
114
+
115
+ await waitFor(() => expect(result.current.tasks).toHaveLength(1));
116
+ await bridge.push([makeTask({ id: 'a', status: 'failed', error: '炸了' })]);
117
+ await waitFor(() => expect(result.current.finished).toHaveLength(1));
118
+
119
+ act(() => result.current.dismissFinished('a'));
120
+
121
+ expect(result.current.finished).toEqual([]);
122
+ expect(result.current.tasks).toHaveLength(1);
123
+ expect(result.current.summary.failed).toBe(1);
124
+ });
125
+
126
+ it('任务表读不到时按「没有任务」处理', async () => {
127
+ (window as { electron?: unknown }).electron = {
128
+ localBackend: { request: vi.fn(() => Promise.reject(new Error('sidecar 离线'))) },
129
+ onTaskUpdated: () => () => {},
130
+ };
131
+ const { result } = renderHook(() => useChatTasks('chat_1'));
132
+
133
+ await waitFor(() => expect(result.current.summary.total).toBe(0));
134
+ expect(result.current.finished).toEqual([]);
135
+ });
136
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `useChatTasks` — 本对话后台任务的单一订阅源(host SQLite 任务表 + 主进程
3
+ * `task-updated` 广播)。header 角标、任务弹层的直达入口和消息列尾部的终态
4
+ * 卡都读它,三者因此永远看到同一份列表、同一个瞬间。
5
+ *
6
+ * `finished` 只收本次挂载期间跑到终态的任务:终态卡是「刚刚发生了什么」的
7
+ * 通知,重开会话后由角标承担持久状态——否则几天前跑完的任务会在每次打开
8
+ * 对话时重新弹一遍。首次加载只播种 `seenRef`,不产出通知。
9
+ */
10
+
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
+ import { listChatTasks, type LocalTask } from '@/lib/local-api';
13
+ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
14
+
15
+ export interface ChatTaskSummary {
16
+ total: number;
17
+ running: number;
18
+ /** 等依赖任务完成后自动点火。 */
19
+ blocked: number;
20
+ /** 跑完的 worktree 任务,等用户合并或丢弃——唯一需要用户动手的状态。 */
21
+ needsReview: number;
22
+ failed: number;
23
+ }
24
+
25
+ export interface ChatTasksState {
26
+ tasks: LocalTask[];
27
+ summary: ChatTaskSummary;
28
+ /** 本次挂载期间跑完、用户还没忽略的任务(新→旧)。 */
29
+ finished: LocalTask[];
30
+ /** 忽略一条终态通知。仅本次挂载生效,不写库。 */
31
+ dismissFinished: (taskId: string) => void;
32
+ }
33
+
34
+ /** 空数组用同一个引用,重置时 setState 才能在值没变时跳过重渲染。 */
35
+ const NO_TASKS: LocalTask[] = [];
36
+ const NO_IDS: string[] = [];
37
+
38
+ function isTerminal(status: LocalTask['status']): boolean {
39
+ return status === 'completed' || status === 'failed';
40
+ }
41
+
42
+ export function summarizeTasks(tasks: LocalTask[]): ChatTaskSummary {
43
+ return {
44
+ total: tasks.length,
45
+ running: tasks.filter((t) => t.status === 'running').length,
46
+ blocked: tasks.filter((t) => t.status === 'blocked').length,
47
+ needsReview: tasks.filter(
48
+ (t) => t.status === 'completed' && t.worktreeState === 'pending',
49
+ ).length,
50
+ failed: tasks.filter((t) => t.status === 'failed').length,
51
+ };
52
+ }
53
+
54
+ /** 等用户处理的任务:worktree 待合并(要动手)或失败(要知道原因)。 */
55
+ export function actionableTasks(tasks: LocalTask[]): LocalTask[] {
56
+ return tasks.filter(
57
+ (t) =>
58
+ (t.status === 'completed' && t.worktreeState === 'pending') ||
59
+ t.status === 'failed',
60
+ );
61
+ }
62
+
63
+ export function useChatTasks(chatId: string | null): ChatTasksState {
64
+ const [tasks, setTasks] = useState<LocalTask[]>(NO_TASKS);
65
+ const [finishedIds, setFinishedIds] = useState<string[]>(NO_IDS);
66
+ const [dismissedIds, setDismissedIds] = useState<string[]>(NO_IDS);
67
+ /** 上一次见到的每个任务的状态;null = 还没播种。 */
68
+ const seenRef = useRef<Map<string, LocalTask['status']> | null>(null);
69
+
70
+ const refresh = useCallback(async () => {
71
+ if (!chatId || !isElectron()) {
72
+ setTasks(NO_TASKS);
73
+ return;
74
+ }
75
+ let next: LocalTask[];
76
+ try {
77
+ next = (await listChatTasks(chatId)).tasks;
78
+ } catch {
79
+ // 任务表读不到(sidecar 离线 / 对话刚删)——按「没有任务」处理。
80
+ setTasks(NO_TASKS);
81
+ return;
82
+ }
83
+ const seen = seenRef.current;
84
+ seenRef.current = new Map(next.map((t) => [t.id, t.status]));
85
+ setTasks(next);
86
+ if (!seen) return;
87
+ // 首次见到就已是终态的任务也算刚跑完:它在两次刷新之间从建到完,
88
+ // 用户同样没看见过它运行。
89
+ const justFinished = next
90
+ .filter((t) => isTerminal(t.status) && seen.get(t.id) !== t.status)
91
+ .map((t) => t.id);
92
+ if (justFinished.length === 0) return;
93
+ setFinishedIds((prev) => [
94
+ ...justFinished.filter((id) => !prev.includes(id)),
95
+ ...prev,
96
+ ]);
97
+ }, [chatId]);
98
+
99
+ useEffect(() => {
100
+ seenRef.current = null;
101
+ setFinishedIds(NO_IDS);
102
+ setDismissedIds(NO_IDS);
103
+ void refresh();
104
+ }, [refresh]);
105
+
106
+ useEffect(() => {
107
+ if (!chatId) return;
108
+ const bridge = getElectronBridge();
109
+ if (!bridge?.onTaskUpdated) return;
110
+ return bridge.onTaskUpdated((payload) => {
111
+ if (payload.chatId === chatId) void refresh();
112
+ });
113
+ }, [chatId, refresh]);
114
+
115
+ const dismissFinished = useCallback((taskId: string) => {
116
+ setDismissedIds((prev) =>
117
+ prev.includes(taskId) ? prev : [...prev, taskId],
118
+ );
119
+ }, []);
120
+
121
+ const summary = useMemo(() => summarizeTasks(tasks), [tasks]);
122
+ const finished = useMemo(
123
+ () =>
124
+ finishedIds
125
+ .filter((id) => !dismissedIds.includes(id))
126
+ .map((id) => tasks.find((t) => t.id === id))
127
+ .filter((t): t is LocalTask => t !== undefined),
128
+ [finishedIds, dismissedIds, tasks],
129
+ );
130
+
131
+ return { tasks, summary, finished, dismissFinished };
132
+ }
133
+
134
+ export default useChatTasks;
@@ -0,0 +1,62 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * `useCopy(text)` — copy-button helper for message bubbles.
5
+ *
6
+ * Returns `{ copied, copy }`. `copy()` writes `text` to clipboard and flips
7
+ * `copied` to true for `resetMs` milliseconds, then back to false so the
8
+ * button can revert to its idle icon.
9
+ *
10
+ * Why not just inline this in each component:
11
+ * - UserMessage + AssistantMessage + (eventually) tool-result cards all
12
+ * need the same "copied / not copied" state machine + clipboard
13
+ * fallback. Factoring keeps the timer-clearing logic in one place.
14
+ * - `navigator.clipboard.writeText` rejects on insecure origins or
15
+ * unsupported browsers (older Electron); we fall back to a hidden
16
+ * textarea + `document.execCommand('copy')` so the chat stays
17
+ * interactive even when permissions are odd.
18
+ */
19
+ export interface UseCopyResult {
20
+ /** True for `resetMs` after a successful copy. */
21
+ copied: boolean;
22
+ /** Trigger a clipboard write. Idempotent if `copied` is already true. */
23
+ copy: () => Promise<void>;
24
+ }
25
+
26
+ export function useCopy(text: string, resetMs = 1500): UseCopyResult {
27
+ const [copied, setCopied] = useState(false);
28
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+
30
+ const copy = useCallback(async () => {
31
+ if (!text) return;
32
+ try {
33
+ if (navigator.clipboard?.writeText) {
34
+ await navigator.clipboard.writeText(text);
35
+ } else {
36
+ // Fallback for older Electron / non-secure contexts.
37
+ const ta = document.createElement('textarea');
38
+ ta.value = text;
39
+ ta.style.position = 'fixed';
40
+ ta.style.opacity = '0';
41
+ document.body.appendChild(ta);
42
+ ta.select();
43
+ document.execCommand('copy');
44
+ document.body.removeChild(ta);
45
+ }
46
+ setCopied(true);
47
+ if (timerRef.current) clearTimeout(timerRef.current);
48
+ timerRef.current = setTimeout(() => setCopied(false), resetMs);
49
+ } catch {
50
+ // Swallow; user can still highlight + ⌘C the bubble manually.
51
+ }
52
+ }, [text, resetMs]);
53
+
54
+ useEffect(
55
+ () => () => {
56
+ if (timerRef.current) clearTimeout(timerRef.current);
57
+ },
58
+ [],
59
+ );
60
+
61
+ return { copied, copy };
62
+ }