@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,161 @@
1
+ /**
2
+ * SessionTreeModal 的接线契约:
3
+ * - 打开时经 localBackend 拉 GET /branches/tree,DFS 渲染缩进树,
4
+ * activeRecordId 行高亮带 ✓;
5
+ * - 点击(或 Enter)非 active 节点 → POST /branches/activate →
6
+ * onBranchSwitched + onClose;
7
+ * - 树只有当前记录一个节点时显示空态文案;
8
+ * - Escape 关闭。
9
+ */
10
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
+ import { afterEach, describe, expect, it, vi } from 'vitest';
12
+ import type { ChatBranchTreeResponse } from '@/lib/local-api';
13
+ import { SessionTreeModal } from './SessionTreeModal';
14
+
15
+ afterEach(() => {
16
+ cleanup();
17
+ delete (window as { electron?: unknown }).electron;
18
+ });
19
+
20
+ const TREE_RESPONSE: ChatBranchTreeResponse = {
21
+ activeRecordId: 'chat_1:r2',
22
+ nodeCount: 4,
23
+ truncated: false,
24
+ tree: {
25
+ recordId: 'chat_1',
26
+ sourceRecordId: null,
27
+ sourceUntilSeq: null,
28
+ label: 'root',
29
+ depth: 0,
30
+ children: [
31
+ {
32
+ recordId: 'chat_1:r2',
33
+ sourceRecordId: 'chat_1',
34
+ sourceUntilSeq: 5,
35
+ label: 'question 1',
36
+ depth: 1,
37
+ children: [
38
+ {
39
+ recordId: 'chat_1:r2:x',
40
+ sourceRecordId: 'chat_1:r2',
41
+ sourceUntilSeq: 2,
42
+ label: 'variant question',
43
+ depth: 2,
44
+ children: [],
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ recordId: 'chat_1:r3',
50
+ sourceRecordId: 'chat_1',
51
+ sourceUntilSeq: 3,
52
+ label: 'question 0',
53
+ depth: 1,
54
+ children: [],
55
+ },
56
+ ],
57
+ },
58
+ };
59
+
60
+ function installBridge(treeResponse: ChatBranchTreeResponse | null = TREE_RESPONSE) {
61
+ const request = vi.fn((input: { method: string; path: string; body?: unknown }) => {
62
+ if (input.method === 'GET' && input.path.endsWith('/branches/tree')) {
63
+ return Promise.resolve(treeResponse);
64
+ }
65
+ if (input.method === 'POST' && input.path.endsWith('/branches/activate')) {
66
+ return Promise.resolve({ activeRecordId: 'x', messageCount: 3 });
67
+ }
68
+ return Promise.reject(new Error(`unexpected request: ${input.method} ${input.path}`));
69
+ });
70
+ (window as { electron?: unknown }).electron = { localBackend: { request } };
71
+ return { request };
72
+ }
73
+
74
+ describe('SessionTreeModal', () => {
75
+ it('渲染全树:缩进行 + active 节点高亮', async () => {
76
+ installBridge();
77
+ render(<SessionTreeModal chatId="chat_1" onClose={() => {}} />);
78
+
79
+ await waitFor(() => expect(screen.queryByText('正在加载分支…')).toBeNull());
80
+
81
+ const rows = screen.getAllByRole('button').filter((b) => b.hasAttribute('data-tree-row'));
82
+ expect(rows).toHaveLength(4);
83
+ // DFS 顺序:root → r2 → r2:x → r3;label 按行展示(label 在 truncate span 里)。
84
+ const labels = rows.map(
85
+ (r) => (r.querySelector('span.truncate') as HTMLElement | null)?.textContent,
86
+ );
87
+ expect(labels).toEqual(['root', 'question 1', 'variant question', 'question 0']);
88
+ // 连接线前缀:根无前缀,孙节点带 │ 缩进。
89
+ const prefixes = rows.map(
90
+ (r) => (r.querySelector('span.font-mono') as HTMLElement | null)?.textContent ?? '',
91
+ );
92
+ expect(prefixes).toEqual(['', '├─ ', '│ └─ ', '└─ ']);
93
+ // active 标记在 r2 行。
94
+ expect(rows[1].getAttribute('data-active')).toBe('true');
95
+ expect(rows[0].getAttribute('data-active')).toBeNull();
96
+ });
97
+
98
+ it('点击堂兄弟节点:activate + onBranchSwitched + 关闭', async () => {
99
+ const { request } = installBridge();
100
+ const onClose = vi.fn();
101
+ const onBranchSwitched = vi.fn();
102
+ render(
103
+ <SessionTreeModal chatId="chat_1" onClose={onClose} onBranchSwitched={onBranchSwitched} />,
104
+ );
105
+ await waitFor(() => screen.getByText('question 0'));
106
+
107
+ fireEvent.click(screen.getByText('question 0'));
108
+
109
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
110
+ expect(request).toHaveBeenCalledWith({
111
+ method: 'POST',
112
+ path: '/api/v2/chats/chat_1/branches/activate',
113
+ body: { recordId: 'chat_1:r3' },
114
+ });
115
+ expect(onBranchSwitched).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it('点击 active 节点不触发切换', async () => {
119
+ const { request } = installBridge();
120
+ render(<SessionTreeModal chatId="chat_1" onClose={() => {}} />);
121
+ await waitFor(() => screen.getByText('question 1'));
122
+
123
+ fireEvent.click(screen.getByText('question 1'));
124
+
125
+ expect(
126
+ request.mock.calls.filter(([input]) => (input as { method: string }).method === 'POST'),
127
+ ).toHaveLength(0);
128
+ });
129
+
130
+ it('单节点树显示空态', async () => {
131
+ installBridge({
132
+ activeRecordId: 'chat_1',
133
+ nodeCount: 1,
134
+ truncated: false,
135
+ tree: {
136
+ recordId: 'chat_1',
137
+ sourceRecordId: null,
138
+ sourceUntilSeq: null,
139
+ label: 'root',
140
+ depth: 0,
141
+ children: [],
142
+ },
143
+ });
144
+ render(<SessionTreeModal chatId="chat_1" onClose={() => {}} />);
145
+
146
+ await waitFor(() => screen.getByText(/重新生成回复后,旧版本会保留在这里/));
147
+ expect(screen.queryAllByRole('button').filter((b) => b.hasAttribute('data-tree-row')))
148
+ .toHaveLength(0);
149
+ });
150
+
151
+ it('Escape 关闭模态', async () => {
152
+ const onClose = vi.fn();
153
+ installBridge();
154
+ render(<SessionTreeModal chatId="chat_1" onClose={onClose} />);
155
+ await waitFor(() => screen.getByText('question 0'));
156
+
157
+ fireEvent.keyDown(document, { key: 'Escape' });
158
+
159
+ expect(onClose).toHaveBeenCalledTimes(1);
160
+ });
161
+ });
@@ -0,0 +1,221 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { LuCheck, LuGitBranch, LuLoaderCircle, LuX } from 'react-icons/lu';
4
+ import {
5
+ activateChatBranch,
6
+ getChatBranchTree,
7
+ type ChatBranchTreeNode,
8
+ type ChatBranchTreeResponse,
9
+ } from '@/lib/local-api';
10
+
11
+ /**
12
+ * SessionTreeModal — pi 式全树分支视图(session tree)。
13
+ *
14
+ * 数据来自 getChatBranchTree(sidecar `agent.session.tree`):从家族根
15
+ * 展开的完整 fork 树,含堂兄弟分支——Header 下拉只有 lineage + 直接
16
+ * 子节点,这里能看到并一键切换到任意家族成员。
17
+ *
18
+ * 渲染参考 pi 的 TreeSelector:DFS 铺平成缩进行,│/├─/└─ 连接线,
19
+ * 当前 activeRecordId 高亮。交互:点击(或 ↑↓ + Enter)切换分支 →
20
+ * activateChatBranch → onBranchSwitched 触发消息区重投影 → 关闭;
21
+ * Escape / 点击遮罩关闭。
22
+ */
23
+
24
+ interface SessionTreeModalProps {
25
+ chatId: string;
26
+ onClose: () => void;
27
+ /** 切换成功后调用——页面据此重新水合消息列表(branchTick 机制)。 */
28
+ onBranchSwitched?: () => void;
29
+ }
30
+
31
+ interface FlatNode {
32
+ node: ChatBranchTreeNode;
33
+ /** 已拼好的缩进前缀,如 "│ ├─ "(根节点为空)。 */
34
+ prefix: string;
35
+ }
36
+
37
+ /** DFS 铺平树为渲染行,前缀沿用 pi 的 │/├─/└─ 连接线风格。 */
38
+ function flattenTree(root: ChatBranchTreeNode): FlatNode[] {
39
+ const out: FlatNode[] = [];
40
+ const walk = (node: ChatBranchTreeNode, ancestorPrefix: string, isLast: boolean, isRoot: boolean) => {
41
+ out.push({
42
+ node,
43
+ prefix: isRoot ? '' : `${ancestorPrefix}${isLast ? '└─ ' : '├─ '}`,
44
+ });
45
+ const childPrefix = isRoot ? '' : `${ancestorPrefix}${isLast ? ' ' : '│ '}`;
46
+ node.children.forEach((child, index) =>
47
+ walk(child, childPrefix, index === node.children.length - 1, false),
48
+ );
49
+ };
50
+ walk(root, '', true, true);
51
+ return out;
52
+ }
53
+
54
+ export function SessionTreeModal({ chatId, onClose, onBranchSwitched }: SessionTreeModalProps) {
55
+ const [data, setData] = useState<ChatBranchTreeResponse | null>(null);
56
+ const [loading, setLoading] = useState(true);
57
+ const [switching, setSwitching] = useState(false);
58
+ const [selectedIndex, setSelectedIndex] = useState(0);
59
+
60
+ useEffect(() => {
61
+ let cancelled = false;
62
+ setLoading(true);
63
+ getChatBranchTree(chatId)
64
+ .then((res) => {
65
+ if (!cancelled) setData(res);
66
+ })
67
+ .catch(() => {
68
+ if (!cancelled) setData(null);
69
+ })
70
+ .finally(() => {
71
+ if (!cancelled) setLoading(false);
72
+ });
73
+ return () => {
74
+ cancelled = true;
75
+ };
76
+ }, [chatId]);
77
+
78
+ const rows = useMemo(() => (data?.tree ? flattenTree(data.tree) : []), [data]);
79
+ // 无分支 = 树只有当前记录一个节点(还没有任何 regenerate fork)。
80
+ const isEmpty = !loading && (!data?.tree || rows.length <= 1);
81
+
82
+ useEffect(() => {
83
+ if (!data) return;
84
+ const activeIndex = rows.findIndex((r) => r.node.recordId === data.activeRecordId);
85
+ setSelectedIndex(activeIndex >= 0 ? activeIndex : 0);
86
+ }, [data, rows]);
87
+
88
+ const switchTo = async (recordId: string) => {
89
+ if (!data || switching || recordId === data.activeRecordId) return;
90
+ setSwitching(true);
91
+ try {
92
+ await activateChatBranch(chatId, recordId);
93
+ onBranchSwitched?.();
94
+ onClose();
95
+ } catch {
96
+ // 切换失败(分支族外记录 / sidecar 离线)——模态保持打开,用户可重试。
97
+ } finally {
98
+ setSwitching(false);
99
+ }
100
+ };
101
+
102
+ useEffect(() => {
103
+ const onKeyDown = (e: KeyboardEvent) => {
104
+ if (e.key === 'Escape') {
105
+ e.stopPropagation();
106
+ onClose();
107
+ return;
108
+ }
109
+ if (rows.length === 0) return;
110
+ if (e.key === 'ArrowDown') {
111
+ e.preventDefault();
112
+ setSelectedIndex((i) => Math.min(i + 1, rows.length - 1));
113
+ } else if (e.key === 'ArrowUp') {
114
+ e.preventDefault();
115
+ setSelectedIndex((i) => Math.max(i - 1, 0));
116
+ } else if (e.key === 'Enter') {
117
+ e.preventDefault();
118
+ const row = rows[selectedIndex];
119
+ if (row) void switchTo(row.node.recordId);
120
+ }
121
+ };
122
+ document.addEventListener('keydown', onKeyDown, true);
123
+ return () => document.removeEventListener('keydown', onKeyDown, true);
124
+ // switchTo 依赖 data/switching,每次渲染重建监听即可(模态生命周期短)。
125
+ });
126
+
127
+ return createPortal(
128
+ <div
129
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
130
+ role="dialog"
131
+ aria-modal="true"
132
+ aria-label="会话分支树"
133
+ onMouseDown={(e) => {
134
+ if (e.target === e.currentTarget) onClose();
135
+ }}
136
+ data-session-tree-modal
137
+ >
138
+ <div className="flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
139
+ <div className="flex items-center gap-2 border-b border-agent-border px-4 py-2.5">
140
+ <LuGitBranch className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
141
+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-agent-foreground">
142
+ 会话分支树
143
+ {data && data.nodeCount > 1 && (
144
+ <span className="ml-1.5 text-xs font-normal text-agent-muted-foreground">
145
+ {data.nodeCount} 个分支
146
+ </span>
147
+ )}
148
+ </span>
149
+ <button
150
+ type="button"
151
+ onClick={onClose}
152
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
153
+ aria-label="关闭"
154
+ >
155
+ <LuX className="h-3.5 w-3.5" />
156
+ </button>
157
+ </div>
158
+
159
+ <div className="min-h-0 flex-1 overflow-auto p-2">
160
+ {loading ? (
161
+ <div className="flex items-center justify-center gap-2 px-3 py-6 text-xs text-agent-muted-foreground">
162
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
163
+ 正在加载分支…
164
+ </div>
165
+ ) : isEmpty ? (
166
+ <div className="px-3 py-6 text-center text-xs text-agent-muted-foreground">
167
+ 暂无分支 — 重新生成回复后,旧版本会保留在这里。
168
+ </div>
169
+ ) : (
170
+ rows.map((row, index) => {
171
+ const active = row.node.recordId === data?.activeRecordId;
172
+ const selected = index === selectedIndex;
173
+ return (
174
+ <button
175
+ key={row.node.recordId}
176
+ type="button"
177
+ onClick={() => void switchTo(row.node.recordId)}
178
+ onMouseEnter={() => setSelectedIndex(index)}
179
+ aria-current={active ? 'true' : undefined}
180
+ aria-disabled={switching || active || undefined}
181
+ className={`flex w-full items-center gap-1 rounded px-2 py-1.5 text-left text-xs transition-colors ${
182
+ active
183
+ ? 'cursor-default font-medium text-agent-foreground'
184
+ : 'text-agent-muted-foreground hover:text-agent-foreground'
185
+ } ${selected ? 'bg-agent-foreground/5' : ''} ${
186
+ switching ? 'pointer-events-none opacity-60' : ''
187
+ }`}
188
+ data-tree-row
189
+ data-active={active || undefined}
190
+ >
191
+ {row.prefix && (
192
+ <span className="shrink-0 whitespace-pre font-mono text-agent-muted-foreground/60">
193
+ {row.prefix}
194
+ </span>
195
+ )}
196
+ <span className="min-w-0 flex-1 truncate">
197
+ {row.node.label || '(空分支)'}
198
+ </span>
199
+ {active && (
200
+ <LuCheck className="h-3 w-3 shrink-0 text-emerald-600 dark:text-emerald-400" />
201
+ )}
202
+ </button>
203
+ );
204
+ })
205
+ )}
206
+ </div>
207
+
208
+ {!loading && !isEmpty && (
209
+ <div className="border-t border-agent-border px-4 py-2 text-[10px] text-agent-muted-foreground/80">
210
+ {data?.truncated
211
+ ? '分支过多,树已被截断(仅显示部分节点)。'
212
+ : '点击切换分支;↑↓ 移动,Enter 切换,Esc 关闭。'}
213
+ </div>
214
+ )}
215
+ </div>
216
+ </div>,
217
+ document.body,
218
+ );
219
+ }
220
+
221
+ export default SessionTreeModal;
@@ -0,0 +1,95 @@
1
+ import { motion } from 'framer-motion';
2
+
3
+ /**
4
+ * StreamingStatus — informative placeholder for an assistant bubble that has
5
+ * been created but doesn't have content tokens yet (or is between rounds).
6
+ *
7
+ * Local-backend's SSE stream gives us these signals while a turn is running:
8
+ * • token chunks (`{content: "..."}`) — handled by useChatStream
9
+ * • round_end (`completion: executing`) — bumps `round` counter
10
+ * • executed_actions — fires after each round's tool calls
11
+ * • completion (completed/failed) — stream end
12
+ *
13
+ * So the meaningful "thinking" states we can actually display are:
14
+ * 1. Round 1, no actions yet → "正在思考..."
15
+ * 2. Round 1+, just executed tools → "已调用 N 个工具,正在分析..."
16
+ * 3. Round ≥ 2, no actions this round → "Round N · 继续推理..."
17
+ *
18
+ * If the backend ever starts emitting real reasoning chunks, we can extend
19
+ * `label` with a streaming text channel and progress dots.
20
+ */
21
+ interface StreamingStatusProps {
22
+ /** Current round number (1-based). `executions_actions` per round, framework appends content tokens between rounds. */
23
+ round: number;
24
+ /** Number of tool calls executed in this turn so far (across all rounds). */
25
+ actionCount: number;
26
+ /**
27
+ * True when the assistant bubble has at least one content token already.
28
+ * Pass through from the parent — when content is non-empty we render
29
+ * nothing (the content + cursor blink is enough).
30
+ */
31
+ hasContent: boolean;
32
+ }
33
+
34
+ export function StreamingStatus({
35
+ round,
36
+ actionCount,
37
+ hasContent,
38
+ }: StreamingStatusProps) {
39
+ // Once tokens are flowing the parent already has plenty of visual feedback
40
+ // (the streaming cursor); a separate status line would be noise.
41
+ if (hasContent) return null;
42
+
43
+ const label = selectLabel(round, actionCount);
44
+
45
+ return (
46
+ <motion.div
47
+ key={label}
48
+ initial={{ opacity: 0, y: 2 }}
49
+ animate={{ opacity: 1, y: 0 }}
50
+ transition={{ duration: 0.2 }}
51
+ // aria-live=polite + aria-atomic=true so AT clients (VoiceOver,
52
+ // NVDA, JAWS) announce the new status text on each transition. We
53
+ // chose `polite` over `assertive` because the change is informational,
54
+ // not actionable — `assertive` would barge in on whatever the user is
55
+ // currently reading.
56
+ role="status"
57
+ aria-live="polite"
58
+ aria-atomic="true"
59
+ className="my-2 inline-flex items-center gap-2 text-xs text-agent-muted-foreground"
60
+ >
61
+ <Dots />
62
+ <span>{label}</span>
63
+ {round > 1 && (
64
+ <span className="rounded-full border border-agent-border bg-agent-muted/40 px-1.5 py-[1px] font-mono text-[10px] text-agent-muted-foreground">
65
+ round {round}
66
+ </span>
67
+ )}
68
+ </motion.div>
69
+ );
70
+ }
71
+
72
+ function selectLabel(round: number, actionCount: number): string {
73
+ if (round >= 2 && actionCount === 0) {
74
+ return `Round ${round} · 继续推理...`;
75
+ }
76
+ if (actionCount > 0) {
77
+ return `已调用 ${actionCount} 个工具,正在分析...`;
78
+ }
79
+ return '正在思考...';
80
+ }
81
+
82
+ function Dots() {
83
+ // Three pulsing dots — matches the inline indicator used inside
84
+ // AssistantMessage when content === '', so the visual hands off cleanly
85
+ // once tokens start streaming.
86
+ return (
87
+ <span className="inline-flex items-center gap-1">
88
+ <span className="h-1 w-1 animate-pulse rounded-full bg-agent-foreground/40 [animation-delay:0ms]" />
89
+ <span className="h-1 w-1 animate-pulse rounded-full bg-agent-foreground/40 [animation-delay:150ms]" />
90
+ <span className="h-1 w-1 animate-pulse rounded-full bg-agent-foreground/40 [animation-delay:300ms]" />
91
+ </span>
92
+ );
93
+ }
94
+
95
+ export default StreamingStatus;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * TaskOutcomeCards 契约:后台任务的终态要出现在用户正在看的对话里。
3
+ * - 完成显示 answer、失败显示 error,任务名都在;
4
+ * - 「查看过程」把任务交给右侧推理面板,「忽略」只发出 taskId;
5
+ * - worktree 待合并的任务提示去任务按钮处理(卡片不重复那套操作);
6
+ * - 一次结束太多任务时只留前几张,其余折成一行汇总。
7
+ */
8
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import type { LocalTask } from '@/lib/local-api';
11
+ import { TaskOutcomeCards } from './TaskOutcomeCards';
12
+
13
+ afterEach(cleanup);
14
+
15
+ function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
16
+ return {
17
+ id: 'task-1',
18
+ chatId: 'chat_1',
19
+ task: '统计仓库行数',
20
+ status: 'completed',
21
+ answer: '共 42 行。',
22
+ error: null,
23
+ worktreePath: null,
24
+ worktreeBranch: null,
25
+ worktreeState: null,
26
+ createdAt: '2026-09-13T01:00:00.000Z',
27
+ updatedAt: '2026-09-13T01:01:00.000Z',
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function renderCards(tasks: LocalTask[]) {
33
+ const onInspect = vi.fn();
34
+ const onDismiss = vi.fn();
35
+ render(<TaskOutcomeCards tasks={tasks} onInspect={onInspect} onDismiss={onDismiss} />);
36
+ return { onInspect, onDismiss };
37
+ }
38
+
39
+ describe('TaskOutcomeCards', () => {
40
+ it('没有终态任务时什么都不渲染', () => {
41
+ renderCards([]);
42
+ expect(screen.queryByTestId('task-outcome-cards')).toBeNull();
43
+ });
44
+
45
+ it('完成的任务显示任务名与结果', () => {
46
+ renderCards([makeTask()]);
47
+
48
+ expect(screen.getByText('统计仓库行数')).toBeTruthy();
49
+ expect(screen.getByText('共 42 行。')).toBeTruthy();
50
+ expect(
51
+ document.querySelector('[data-task-outcome="task-1"]')?.getAttribute('data-status'),
52
+ ).toBe('completed');
53
+ });
54
+
55
+ it('失败的任务显示错误原因', () => {
56
+ renderCards([makeTask({ status: 'failed', answer: null, error: '依赖任务失败:abc' })]);
57
+
58
+ expect(screen.getByText('依赖任务失败:abc')).toBeTruthy();
59
+ expect(
60
+ document.querySelector('[data-task-outcome="task-1"]')?.getAttribute('data-status'),
61
+ ).toBe('failed');
62
+ });
63
+
64
+ it('「查看过程」交出整个任务,「忽略」交出 taskId', () => {
65
+ const task = makeTask();
66
+ const { onInspect, onDismiss } = renderCards([task]);
67
+
68
+ fireEvent.click(screen.getByRole('button', { name: '查看过程' }));
69
+ expect(onInspect).toHaveBeenCalledWith(task);
70
+
71
+ fireEvent.click(screen.getByRole('button', { name: '忽略' }));
72
+ expect(onDismiss).toHaveBeenCalledWith('task-1');
73
+ });
74
+
75
+ it('worktree 待合并时指回任务按钮,卡片自己不放合并操作', () => {
76
+ renderCards([
77
+ makeTask({
78
+ worktreeState: 'pending',
79
+ worktreePath: '/tmp/wt',
80
+ worktreeBranch: 'steerable/fix',
81
+ }),
82
+ ]);
83
+
84
+ expect(screen.getByText('steerable/fix')).toBeTruthy();
85
+ expect(screen.getByText(/待合并 — 在标题栏的任务按钮里处理/)).toBeTruthy();
86
+ expect(screen.queryByRole('button', { name: /合并/ })).toBeNull();
87
+ });
88
+
89
+ it('超过三个时只留前三张,其余折成一行汇总', () => {
90
+ renderCards(
91
+ Array.from({ length: 5 }, (_, i) => makeTask({ id: `t${i}`, task: `任务 ${i}` })),
92
+ );
93
+
94
+ expect(document.querySelectorAll('[data-task-outcome]')).toHaveLength(3);
95
+ expect(screen.getByText(/另有 2 个后台任务已结束/)).toBeTruthy();
96
+ });
97
+ });