@steerable/agent-shell-web 0.6.51 → 0.6.53

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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/brand.ts +7 -0
  3. package/src/components/AgentSidebar.tsx +284 -188
  4. package/src/components/ConfirmDialog.tsx +90 -0
  5. package/src/components/CreateProjectModal.tsx +32 -16
  6. package/src/components/chat/ApprovalModal.tsx +2 -5
  7. package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
  8. package/src/components/chat/AskUserPromptProvider.tsx +1 -32
  9. package/src/components/chat/ChatInput.tsx +129 -59
  10. package/src/components/chat/ChatProjectBadge.tsx +1 -1
  11. package/src/components/chat/EmptyChatHero.tsx +22 -0
  12. package/src/components/chat/ExecPolicyPicker.tsx +2 -2
  13. package/src/components/chat/ExecutedActionsCard.tsx +22 -12
  14. package/src/components/chat/LocalChatPanel.tsx +25 -19
  15. package/src/components/chat/MessageList.tsx +77 -5
  16. package/src/components/chat/ModelPicker.tsx +21 -1
  17. package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
  18. package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
  19. package/src/components/chat/SessionTodoList.tsx +12 -3
  20. package/src/components/chat/UserMessage.tsx +116 -25
  21. package/src/components/chat/composer-status-stack.tsx +79 -0
  22. package/src/components/chat/orchestration-flow-model.ts +108 -43
  23. package/src/components/chat/prompt-chat-scope.ts +33 -0
  24. package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
  25. package/src/components/settings/McpSettingsPanel.tsx +144 -48
  26. package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
  27. package/src/hooks/useProjects.ts +82 -0
  28. package/src/lib/attachments.ts +8 -0
  29. package/src/lib/electron-bridge.ts +16 -1
  30. package/src/lib/exec-policy.ts +1 -1
  31. package/src/lib/host-file-drop.ts +68 -0
  32. package/src/lib/host-paste.ts +38 -9
  33. package/src/lib/local-api.ts +1 -1
  34. package/src/lib/tauri-bridge.ts +5 -0
  35. package/src/pages/AgentPage.tsx +114 -64
  36. package/src/pages/SettingsPage.tsx +131 -46
  37. package/src/vite-env.d.ts +2 -0
  38. package/vite.base.js +1 -0
@@ -41,7 +41,7 @@
41
41
  *
42
42
  * • Active chat id comes from the URL (`useParams().chatId`) rather than
43
43
  * a Context (we drive navigation, not vice versa).
44
- * • Agent CRUD lives on `/settings?section=agents`(侧栏「智能体管理」),
44
+ * • Agent CRUD lives on `/settings?section=plugins`(侧栏「插件」里的智能体分类),
45
45
  * not a modal. Custom agents show up in ChatInput's expert picker.
46
46
  * • Cmd+N / Cmd+T trigger `menu:new-chat` / `menu:open-terminal` via the
47
47
  * preload bridge; we subscribe here so the shortcuts work regardless of
@@ -53,28 +53,19 @@
53
53
  * ┌─────────────────────────────────┐
54
54
  * │ ✨ Product Agent +• │ ← + has a color dot of the selected agent
55
55
  * ├─────────────────────────────────┤
56
- * │ v 专家团队 · 3 │
57
- * │ ● 教练 (selected) │ ← highlight = selectedAgentId
58
- * │ ● 文学顾问 · 当前 │ ← "当前" pill = activeChatAgentId
59
- * │ ● 哲学家 │
60
- * │ + 用「教练」新建对话 │ ← inline CTA when sel ≠ current
61
- * ├──── divider ────────────────────┤
62
56
  * │ ✎ 新对话 │ ← 只打开落地页,有内容才落库
63
- * │ ⬡ 智能体管理 │ ← /settings?section=agents(独立页)
64
- * │ ⬡ Skill 设置 │ ← /settings?section=skills(独立页)
65
- * │ 🔌 MCP 设置 │ ← /settings?section=mcp(独立页)
57
+ * │ 🧩 插件 │ ← /settings?section=plugins(智能体 / Skills / MCP / 网络搜索)
66
58
  * │ 会话 v 📁+ │ ← 📁+ 打开新建项目弹窗
67
59
  * │ v 📁 项目A (hover: ✎··)│ ← ✎ 新建对话;·· 菜单:重命名/换目录/访达/删
68
60
  * │ ...(项目内对话) │
69
61
  * │ 今天 │
70
62
  * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
71
63
  * ├─────────────────────────────────┤
72
- * │ ▢_ 终端 ⌘T │
73
64
  * │ ⚙ 设置 v0.2.2 │ ← /settings;右侧是当前版本,检查更新在设置页
