@steerable/agent-shell-web 0.6.17 → 0.6.19

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 (56) hide show
  1. package/package.json +12 -19
  2. package/src/brand.test.ts +22 -0
  3. package/src/components/AgentSidebar.test.tsx +791 -0
  4. package/src/components/AgentSidebar.tsx +5 -2
  5. package/src/components/ChatHeader.test.tsx +230 -0
  6. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  7. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  8. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  9. package/src/components/chat/AskUserQuestionMenu.test.tsx +190 -0
  10. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  11. package/src/components/chat/ChatInput.test.tsx +609 -0
  12. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  13. package/src/components/chat/Markdown.test.tsx +170 -0
  14. package/src/components/chat/ModelPicker.test.tsx +149 -0
  15. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  16. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  17. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  18. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  19. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  20. package/src/components/chat/elapsed.test.ts +42 -0
  21. package/src/components/chat/executed-actions-model.test.ts +134 -0
  22. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  23. package/src/components/chat/turn-timeline.test.ts +97 -0
  24. package/src/components/chat/useChatTasks.test.ts +136 -0
  25. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  26. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  27. package/src/components/settings/LlmSettingsPanel.test.tsx +461 -0
  28. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  29. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  30. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  31. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  32. package/src/components/settings/compat-flags-model.test.ts +71 -0
  33. package/src/components/settings/llm-vendors.test.ts +171 -0
  34. package/src/components/settings/model-capabilities.test.ts +88 -0
  35. package/src/components/settings/preset-choice-model.test.ts +96 -0
  36. package/src/hooks/useChatsAndAgents.test.ts +165 -0
  37. package/src/lib/attachments.test.ts +127 -0
  38. package/src/lib/chat-title.test.ts +53 -0
  39. package/src/lib/chat-transport.test.ts +282 -0
  40. package/src/lib/chat-transport.ts +1 -2
  41. package/src/lib/date-groups.test.ts +62 -0
  42. package/src/lib/demo-flag.test.ts +17 -0
  43. package/src/lib/electron-bridge.test.ts +54 -0
  44. package/src/lib/electron-bridge.ts +2 -2
  45. package/src/lib/exec-policy.test.ts +71 -0
  46. package/src/lib/http-bridge.test.ts +283 -0
  47. package/src/lib/http-bridge.ts +18 -4
  48. package/src/lib/insights.test.ts +56 -0
  49. package/src/lib/local-api.test.ts +353 -0
  50. package/src/lib/pending-first-message.test.ts +17 -0
  51. package/src/lib/slash-sources.test.ts +161 -0
  52. package/src/pages/AgentPage.test.tsx +627 -0
  53. package/src/pages/SettingsPage.test.tsx +102 -0
  54. package/vite.base.ts +94 -0
  55. package/vite.base.d.ts +0 -7
  56. package/vite.base.js +0 -83
