@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,1010 @@
1
+ /**
2
+ * AgentSidebar — parity rewrite that mirrors the visual + interaction
3
+ * surface of `deeppath/apps/web/src/app/agent/AgentSidebar.tsx`.
4
+ *
5
+ * ## Agent picker semantics (this was a UX bug — read before changing!)
6
+ *
7
+ * Two distinct concepts share the agent list, and conflating them was the
8
+ * original sin of the first cut:
9
+ *
10
+ * • **selectedAgentId** — "which agent will the NEXT `+ 新对话` use".
11
+ * Driven by user clicks in the sidebar. Persists until the user clicks
12
+ * another agent. Defaults to this flavor's builtin expert on first load
13
+ * (场景产品 → 包的主打专家;无包产品 → shell 默认智能体).
14
+ * • **activeChatAgentId** — "which agent the CURRENT chat is bound to"
15
+ * (URL-driven, can't be re-bound without a backend endpoint). Purely
16
+ * informational in the sidebar — there's no UI for switching mid-chat
17
+ * yet.
18
+ *
19
+ * The original code computed `displayAgentId = activeChatAgentId ?? selectedAgentId`
20
+ * and lit up THAT row. Result: when the user was inside a chat and clicked a
21
+ * DIFFERENT agent, the click silently mutated `selectedAgentId`, but the
22
+ * highlight stayed pinned to the chat's existing agent → the user saw zero
23
+ * feedback and assumed the button was broken. They only noticed selection
24
+ * worked after clicking `+ 新对话` and seeing the new agent in the new chat.
25
+ *
26
+ * Current behavior:
27
+ * 1. **Row highlight always tracks `selectedAgentId`** — click = visible
28
+ * response, no exceptions.
29
+ * 2. **Current chat's agent gets a "当前" pill** — small badge to the
30
+ * right of the row, never blocks selection feedback.
31
+ * 3. **Navigating to a chat auto-syncs `selectedAgentId = chat.agentId`**
32
+ * via a useEffect on `activeChatAgentId`. This way the user's mental
33
+ * model "I'm working with agent X" stays consistent: switching chat
34
+ * switches the selection; clicking an agent overrides it.
35
+ * 4. **When `selectedAgentId !== activeChatAgentId`** (the user has
36
+ * explicitly diverged), an inline `+ 用「name」新建对话` CTA appears
37
+ * below the agent list. This makes the "your click set up a new chat"
38
+ * contract impossible to miss.
39
+ *
40
+ * ## Other intentional differences from the cloud sibling
41
+ *
42
+ * • Active chat id comes from the URL (`useParams().chatId`) rather than
43
+ * a Context (we drive navigation, not vice versa).
44
+ * • Agent CRUD lives on `/settings?section=agents`(侧栏「智能体管理」),
45
+ * not a modal. Custom agents show up in ChatInput's expert picker.
46
+ * • Cmd+N / Cmd+T trigger `menu:new-chat` / `menu:open-terminal` via the
47
+ * preload bridge; we subscribe here so the shortcuts work regardless of
48
+ * focused window. The terminal is a toggleable panel BESIDE the chat
49
+ * (owned by AgentLayout), not a route and not a separate window.
50
+ *
51
+ * Layout map:
52
+ *
53
+ * ┌─────────────────────────────────┐
54
+ * │ ✨ Product Agent +• │ ← + has a color dot of the selected agent
55
+ * ├─────────────────────────────────┤
56
+ * │ v 专家团队 · 3 │
57
+ * │ ● 教练 (selected) │ ← highlight = selectedAgentId
58
+ * │ ● 文学顾问 · 当前 │ ← "当前" pill = activeChatAgentId
59
+ * │ ● 哲学家 │
60
+ * │ + 用「教练」新建对话 │ ← inline CTA when sel ≠ current
61
+ * ├──── divider ────────────────────┤
62
+ * │ + 新对话 │ ← 只打开落地页,有内容才落库
63
+ * │ ⬡ 智能体管理 │ ← /settings?section=agents(独立页)
64
+ * │ ⬡ Skill 设置 │ ← /settings?section=skills(独立页)
65
+ * │ 🔌 MCP 设置 │ ← /settings?section=mcp(独立页)
66
+ * │ v 会话 · 12 📁+ │ ← 📁+ 新建项目
67
+ * │ v 📁 项目A · 3 (hover: +✏📂🗑)│ ← 项目组:折叠/新建/重命名/换文件夹/删
68
+ * │ ...(项目内对话) │
69
+ * │ 今天 │
70
+ * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
71
+ * ├─────────────────────────────────┤
72
+ * │ ▢_ 终端 ⌘T │
73
+ * │ ⚙ 设置 │ ← /settings(模型 + 洞察/遥测/用量/搜索/安全)
74
+ * └─────────────────────────────────┘
75
+ *
76
+ * 项目模式:项目 = 名字 + 绑定文件夹(ProjectRegistry,electron-store)。
77
+ * 项目内对话的 agent 文件读写与命令执行被硬沙箱在该文件夹内(见
78
+ * tool-router.ts ToolExecContext.projectRoot);无项目对话不沙箱。
79
+ */
80
+
81
+ import {
82
+ useCallback,
83
+ useEffect,
84
+ useMemo,
85
+ useRef,
86
+ useState,
87
+ } from 'react';
88
+ import { useLocation, useNavigate, useParams } from 'react-router-dom';
89
+ import {
90
+ LuChevronDown,
91
+ LuChevronUp,
92
+ LuMessageSquare,
93
+ LuCloudCog,
94
+ LuTrash2,
95
+ LuLoaderCircle,
96
+ LuTerminal,
97
+ LuSettings,
98
+ LuPlus,
99
+ LuPanelLeftClose,
100
+ LuBlocks,
101
+ LuBot,
102
+ LuFolder,
103
+ LuFolderOpen,
104
+ LuFolderPlus,
105
+ LuPencil,
106
+ LuPlug,
107
+ } from 'react-icons/lu';
108
+ import { parseChatTitle } from '@/lib/chat-title';
109
+ import { getDateGroupLabel, getDateGroupPriority } from '@/lib/date-groups';
110
+ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
111
+ import {
112
+ createProject,
113
+ deleteProject,
114
+ listProjects,
115
+ updateProject,
116
+ type LocalChat,
117
+ type LocalChatAgent,
118
+ type LocalProject,
119
+ } from '@/lib/local-api';
120
+ import type { UseChatsAndAgentsResult } from '@/hooks/useChatsAndAgents';
121
+ import type { PackChatSlotContribution } from '@/packs/registry';
122
+ import type { RightPanelState } from '@/layouts/AgentLayout';
123
+ // 必须 import 而不是写 src="/favicon.png":public/ 下的资源 Vite 永远按绝对路径
124
+ // /favicon.png 输出,dev 模式下 dev server 提供根路径所以能加载,但打包成
125
+ // Electron 后渲染进程走 file:// 协议,/favicon.png 会被解析成文件系统根目录
126
+ // 下的 favicon.png(必然 404)。改成模块导入后,Vite 会把图片放到 dist/assets
127
+ // 下并发出 base-relative URL,配合 vite.config.ts 的 `base: './'` 在两种模式
128
+ // 下都能正确加载。
129
+ import { getBrandLogoUrl, BRAND_NAME } from '@/brand';
130
+
131
+ const DEFAULT_DOT_COLOR = '#7c3aed';
132
+
133
+ function agentInitial(agent: LocalChatAgent | null): string {
134
+ const name = (agent?.name || '').trim();
135
+ return name ? name[0].toUpperCase() : 'A';
136
+ }
137
+
138
+ function AgentDot({
139
+ agent,
140
+ size = 18,
141
+ }: {
142
+ agent: LocalChatAgent | null;
143
+ size?: number;
144
+ }) {
145
+ const color = agent?.color || DEFAULT_DOT_COLOR;
146
+ return (
147
+ <span
148
+ className="inline-flex flex-shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white shadow-sm"
149
+ style={{ width: size, height: size, backgroundColor: color }}
150
+ >
151
+ {agentInitial(agent)}
152
+ </span>
153
+ );
154
+ }
155
+
156
+ interface AgentSidebarProps {
157
+ data: UseChatsAndAgentsResult;
158
+ /** 右侧栏当前打开的面板:null / 'terminal' / 包槽位 id(互斥)。 */
159
+ rightPanel: RightPanelState;
160
+ /** 切换右侧栏面板显隐——面板不是路由也不是独立窗口,只是布局里的一栏。 */
161
+ onToggleRightPanel: (kind: string) => void;
162
+ /** 包注册的聊天页槽位(分段控件里每个槽位一个按钮);空 = 只有终端。 */
163
+ chatSlots: readonly PackChatSlotContribution[];
164
+ /** 收起侧边栏(AgentLayout 换成窄 rail,展开按钮在 rail 上)。 */
165
+ onCollapse: () => void;
166
+ }
167
+
168
+ export function AgentSidebar({
169
+ data,
170
+ rightPanel,
171
+ onToggleRightPanel,
172
+ chatSlots,
173
+ onCollapse,
174
+ }: AgentSidebarProps) {
175
+ const navigate = useNavigate();
176
+ const location = useLocation();
177
+ const { chatId: currentChatId } = useParams<{ chatId?: string }>();
178
+ const bridge = getElectronBridge();
179
+ const onSettingsPage = location.pathname === '/settings';
180
+ // /settings?section=skills|mcp|agents|(缺省 = 综合设置)—— 各自高亮。
181
+ const settingsSection = useMemo(
182
+ () => new URLSearchParams(location.search).get('section'),
183
+ [location.search],
184
+ );
185
+ const onGeneralSettings =
186
+ onSettingsPage &&
187
+ settingsSection !== 'skills' &&
188
+ settingsSection !== 'mcp' &&
189
+ settingsSection !== 'agents';
190
+ const onNewChatHome = !currentChatId && !onSettingsPage;
191
+
192
+ const {
193
+ chats,
194
+ agents,
195
+ isLoading: isChatLoading,
196
+ error,
197
+ setSelectedAgentId,
198
+ deleteChat,
199
+ isLoadingMoreChats,
200
+ hasMoreChats,
201
+ loadMoreChats,
202
+ } = data;
203
+
204
+ const [isMac, setIsMac] = useState(false);
205
+
206
+ useEffect(() => {
207
+ if (typeof navigator !== 'undefined') {
208
+ setIsMac(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
209
+ }
210
+ }, []);
211
+ const [confirmDeleteChatId, setConfirmDeleteChatId] = useState<string | null>(
212
+ null,
213
+ );
214
+ const [deletingChatId, setDeletingChatId] = useState<string | null>(null);
215
+ const [chatsExpanded, setChatsExpanded] = useState(true);
216
+ const chatScrollRef = useRef<HTMLDivElement>(null);
217
+
218
+ // ───── 项目模式 ─────
219
+ // 项目列表从 local-backend 拉取(electron-store 持久化)。会话按
220
+ // projectId 分组:项目分组在上(每个项目一个可折叠分组,组头支持内联
221
+ // 管理:新建对话 / 重命名 / 换文件夹 / 删除),无项目对话在下(日期分组)。
222
+ const [projects, setProjects] = useState<LocalProject[]>([]);
223
+ const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
224
+ () => new Set(),
225
+ );
226
+ const [renamingProjectId, setRenamingProjectId] = useState<string | null>(null);
227
+ const [renamingValue, setRenamingValue] = useState('');
228
+ const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null);
229
+ const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
230
+ const [projectError, setProjectError] = useState<string | null>(null);
231
+
232
+ const fetchProjects = useCallback(async () => {
233
+ if (!isElectron()) return;
234
+ try {
235
+ const res = await listProjects();
236
+ setProjects(res.projects || []);
237
+ } catch (err) {
238
+ console.error('获取项目列表失败:', err);
239
+ setProjectError(err instanceof Error ? err.message : String(err));
240
+ }
241
+ }, []);
242
+
243
+ useEffect(() => {
244
+ void fetchProjects();
245
+ }, [fetchProjects]);
246
+
247
+ const handleCreateProject = useCallback(async () => {
248
+ if (!isElectron()) return;
249
+ setProjectError(null);
250
+ try {
251
+ const result = await bridge?.local?.selectDirectory({
252
+ title: '选择项目文件夹',
253
+ });
254
+ if (!result || result.canceled || result.filePaths.length === 0) return;
255
+ const folderPath = result.filePaths[0];
256
+ // 默认用文件夹名做项目名,用户可随后内联重命名。
257
+ const baseName =
258
+ folderPath.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '新项目';
259
+ await createProject({ name: baseName, folderPath });
260
+ await fetchProjects();
261
+ } catch (err) {
262
+ setProjectError(err instanceof Error ? err.message : String(err));
263
+ }
264
+ }, [bridge, fetchProjects]);
265
+
266
+ const handleRenameProject = useCallback(
267
+ async (projectId: string) => {
268
+ const name = renamingValue.trim();
269
+ setRenamingProjectId(null);
270
+ if (!name) return;
271
+ try {
272
+ await updateProject(projectId, { name });
273
+ await fetchProjects();
274
+ } catch (err) {
275
+ setProjectError(err instanceof Error ? err.message : String(err));
276
+ }
277
+ },
278
+ [renamingValue, fetchProjects],
279
+ );
280
+
281
+ const handleChangeProjectFolder = useCallback(
282
+ async (projectId: string) => {
283
+ if (!isElectron()) return;
284
+ setProjectError(null);
285
+ try {
286
+ const result = await bridge?.local?.selectDirectory({
287
+ title: '重新选择项目文件夹',
288
+ });
289
+ if (!result || result.canceled || result.filePaths.length === 0) return;
290
+ await updateProject(projectId, { folderPath: result.filePaths[0] });
291
+ await fetchProjects();
292
+ } catch (err) {
293
+ setProjectError(err instanceof Error ? err.message : String(err));
294
+ }
295
+ },
296
+ [bridge, fetchProjects],
297
+ );
298
+
299
+ const handleDeleteProject = useCallback(
300
+ async (projectId: string) => {
301
+ if (deletingProjectId === projectId) return;
302
+ // 与会话删除同款两段确认:第一次点击武装红色按钮,第二次才真删。
303
+ if (confirmDeleteProjectId !== projectId) {
304
+ setConfirmDeleteProjectId(projectId);
305
+ return;
306
+ }
307
+ try {
308
+ setDeletingProjectId(projectId);
309
+ // 后端会把该项目下的会话降级为无项目对话(不删会话)。
310
+ await deleteProject(projectId);
311
+ setConfirmDeleteProjectId(null);
312
+ await fetchProjects();
313
+ await data.refreshChats();
314
+ } catch (err) {
315
+ setProjectError(err instanceof Error ? err.message : String(err));
316
+ } finally {
317
+ setDeletingProjectId(null);
318
+ }
319
+ },
320
+ [confirmDeleteProjectId, deletingProjectId, fetchProjects, data],
321
+ );
322
+
323
+ const toggleProjectCollapsed = useCallback((projectId: string) => {
324
+ setCollapsedProjectIds((prev) => {
325
+ const next = new Set(prev);
326
+ if (next.has(projectId)) next.delete(projectId);
327
+ else next.add(projectId);
328
+ return next;
329
+ });
330
+ }, []);
331
+
332
+ // Keep the shared "next new chat" agent aligned with the current chat. The
333
+ // actual picker now lives in ChatInput, so the sidebar no longer renders the
334
+ // expert list itself.
335
+ const activeChatAgentId = useMemo(() => {
336
+ if (!currentChatId) return null;
337
+ return chats.find((c) => c.id === currentChatId)?.agentId ?? null;
338
+ }, [chats, currentChatId]);
339
+
340
+ // Auto-sync `selectedAgentId` when the user navigates to a chat. Without
341
+ // this, the user lands in chat A (built on agent X), opens the sidebar,
342
+ // sees agent Y still highlighted from a stale selection, and gets confused
343
+ // about which agent is "active". Following the URL keeps the mental model
344
+ // straight: "the agent I'm currently working with is the highlighted one".
345
+ // Any explicit click in the sidebar overrides this back to the clicked
346
+ // agent (see handlePickAgent).
347
+ useEffect(() => {
348
+ if (activeChatAgentId) setSelectedAgentId(activeChatAgentId);
349
+ }, [activeChatAgentId, setSelectedAgentId]);
350
+
351
+ // 「新对话」只打开落地页,不落库。有内容才在 EmptyChatGate 提交时
352
+ // createChat。项目组头的「+」带上 projectId,落地页预选该项目。
353
+ const handleOpenNewChat = useCallback(
354
+ (projectId?: string) => {
355
+ if (projectId) {
356
+ navigate(`/agent?projectId=${encodeURIComponent(projectId)}`);
357
+ return;
358
+ }
359
+ navigate('/agent');
360
+ },
361
+ [navigate],
362
+ );
363
+
364
+ const normalizedChats = useMemo(() => {
365
+ return chats
366
+ .map((chat) => {
367
+ const updated = chat.updatedAt ? new Date(chat.updatedAt) : null;
368
+ const created = new Date(chat.createdAt);
369
+ const sortDate =
370
+ updated && !Number.isNaN(updated.getTime())
371
+ ? updated
372
+ : !Number.isNaN(created.getTime())
373
+ ? created
374
+ : new Date();
375
+ const { displayTitle, isAutomation } = parseChatTitle(chat.title);
376
+ return {
377
+ id: chat.id,
378
+ title: displayTitle || '新会话',
379
+ isAutomation,
380
+ isPinned: chat.isPinned,
381
+ sortDate,
382
+ agentId: chat.agentId ?? null,
383
+ projectId: chat.projectId ?? null,
384
+ };
385
+ })
386
+ .sort((a, b) => {
387
+ if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
388
+ return b.sortDate.getTime() - a.sortDate.getTime();
389
+ });
390
+ }, [chats]);
391
+
392
+ // 顶层按项目分组:无项目对话(含项目已被删但列表还没刷新的孤儿会话)
393
+ // 保持原有的日期分组;每个项目一个分组,组内按 pin + 时间排序。
394
+ const knownProjectIds = useMemo(
395
+ () => new Set(projects.map((p) => p.id)),
396
+ [projects],
397
+ );
398
+
399
+ const noProjectChats = useMemo(
400
+ () =>
401
+ normalizedChats.filter(
402
+ (c) => !c.projectId || !knownProjectIds.has(c.projectId),
403
+ ),
404
+ [normalizedChats, knownProjectIds],
405
+ );
406
+
407
+ const projectGroups = useMemo(
408
+ () =>
409
+ projects.map((project) => ({
410
+ project,
411
+ items: normalizedChats.filter((c) => c.projectId === project.id),
412
+ })),
413
+ [projects, normalizedChats],
414
+ );
415
+
416
+ const chatGroups = useMemo(() => {
417
+ const map = new Map<
418
+ string,
419
+ {
420
+ label: string;
421
+ priority: number;
422
+ items: typeof normalizedChats;
423
+ }
424
+ >();
425
+ noProjectChats.forEach((chat) => {
426
+ const label = getDateGroupLabel(chat.sortDate);
427
+ if (!map.has(label)) {
428
+ map.set(label, {
429
+ label,
430
+ priority: getDateGroupPriority(label),
431
+ items: [],
432
+ });
433
+ }
434
+ map.get(label)!.items.push(chat);
435
+ });
436
+ return Array.from(map.values()).sort((a, b) => a.priority - b.priority);
437
+ }, [noProjectChats]);
438
+
439
+ const handleDeleteChat = useCallback(
440
+ async (id: string) => {
441
+ if (deletingChatId === id) return;
442
+ // Two-step confirm — first click arms the red button, second click
443
+ // actually deletes. Matches the cloud sibling exactly so users don't
444
+ // get muscle-memory whiplash.
445
+ if (confirmDeleteChatId !== id) {
446
+ setConfirmDeleteChatId(id);
447
+ return;
448
+ }
449
+ try {
450
+ setDeletingChatId(id);
451
+ const ok = await deleteChat(id);
452
+ if (ok) {
453
+ setConfirmDeleteChatId(null);
454
+ if (id === currentChatId) navigate('/agent');
455
+ }
456
+ } finally {
457
+ setDeletingChatId(null);
458
+ }
459
+ },
460
+ [confirmDeleteChatId, deleteChat, deletingChatId, currentChatId, navigate],
461
+ );
462
+
463
+ // 单条会话行——无项目日期分组和项目分组共用同一个渲染,避免两份 JSX。
464
+ const renderChatRow = (chat: (typeof normalizedChats)[number]) => {
465
+ const isCurrent = currentChatId === chat.id;
466
+ const isConfirmingDelete = confirmDeleteChatId === chat.id;
467
+ const isDeleting = deletingChatId === chat.id;
468
+ const chatAgent = agents.find((a) => a.id === chat.agentId) ?? null;
469
+ return (
470
+ <div
471
+ key={chat.id}
472
+ className="group/item relative"
473
+ data-testid="sidebar-chat-row"
474
+ data-chat-id={chat.id}
475
+ >
476
+ <button
477
+ type="button"
478
+ onClick={() => {
479
+ setConfirmDeleteChatId(null);
480
+ navigate(`/agent/${chat.id}`);
481
+ }}
482
+ className={[
483
+ 'flex h-8 w-full min-w-0 items-center gap-1.5 rounded-full px-3 text-sm transition-colors duration-200',
484
+ isCurrent
485
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
486
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
487
+ ].join(' ')}
488
+ title={
489
+ chat.isAutomation
490
+ ? `[由自动化触发] ${chat.title}`
491
+ : chat.title
492
+ }
493
+ >
494
+ {chat.isPinned && (
495
+ <span
496
+ className="shrink-0 text-[10px]"
497
+ aria-label="已置顶"
498
+ >
499
+ 📌
500
+ </span>
501
+ )}
502
+ {chat.isAutomation && (
503
+ <LuCloudCog
504
+ className="h-3 w-3 shrink-0 text-agent-muted-foreground"
505
+ aria-label="由自动化触发"
506
+ />
507
+ )}
508
+ <AgentDot agent={chatAgent} size={16} />
509
+ <span className="min-w-0 truncate leading-none">
510
+ {chat.title}
511
+ </span>
512
+ </button>
513
+ {/* Gradient mask so the trash button doesn't paint
514
+ over the chat title — fade matches the row's
515
+ own background (canvas when selected, muted when
516
+ hovered). */}
517
+ <div
518
+ aria-hidden="true"
519
+ className={[
520
+ 'pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity duration-200',
521
+ isConfirmingDelete
522
+ ? isCurrent
523
+ ? 'bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-100'
524
+ : 'bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-100'
525
+ : isCurrent
526
+ ? 'bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-0 group-hover/item:opacity-100'
527
+ : 'bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-0 group-hover/item:opacity-100',
528
+ ].join(' ')}
529
+ />
530
+ <button
531
+ type="button"
532
+ onClick={(event) => {
533
+ event.stopPropagation();
534
+ void handleDeleteChat(chat.id);
535
+ }}
536
+ disabled={isDeleting}
537
+ className={[
538
+ 'absolute right-1 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full transition-all duration-200',
539
+ isConfirmingDelete
540
+ ? 'bg-agent-destructive/10 text-agent-destructive opacity-100 hover:bg-agent-destructive/20'
541
+ : 'text-agent-muted-foreground opacity-0 hover:bg-agent-foreground/5 hover:text-agent-destructive group-hover/item:opacity-100 focus:opacity-100',
542
+ 'disabled:cursor-not-allowed disabled:opacity-100',
543
+ ].join(' ')}
544
+ title={
545
+ isConfirmingDelete
546
+ ? '再次点击确认删除'
547
+ : '删除会话'
548
+ }
549
+ aria-label={
550
+ isConfirmingDelete
551
+ ? '再次点击确认删除'
552
+ : '删除会话'
553
+ }
554
+ data-testid="sidebar-chat-delete"
555
+ >
556
+ <LuTrash2
557
+ className={[
558
+ 'h-3.5 w-3.5',
559
+ isDeleting ? 'animate-pulse' : '',
560
+ ].join(' ')}
561
+ />
562
+ </button>
563
+ </div>
564
+ );
565
+ };
566
+
567
+ // Cmd+N from the app menu — wired in src/main.ts:createMenu (sends
568
+ // `menu:new-chat`). The bridge methods are optional because non-Electron
569
+ // dev previews don't have them.
570
+ useEffect(() => {
571
+ if (!bridge?.onMenuNewChat) return;
572
+ bridge.onMenuNewChat(() => {
573
+ handleOpenNewChat();
574
+ });
575
+ return () => {
576
+ bridge.offMenuNewChat?.();
577
+ };
578
+ }, [bridge, handleOpenNewChat]);
579
+
580
+ // Auto-load next page when the chat scroller approaches the bottom.
581
+ // Throttled by `isLoadingMoreChats` inside the hook.
582
+ useEffect(() => {
583
+ const container = chatScrollRef.current;
584
+ if (!container || !chatsExpanded) return;
585
+
586
+ const maybeLoadMore = () => {
587
+ if (!hasMoreChats || isLoadingMoreChats) return;
588
+ const nearBottom =
589
+ container.scrollTop + container.clientHeight >=
590
+ container.scrollHeight - 60;
591
+ if (nearBottom) void loadMoreChats();
592
+ };
593
+ container.addEventListener('scroll', maybeLoadMore, { passive: true });
594
+ const tickId = window.requestAnimationFrame(maybeLoadMore);
595
+ return () => {
596
+ container.removeEventListener('scroll', maybeLoadMore);
597
+ window.cancelAnimationFrame(tickId);
598
+ };
599
+ }, [
600
+ hasMoreChats,
601
+ isLoadingMoreChats,
602
+ loadMoreChats,
603
+ chatsExpanded,
604
+ normalizedChats.length,
605
+ ]);
606
+
607
+ // Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
608
+ // `menu:open-terminal`). Same pattern as `menu:new-chat` above. The
609
+ // terminal is a toggleable panel beside the chat, so this just flips
610
+ // the layout state owned by AgentLayout.
611
+ useEffect(() => {
612
+ if (!bridge?.onMenuOpenTerminal) return;
613
+ bridge.onMenuOpenTerminal(() => {
614
+ onToggleRightPanel('terminal');
615
+ });
616
+ return () => {
617
+ bridge.offMenuOpenTerminal?.();
618
+ };
619
+ }, [bridge, onToggleRightPanel]);
620
+
621
+ const hasElectron = isElectron();
622
+
623
+ return (
624
+ <div className="flex h-full w-full flex-col border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
625
+ {/* ───── Brand + actions ───── */}
626
+ <div className="flex h-12 flex-shrink-0 items-center justify-between px-3">
627
+ <div className="flex min-w-0 items-center gap-2">
628
+ {/*
629
+ 品牌 logo —— src 用 module-imported asset,不要写 /favicon.png(见
630
+ 文件顶部 import 的注释)。shell 默认是中性通用图标;产品品牌
631
+ logo 由激活包的 web 模块在注册时注入(getBrandLogoUrl)。
632
+ */}
633
+ <img
634
+ src={getBrandLogoUrl()}
635
+ alt={BRAND_NAME}
636
+ className="h-6 w-6 flex-shrink-0 select-none"
637
+ draggable={false}
638
+ />
639
+ <span className="truncate text-sm font-semibold tracking-tight text-agent-foreground">
640
+ {BRAND_NAME}
641
+ </span>
642
+ </div>
643
+ <div className="flex items-center gap-1">
644
+ <button
645
+ type="button"
646
+ onClick={onCollapse}
647
+ className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors duration-200 hover:bg-agent-foreground/5 hover:text-agent-foreground"
648
+ title="收起侧边栏"
649
+ aria-label="收起侧边栏"
650
+ >
651
+ <LuPanelLeftClose className="h-3.5 w-3.5" />
652
+ </button>
653
+ </div>
654
+ </div>
655
+
656
+ {/* ───── 会话 ───── */}
657
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
658
+ {/* 新对话 + 智能体 / Skill / MCP 设置入口 — 横版行按钮,各自打开独立设置页 */}
659
+ <div className="flex-shrink-0 space-y-0.5 px-3 pb-0.5">
660
+ <button
661
+ type="button"
662
+ onClick={() => handleOpenNewChat()}
663
+ className={[
664
+ 'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
665
+ onNewChatHome
666
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
667
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
668
+ ].join(' ')}
669
+ title="新建对话"
670
+ data-testid="sidebar-new-chat"
671
+ >
672
+ <LuPlus className="h-4 w-4" />
673
+ <span>新对话</span>
674
+ </button>
675
+ <button
676
+ type="button"
677
+ onClick={() => navigate('/settings?section=agents')}
678
+ className={[
679
+ 'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
680
+ onSettingsPage && settingsSection === 'agents'
681
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
682
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
683
+ ].join(' ')}
684
+ title="智能体管理"
685
+ data-testid="sidebar-agent-settings"
686
+ >
687
+ <LuBot className="h-4 w-4" />
688
+ <span>智能体管理</span>
689
+ </button>
690
+ <button
691
+ type="button"
692
+ onClick={() => navigate('/settings?section=skills')}
693
+ className={[
694
+ 'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
695
+ onSettingsPage && settingsSection === 'skills'
696
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
697
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
698
+ ].join(' ')}
699
+ title="Skill 设置"
700
+ data-testid="sidebar-skill-settings"
701
+ >
702
+ <LuBlocks className="h-4 w-4" />
703
+ <span>Skill 设置</span>
704
+ </button>
705
+ <button
706
+ type="button"
707
+ onClick={() => navigate('/settings?section=mcp')}
708
+ className={[
709
+ 'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
710
+ onSettingsPage && settingsSection === 'mcp'
711
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
712
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
713
+ ].join(' ')}
714
+ title="MCP 设置"
715
+ data-testid="sidebar-mcp-settings"
716
+ >
717
+ <LuPlug className="h-4 w-4" />
718
+ <span>MCP 设置</span>
719
+ </button>
720
+ </div>
721
+ <div className="flex flex-shrink-0 items-center justify-between py-1.5 pl-4 pr-3">
722
+ <button
723
+ type="button"
724
+ onClick={() => setChatsExpanded((v) => !v)}
725
+ className="flex items-center text-xs font-semibold tracking-wider text-agent-muted-foreground transition-colors hover:text-agent-foreground"
726
+ >
727
+ <span className="mr-1">
728
+ {chatsExpanded ? (
729
+ <LuChevronUp className="h-3 w-3" />
730
+ ) : (
731
+ <LuChevronDown className="h-3 w-3" />
732
+ )}
733
+ </span>
734
+ 会话
735
+ {normalizedChats.length > 0 && (
736
+ <span className="ml-1.5 text-[10px] font-normal text-agent-muted-foreground/70">
737
+ · {normalizedChats.length}
738
+ </span>
739
+ )}
740
+ </button>
741
+ <div className="flex items-center gap-1">
742
+ {/* 新建项目:选文件夹 → 以文件夹名建项目,之后可在组头重命名 */}
743
+ {hasElectron && (
744
+ <button
745
+ type="button"
746
+ onClick={() => void handleCreateProject()}
747
+ className="flex h-6 w-6 items-center justify-center rounded-full text-agent-muted-foreground transition-colors duration-200 hover:bg-agent-foreground/5 hover:text-agent-foreground"
748
+ title="新建项目(绑定文件夹,项目内对话的文件操作被限制在该文件夹)"
749
+ aria-label="新建项目"
750
+ >
751
+ <LuFolderPlus className="h-3.5 w-3.5" />
752
+ </button>
753
+ )}
754
+ </div>
755
+ </div>
756
+
757
+ {chatsExpanded && (
758
+ <div
759
+ ref={chatScrollRef}
760
+ className="flex-1 overflow-y-auto px-2 pb-1"
761
+ >
762
+ {isChatLoading && chats.length === 0 && projects.length === 0 ? (
763
+ <div className="flex items-center justify-center py-4 text-xs text-agent-muted-foreground">
764
+ <LuLoaderCircle className="mr-1.5 h-3 w-3 animate-spin" />
765
+ 加载中...
766
+ </div>
767
+ ) : chatGroups.length === 0 && projectGroups.length === 0 ? (
768
+ <div className="flex flex-col items-center gap-1.5 py-6 text-xs text-agent-muted-foreground">
769
+ <LuMessageSquare className="h-4 w-4 text-agent-muted-foreground/60" />
770
+ 暂无会话
771
+ </div>
772
+ ) : (
773
+ <>
774
+ {/* 项目分组在前:组头可折叠,hover 出内联管理动作 */}
775
+ {projectGroups.map(({ project, items }) => (
776
+ <div key={project.id} className="mb-1">
777
+ <div className="group/proj relative">
778
+ {renamingProjectId === project.id ? (
779
+ <form
780
+ className="flex items-center px-3 pb-1 pt-2"
781
+ onSubmit={(event) => {
782
+ event.preventDefault();
783
+ void handleRenameProject(project.id);
784
+ }}
785
+ >
786
+ <input
787
+ autoFocus
788
+ value={renamingValue}
789
+ onChange={(event) => setRenamingValue(event.target.value)}
790
+ onBlur={() => void handleRenameProject(project.id)}
791
+ onKeyDown={(event) => {
792
+ if (event.key === 'Escape') setRenamingProjectId(null);
793
+ }}
794
+ className="h-6 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
795
+ />
796
+ </form>
797
+ ) : (
798
+ <>
799
+ <button
800
+ type="button"
801
+ onClick={() => toggleProjectCollapsed(project.id)}
802
+ className="flex w-full min-w-0 items-center px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80 transition-colors hover:text-agent-foreground"
803
+ title={`${project.name}\n${project.folderPath}`}
804
+ >
805
+ <span className="mr-1">
806
+ {collapsedProjectIds.has(project.id) ? (
807
+ <LuChevronDown className="h-3 w-3" />
808
+ ) : (
809
+ <LuChevronUp className="h-3 w-3" />
810
+ )}
811
+ </span>
812
+ <LuFolder className="mr-1 h-3 w-3 shrink-0" />
813
+ <span className="min-w-0 truncate normal-case">
814
+ {project.name}
815
+ </span>
816
+ {items.length > 0 && (
817
+ <span className="ml-1 shrink-0 font-normal">
818
+ · {items.length}
819
+ </span>
820
+ )}
821
+ </button>
822
+ {/* 内联管理:新建对话 / 重命名 / 换文件夹 / 删除(两段确认) */}
823
+ <div className="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/proj:opacity-100">
824
+ <button
825
+ type="button"
826
+ onClick={() => handleOpenNewChat(project.id)}
827
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
828
+ title="在此项目下新建对话"
829
+ >
830
+ <LuPlus className="h-3 w-3" />
831
+ </button>
832
+ <button
833
+ type="button"
834
+ onClick={() => {
835
+ setRenamingProjectId(project.id);
836
+ setRenamingValue(project.name);
837
+ }}
838
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
839
+ title="重命名项目"
840
+ >
841
+ <LuPencil className="h-3 w-3" />
842
+ </button>
843
+ <button
844
+ type="button"
845
+ onClick={() => void handleChangeProjectFolder(project.id)}
846
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
847
+ title="更换绑定文件夹"
848
+ >
849
+ <LuFolderOpen className="h-3 w-3" />
850
+ </button>
851
+ <button
852
+ type="button"
853
+ onClick={() => void handleDeleteProject(project.id)}
854
+ disabled={deletingProjectId === project.id}
855
+ className={`flex h-5 w-5 items-center justify-center rounded-full transition-colors ${
856
+ confirmDeleteProjectId === project.id
857
+ ? 'bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20'
858
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-destructive'
859
+ }`}
860
+ title={
861
+ confirmDeleteProjectId === project.id
862
+ ? '再次点击确认删除(会话会保留为无项目对话)'
863
+ : '删除项目(会话保留为无项目对话)'
864
+ }
865
+ >
866
+ <LuTrash2
867
+ className={`h-3 w-3 ${deletingProjectId === project.id ? 'animate-pulse' : ''}`}
868
+ />
869
+ </button>
870
+ </div>
871
+ </>
872
+ )}
873
+ </div>
874
+ {!collapsedProjectIds.has(project.id) && (
875
+ <div className="space-y-0.5">
876
+ {items.length === 0 ? (
877
+ <div className="px-3 py-1 text-[11px] text-agent-muted-foreground/60">
878
+ 暂无会话 — hover 项目名点 + 新建
879
+ </div>
880
+ ) : (
881
+ items.map((chat) => renderChatRow(chat))
882
+ )}
883
+ </div>
884
+ )}
885
+ </div>
886
+ ))}
887
+ {/* 无项目对话排在项目分组之后:保持原有日期分组 */}
888
+ {chatGroups.map((group) => (
889
+ <div key={group.label} className="mb-1">
890
+ <div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80">
891
+ {group.label}
892
+ </div>
893
+ <div className="space-y-0.5">
894
+ {group.items.map((chat) => renderChatRow(chat))}
895
+ </div>
896
+ </div>
897
+ ))}
898
+ </>
899
+ )}
900
+ {(chatGroups.length > 0 || projectGroups.length > 0) && (
901
+ <div className="px-2 py-2 text-center text-[11px] text-agent-muted-foreground">
902
+ {isLoadingMoreChats ? (
903
+ <span className="inline-flex items-center gap-1">
904
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
905
+ 加载更多...
906
+ </span>
907
+ ) : hasMoreChats ? (
908
+ '继续下滑加载更多'
909
+ ) : (
910
+ `共 ${normalizedChats.length} 个会话`
911
+ )}
912
+ </div>
913
+ )}
914
+ </div>
915
+ )}
916
+ </div>
917
+
918
+ {(error || projectError) && (
919
+ <div
920
+ className="flex-shrink-0 border-t border-agent-destructive/40 bg-agent-destructive/10 px-3 py-2 text-[11px] text-agent-destructive"
921
+ role="alert"
922
+ >
923
+ {error ?? projectError}
924
+ </div>
925
+ )}
926
+
927
+ {/* ───── Footer: 右侧面板切换(终端 | 包槽位)+ 设置 ───── */}
928
+ <div className="flex-shrink-0 border-t border-agent-border/40 px-3 py-2">
929
+ {chatSlots.length === 0 ? (
930
+ <button
931
+ type="button"
932
+ onClick={() => onToggleRightPanel('terminal')}
933
+ className={[
934
+ 'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
935
+ rightPanel === 'terminal'
936
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
937
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground',
938
+ ].join(' ')}
939
+ title={`${rightPanel === 'terminal' ? '关闭' : '打开'}终端面板 (${isMac ? '⌘T' : 'Ctrl+T'})`}
940
+ data-testid="sidebar-terminal"
941
+ >
942
+ <LuTerminal className="h-4 w-4" />
943
+ <span>终端</span>
944
+ <span className="ml-auto text-[10px] text-agent-muted-foreground/70">
945
+ {isMac ? '⌘T' : 'Ctrl+T'}
946
+ </span>
947
+ </button>
948
+ ) : (
949
+ <div
950
+ role="group"
951
+ aria-label="右侧面板切换"
952
+ className="flex h-8 w-full items-center gap-0.5 rounded-full bg-agent-foreground/5 p-0.5"
953
+ >
954
+ <button
955
+ type="button"
956
+ onClick={() => onToggleRightPanel('terminal')}
957
+ className={[
958
+ 'flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors',
959
+ rightPanel === 'terminal'
960
+ ? 'bg-agent-foreground/10 text-agent-foreground'
961
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
962
+ ].join(' ')}
963
+ title={`${rightPanel === 'terminal' ? '关闭' : '打开'}终端面板`}
964
+ data-testid="sidebar-terminal"
965
+ >
966
+ <LuTerminal className="h-3.5 w-3.5" />
967
+ <span>终端</span>
968
+ </button>
969
+ {chatSlots.map((slot) => (
970
+ <button
971
+ key={slot.slotId}
972
+ type="button"
973
+ onClick={() => onToggleRightPanel(slot.slotId)}
974
+ className={[
975
+ 'flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors',
976
+ rightPanel === slot.slotId
977
+ ? 'bg-agent-foreground/10 text-agent-foreground'
978
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
979
+ ].join(' ')}
980
+ title={`${rightPanel === slot.slotId ? '关闭' : '打开'}${slot.title}`}
981
+ data-testid={`sidebar-slot-${slot.slotId}`}
982
+ >
983
+ <slot.Icon className="h-3.5 w-3.5" />
984
+ <span>{slot.title}</span>
985
+ </button>
986
+ ))}
987
+ </div>
988
+ )}
989
+ <button
990
+ type="button"
991
+ onClick={() => navigate('/settings')}
992
+ className={[
993
+ 'mt-0.5 flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
994
+ onGeneralSettings
995
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
996
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
997
+ ].join(' ')}
998
+ title="设置"
999
+ data-testid="sidebar-llm-settings"
1000
+ >
1001
+ <LuSettings className="h-4 w-4" />
1002
+ <span>设置</span>
1003
+ </button>
1004
+ </div>
1005
+ </div>
1006
+ );
1007
+ }
1008
+
1009
+ // Re-export for callers that still import the type from here.
1010
+ export type { LocalChat, LocalChatAgent };