74
65
  * └─────────────────────────────────┘
75
66
  *
76
67
  * 项目模式:项目 = 名字 + 托管家目录(Documents/<应用名>/<项目名>/)+
77
- * 可选源文件夹。写入/命令围栏在家目录;源文件夹只放宽读取。
68
+ * 可选源文件夹。家目录与源文件夹(含各自子目录)都可读写。
78
69
  */
79
70
 
80
71
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -91,12 +82,10 @@ import {
91
82
  LuSquarePen,
92
83
  LuPanelLeftClose,
93
84
  LuBlocks,
94
- LuBot,
95
85
  LuFolder,
96
86
  LuFolderOpen,
97
87
  LuFolderPlus,
98
88
  LuPencil,
99
- LuPlug,
100
89
  LuEllipsis,
101
90
  LuCircleHelp,
102
91
  } from "react-icons/lu";
@@ -108,7 +97,7 @@ import { hasGeneralSettingsChrome, hostToolChrome, settingsChrome } from "@/lib/
108
97
  import {
109
98
  createProject,
110
99
  deleteProject,
111
- listProjects,
100
+ getChatLiveStream,
112
101
  openLocalPath,
113
102
  setChatPinned,
114
103
  updateProject,
@@ -119,8 +108,10 @@ import {
119
108
  import { usePendingAskUserChatIds } from "@/components/chat/AskUserPromptProvider";
120
109
  import { usePendingApprovalChatIds } from "@/components/chat/ApprovalPromptProvider";
121
110
  import type { UseChatsAndAgentsResult } from "@/hooks/useChatsAndAgents";
111
+ import { useProjects } from "@/hooks/useProjects";
122
112
  import type { RightPanelState } from "@/layouts/AgentLayout";
123
113
  import { BrandLockup } from "@/components/BrandLockup";
114
+ import { ConfirmDialog } from "@/components/ConfirmDialog";
124
115
  import {
125
116
  SidebarVersionLabel,
126
117
  useAppRelease,
@@ -139,8 +130,6 @@ function ProjectOverflowMenu({
139
130
  chatCount,
140
131
  anchor,
141
132
  revealLabel,
142
- confirmDelete,
143
- deleting,
144
133
  onClose,
145
134
  onRename,
146
135
  onChangeFolder,
@@ -151,8 +140,6 @@ function ProjectOverflowMenu({
151
140
  chatCount: number;
152
141
  anchor: HTMLElement;
153
142
  revealLabel: string;
154
- confirmDelete: boolean;
155
- deleting: boolean;
156
143
  onClose: () => void;
157
144
  onRename: () => void;
158
145
  onChangeFolder: () => void;
@@ -228,19 +215,12 @@ function ProjectOverflowMenu({
228
215
  <button
229
216
  type="button"
230
217
  role="menuitem"
231
- disabled={deleting}
232
- title={
233
- confirmDelete
234
- ? "再次点击确认删除(会话会保留为无项目对话)"
235
- : "删除项目(会话保留为无项目对话)"
236
- }
218
+ title="删除项目(会话保留为无项目对话)"
237
219
  onClick={onDelete}
238
- className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-agent-destructive/10 ${
239
- confirmDelete ? "text-agent-destructive" : "text-agent-foreground"
240
- }`}
220
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-agent-foreground hover:bg-agent-destructive/10"
241
221
  >
242
- <LuTrash2 className={`h-3.5 w-3.5 shrink-0 ${deleting ? "animate-pulse" : ""}`} />
243
- {confirmDelete ? "再次点击确认删除" : "删除项目"}
222
+ <LuTrash2 className="h-3.5 w-3.5 shrink-0" />
223
+ 删除项目
244
224
  </button>
245
225
  </div>
246
226
  </>,
@@ -248,6 +228,48 @@ function ProjectOverflowMenu({
248
228
  );
249
229
  }
250
230
 
231
+ /** 置顶 / 项目 / 最近 的展开状态。缺省键走组件内默认。 */
232
+ const SIDEBAR_SECTIONS_KEY = "deeppath.agent.sidebarSections";
233
+
234
+ type SidebarSectionId = "pinned" | "projects" | "recents";
235
+
236
+ function readStoredSidebarSections(): Partial<Record<SidebarSectionId, boolean>> {
237
+ try {
238
+ const raw = window.localStorage.getItem(SIDEBAR_SECTIONS_KEY);
239
+ if (!raw) return {};
240
+ const parsed: unknown = JSON.parse(raw);
241
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
242
+ const record = parsed as Record<string, unknown>;
243
+ const out: Partial<Record<SidebarSectionId, boolean>> = {};
244
+ for (const id of ["pinned", "projects", "recents"] as const) {
245
+ if (typeof record[id] === "boolean") out[id] = record[id];
246
+ }
247
+ return out;
248
+ } catch {
249
+ return {};
250
+ }
251
+ }
252
+
253
+ /** 切走后对后台会话轮询 live-stream 的间隔,与对话页恢复轮询同级。 */
254
+ const BACKGROUND_STREAM_POLL_MS = 750;
255
+ /**
256
+ * 标记成正在生成之后,快照可能还没注册。这段时间内 live-stream 报
257
+ * inactive 不摘指示;见过 active,或超过这段时间仍 inactive,才摘掉。
258
+ */
259
+ const BACKGROUND_STREAM_GRACE_MS = 3_000;
260
+
261
+ function writeSidebarSectionExpanded(id: SidebarSectionId, expanded: boolean) {
262
+ try {
263
+ const current = readStoredSidebarSections();
264
+ window.localStorage.setItem(
265
+ SIDEBAR_SECTIONS_KEY,
266
+ JSON.stringify({ ...current, [id]: expanded }),
267
+ );
268
+ } catch {
269
+ /* ignore quota / private mode */
270
+ }
271
+ }
272
+
251
273
  interface AgentSidebarProps {
252
274
  data: UseChatsAndAgentsResult;
253
275
  /** 右侧栏当前打开的面板:null / 'terminal' / 包槽位 id(互斥)。 */
@@ -270,16 +292,24 @@ export function AgentSidebar({
270
292
  const bridge = getElectronBridge();
271
293
  const release = useAppRelease();
272
294
  const onSettingsPage = location.pathname === "/settings";
273
- // /settings?section=skills|mcp|agents|(缺省 = 综合设置)—— 各自高亮。
295
+ // 插件页(及旧的 agents/skills/mcp 深链)高亮「插件」;其余 /settings 高亮底部综合设置。
274
296
  const settingsSection = useMemo(
275
297
  () => new URLSearchParams(location.search).get("section"),
276
298
  [location.search],
277
299
  );
278
- const onGeneralSettings =
300
+ const pluginsAvailable =
301
+ settingsChrome("agents") ||
302
+ settingsChrome("skills") ||
303
+ settingsChrome("mcp") ||
304
+ settingsChrome("web-search");
305
+ const onPluginsSettings =
306
+ pluginsAvailable &&
279
307
  onSettingsPage &&
280
- settingsSection !== "skills" &&
281
- settingsSection !== "mcp" &&
282
- settingsSection !== "agents";
308
+ (settingsSection === "plugins" ||
309
+ settingsSection === "agents" ||
310
+ settingsSection === "skills" ||
311
+ settingsSection === "mcp");
312
+ const onGeneralSettings = onSettingsPage && !onPluginsSettings;
283
313
  const onNewChatHome = !currentChatId && !onSettingsPage;
284
314
 
285
315
  const {
@@ -301,9 +331,10 @@ export function AgentSidebar({
301
331
  setIsMac(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
302
332
  }
303
333
  }, []);
304
- const [confirmDeleteChatId, setConfirmDeleteChatId] = useState<string | null>(
305
- null,
306
- );
334
+ const [pendingDeleteChat, setPendingDeleteChat] = useState<{
335
+ id: string;
336
+ title: string;
337
+ } | null>(null);
307
338
  const [deletingChatId, setDeletingChatId] = useState<string | null>(null);
308
339
  const [pinningChatId, setPinningChatId] = useState<string | null>(null);
309
340
  const [streamingChatIds, setStreamingChatIds] = useState<Set<string>>(
@@ -339,16 +370,111 @@ export function AgentSidebar({
339
370
  };
340
371
  }, []);
341
372
 
342
- const [pinnedExpanded, setPinnedExpanded] = useState<boolean | null>(null);
343
- const [projectsExpanded, setProjectsExpanded] = useState<boolean | null>(null);
344
- const [recentsExpanded, setRecentsExpanded] = useState<boolean | null>(null);
373
+ // 当前打开的会话由 AgentChatView 自己报开始/结束。切走后的会话回合还在
374
+ // 后端跑,卸载时不会再报结束,这里用 live-stream 对账,结束后摘掉指示。
375
+ const seenActiveBackgroundRef = useRef<Set<string>>(new Set());
376
+ const backgroundSinceRef = useRef<Map<string, number>>(new Map());
377
+ useEffect(() => {
378
+ for (const id of seenActiveBackgroundRef.current) {
379
+ if (!streamingChatIds.has(id)) seenActiveBackgroundRef.current.delete(id);
380
+ }
381
+ for (const id of backgroundSinceRef.current.keys()) {
382
+ if (!streamingChatIds.has(id)) backgroundSinceRef.current.delete(id);
383
+ }
384
+ const backgroundIds = [...streamingChatIds].filter(
385
+ (id) => id !== currentChatId,
386
+ );
387
+ if (backgroundIds.length === 0 || !isElectron()) return;
388
+
389
+ let cancelled = false;
390
+ let ticking = false;
391
+ const tick = async () => {
392
+ if (ticking || cancelled) return;
393
+ ticking = true;
394
+ try {
395
+ const now = Date.now();
396
+ const settled = await Promise.all(
397
+ backgroundIds.map(async (id) => {
398
+ try {
399
+ const live = await getChatLiveStream(id);
400
+ return { id, active: live?.active === true, ok: true as const };
401
+ } catch {
402
+ return { id, active: false, ok: false as const };
403
+ }
404
+ }),
405
+ );
406
+ if (cancelled) return;
407
+ const drop = new Set<string>();
408
+ for (const row of settled) {
409
+ if (!row.ok) continue;
410
+ if (row.active) {
411
+ seenActiveBackgroundRef.current.add(row.id);
412
+ backgroundSinceRef.current.delete(row.id);
413
+ continue;
414
+ }
415
+ // 已经对上过运行中的快照:inactive 就是回合结束。
416
+ if (seenActiveBackgroundRef.current.has(row.id)) {
417
+ drop.add(row.id);
418
+ seenActiveBackgroundRef.current.delete(row.id);
419
+ backgroundSinceRef.current.delete(row.id);
420
+ continue;
421
+ }
422
+ // 还没见过快照(切走时回合可能刚起步)。宽限期内保留指示。
423
+ const since = backgroundSinceRef.current.get(row.id);
424
+ if (since === undefined) {
425
+ backgroundSinceRef.current.set(row.id, now);
426
+ continue;
427
+ }
428
+ if (now - since >= BACKGROUND_STREAM_GRACE_MS) {
429
+ drop.add(row.id);
430
+ backgroundSinceRef.current.delete(row.id);
431
+ }
432
+ }
433
+ if (drop.size === 0) return;
434
+ setStreamingChatIds((prev) => {
435
+ let changed = false;
436
+ const next = new Set(prev);
437
+ for (const id of drop) {
438
+ if (next.delete(id)) changed = true;
439
+ }
440
+ return changed ? next : prev;
441
+ });
442
+ } finally {
443
+ ticking = false;
444
+ }
445
+ };
446
+
447
+ void tick();
448
+ const timer = window.setInterval(() => {
449
+ void tick();
450
+ }, BACKGROUND_STREAM_POLL_MS);
451
+ return () => {
452
+ cancelled = true;
453
+ window.clearInterval(timer);
454
+ };
455
+ }, [streamingChatIds, currentChatId]);
456
+
457
+ // null = 用户还没点过这一组,走默认(置顶:有置顶会话才展开;项目、最近:展开)。
458
+ // 点过之后写入 localStorage,刷新和重启后保持。
459
+ const [pinnedExpanded, setPinnedExpanded] = useState<boolean | null>(
460
+ () => readStoredSidebarSections().pinned ?? null,
461
+ );
462
+ const [projectsExpanded, setProjectsExpanded] = useState<boolean | null>(
463
+ () => readStoredSidebarSections().projects ?? null,
464
+ );
465
+ const [recentsExpanded, setRecentsExpanded] = useState<boolean | null>(
466
+ () => readStoredSidebarSections().recents ?? null,
467
+ );
345
468
  const chatScrollRef = useRef<HTMLDivElement>(null);
346
469
 
347
470
  // ───── 项目模式 ─────
348
- // 项目列表从 local-backend 拉取(electron-store 持久化)。会话按
349
- // projectId 分组:项目分组在上(组头 hover:+ 新建对话 / ·· 菜单),
350
- // 无项目对话在下(日期分组)。
351
- const [projects, setProjects] = useState<LocalProject[]>([]);
471
+ // 项目列表与输入框「关联到项目」共用 useProjects。会话按 projectId
472
+ // 分组:项目分组在上(组头 hover:+ 新建对话 / ·· 菜单),无项目对话在下。
473
+ const {
474
+ projects,
475
+ error: projectsLoadError,
476
+ refresh: fetchProjects,
477
+ } = useProjects();
352
478
  const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
353
479
  () => new Set(),
354
480
  );
@@ -356,9 +482,8 @@ export function AgentSidebar({
356
482
  null,
357
483
  );
358
484
  const [renamingValue, setRenamingValue] = useState("");
359
- const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<
360
- string | null
361
- >(null);
485
+ const [pendingDeleteProject, setPendingDeleteProject] =
486
+ useState<LocalProject | null>(null);
362
487
  const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
363
488
  null,
364
489
  );
@@ -372,21 +497,6 @@ export function AgentSidebar({
372
497
 
373
498
  const showProjectsChrome = hostToolChrome("projects");
374
499
 
375
- const fetchProjects = useCallback(async () => {
376
- if (!isElectron() || !hostToolChrome("projects")) return;
377
- try {
378
- const res = await listProjects();
379
- setProjects(res.projects || []);
380
- } catch (err) {
381
- console.error("获取项目列表失败:", err);
382
- setProjectError(err instanceof Error ? err.message : String(err));
383
- }
384
- }, []);
385
-
386
- useEffect(() => {
387
- void fetchProjects();
388
- }, [fetchProjects]);
389
-
390
500
  const handleCreateProject = useCallback(
391
501
  async (input: { name: string; sourceFolders: string[] }) => {
392
502
  setProjectError(null);
@@ -449,7 +559,7 @@ export function AgentSidebar({
449
559
  try {
450
560
  setDeletingProjectId(projectId);
451
561
  await deleteProject(projectId);
452
- setConfirmDeleteProjectId(null);
562
+ setPendingDeleteProject(null);
453
563
  setProjectMenu(null);
454
564
  setEditingProjectId(null);
455
565
  await fetchProjects();
@@ -465,25 +575,8 @@ export function AgentSidebar({
465
575
  [deletingProjectId, fetchProjects, data],
466
576
  );
467
577
 
468
- const handleDeleteProject = useCallback(
469
- async (projectId: string) => {
470
- // 与会话删除同款两段确认:第一次点击武装红色按钮,第二次才真删。
471
- if (confirmDeleteProjectId !== projectId) {
472
- setConfirmDeleteProjectId(projectId);
473
- return;
474
- }
475
- try {
476
- await removeProject(projectId);
477
- } catch {
478
- // 横幅已写 projectError
479
- }
480
- },
481
- [confirmDeleteProjectId, removeProject],
482
- );
483
-
484
578
  const closeProjectMenu = useCallback(() => {
485
579
  setProjectMenu(null);
486
- setConfirmDeleteProjectId(null);
487
580
  }, []);
488
581
 
489
582
  const handleRevealProjectFolder = useCallback(async (folderPath: string) => {
@@ -630,29 +723,20 @@ export function AgentSidebar({
630
723
  const isProjectsExpanded = projectsExpanded ?? true;
631
724
  const isRecentsExpanded = recentsExpanded ?? true;
632
725
 
633
- const handleDeleteChat = useCallback(
634
- async (id: string) => {
635
- if (deletingChatId === id) return;
636
- // Two-step confirm — first click arms the red button, second click
637
- // actually deletes. Matches the cloud sibling exactly so users don't
638
- // get muscle-memory whiplash.
639
- if (confirmDeleteChatId !== id) {
640
- setConfirmDeleteChatId(id);
641
- return;
642
- }
643
- try {
644
- setDeletingChatId(id);
645
- const ok = await deleteChat(id);
646
- if (ok) {
647
- setConfirmDeleteChatId(null);
648
- if (id === currentChatId) navigate("/agent");
649
- }
650
- } finally {
651
- setDeletingChatId(null);
726
+ const handleDeleteChat = useCallback(async () => {
727
+ if (!pendingDeleteChat || deletingChatId === pendingDeleteChat.id) return;
728
+ const id = pendingDeleteChat.id;
729
+ try {
730
+ setDeletingChatId(id);
731
+ const ok = await deleteChat(id);
732
+ if (ok) {
733
+ setPendingDeleteChat(null);
734
+ if (id === currentChatId) navigate("/agent");
652
735
  }
653
- },
654
- [confirmDeleteChatId, deleteChat, deletingChatId, currentChatId, navigate],
655
- );
736
+ } finally {
737
+ setDeletingChatId(null);
738
+ }
739
+ }, [pendingDeleteChat, deleteChat, deletingChatId, currentChatId, navigate]);
656
740
 
657
741
  const handleTogglePinChat = useCallback(
658
742
  async (id: string, nextPinned: boolean) => {
@@ -673,7 +757,6 @@ export function AgentSidebar({
673
757
  // 单条会话行——无项目日期分组和项目分组共用同一个渲染,避免两份 JSX。
674
758
  const renderChatRow = (chat: (typeof normalizedChats)[number]) => {
675
759
  const isCurrent = currentChatId === chat.id;
676
- const isConfirmingDelete = confirmDeleteChatId === chat.id;
677
760
  const isDeleting = deletingChatId === chat.id;
678
761
  const isPinning = pinningChatId === chat.id;
679
762
  const isStreaming = Boolean(
@@ -695,7 +778,6 @@ export function AgentSidebar({
695
778
  <button
696
779
  type="button"
697
780
  onClick={() => {
698
- setConfirmDeleteChatId(null);
699
781
  navigate(`/agent/${chat.id}`);
700
782
  }}
701
783
  className={[
@@ -739,47 +821,37 @@ export function AgentSidebar({
739
821
  className={[
740
822
  "absolute inset-y-0 right-0 flex items-center justify-end gap-0.5 pr-1 pl-8 rounded-r-full transition-all duration-200",
741
823
  "bg-gradient-to-l from-agent-muted/95 via-agent-muted/80 to-transparent",
742
- isConfirmingDelete
743
- ? "opacity-100 pointer-events-auto"
744
- : "opacity-0 group-hover/item:opacity-100 focus-within:opacity-100 pointer-events-none group-hover/item:pointer-events-auto focus-within:pointer-events-auto",
824
+ "opacity-0 group-hover/item:opacity-100 focus-within:opacity-100 pointer-events-none group-hover/item:pointer-events-auto focus-within:pointer-events-auto",
745
825
  ].join(" ")}
746
826
  >
747
- {!isConfirmingDelete && (
748
- <button
749
- type="button"
750
- onClick={(event) => {
751
- event.stopPropagation();
752
- void handleTogglePinChat(chat.id, !chat.isPinned);
753
- }}
754
- disabled={isPinning}
755
- className="flex h-6 w-6 items-center justify-center rounded-full text-agent-foreground/70 transition-all duration-200 hover:bg-agent-foreground/10 hover:text-agent-foreground disabled:cursor-not-allowed"
756
- title={chat.isPinned ? "取消置顶" : "置顶会话"}
757
- aria-label={chat.isPinned ? "取消置顶" : "置顶会话"}
758
- data-testid="sidebar-chat-pin"
759
- >
760
- {chat.isPinned ? (
761
- <RiPushpin2Fill className="h-3.5 w-3.5 text-agent-foreground" />
762
- ) : (
763
- <RiPushpin2Line className="h-3.5 w-3.5 text-agent-foreground/75 hover:text-agent-foreground" />
764
- )}
765
- </button>
766
- )}
767
827
  <button
768
828
  type="button"
769
829
  onClick={(event) => {
770
830
  event.stopPropagation();
771
- void handleDeleteChat(chat.id);
831
+ void handleTogglePinChat(chat.id, !chat.isPinned);
832
+ }}
833
+ disabled={isPinning}
834
+ className="flex h-6 w-6 items-center justify-center rounded-full text-agent-foreground/70 transition-all duration-200 hover:bg-agent-foreground/10 hover:text-agent-foreground disabled:cursor-not-allowed"
835
+ title={chat.isPinned ? "取消置顶" : "置顶会话"}
836
+ aria-label={chat.isPinned ? "取消置顶" : "置顶会话"}
837
+ data-testid="sidebar-chat-pin"
838
+ >
839
+ {chat.isPinned ? (
840
+ <RiPushpin2Fill className="h-3.5 w-3.5 text-agent-foreground" />
841
+ ) : (
842
+ <RiPushpin2Line className="h-3.5 w-3.5 text-agent-foreground/75 hover:text-agent-foreground" />
843
+ )}
844
+ </button>
845
+ <button
846
+ type="button"
847
+ onClick={(event) => {
848
+ event.stopPropagation();
849
+ setPendingDeleteChat({ id: chat.id, title: chat.title });
772
850
  }}
773
851
  disabled={isDeleting}
774
- className={[
775
- "flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200",
776
- isConfirmingDelete
777
- ? "bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20"
778
- : "text-agent-foreground/70 hover:bg-agent-foreground/10 hover:text-agent-destructive",
779
- "disabled:cursor-not-allowed disabled:opacity-100",
780
- ].join(" ")}
781
- title={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
782
- aria-label={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
852
+ className="flex h-6 w-6 items-center justify-center rounded-full text-agent-foreground/70 transition-all duration-200 hover:bg-agent-foreground/10 hover:text-agent-destructive disabled:cursor-not-allowed disabled:opacity-100"
853
+ title="删除会话"
854
+ aria-label="删除会话"
783
855
  data-testid="sidebar-chat-delete"
784
856
  >
785
857
  <LuTrash2
@@ -855,7 +927,7 @@ export function AgentSidebar({
855
927
 
856
928
  {/* ───── 会话 ───── */}
857
929
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
858
- {/* 新对话 + 智能体 / Skill / MCP 设置入口 — 横版行按钮,各自打开独立设置页 */}
930
+ {/* 新对话 + 插件(智能体 / Skills / MCP / 网络搜索在同一页用分类切换) */}
859
931
  <div className="flex-shrink-0 space-y-0.5 px-2.5 pb-0.5">
860
932
  <button
861
933
  type="button"
@@ -872,55 +944,21 @@ export function AgentSidebar({
872
944
  <LuSquarePen className="h-3.5 w-3.5" />
873
945
  <span>新对话</span>
874
946
  </button>
875
- {settingsChrome("agents") && (
947
+ {pluginsAvailable && (
876
948
  <button
877
949
  type="button"
878
- onClick={() => navigate("/settings?section=agents")}
950
+ onClick={() => navigate("/settings?section=plugins")}
879
951
  className={[
880
952
  "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
881
- onSettingsPage && settingsSection === "agents"
953
+ onPluginsSettings
882
954
  ? "bg-agent-foreground/10 font-medium text-agent-foreground"
883
955
  : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
884
956
  ].join(" ")}
885
- title="智能体管理"
886
- data-testid="sidebar-agent-settings"
887
- >
888
- <LuBot className="h-3.5 w-3.5" />
889
- <span>智能体管理</span>
890
- </button>
891
- )}
892
- {settingsChrome("skills") && (
893
- <button
894
- type="button"
895
- onClick={() => navigate("/settings?section=skills")}
896
- className={[
897
- "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
898
- onSettingsPage && settingsSection === "skills"
899
- ? "bg-agent-foreground/10 font-medium text-agent-foreground"
900
- : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
901
- ].join(" ")}
902
- title="Skill 设置"
903
- data-testid="sidebar-skill-settings"
957
+ title="插件"
958
+ data-testid="sidebar-plugins"
904
959
  >
905
960
  <LuBlocks className="h-3.5 w-3.5" />
906
- <span>Skill 设置</span>
907
- </button>
908
- )}
909
- {settingsChrome("mcp") && (
910
- <button
911
- type="button"
912
- onClick={() => navigate("/settings?section=mcp")}
913
- className={[
914
- "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
915
- onSettingsPage && settingsSection === "mcp"
916
- ? "bg-agent-foreground/10 font-medium text-agent-foreground"
917
- : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
918
- ].join(" ")}
919
- title="MCP 设置"
920
- data-testid="sidebar-mcp-settings"
921
- >
922
- <LuPlug className="h-3.5 w-3.5" />
923
- <span>MCP 设置</span>
961
+ <span>插件</span>
924
962
  </button>
925
963
  )}
926
964
  </div>
@@ -942,7 +980,12 @@ export function AgentSidebar({
942
980
  <div className="group/section flex h-7 items-center justify-between rounded-agent-md px-1.5 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground">
943
981
  <button
944
982
  type="button"
945
- onClick={() => setPinnedExpanded(!isPinnedExpanded)}
983
+ aria-expanded={isPinnedExpanded}
984
+ onClick={() => {
985
+ const next = !isPinnedExpanded;
986
+ setPinnedExpanded(next);
987
+ writeSidebarSectionExpanded("pinned", next);
988
+ }}
946
989
  className="flex flex-1 min-w-0 items-center gap-1 text-left font-medium text-agent-muted-foreground hover:text-agent-foreground"
947
990
  >
948
991
  <span>置顶</span>
@@ -967,7 +1010,12 @@ export function AgentSidebar({
967
1010
  <div className="group/section flex h-7 items-center justify-between rounded-agent-md px-1.5 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground">
968
1011
  <button
969
1012
  type="button"
970
- onClick={() => setProjectsExpanded(!isProjectsExpanded)}
1013
+ aria-expanded={isProjectsExpanded}
1014
+ onClick={() => {
1015
+ const next = !isProjectsExpanded;
1016
+ setProjectsExpanded(next);
1017
+ writeSidebarSectionExpanded("projects", next);
1018
+ }}
971
1019
  className="flex flex-1 min-w-0 items-center gap-1 text-left font-medium text-agent-muted-foreground hover:text-agent-foreground"
972
1020
  >
973
1021
  <span>项目</span>
@@ -1065,7 +1113,6 @@ export function AgentSidebar({
1065
1113
  aria-expanded={projectMenu?.id === project.id}
1066
1114
  onClick={(event) => {
1067
1115
  const button = event.currentTarget;
1068
- setConfirmDeleteProjectId(null);
1069
1116
  setProjectMenu((current) =>
1070
1117
  current?.id === project.id
1071
1118
  ? null
@@ -1105,7 +1152,12 @@ export function AgentSidebar({
1105
1152
  <div className="group/section flex h-7 items-center justify-between rounded-agent-md px-1.5 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground">
1106
1153
  <button
1107
1154
  type="button"
1108
- onClick={() => setRecentsExpanded(!isRecentsExpanded)}
1155
+ aria-expanded={isRecentsExpanded}
1156
+ onClick={() => {
1157
+ const next = !isRecentsExpanded;
1158
+ setRecentsExpanded(next);
1159
+ writeSidebarSectionExpanded("recents", next);
1160
+ }}
1109
1161
  className="flex flex-1 min-w-0 items-center gap-1 text-left font-medium text-agent-muted-foreground hover:text-agent-foreground"
1110
1162
  >
1111
1163
  <span>最近</span>
@@ -1177,12 +1229,12 @@ export function AgentSidebar({
1177
1229
  </div>
1178
1230
  </div>
1179
1231
 
1180
- {(error || projectError) && (
1232
+ {(error || projectError || projectsLoadError) && (
1181
1233
  <div
1182
1234
  className="flex-shrink-0 border-t border-agent-destructive/40 bg-agent-destructive/10 px-2.5 py-1.5 text-[11px] text-agent-destructive"
1183
1235
  role="alert"
1184
1236
  >
1185
- {error ?? projectError}
1237
+ {error ?? projectError ?? projectsLoadError}
1186
1238
  </div>
1187
1239
  )}
1188
1240
 
@@ -1226,8 +1278,6 @@ export function AgentSidebar({
1226
1278
  chatCount={chatCount}
1227
1279
  anchor={projectMenu.anchor}
1228
1280
  revealLabel={isMac ? "在访达中显示" : "在文件管理器中显示"}
1229
- confirmDelete={confirmDeleteProjectId === project.id}
1230
- deleting={deletingProjectId === project.id}
1231
1281
  onClose={closeProjectMenu}
1232
1282
  onRename={() => {
1233
1283
  setProjectMenu(null);
@@ -1246,11 +1296,57 @@ export function AgentSidebar({
1246
1296
  }
1247
1297
  : undefined
1248
1298
  }
1249
- onDelete={() => void handleDeleteProject(project.id)}
1299
+ onDelete={() => {
1300
+ setProjectMenu(null);
1301
+ setPendingDeleteProject(project);
1302
+ }}
1250
1303
  />
1251
1304
  );
1252
1305
  })()}
1253
1306
 
1307
+ <ConfirmDialog
1308
+ open={pendingDeleteChat !== null}
1309
+ title="删除会话"
1310
+ description={
1311
+ pendingDeleteChat
1312
+ ? `确定删除「${pendingDeleteChat.title}」?此操作无法撤销。`
1313
+ : ""
1314
+ }
1315
+ pending={
1316
+ pendingDeleteChat !== null && deletingChatId === pendingDeleteChat.id
1317
+ }
1318
+ onCancel={() => {
1319
+ if (deletingChatId) return;
1320
+ setPendingDeleteChat(null);
1321
+ }}
1322
+ onConfirm={() => void handleDeleteChat()}
1323
+ testId="sidebar-delete-chat-dialog"
1324
+ />
1325
+ <ConfirmDialog
1326
+ open={pendingDeleteProject !== null}
1327
+ title="删除项目"
1328
+ description={
1329
+ pendingDeleteProject
1330
+ ? `确定删除项目「${pendingDeleteProject.name}」?会话会保留为无项目对话。`
1331
+ : ""
1332
+ }
1333
+ confirmLabel="删除项目"
1334
+ pending={
1335
+ pendingDeleteProject !== null &&
1336
+ deletingProjectId === pendingDeleteProject.id
1337
+ }
1338
+ onCancel={() => {
1339
+ if (deletingProjectId) return;
1340
+ setPendingDeleteProject(null);
1341
+ }}
1342
+ onConfirm={() => {
1343
+ if (!pendingDeleteProject) return;
1344
+ void removeProject(pendingDeleteProject.id).catch(() => {
1345
+ // 横幅已写 projectError
1346
+ });
1347
+ }}
1348
+ testId="sidebar-delete-project-dialog"
1349
+ />
1254
1350
  <CreateProjectModal
1255
1351
  open={createProjectOpen}
1256
1352
  onClose={() => setCreateProjectOpen(false)}