@@ -0,0 +1,791 @@
1
+ /**
2
+ * AgentSidebar 交互契约:
3
+ * - 新对话只开落地页不落库(既有用例,见第一个 describe);
4
+ * - 会话列表:置顶优先 + 时间倒序、[自动化] 标题解析、智能体首字母圆点、
5
+ * 日期分组、空态 / 加载态 / 错误横幅、底部分页提示;
6
+ * - 会话行:点击导航、删除两段确认(第一次武装第二次才删)、
7
+ * 删除当前会话回落地页、武装后点行解除武装;
8
+ * - 入口导航与高亮:智能体管理 / Skill / MCP / 综合设置随路由各自高亮;
9
+ * - 右侧面板:终端按钮快捷键提示随平台变化,包槽位渲染分段控件;
10
+ * - 副作用:进入会话路由同步 selectedAgentId、菜单 Cmd+N / Cmd+T 订阅与
11
+ * 退订、滚动接近底部自动加载下一页;
12
+ * - 项目模式(Electron):项目分组与折叠、孤儿会话回落日期分组、
13
+ * 新建(文件夹名即项目名)/ 内联重命名 / 换文件夹 / 两段确认删除。
14
+ * 智能体挑选列表已迁到 ChatInput(见组件头注释),侧栏只剩同步副作用可测。
15
+ */
16
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
17
+ import { MemoryRouter, Outlet, Route, Routes, useLocation } from 'react-router-dom';
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19
+ import type { ElectronBridge } from '@/lib/electron-bridge';
20
+ import type { UseChatsAndAgentsResult } from '@/hooks/useChatsAndAgents';
21
+ import type { LocalChat, LocalChatAgent, LocalProject } from '@/lib/local-api';
22
+ import type { RightPanelState } from '@/layouts/AgentLayout';
23
+ import type { PackChatSlotContribution } from '@/packs/registry';
24
+
25
+ // 可控桥桩:bridgeStub 为 null 时 isElectron() = false(纯浏览器预览路径),
26
+ // 项目模式用例经 enterElectron() 装上带 selectDirectory 的桥。
27
+ let bridgeStub: ElectronBridge | null = null;
28
+
29
+ vi.mock('@/lib/electron-bridge', () => ({
30
+ isElectron: () => bridgeStub !== null,
31
+ getElectronBridge: () => bridgeStub,
32
+ }));
33
+
34
+ const listProjects = vi.fn();
35
+ const createProject = vi.fn();
36
+ const updateProject = vi.fn();
37
+ const deleteProject = vi.fn();
38
+
39
+ vi.mock('@/lib/local-api', () => ({
40
+ listProjects: (...args: unknown[]) => listProjects(...args),
41
+ createProject: (...args: unknown[]) => createProject(...args),
42
+ updateProject: (...args: unknown[]) => updateProject(...args),
43
+ deleteProject: (...args: unknown[]) => deleteProject(...args),
44
+ }));
45
+
46
+ vi.mock('@/brand', () => ({
47
+ BRAND_NAME: '测试助手',
48
+ getBrandLogoUrl: () => 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
49
+ }));
50
+
51
+ const { AgentSidebar } = await import('./AgentSidebar');
52
+
53
+ const originalPlatform = window.navigator.platform;
54
+
55
+ beforeEach(() => {
56
+ bridgeStub = null;
57
+ listProjects.mockReset();
58
+ createProject.mockReset();
59
+ updateProject.mockReset();
60
+ deleteProject.mockReset();
61
+ listProjects.mockResolvedValue({ projects: [] });
62
+ });
63
+
64
+ afterEach(() => {
65
+ cleanup();
66
+ Object.defineProperty(window.navigator, 'platform', {
67
+ value: originalPlatform,
68
+ configurable: true,
69
+ });
70
+ });
71
+
72
+ const agent: LocalChatAgent = {
73
+ id: 'local-assistant',
74
+ slug: 'local-assistant',
75
+ name: '电脑操作员',
76
+ icon: null,
77
+ color: '#4f46e5',
78
+ description: null,
79
+ rolePrompt: null,
80
+ isBuiltin: true,
81
+ };
82
+
83
+ const existingChat: LocalChat = {
84
+ id: 'chat-with-content',
85
+ projectId: null,
86
+ userId: 'local',
87
+ title: '已经聊过的对话',
88
+ agentId: agent.id,
89
+ createdAt: '2026-01-01T00:00:00.000Z',
90
+ updatedAt: '2026-01-01T00:01:00.000Z',
91
+ isPinned: false,
92
+ systemPrompt: null,
93
+ pinnedRefs: null,
94
+ };
95
+
96
+ function makeChat(overrides: Partial<LocalChat> = {}): LocalChat {
97
+ return {
98
+ id: 'chat-x',
99
+ projectId: null,
100
+ userId: 'local',
101
+ title: '测试会话',
102
+ agentId: null,
103
+ createdAt: '2026-01-01T00:00:00.000Z',
104
+ updatedAt: '2026-01-01T00:01:00.000Z',
105
+ isPinned: false,
106
+ systemPrompt: null,
107
+ pinnedRefs: null,
108
+ ...overrides,
109
+ };
110
+ }
111
+
112
+ function makeProject(overrides: Partial<LocalProject> = {}): LocalProject {
113
+ return {
114
+ id: 'proj-1',
115
+ name: '项目甲',
116
+ folderPath: '/tmp/proj-a',
117
+ createdAt: '2026-01-01T00:00:00.000Z',
118
+ updatedAt: '2026-01-01T00:00:00.000Z',
119
+ ...overrides,
120
+ };
121
+ }
122
+
123
+ /** 相对今天零点偏移 n 天的 ISO 时间(日期分组用例用)。 */
124
+ function daysAgo(n: number): string {
125
+ const d = new Date();
126
+ d.setDate(d.getDate() - n);
127
+ return d.toISOString();
128
+ }
129
+
130
+ /** 今天指定整点的 ISO 时间(同分组内排序用例用,避免跨组)。 */
131
+ function todayAt(hour: number): string {
132
+ const d = new Date();
133
+ d.setHours(hour, 0, 0, 0);
134
+ return d.toISOString();
135
+ }
136
+
137
+ function setPlatform(platform: string) {
138
+ Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true });
139
+ }
140
+
141
+ function baseElectronBridge(overrides: Partial<ElectronBridge> = {}): ElectronBridge {
142
+ return {
143
+ runtime: 'local',
144
+ platform: 'darwin',
145
+ local: {
146
+ selectDirectory: vi.fn(async () => ({ canceled: true, filePaths: [] as string[] })),
147
+ captureScreenshot: vi.fn(async () => ({ success: false as const, error: '未实现' })),
148
+ },
149
+ localBackend: {
150
+ // 侧栏用例不对 request 做断言;vi.fn 保不住泛型签名,这里按接口收窄。
151
+ request: vi.fn() as unknown as ElectronBridge['localBackend']['request'],
152
+ startStream: vi.fn(async () => null),
153
+ cancelStream: vi.fn(),
154
+ },
155
+ ...overrides,
156
+ };
157
+ }
158
+
159
+ /** 进入 Electron 模式:listProjects 应答给定项目列表,桥带可断言的目录选择器。 */
160
+ function enterElectron(projects: LocalProject[]) {
161
+ listProjects.mockResolvedValue({ projects });
162
+ const selectDirectory = vi.fn(async () => ({ canceled: true, filePaths: [] as string[] }));
163
+ bridgeStub = baseElectronBridge();
164
+ bridgeStub.local!.selectDirectory = selectDirectory;
165
+ return { selectDirectory };
166
+ }
167
+
168
+ function LocationProbe() {
169
+ const loc = useLocation();
170
+ return (
171
+ <div data-testid="loc">
172
+ {loc.pathname}
173
+ {loc.search}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ function makeData(overrides: Partial<UseChatsAndAgentsResult> = {}): UseChatsAndAgentsResult {
179
+ return {
180
+ chats: [existingChat],
181
+ agents: [agent],
182
+ isLoading: false,
183
+ error: null,
184
+ selectedAgentId: agent.id,
185
+ setSelectedAgentId: vi.fn(),
186
+ refreshChats: vi.fn(async () => {}),
187
+ refreshAgents: vi.fn(async () => {}),
188
+ createChat: vi.fn(),
189
+ deleteChat: vi.fn(async () => true),
190
+ patchChatTitle: vi.fn(),
191
+ isLoadingMoreChats: false,
192
+ hasMoreChats: false,
193
+ loadMoreChats: vi.fn(async () => {}),
194
+ ...overrides,
195
+ };
196
+ }
197
+
198
+ interface RenderSidebarOptions {
199
+ data?: Partial<UseChatsAndAgentsResult>;
200
+ rightPanel?: RightPanelState;
201
+ onToggleRightPanel?: (kind: string) => void;
202
+ chatSlots?: readonly PackChatSlotContribution[];
203
+ onCollapse?: () => void;
204
+ }
205
+
206
+ function renderSidebar(
207
+ initialEntry: string,
208
+ createChat = vi.fn(),
209
+ options: RenderSidebarOptions = {},
210
+ ) {
211
+ const data = makeData({ createChat, ...options.data });
212
+ const onToggleRightPanel = options.onToggleRightPanel ?? vi.fn();
213
+ const onCollapse = options.onCollapse ?? vi.fn();
214
+
215
+ const utils = render(
216
+ <MemoryRouter initialEntries={[initialEntry]}>
217
+ <Routes>
218
+ <Route
219
+ element={
220
+ <>
221
+ <AgentSidebar
222
+ data={data}
223
+ rightPanel={options.rightPanel ?? null}
224
+ onToggleRightPanel={onToggleRightPanel}
225
+ chatSlots={options.chatSlots ?? []}
226
+ onCollapse={onCollapse}
227
+ />
228
+ <LocationProbe />
229
+ <Outlet />
230
+ </>
231
+ }
232
+ >
233
+ <Route path="/" element={<div />} />
234
+ <Route path="/agent" element={<div />} />
235
+ <Route path="/agent/:chatId" element={<div />} />
236
+ <Route path="/settings" element={<div />} />
237
+ </Route>
238
+ </Routes>
239
+ </MemoryRouter>,
240
+ );
241
+
242
+ return { createChat, data, onToggleRightPanel, onCollapse, ...utils };
243
+ }
244
+
245
+ /** 按 data-chat-id 取会话行容器。 */
246
+ function chatRow(chatId: string): HTMLElement {
247
+ const row = document.querySelector(`[data-chat-id="${chatId}"]`);
248
+ if (!row) throw new Error(`会话行不存在: ${chatId}`);
249
+ return row as HTMLElement;
250
+ }
251
+
252
+ describe('AgentSidebar 新对话不落库', () => {
253
+ it('打开落地页且不调用 createChat', () => {
254
+ const { createChat } = renderSidebar('/agent/chat-with-content');
255
+ fireEvent.click(screen.getByTestId('sidebar-new-chat'));
256
+ expect(createChat).not.toHaveBeenCalled();
257
+ expect(screen.getByTestId('loc').textContent).toBe('/agent');
258
+ });
259
+
260
+ it('连点两次也不会多出侧栏行', () => {
261
+ renderSidebar('/agent');
262
+ const before = screen.getAllByTestId('sidebar-chat-row').length;
263
+ fireEvent.click(screen.getByTestId('sidebar-new-chat'));
264
+ fireEvent.click(screen.getByTestId('sidebar-new-chat'));
265
+ expect(screen.getAllByTestId('sidebar-chat-row')).toHaveLength(before);
266
+ });
267
+
268
+ it('打开智能体管理页', () => {
269
+ renderSidebar('/agent');
270
+ fireEvent.click(screen.getByTestId('sidebar-agent-settings'));
271
+ expect(screen.getByTestId('loc').textContent).toBe('/settings?section=agents');
272
+ });
273
+ });
274
+
275
+ describe('AgentSidebar 会话列表渲染', () => {
276
+ it('渲染会话标题与绑定智能体的首字母圆点,无智能体回落为 A', () => {
277
+ const noAgent = makeChat({ id: 'c-no-agent', title: '无智能体会话', agentId: null });
278
+ renderSidebar('/agent', vi.fn(), { data: { chats: [existingChat, noAgent] } });
279
+
280
+ expect(chatRow('chat-with-content').textContent).toContain('已经聊过的对话');
281
+ expect(chatRow('chat-with-content').textContent).toContain('电');
282
+ expect(chatRow('c-no-agent').textContent).toContain('A');
283
+ });
284
+
285
+ it('同一日期分组内置顶会话排在普通会话之前并带图钉标记', () => {
286
+ const pinnedEarly = makeChat({
287
+ id: 'c-pinned',
288
+ title: '置顶的较早会话',
289
+ isPinned: true,
290
+ updatedAt: todayAt(1),
291
+ });
292
+ const fresh = makeChat({ id: 'c-fresh', title: '较晚的普通会话', updatedAt: todayAt(23) });
293
+ renderSidebar('/agent', vi.fn(), { data: { chats: [fresh, pinnedEarly] } });
294
+
295
+ const ids = screen.getAllByTestId('sidebar-chat-row').map((r) => r.getAttribute('data-chat-id'));
296
+ expect(ids).toEqual(['c-pinned', 'c-fresh']);
297
+ expect(screen.getByLabelText('已置顶')).toBeTruthy();
298
+ });
299
+
300
+ it('置顶会话跨日期分组也排在最前:独立「置顶」组优先于「今天」', () => {
301
+ const pinnedOld = makeChat({
302
+ id: 'c-pinned-old',
303
+ title: '五天前置顶的会话',
304
+ isPinned: true,
305
+ updatedAt: daysAgo(5),
306
+ });
307
+ const fresh = makeChat({ id: 'c-fresh', title: '今天的普通会话', updatedAt: daysAgo(0) });
308
+ renderSidebar('/agent', vi.fn(), { data: { chats: [fresh, pinnedOld] } });
309
+
310
+ // 组头顺序:置顶组在「今天」之前
311
+ expect(screen.getByText('置顶')).toBeTruthy();
312
+ const pinnedHeader = screen.getByText('置顶');
313
+ const todayHeader = screen.getByText('今天');
314
+ expect(
315
+ pinnedHeader.compareDocumentPosition(todayHeader) & Node.DOCUMENT_POSITION_FOLLOWING,
316
+ ).toBeTruthy();
317
+ // 行顺序:旧的置顶会话仍在今天的普通会话之前
318
+ const ids = screen.getAllByTestId('sidebar-chat-row').map((r) => r.getAttribute('data-chat-id'));
319
+ expect(ids).toEqual(['c-pinned-old', 'c-fresh']);
320
+ });
321
+
322
+ it('[自动化] 前缀被解析为图标与提示,不进入展示标题', () => {
323
+ const auto = makeChat({ id: 'c-auto', title: '[自动化] 每小时巡检' });
324
+ renderSidebar('/agent', vi.fn(), { data: { chats: [auto] } });
325
+
326
+ const rowButton = screen.getByTitle('[由自动化触发] 每小时巡检');
327
+ expect(rowButton.textContent).toContain('每小时巡检');
328
+ expect(rowButton.textContent).not.toContain('[自动化]');
329
+ expect(screen.getByLabelText('由自动化触发')).toBeTruthy();
330
+ });
331
+
332
+ it('无项目会话按今天 / 昨天日期分组且新组在前', () => {
333
+ const todayChat = makeChat({ id: 'c-today', title: '今天的会话', updatedAt: daysAgo(0) });
334
+ const yesterdayChat = makeChat({ id: 'c-yesterday', title: '昨天的会话', updatedAt: daysAgo(1) });
335
+ renderSidebar('/agent', vi.fn(), { data: { chats: [yesterdayChat, todayChat] } });
336
+
337
+ expect(screen.getByText('今天')).toBeTruthy();
338
+ expect(screen.getByText('昨天')).toBeTruthy();
339
+ const ids = screen.getAllByTestId('sidebar-chat-row').map((r) => r.getAttribute('data-chat-id'));
340
+ expect(ids).toEqual(['c-today', 'c-yesterday']);
341
+ });
342
+
343
+ it('空列表展示空态,加载中展示 spinner,错误走 alert 横幅', () => {
344
+ const { unmount } = renderSidebar('/agent', vi.fn(), { data: { chats: [] } });
345
+ expect(screen.getByText('暂无会话')).toBeTruthy();
346
+ unmount();
347
+
348
+ const second = renderSidebar('/agent', vi.fn(), { data: { chats: [], isLoading: true } });
349
+ expect(screen.getByText('加载中...')).toBeTruthy();
350
+ second.unmount();
351
+
352
+ renderSidebar('/agent', vi.fn(), { data: { error: '列表加载失败' } });
353
+ expect(screen.getByRole('alert').textContent).toContain('列表加载失败');
354
+ });
355
+
356
+ it('头部计数与底部汇总随会话数变化', () => {
357
+ renderSidebar('/agent', vi.fn(), {
358
+ data: { chats: [existingChat, makeChat({ id: 'c-2', title: '第二条' })] },
359
+ });
360
+ expect(screen.getByText('· 2')).toBeTruthy();
361
+ expect(screen.getByText('共 2 个会话')).toBeTruthy();
362
+ });
363
+
364
+ it('有更多页时底部提示继续下滑,加载中提示加载更多', () => {
365
+ const { unmount } = renderSidebar('/agent', vi.fn(), { data: { hasMoreChats: true } });
366
+ expect(screen.getByText('继续下滑加载更多')).toBeTruthy();
367
+ unmount();
368
+
369
+ renderSidebar('/agent', vi.fn(), { data: { hasMoreChats: true, isLoadingMoreChats: true } });
370
+ expect(screen.getByText('加载更多...')).toBeTruthy();
371
+ });
372
+ });
373
+
374
+ describe('AgentSidebar 会话行交互', () => {
375
+ it('点击会话行导航到对应会话', () => {
376
+ renderSidebar('/agent');
377
+ fireEvent.click(screen.getByText('已经聊过的对话'));
378
+ expect(screen.getByTestId('loc').textContent).toBe('/agent/chat-with-content');
379
+ });
380
+
381
+ it('删除需两段确认:第一次武装红色按钮,第二次才调 deleteChat', async () => {
382
+ const deleteChat = vi.fn(async () => true);
383
+ renderSidebar('/agent', vi.fn(), { data: { deleteChat } });
384
+
385
+ const del = screen.getByTestId('sidebar-chat-delete');
386
+ fireEvent.click(del);
387
+ expect(deleteChat).not.toHaveBeenCalled();
388
+ expect(del.getAttribute('aria-label')).toBe('再次点击确认删除');
389
+
390
+ fireEvent.click(del);
391
+ await waitFor(() => expect(deleteChat).toHaveBeenCalledWith('chat-with-content'));
392
+ });
393
+
394
+ it('删除当前会话后回到落地页', async () => {
395
+ const deleteChat = vi.fn(async () => true);
396
+ renderSidebar('/agent/chat-with-content', vi.fn(), { data: { deleteChat } });
397
+
398
+ const del = screen.getByTestId('sidebar-chat-delete');
399
+ fireEvent.click(del);
400
+ fireEvent.click(del);
401
+ await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/agent'));
402
+ });
403
+
404
+ it('deleteChat 返回 false 时不导航且保持武装态', async () => {
405
+ const deleteChat = vi.fn(async () => false);
406
+ renderSidebar('/agent/chat-with-content', vi.fn(), { data: { deleteChat } });
407
+
408
+ const del = screen.getByTestId('sidebar-chat-delete');
409
+ fireEvent.click(del);
410
+ fireEvent.click(del);
411
+ await waitFor(() => expect(deleteChat).toHaveBeenCalled());
412
+ expect(screen.getByTestId('loc').textContent).toBe('/agent/chat-with-content');
413
+ expect(screen.getByTestId('sidebar-chat-delete').getAttribute('aria-label')).toBe(
414
+ '再次点击确认删除',
415
+ );
416
+ });
417
+
418
+ it('武装后点击会话行会解除武装并正常导航', () => {
419
+ renderSidebar('/agent');
420
+ const del = screen.getByTestId('sidebar-chat-delete');
421
+ fireEvent.click(del);
422
+ expect(del.getAttribute('aria-label')).toBe('再次点击确认删除');
423
+
424
+ fireEvent.click(screen.getByText('已经聊过的对话'));
425
+ expect(screen.getByTestId('loc').textContent).toBe('/agent/chat-with-content');
426
+ expect(screen.getByTestId('sidebar-chat-delete').getAttribute('aria-label')).toBe('删除会话');
427
+ });
428
+
429
+ it('会话区可折叠再展开', () => {
430
+ renderSidebar('/agent');
431
+ fireEvent.click(screen.getByRole('button', { name: /^会话/ }));
432
+ expect(screen.queryByTestId('sidebar-chat-row')).toBeNull();
433
+
434
+ fireEvent.click(screen.getByRole('button', { name: /^会话/ }));
435
+ expect(screen.getByTestId('sidebar-chat-row')).toBeTruthy();
436
+ });
437
+ });
438
+
439
+ describe('AgentSidebar 入口导航与高亮', () => {
440
+ it('Skill / MCP / 综合设置入口分别导航到对应设置页', () => {
441
+ renderSidebar('/agent');
442
+ fireEvent.click(screen.getByTestId('sidebar-skill-settings'));
443
+ expect(screen.getByTestId('loc').textContent).toBe('/settings?section=skills');
444
+ fireEvent.click(screen.getByTestId('sidebar-mcp-settings'));
445
+ expect(screen.getByTestId('loc').textContent).toBe('/settings?section=mcp');
446
+ fireEvent.click(screen.getByTestId('sidebar-llm-settings'));
447
+ expect(screen.getByTestId('loc').textContent).toBe('/settings');
448
+ });
449
+
450
+ it('当前路由对应的入口高亮:?section=skills 只亮 Skill 设置', () => {
451
+ renderSidebar('/settings?section=skills');
452
+ expect(screen.getByTestId('sidebar-skill-settings').className).toContain('bg-agent-foreground/10');
453
+ expect(screen.getByTestId('sidebar-llm-settings').className).not.toContain(
454
+ 'bg-agent-foreground/10',
455
+ );
456
+ });
457
+
458
+ it('/settings 无 section 时高亮底部综合设置', () => {
459
+ renderSidebar('/settings');
460
+ expect(screen.getByTestId('sidebar-llm-settings').className).toContain('bg-agent-foreground/10');
461
+ expect(screen.getByTestId('sidebar-skill-settings').className).not.toContain(
462
+ 'bg-agent-foreground/10',
463
+ );
464
+ });
465
+
466
+ it('当前会话行高亮,其余行不高亮', () => {
467
+ const other = makeChat({ id: 'c-other', title: '另一条会话' });
468
+ renderSidebar('/agent/chat-with-content', vi.fn(), {
469
+ data: { chats: [existingChat, other] },
470
+ });
471
+ expect(chatRow('chat-with-content').querySelector('button')!.className).toContain(
472
+ 'bg-agent-foreground/10',
473
+ );
474
+ expect(chatRow('c-other').querySelector('button')!.className).not.toContain(
475
+ 'bg-agent-foreground/10',
476
+ );
477
+ });
478
+
479
+ it('收起按钮回调 onCollapse', () => {
480
+ const onCollapse = vi.fn();
481
+ renderSidebar('/agent', vi.fn(), { onCollapse });
482
+ fireEvent.click(screen.getByLabelText('收起侧边栏'));
483
+ expect(onCollapse).toHaveBeenCalledTimes(1);
484
+ });
485
+ });
486
+
487
+ describe('AgentSidebar 右侧面板切换', () => {
488
+ it('Mac 上终端按钮提示 ⌘T,点击切换终端面板', () => {
489
+ setPlatform('MacIntel');
490
+ const onToggleRightPanel = vi.fn();
491
+ renderSidebar('/agent', vi.fn(), { onToggleRightPanel });
492
+
493
+ const btn = screen.getByTestId('sidebar-terminal');
494
+ expect(btn.getAttribute('title')).toBe('打开终端面板 (⌘T)');
495
+ fireEvent.click(btn);
496
+ expect(onToggleRightPanel).toHaveBeenCalledWith('terminal');
497
+ });
498
+
499
+ it('非 Mac 提示 Ctrl+T;面板已打开时 title 变为关闭', () => {
500
+ setPlatform('Linux');
501
+ renderSidebar('/agent', vi.fn(), { rightPanel: 'terminal' });
502
+
503
+ const btn = screen.getByTestId('sidebar-terminal');
504
+ expect(btn.textContent).toContain('Ctrl+T');
505
+ expect(btn.getAttribute('title')).toBe('关闭终端面板 (Ctrl+T)');
506
+ expect(btn.className).toContain('bg-agent-foreground/10');
507
+ });
508
+
509
+ it('有包槽位时渲染分段控件,点击槽位切换对应面板', () => {
510
+ const previewSlot: PackChatSlotContribution = {
511
+ slotId: 'preview',
512
+ title: '预览',
513
+ Icon: ({ className }: { className?: string }) => <svg className={className} />,
514
+ Component: () => null,
515
+ };
516
+ const onToggleRightPanel = vi.fn();
517
+ renderSidebar('/agent', vi.fn(), {
518
+ chatSlots: [previewSlot],
519
+ rightPanel: 'preview',
520
+ onToggleRightPanel,
521
+ });
522
+
523
+ const slotBtn = screen.getByTestId('sidebar-slot-preview');
524
+ expect(slotBtn.textContent).toContain('预览');
525
+ expect(slotBtn.className).toContain('bg-agent-foreground/10');
526
+ fireEvent.click(slotBtn);
527
+ expect(onToggleRightPanel).toHaveBeenCalledWith('preview');
528
+ // 终端按钮收进分段控件,仍然可用。
529
+ fireEvent.click(screen.getByTestId('sidebar-terminal'));
530
+ expect(onToggleRightPanel).toHaveBeenCalledWith('terminal');
531
+ });
532
+ });
533
+
534
+ describe('AgentSidebar 副作用', () => {
535
+ it('进入会话路由时把 selectedAgentId 同步为该会话绑定的智能体', () => {
536
+ const setSelectedAgentId = vi.fn();
537
+ renderSidebar('/agent/chat-with-content', vi.fn(), { data: { setSelectedAgentId } });
538
+ expect(setSelectedAgentId).toHaveBeenCalledWith('local-assistant');
539
+ });
540
+
541
+ it('落地页没有当前会话,不同步 selectedAgentId', () => {
542
+ const setSelectedAgentId = vi.fn();
543
+ renderSidebar('/agent', vi.fn(), { data: { setSelectedAgentId } });
544
+ expect(setSelectedAgentId).not.toHaveBeenCalled();
545
+ });
546
+
547
+ it('订阅菜单新建对话事件:回调导航落地页,卸载时退订', () => {
548
+ let menuCb: (() => void) | null = null;
549
+ const offMenuNewChat = vi.fn();
550
+ bridgeStub = baseElectronBridge({
551
+ onMenuNewChat: (cb) => {
552
+ menuCb = cb;
553
+ },
554
+ offMenuNewChat,
555
+ });
556
+ const { unmount } = renderSidebar('/agent/chat-with-content');
557
+
558
+ expect(menuCb).not.toBeNull();
559
+ act(() => {
560
+ menuCb!();
561
+ });
562
+ expect(screen.getByTestId('loc').textContent).toBe('/agent');
563
+
564
+ unmount();
565
+ // 导航后 useNavigate 返回值的 identity 会变,handleOpenNewChat 随之变化
566
+ // 导致订阅 effect 重跑(先退订再订阅),所以这里不断言精确次数——
567
+ // 精确退订契约由下一个用例锁定。
568
+ expect(offMenuNewChat).toHaveBeenCalled();
569
+ });
570
+
571
+ it('未触发导航直接卸载时,菜单订阅恰好退订一次', () => {
572
+ const offMenuNewChat = vi.fn();
573
+ bridgeStub = baseElectronBridge({
574
+ onMenuNewChat: () => {},
575
+ offMenuNewChat,
576
+ });
577
+ const { unmount } = renderSidebar('/agent');
578
+
579
+ unmount();
580
+ expect(offMenuNewChat).toHaveBeenCalledTimes(1);
581
+ });
582
+
583
+ it('订阅菜单打开终端事件:回调切换终端面板,卸载时退订', () => {
584
+ let menuCb: (() => void) | null = null;
585
+ const offMenuOpenTerminal = vi.fn();
586
+ const onToggleRightPanel = vi.fn();
587
+ bridgeStub = baseElectronBridge({
588
+ onMenuOpenTerminal: (cb) => {
589
+ menuCb = cb;
590
+ },
591
+ offMenuOpenTerminal,
592
+ });
593
+ const { unmount } = renderSidebar('/agent', vi.fn(), { onToggleRightPanel });
594
+
595
+ act(() => {
596
+ menuCb!();
597
+ });
598
+ expect(onToggleRightPanel).toHaveBeenCalledWith('terminal');
599
+
600
+ unmount();
601
+ expect(offMenuOpenTerminal).toHaveBeenCalledTimes(1);
602
+ });
603
+
604
+ it('滚动容器接近底部时自动加载下一页(测试环境零高度即触底)', async () => {
605
+ const loadMoreChats = vi.fn(async () => {});
606
+ renderSidebar('/agent', vi.fn(), { data: { hasMoreChats: true, loadMoreChats } });
607
+ await waitFor(() => expect(loadMoreChats).toHaveBeenCalled());
608
+ });
609
+
610
+ it('没有更多会话时不触发加载', async () => {
611
+ const loadMoreChats = vi.fn(async () => {});
612
+ renderSidebar('/agent', vi.fn(), { data: { hasMoreChats: false, loadMoreChats } });
613
+ await act(async () => {
614
+ await new Promise((resolve) => setTimeout(resolve, 60));
615
+ });
616
+ expect(loadMoreChats).not.toHaveBeenCalled();
617
+ });
618
+
619
+ it('正在加载更多时不重复触发', async () => {
620
+ const loadMoreChats = vi.fn(async () => {});
621
+ renderSidebar('/agent', vi.fn(), {
622
+ data: { hasMoreChats: true, isLoadingMoreChats: true, loadMoreChats },
623
+ });
624
+ await act(async () => {
625
+ await new Promise((resolve) => setTimeout(resolve, 60));
626
+ });
627
+ expect(loadMoreChats).not.toHaveBeenCalled();
628
+ });
629
+ });
630
+
631
+ describe('AgentSidebar 项目模式(Electron)', () => {
632
+ it('项目分组渲染在前,孤儿会话回落到无项目日期分组', async () => {
633
+ enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
634
+ const inProject = makeChat({ id: 'c-in', title: '项目内会话', projectId: 'proj-1' });
635
+ const orphan = makeChat({
636
+ id: 'c-orphan',
637
+ title: '孤儿会话',
638
+ projectId: 'proj-gone',
639
+ updatedAt: daysAgo(0),
640
+ });
641
+ renderSidebar('/agent', vi.fn(), { data: { chats: [inProject, orphan] } });
642
+
643
+ await screen.findByText('项目甲');
644
+ // 项目组头计数只算真正属于该项目的会话。
645
+ expect(screen.getByText('· 1')).toBeTruthy();
646
+ expect(chatRow('c-in')).toBeTruthy();
647
+ // 孤儿会话按无项目处理,进入日期分组。
648
+ expect(chatRow('c-orphan')).toBeTruthy();
649
+ expect(screen.getByText('今天')).toBeTruthy();
650
+ });
651
+
652
+ it('项目组头可折叠再展开', async () => {
653
+ enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
654
+ renderSidebar('/agent', vi.fn(), {
655
+ data: { chats: [makeChat({ id: 'c-in', title: '项目内会话', projectId: 'proj-1' })] },
656
+ });
657
+
658
+ await screen.findByText('项目甲');
659
+ fireEvent.click(screen.getByText('项目甲').closest('button')!);
660
+ expect(screen.queryByText('项目内会话')).toBeNull();
661
+
662
+ fireEvent.click(screen.getByText('项目甲').closest('button')!);
663
+ expect(screen.getByText('项目内会话')).toBeTruthy();
664
+ });
665
+
666
+ it('项目组头的 + 导航到带 projectId 的落地页', async () => {
667
+ enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
668
+ renderSidebar('/agent');
669
+
670
+ await screen.findByText('项目甲');
671
+ fireEvent.click(screen.getByTitle('在此项目下新建对话'));
672
+ expect(screen.getByTestId('loc').textContent).toBe('/agent?projectId=proj-1');
673
+ });
674
+
675
+ it('新建项目:选中文件夹后以文件夹名建项目并刷新列表', async () => {
676
+ const { selectDirectory } = enterElectron([]);
677
+ selectDirectory.mockResolvedValue({ canceled: false, filePaths: ['/tmp/演示项目'] });
678
+ createProject.mockResolvedValue({ success: true, project: makeProject() });
679
+ renderSidebar('/agent');
680
+
681
+ fireEvent.click(screen.getByLabelText('新建项目'));
682
+ await waitFor(() =>
683
+ expect(createProject).toHaveBeenCalledWith({ name: '演示项目', folderPath: '/tmp/演示项目' }),
684
+ );
685
+ expect(selectDirectory).toHaveBeenCalledWith({ title: '选择项目文件夹' });
686
+ // 挂载时拉一次,建完再拉一次。
687
+ await waitFor(() => expect(listProjects).toHaveBeenCalledTimes(2));
688
+ });
689
+
690
+ it('新建项目:文件夹路径尾部分隔符被剥掉再取名', async () => {
691
+ const { selectDirectory } = enterElectron([]);
692
+ selectDirectory.mockResolvedValue({ canceled: false, filePaths: ['/tmp/演示目录/'] });
693
+ createProject.mockResolvedValue({ success: true, project: makeProject() });
694
+ renderSidebar('/agent');
695
+
696
+ fireEvent.click(screen.getByLabelText('新建项目'));
697
+ await waitFor(() =>
698
+ expect(createProject).toHaveBeenCalledWith({ name: '演示目录', folderPath: '/tmp/演示目录/' }),
699
+ );
700
+ });
701
+
702
+ it('新建项目:取消目录选择则不建项目', async () => {
703
+ const { selectDirectory } = enterElectron([]);
704
+ renderSidebar('/agent');
705
+
706
+ fireEvent.click(screen.getByLabelText('新建项目'));
707
+ await waitFor(() => expect(selectDirectory).toHaveBeenCalled());
708
+ expect(createProject).not.toHaveBeenCalled();
709
+ });
710
+
711
+ it('内联重命名:提交新名后调 updateProject 并刷新', async () => {
712
+ enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
713
+ updateProject.mockResolvedValue({ success: true, project: makeProject() });
714
+ renderSidebar('/agent');
715
+
716
+ await screen.findByText('项目甲');
717
+ fireEvent.click(screen.getByTitle('重命名项目'));
718
+ const input = screen.getByDisplayValue('项目甲');
719
+ fireEvent.change(input, { target: { value: '项目甲改' } });
720
+ fireEvent.submit(input.closest('form')!);
721
+
722
+ await waitFor(() => expect(updateProject).toHaveBeenCalledWith('proj-1', { name: '项目甲改' }));
723
+ await waitFor(() => expect(listProjects).toHaveBeenCalledTimes(2));
724
+ });
725
+
726
+ it('内联重命名:Escape 取消,不调 updateProject', async () => {
727
+ enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
728
+ renderSidebar('/agent');
729
+
730
+ await screen.findByText('项目甲');
731
+ fireEvent.click(screen.getByTitle('重命名项目'));
732
+ const input = screen.getByDisplayValue('项目甲');
733
+ fireEvent.keyDown(input, { key: 'Escape' });
734
+
735
+ expect(screen.queryByDisplayValue('项目甲')).toBeNull();
736
+ expect(updateProject).not.toHaveBeenCalled();
737
+ });
738
+
739
+ it('内联重命名:空白名直接丢弃,不调 updateProject', async () => {
740
+ enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
741
+ renderSidebar('/agent');
742
+
743
+ await screen.findByText('项目甲');
744
+ fireEvent.click(screen.getByTitle('重命名项目'));
745
+ const input = screen.getByDisplayValue('项目甲');
746
+ fireEvent.change(input, { target: { value: ' ' } });
747
+ fireEvent.submit(input.closest('form')!);
748
+
749
+ expect(screen.queryByDisplayValue('项目甲')).toBeNull();
750
+ expect(updateProject).not.toHaveBeenCalled();
751
+ });
752
+
753
+ it('更换绑定文件夹:选中新路径后调 updateProject', async () => {
754
+ const { selectDirectory } = enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
755
+ selectDirectory.mockResolvedValue({ canceled: false, filePaths: ['/tmp/new-dir'] });
756
+ updateProject.mockResolvedValue({ success: true, project: makeProject() });
757
+ renderSidebar('/agent');
758
+
759
+ await screen.findByText('项目甲');
760
+ fireEvent.click(screen.getByTitle('更换绑定文件夹'));
761
+ await waitFor(() =>
762
+ expect(updateProject).toHaveBeenCalledWith('proj-1', { folderPath: '/tmp/new-dir' }),
763
+ );
764
+ expect(selectDirectory).toHaveBeenCalledWith({ title: '重新选择项目文件夹' });
765
+ });
766
+
767
+ it('删除项目两段确认:第二次点击才删除并刷新会话列表', async () => {
768
+ enterElectron([makeProject({ id: 'proj-1', name: '项目甲' })]);
769
+ deleteProject.mockResolvedValue({ success: true, detachedChats: 2 });
770
+ const { data } = renderSidebar('/agent');
771
+
772
+ await screen.findByText('项目甲');
773
+ fireEvent.click(screen.getByTitle('删除项目(会话保留为无项目对话)'));
774
+ expect(deleteProject).not.toHaveBeenCalled();
775
+
776
+ fireEvent.click(screen.getByTitle('再次点击确认删除(会话会保留为无项目对话)'));
777
+ await waitFor(() => expect(deleteProject).toHaveBeenCalledWith('proj-1'));
778
+ await waitFor(() => expect(data.refreshChats).toHaveBeenCalled());
779
+ });
780
+
781
+ it('项目列表拉取失败时错误进 alert 横幅', async () => {
782
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
783
+ listProjects.mockReset();
784
+ listProjects.mockRejectedValue(new Error('存储读取失败'));
785
+ bridgeStub = baseElectronBridge();
786
+ renderSidebar('/agent');
787
+
788
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('存储读取失败'));
789
+ consoleSpy.mockRestore();
790
+ });
791
+ });