@steerable/agent-shell-web 0.6.30 → 0.6.32

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.
@@ -63,8 +63,8 @@
63
63
  * │ ⬡ 智能体管理 │ ← /settings?section=agents(独立页)
64
64
  * │ ⬡ Skill 设置 │ ← /settings?section=skills(独立页)
65
65
  * │ 🔌 MCP 设置 │ ← /settings?section=mcp(独立页)
66
- * │ v 会话 · 12 📁+ │ ← 📁+ 新建项目
67
- * │ v 📁 项目A · 3 (hover: +✏📂🗑)│ ← 项目组:折叠/新建/重命名/换文件夹/删
66
+ * │ 会话 v 📁+ │ ← 📁+ 打开新建项目弹窗
67
+ * │ v 📁 项目A · 3 (hover: +··)│ ← + 新建对话;·· 菜单:重命名/换目录/访达/删
68
68
  * │ ...(项目内对话) │
69
69
  * │ 今天 │
70
70
  * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
@@ -73,19 +73,13 @@
73
73
  * │ ⚙ 设置 │ ← /settings(模型 + 洞察/遥测/用量/搜索/安全)
74
74
  * └─────────────────────────────────┘
75
75
  *
76
- * 项目模式:项目 = 名字 + 绑定文件夹(ProjectRegistry,electron-store)。
77
- * 项目内对话的 agent 文件读写与命令执行被硬沙箱在该文件夹内(见
78
- * tool-router.ts ToolExecContext.projectRoot);无项目对话不沙箱。
76
+ * 项目模式:项目 = 名字 + 托管家目录(Documents/<应用名>/<项目名>/)+
77
+ * 可选源文件夹。写入/命令围栏在家目录;源文件夹只放宽读取。
79
78
  */
80
79
 
81
- import {
82
- useCallback,
83
- useEffect,
84
- useMemo,
85
- useRef,
86
- useState,
87
- } from 'react';
88
- import { useLocation, useNavigate, useParams } from 'react-router-dom';
80
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
81
+ import { createPortal } from "react-dom";
82
+ import { useLocation, useNavigate, useParams } from "react-router-dom";
89
83
  import {
90
84
  LuChevronDown,
91
85
  LuChevronUp,
@@ -104,29 +98,32 @@ import {
104
98
  LuFolderPlus,
105
99
  LuPencil,
106
100
  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';
101
+ LuEllipsis,
102
+ } from "react-icons/lu";
103
+ import { parseChatTitle } from "@/lib/chat-title";
104
+ import { getDateGroupLabel, getDateGroupPriority } from "@/lib/date-groups";
105
+ import { getElectronBridge, isElectron } from "@/lib/electron-bridge";
111
106
  import {
112
107
  createProject,
113
108
  deleteProject,
114
109
  listProjects,
110
+ openLocalPath,
115
111
  updateProject,
116
112
  type LocalChat,
117
113
  type LocalChatAgent,
118
114
  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 { BrandLockup } from '@/components/BrandLockup';
115
+ } from "@/lib/local-api";
116
+ import type { UseChatsAndAgentsResult } from "@/hooks/useChatsAndAgents";
117
+ import type { PackChatSlotContribution } from "@/packs/registry";
118
+ import type { RightPanelState } from "@/layouts/AgentLayout";
119
+ import { BrandLockup } from "@/components/BrandLockup";
120
+ import { CreateProjectModal } from "@/components/CreateProjectModal";
124
121
 
125
- const DEFAULT_DOT_COLOR = '#7c3aed';
122
+ const DEFAULT_DOT_COLOR = "#7c3aed";
126
123
 
127
124
  function agentInitial(agent: LocalChatAgent | null): string {
128
- const name = (agent?.name || '').trim();
129
- return name ? name[0].toUpperCase() : 'A';
125
+ const name = (agent?.name || "").trim();
126
+ return name ? name[0].toUpperCase() : "A";
130
127
  }
131
128
 
132
129
  function AgentDot({
@@ -147,6 +144,125 @@ function AgentDot({
147
144
  );
148
145
  }
149
146
 
147
+ function placeProjectMenu(anchor: HTMLElement): { top: number; left: number } {
148
+ const box = anchor.getBoundingClientRect();
149
+ const width = 224;
150
+ const left = Math.min(box.right + 4, window.innerWidth - width - 8);
151
+ return { top: Math.max(8, box.top), left };
152
+ }
153
+
154
+ function ProjectOverflowMenu({
155
+ project,
156
+ chatCount,
157
+ anchor,
158
+ revealLabel,
159
+ confirmDelete,
160
+ deleting,
161
+ onClose,
162
+ onRename,
163
+ onChangeFolder,
164
+ onReveal,
165
+ onDelete,
166
+ }: {
167
+ project: LocalProject;
168
+ chatCount: number;
169
+ anchor: HTMLElement;
170
+ revealLabel: string;
171
+ confirmDelete: boolean;
172
+ deleting: boolean;
173
+ onClose: () => void;
174
+ onRename: () => void;
175
+ onChangeFolder: () => void;
176
+ onReveal: () => void;
177
+ onDelete: () => void;
178
+ }) {
179
+ const pos = placeProjectMenu(anchor);
180
+ useEffect(() => {
181
+ const onKey = (event: KeyboardEvent) => {
182
+ if (event.key === "Escape") onClose();
183
+ };
184
+ window.addEventListener("keydown", onKey);
185
+ return () => window.removeEventListener("keydown", onKey);
186
+ }, [onClose]);
187
+ return createPortal(
188
+ <>
189
+ <div className="fixed inset-0 z-[90]" onClick={onClose} />
190
+ <div
191
+ role="menu"
192
+ className="fixed z-[91] w-56 overflow-hidden rounded-2xl border border-agent-border bg-agent-canvas p-1 shadow-lg"
193
+ style={pos}
194
+ data-testid="project-overflow-menu"
195
+ >
196
+ <div className="px-2 py-1.5">
197
+ <div className="flex items-center gap-2 text-xs font-medium text-agent-foreground">
198
+ <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
199
+ <span className="min-w-0 truncate">{project.name}</span>
200
+ </div>
201
+ <div className="mt-0.5 pl-[22px] text-[10px] text-agent-muted-foreground">
202
+ {chatCount} 个会话
203
+ </div>
204
+ <div
205
+ className="mt-0.5 truncate pl-[22px] font-mono text-[10px] text-agent-muted-foreground/70"
206
+ title={project.folderPath}
207
+ >
208
+ {project.folderPath}
209
+ </div>
210
+ </div>
211
+ <div className="mx-1 my-1 border-t border-agent-border/60" />
212
+ <button
213
+ type="button"
214
+ role="menuitem"
215
+ title="重命名项目"
216
+ onClick={onRename}
217
+ 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-foreground/5"
218
+ >
219
+ <LuPencil className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
220
+ 重命名
221
+ </button>
222
+ <button
223
+ type="button"
224
+ role="menuitem"
225
+ title="编辑项目"
226
+ onClick={onChangeFolder}
227
+ 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-foreground/5"
228
+ >
229
+ <LuFolderOpen className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
230
+ 编辑项目
231
+ </button>
232
+ <button
233
+ type="button"
234
+ role="menuitem"
235
+ title={revealLabel}
236
+ onClick={onReveal}
237
+ 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-foreground/5"
238
+ >
239
+ <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
240
+ {revealLabel}
241
+ </button>
242
+ <div className="mx-1 my-1 border-t border-agent-border/60" />
243
+ <button
244
+ type="button"
245
+ role="menuitem"
246
+ disabled={deleting}
247
+ title={
248
+ confirmDelete
249
+ ? "再次点击确认删除(会话会保留为无项目对话)"
250
+ : "删除项目(会话保留为无项目对话)"
251
+ }
252
+ onClick={onDelete}
253
+ className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-agent-destructive/10 ${
254
+ confirmDelete ? "text-agent-destructive" : "text-agent-foreground"
255
+ }`}
256
+ >
257
+ <LuTrash2 className={`h-3.5 w-3.5 shrink-0 ${deleting ? "animate-pulse" : ""}`} />
258
+ {confirmDelete ? "再次点击确认删除" : "删除项目"}
259
+ </button>
260
+ </div>
261
+ </>,
262
+ document.body,
263
+ );
264
+ }
265
+
150
266
  interface AgentSidebarProps {
151
267
  data: UseChatsAndAgentsResult;
152
268
  /** 右侧栏当前打开的面板:null / 'terminal' / 包槽位 id(互斥)。 */
@@ -170,17 +286,17 @@ export function AgentSidebar({
170
286
  const location = useLocation();
171
287
  const { chatId: currentChatId } = useParams<{ chatId?: string }>();
172
288
  const bridge = getElectronBridge();
173
- const onSettingsPage = location.pathname === '/settings';
289
+ const onSettingsPage = location.pathname === "/settings";
174
290
  // /settings?section=skills|mcp|agents|(缺省 = 综合设置)—— 各自高亮。
175
291
  const settingsSection = useMemo(
176
- () => new URLSearchParams(location.search).get('section'),
292
+ () => new URLSearchParams(location.search).get("section"),
177
293
  [location.search],
178
294
  );
179
295
  const onGeneralSettings =
180
296
  onSettingsPage &&
181
- settingsSection !== 'skills' &&
182
- settingsSection !== 'mcp' &&
183
- settingsSection !== 'agents';
297
+ settingsSection !== "skills" &&
298
+ settingsSection !== "mcp" &&
299
+ settingsSection !== "agents";
184
300
  const onNewChatHome = !currentChatId && !onSettingsPage;
185
301
 
186
302
  const {
@@ -198,7 +314,7 @@ export function AgentSidebar({
198
314
  const [isMac, setIsMac] = useState(false);
199
315
 
200
316
  useEffect(() => {
201
- if (typeof navigator !== 'undefined') {
317
+ if (typeof navigator !== "undefined") {
202
318
  setIsMac(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
203
319
  }
204
320
  }, []);
@@ -211,17 +327,29 @@ export function AgentSidebar({
211
327
 
212
328
  // ───── 项目模式 ─────
213
329
  // 项目列表从 local-backend 拉取(electron-store 持久化)。会话按
214
- // projectId 分组:项目分组在上(每个项目一个可折叠分组,组头支持内联
215
- // 管理:新建对话 / 重命名 / 换文件夹 / 删除),无项目对话在下(日期分组)。
330
+ // projectId 分组:项目分组在上(组头 hover:+ 新建对话 / ·· 菜单),
331
+ // 无项目对话在下(日期分组)。
216
332
  const [projects, setProjects] = useState<LocalProject[]>([]);
217
333
  const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
218
334
  () => new Set(),
219
335
  );
220
- const [renamingProjectId, setRenamingProjectId] = useState<string | null>(null);
221
- const [renamingValue, setRenamingValue] = useState('');
222
- const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null);
223
- const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
336
+ const [renamingProjectId, setRenamingProjectId] = useState<string | null>(
337
+ null,
338
+ );
339
+ const [renamingValue, setRenamingValue] = useState("");
340
+ const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<
341
+ string | null
342
+ >(null);
343
+ const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
344
+ null,
345
+ );
224
346
  const [projectError, setProjectError] = useState<string | null>(null);
347
+ const [createProjectOpen, setCreateProjectOpen] = useState(false);
348
+ const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
349
+ const [projectMenu, setProjectMenu] = useState<{
350
+ id: string;
351
+ anchor: HTMLElement;
352
+ } | null>(null);
225
353
 
226
354
  const fetchProjects = useCallback(async () => {
227
355
  if (!isElectron()) return;
@@ -229,7 +357,7 @@ export function AgentSidebar({
229
357
  const res = await listProjects();
230
358
  setProjects(res.projects || []);
231
359
  } catch (err) {
232
- console.error('获取项目列表失败:', err);
360
+ console.error("获取项目列表失败:", err);
233
361
  setProjectError(err instanceof Error ? err.message : String(err));
234
362
  }
235
363
  }, []);
@@ -238,24 +366,25 @@ export function AgentSidebar({
238
366
  void fetchProjects();
239
367
  }, [fetchProjects]);
240
368
 
241
- const handleCreateProject = useCallback(async () => {
242
- if (!isElectron()) return;
243
- setProjectError(null);
244
- try {
245
- const result = await bridge?.local?.selectDirectory({
246
- title: '选择项目文件夹',
247
- });
248
- if (!result || result.canceled || result.filePaths.length === 0) return;
249
- const folderPath = result.filePaths[0];
250
- // 默认用文件夹名做项目名,用户可随后内联重命名。
251
- const baseName =
252
- folderPath.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '新项目';
253
- await createProject({ name: baseName, folderPath });
254
- await fetchProjects();
255
- } catch (err) {
256
- setProjectError(err instanceof Error ? err.message : String(err));
257
- }
258
- }, [bridge, fetchProjects]);
369
+ const handleCreateProject = useCallback(
370
+ async (input: { name: string; sourceFolders: string[] }) => {
371
+ setProjectError(null);
372
+ try {
373
+ await createProject({
374
+ name: input.name,
375
+ ...(input.sourceFolders.length > 0
376
+ ? { sourceFolders: input.sourceFolders }
377
+ : {}),
378
+ });
379
+ await fetchProjects();
380
+ } catch (err) {
381
+ const message = err instanceof Error ? err.message : String(err);
382
+ setProjectError(message);
383
+ throw err instanceof Error ? err : new Error(message);
384
+ }
385
+ },
386
+ [fetchProjects],
387
+ );
259
388
 
260
389
  const handleRenameProject = useCallback(
261
390
  async (projectId: string) => {
@@ -272,48 +401,80 @@ export function AgentSidebar({
272
401
  [renamingValue, fetchProjects],
273
402
  );
274
403
 
275
- const handleChangeProjectFolder = useCallback(
276
- async (projectId: string) => {
277
- if (!isElectron()) return;
404
+ const handleUpdateProject = useCallback(
405
+ async (
406
+ projectId: string,
407
+ input: { name: string; sourceFolders: string[] },
408
+ ) => {
278
409
  setProjectError(null);
279
410
  try {
280
- const result = await bridge?.local?.selectDirectory({
281
- title: '重新选择项目文件夹',
411
+ await updateProject(projectId, {
412
+ name: input.name,
413
+ sourceFolders: input.sourceFolders,
282
414
  });
283
- if (!result || result.canceled || result.filePaths.length === 0) return;
284
- await updateProject(projectId, { folderPath: result.filePaths[0] });
285
415
  await fetchProjects();
286
416
  } catch (err) {
287
- setProjectError(err instanceof Error ? err.message : String(err));
417
+ const message = err instanceof Error ? err.message : String(err);
418
+ setProjectError(message);
419
+ throw err instanceof Error ? err : new Error(message);
288
420
  }
289
421
  },
290
- [bridge, fetchProjects],
422
+ [fetchProjects],
291
423
  );
292
424
 
293
- const handleDeleteProject = useCallback(
425
+ const removeProject = useCallback(
294
426
  async (projectId: string) => {
295
427
  if (deletingProjectId === projectId) return;
296
- // 与会话删除同款两段确认:第一次点击武装红色按钮,第二次才真删。
297
- if (confirmDeleteProjectId !== projectId) {
298
- setConfirmDeleteProjectId(projectId);
299
- return;
300
- }
301
428
  try {
302
429
  setDeletingProjectId(projectId);
303
- // 后端会把该项目下的会话降级为无项目对话(不删会话)。
304
430
  await deleteProject(projectId);
305
431
  setConfirmDeleteProjectId(null);
432
+ setProjectMenu(null);
433
+ setEditingProjectId(null);
306
434
  await fetchProjects();
307
435
  await data.refreshChats();
308
436
  } catch (err) {
309
- setProjectError(err instanceof Error ? err.message : String(err));
437
+ const message = err instanceof Error ? err.message : String(err);
438
+ setProjectError(message);
439
+ throw err instanceof Error ? err : new Error(message);
310
440
  } finally {
311
441
  setDeletingProjectId(null);
312
442
  }
313
443
  },
314
- [confirmDeleteProjectId, deletingProjectId, fetchProjects, data],
444
+ [deletingProjectId, fetchProjects, data],
445
+ );
446
+
447
+ const handleDeleteProject = useCallback(
448
+ async (projectId: string) => {
449
+ // 与会话删除同款两段确认:第一次点击武装红色按钮,第二次才真删。
450
+ if (confirmDeleteProjectId !== projectId) {
451
+ setConfirmDeleteProjectId(projectId);
452
+ return;
453
+ }
454
+ try {
455
+ await removeProject(projectId);
456
+ } catch {
457
+ // 横幅已写 projectError
458
+ }
459
+ },
460
+ [confirmDeleteProjectId, removeProject],
315
461
  );
316
462
 
463
+ const closeProjectMenu = useCallback(() => {
464
+ setProjectMenu(null);
465
+ setConfirmDeleteProjectId(null);
466
+ }, []);
467
+
468
+ const handleRevealProjectFolder = useCallback(async (folderPath: string) => {
469
+ setProjectError(null);
470
+ try {
471
+ const res = await openLocalPath(folderPath);
472
+ if (!res.success && res.error) setProjectError(res.error);
473
+ } catch (err) {
474
+ setProjectError(err instanceof Error ? err.message : String(err));
475
+ }
476
+ }, []);
477
+
317
478
  const toggleProjectCollapsed = useCallback((projectId: string) => {
318
479
  setCollapsedProjectIds((prev) => {
319
480
  const next = new Set(prev);
@@ -350,7 +511,7 @@ export function AgentSidebar({
350
511
  navigate(`/agent?projectId=${encodeURIComponent(projectId)}`);
351
512
  return;
352
513
  }
353
- navigate('/agent');
514
+ navigate("/agent");
354
515
  },
355
516
  [navigate],
356
517
  );
@@ -369,7 +530,7 @@ export function AgentSidebar({
369
530
  const { displayTitle, isAutomation } = parseChatTitle(chat.title);
370
531
  return {
371
532
  id: chat.id,
372
- title: displayTitle || '新会话',
533
+ title: displayTitle || "新会话",
373
534
  isAutomation,
374
535
  isPinned: chat.isPinned,
375
536
  sortDate,
@@ -420,7 +581,7 @@ export function AgentSidebar({
420
581
  // 分组,「5 天前置顶的会话」会排在「今天」的普通会话之后,置顶语义
421
582
  // 就只剩组内有效——与用户点图钉时的预期不符。
422
583
  noProjectChats.forEach((chat) => {
423
- const label = chat.isPinned ? '置顶' : getDateGroupLabel(chat.sortDate);
584
+ const label = chat.isPinned ? "置顶" : getDateGroupLabel(chat.sortDate);
424
585
  if (!map.has(label)) {
425
586
  map.set(label, {
426
587
  label,
@@ -448,7 +609,7 @@ export function AgentSidebar({
448
609
  const ok = await deleteChat(id);
449
610
  if (ok) {
450
611
  setConfirmDeleteChatId(null);
451
- if (id === currentChatId) navigate('/agent');
612
+ if (id === currentChatId) navigate("/agent");
452
613
  }
453
614
  } finally {
454
615
  setDeletingChatId(null);
@@ -477,22 +638,17 @@ export function AgentSidebar({
477
638
  navigate(`/agent/${chat.id}`);
478
639
  }}
479
640
  className={[
480
- 'flex h-7 w-full min-w-0 items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors duration-200',
641
+ "flex h-7 w-full min-w-0 items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors duration-200",
481
642
  isCurrent
482
- ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
483
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
484
- ].join(' ')}
643
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
644
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
645
+ ].join(" ")}
485
646
  title={
486
- chat.isAutomation
487
- ? `[由自动化触发] ${chat.title}`
488
- : chat.title
647
+ chat.isAutomation ? `[由自动化触发] ${chat.title}` : chat.title
489
648
  }
490
649
  >
491
650
  {chat.isPinned && (
492
- <span
493
- className="shrink-0 text-[10px]"
494
- aria-label="已置顶"
495
- >
651
+ <span className="shrink-0 text-[10px]" aria-label="已置顶">
496
652
  📌
497
653
  </span>
498
654
  )}
@@ -503,9 +659,7 @@ export function AgentSidebar({
503
659
  />
504
660
  )}
505
661
  <AgentDot agent={chatAgent} size={16} />
506
- <span className="min-w-0 truncate leading-none">
507
- {chat.title}
508
- </span>
662
+ <span className="min-w-0 truncate leading-none">{chat.title}</span>
509
663
  </button>
510
664
  {/* Gradient mask so the trash button doesn't paint
511
665
  over the chat title — fade matches the row's
@@ -514,15 +668,15 @@ export function AgentSidebar({
514
668
  <div
515
669
  aria-hidden="true"
516
670
  className={[
517
- 'pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity duration-200',
671
+ "pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity duration-200",
518
672
  isConfirmingDelete
519
673
  ? isCurrent
520
- ? 'bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-100'
521
- : 'bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-100'
674
+ ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-100"
675
+ : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-100"
522
676
  : isCurrent
523
- ? 'bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-0 group-hover/item:opacity-100'
524
- : 'bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-0 group-hover/item:opacity-100',
525
- ].join(' ')}
677
+ ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-0 group-hover/item:opacity-100"
678
+ : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-0 group-hover/item:opacity-100",
679
+ ].join(" ")}
526
680
  />
527
681
  <button
528
682
  type="button"
@@ -532,29 +686,20 @@ export function AgentSidebar({
532
686
  }}
533
687
  disabled={isDeleting}
534
688
  className={[
535
- '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',
689
+ "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",
536
690
  isConfirmingDelete
537
- ? 'bg-agent-destructive/10 text-agent-destructive opacity-100 hover:bg-agent-destructive/20'
538
- : 'text-agent-muted-foreground opacity-0 hover:bg-agent-foreground/5 hover:text-agent-destructive group-hover/item:opacity-100 focus:opacity-100',
539
- 'disabled:cursor-not-allowed disabled:opacity-100',
540
- ].join(' ')}
541
- title={
542
- isConfirmingDelete
543
- ? '再次点击确认删除'
544
- : '删除会话'
545
- }
546
- aria-label={
547
- isConfirmingDelete
548
- ? '再次点击确认删除'
549
- : '删除会话'
550
- }
691
+ ? "bg-agent-destructive/10 text-agent-destructive opacity-100 hover:bg-agent-destructive/20"
692
+ : "text-agent-muted-foreground opacity-0 hover:bg-agent-foreground/5 hover:text-agent-destructive group-hover/item:opacity-100 focus:opacity-100",
693
+ "disabled:cursor-not-allowed disabled:opacity-100",
694
+ ].join(" ")}
695
+ title={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
696
+ aria-label={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
551
697
  data-testid="sidebar-chat-delete"
552
698
  >
553
699
  <LuTrash2
554
- className={[
555
- 'h-3.5 w-3.5',
556
- isDeleting ? 'animate-pulse' : '',
557
- ].join(' ')}
700
+ className={["h-3.5 w-3.5", isDeleting ? "animate-pulse" : ""].join(
701
+ " ",
702
+ )}
558
703
  />
559
704
  </button>
560
705
  </div>
@@ -587,10 +732,10 @@ export function AgentSidebar({
587
732
  container.scrollHeight - 60;
588
733
  if (nearBottom) void loadMoreChats();
589
734
  };
590
- container.addEventListener('scroll', maybeLoadMore, { passive: true });
735
+ container.addEventListener("scroll", maybeLoadMore, { passive: true });
591
736
  const tickId = window.requestAnimationFrame(maybeLoadMore);
592
737
  return () => {
593
- container.removeEventListener('scroll', maybeLoadMore);
738
+ container.removeEventListener("scroll", maybeLoadMore);
594
739
  window.cancelAnimationFrame(tickId);
595
740
  };
596
741
  }, [
@@ -608,7 +753,7 @@ export function AgentSidebar({
608
753
  useEffect(() => {
609
754
  if (!bridge?.onMenuOpenTerminal) return;
610
755
  bridge.onMenuOpenTerminal(() => {
611
- onToggleRightPanel('terminal');
756
+ onToggleRightPanel("terminal");
612
757
  });
613
758
  return () => {
614
759
  bridge.offMenuOpenTerminal?.();
@@ -620,7 +765,7 @@ export function AgentSidebar({
620
765
  return (
621
766
  <div className="flex h-full w-full flex-col border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
622
767
  {/* ───── Brand + actions ───── */}
623
- <div className="flex h-9 flex-shrink-0 items-center justify-between px-2.5">
768
+ <div className="flex h-11 flex-shrink-0 items-center justify-between px-2.5">
624
769
  <BrandLockup onClick={() => handleOpenNewChat()} />
625
770
  <div className="flex items-center gap-1">
626
771
  <button
@@ -643,11 +788,11 @@ export function AgentSidebar({
643
788
  type="button"
644
789
  onClick={() => handleOpenNewChat()}
645
790
  className={[
646
- 'flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
791
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
647
792
  onNewChatHome
648
- ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
649
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
650
- ].join(' ')}
793
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
794
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
795
+ ].join(" ")}
651
796
  title="新建对话"
652
797
  data-testid="sidebar-new-chat"
653
798
  >
@@ -656,13 +801,13 @@ export function AgentSidebar({
656
801
  </button>
657
802
  <button
658
803
  type="button"
659
- onClick={() => navigate('/settings?section=agents')}
804
+ onClick={() => navigate("/settings?section=agents")}
660
805
  className={[
661
- 'flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
662
- onSettingsPage && settingsSection === 'agents'
663
- ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
664
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
665
- ].join(' ')}
806
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
807
+ onSettingsPage && settingsSection === "agents"
808
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
809
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
810
+ ].join(" ")}
666
811
  title="智能体管理"
667
812
  data-testid="sidebar-agent-settings"
668
813
  >
@@ -671,13 +816,13 @@ export function AgentSidebar({
671
816
  </button>
672
817
  <button
673
818
  type="button"
674
- onClick={() => navigate('/settings?section=skills')}
819
+ onClick={() => navigate("/settings?section=skills")}
675
820
  className={[
676
- 'flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
677
- onSettingsPage && settingsSection === 'skills'
678
- ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
679
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
680
- ].join(' ')}
821
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
822
+ onSettingsPage && settingsSection === "skills"
823
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
824
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
825
+ ].join(" ")}
681
826
  title="Skill 设置"
682
827
  data-testid="sidebar-skill-settings"
683
828
  >
@@ -686,13 +831,13 @@ export function AgentSidebar({
686
831
  </button>
687
832
  <button
688
833
  type="button"
689
- onClick={() => navigate('/settings?section=mcp')}
834
+ onClick={() => navigate("/settings?section=mcp")}
690
835
  className={[
691
- 'flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
692
- onSettingsPage && settingsSection === 'mcp'
693
- ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
694
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
695
- ].join(' ')}
836
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
837
+ onSettingsPage && settingsSection === "mcp"
838
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
839
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
840
+ ].join(" ")}
696
841
  title="MCP 设置"
697
842
  data-testid="sidebar-mcp-settings"
698
843
  >
@@ -706,28 +851,22 @@ export function AgentSidebar({
706
851
  onClick={() => setChatsExpanded((v) => !v)}
707
852
  className="flex h-6 items-center rounded-full px-2.5 text-xs font-semibold tracking-wider text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
708
853
  >
709
- <span className="mr-1">
854
+ 会话
855
+ <span className="ml-1">
710
856
  {chatsExpanded ? (
711
857
  <LuChevronUp className="h-3 w-3" />
712
858
  ) : (
713
859
  <LuChevronDown className="h-3 w-3" />
714
860
  )}
715
861
  </span>
716
- 会话
717
- {normalizedChats.length > 0 && (
718
- <span className="ml-1.5 text-[10px] font-normal text-agent-muted-foreground/70">
719
- · {normalizedChats.length}
720
- </span>
721
- )}
722
862
  </button>
723
863
  <div className="flex items-center gap-1">
724
- {/* 新建项目:选文件夹 → 以文件夹名建项目,之后可在组头重命名 */}
725
864
  {hasElectron && (
726
865
  <button
727
866
  type="button"
728
- onClick={() => void handleCreateProject()}
867
+ onClick={() => setCreateProjectOpen(true)}
729
868
  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"
730
- title="新建项目(绑定文件夹,项目内对话的文件操作被限制在该文件夹)"
869
+ title="新建项目"
731
870
  aria-label="新建项目"
732
871
  >
733
872
  <LuFolderPlus className="h-3.5 w-3.5" />
@@ -753,7 +892,7 @@ export function AgentSidebar({
753
892
  </div>
754
893
  ) : (
755
894
  <>
756
- {/* 项目分组在前:组头可折叠,hover 出内联管理动作 */}
895
+ {/* 项目分组在前:组头可折叠,hover 出 + 新建对话 / ·· 菜单 */}
757
896
  {projectGroups.map(({ project, items }) => (
758
897
  <div key={project.id} className="mb-1">
759
898
  <div className="group/proj relative">
@@ -768,10 +907,13 @@ export function AgentSidebar({
768
907
  <input
769
908
  autoFocus
770
909
  value={renamingValue}
771
- onChange={(event) => setRenamingValue(event.target.value)}
910
+ onChange={(event) =>
911
+ setRenamingValue(event.target.value)
912
+ }
772
913
  onBlur={() => void handleRenameProject(project.id)}
773
914
  onKeyDown={(event) => {
774
- if (event.key === 'Escape') setRenamingProjectId(null);
915
+ if (event.key === "Escape")
916
+ setRenamingProjectId(null);
775
917
  }}
776
918
  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"
777
919
  />
@@ -784,25 +926,30 @@ export function AgentSidebar({
784
926
  className="flex w-full min-w-0 items-center px-2.5 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80 transition-colors hover:text-agent-foreground"
785
927
  title={`${project.name}\n${project.folderPath}`}
786
928
  >
787
- <span className="mr-1">
929
+ <LuFolder className="mr-1 h-3 w-3 shrink-0" />
930
+ <span className="min-w-0 truncate normal-case">
931
+ {project.name}
932
+ </span>
933
+ <span className="ml-0.5 shrink-0">
788
934
  {collapsedProjectIds.has(project.id) ? (
789
935
  <LuChevronDown className="h-3 w-3" />
790
936
  ) : (
791
937
  <LuChevronUp className="h-3 w-3" />
792
938
  )}
793
939
  </span>
794
- <LuFolder className="mr-1 h-3 w-3 shrink-0" />
795
- <span className="min-w-0 truncate normal-case">
796
- {project.name}
797
- </span>
798
940
  {items.length > 0 && (
799
941
  <span className="ml-1 shrink-0 font-normal">
800
942
  · {items.length}
801
943
  </span>
802
944
  )}
803
945
  </button>
804
- {/* 内联管理:新建对话 / 重命名 / 换文件夹 / 删除(两段确认) */}
805
- <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">
946
+ <div
947
+ className={`absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 transition-opacity ${
948
+ projectMenu?.id === project.id
949
+ ? "opacity-100"
950
+ : "opacity-0 group-hover/proj:opacity-100"
951
+ }`}
952
+ >
806
953
  <button
807
954
  type="button"
808
955
  onClick={() => handleOpenNewChat(project.id)}
@@ -813,41 +960,21 @@ export function AgentSidebar({
813
960
  </button>
814
961
  <button
815
962
  type="button"
816
- onClick={() => {
817
- setRenamingProjectId(project.id);
818
- setRenamingValue(project.name);
963
+ aria-label="项目菜单"
964
+ aria-expanded={projectMenu?.id === project.id}
965
+ onClick={(event) => {
966
+ const button = event.currentTarget;
967
+ setConfirmDeleteProjectId(null);
968
+ setProjectMenu((current) =>
969
+ current?.id === project.id
970
+ ? null
971
+ : { id: project.id, anchor: button },
972
+ );
819
973
  }}
820
974
  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"
821
- title="重命名项目"
822
- >
823
- <LuPencil className="h-3 w-3" />
824
- </button>
825
- <button
826
- type="button"
827
- onClick={() => void handleChangeProjectFolder(project.id)}
828
- 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"
829
- title="更换绑定文件夹"
830
- >
831
- <LuFolderOpen className="h-3 w-3" />
832
- </button>
833
- <button
834
- type="button"
835
- onClick={() => void handleDeleteProject(project.id)}
836
- disabled={deletingProjectId === project.id}
837
- className={`flex h-5 w-5 items-center justify-center rounded-full transition-colors ${
838
- confirmDeleteProjectId === project.id
839
- ? 'bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20'
840
- : 'text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-destructive'
841
- }`}
842
- title={
843
- confirmDeleteProjectId === project.id
844
- ? '再次点击确认删除(会话会保留为无项目对话)'
845
- : '删除项目(会话保留为无项目对话)'
846
- }
975
+ title="项目菜单"
847
976
  >
848
- <LuTrash2
849
- className={`h-3 w-3 ${deletingProjectId === project.id ? 'animate-pulse' : ''}`}
850
- />
977
+ <LuEllipsis className="h-3 w-3" />
851
978
  </button>
852
979
  </div>
853
980
  </>
@@ -887,7 +1014,7 @@ export function AgentSidebar({
887
1014
  加载更多...
888
1015
  </span>
889
1016
  ) : hasMoreChats ? (
890
- '继续下滑加载更多'
1017
+ "继续下滑加载更多"
891
1018
  ) : (
892
1019
  `共 ${normalizedChats.length} 个会话`
893
1020
  )}
@@ -911,20 +1038,20 @@ export function AgentSidebar({
911
1038
  {chatSlots.length === 0 ? (
912
1039
  <button
913
1040
  type="button"
914
- onClick={() => onToggleRightPanel('terminal')}
1041
+ onClick={() => onToggleRightPanel("terminal")}
915
1042
  className={[
916
- 'flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
917
- rightPanel === 'terminal'
918
- ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
919
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground',
920
- ].join(' ')}
921
- title={`${rightPanel === 'terminal' ? '关闭' : '打开'}终端面板 (${isMac ? '⌘T' : 'Ctrl+T'})`}
1043
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
1044
+ rightPanel === "terminal"
1045
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1046
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground",
1047
+ ].join(" ")}
1048
+ title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板 (${isMac ? "⌘T" : "Ctrl+T"})`}
922
1049
  data-testid="sidebar-terminal"
923
1050
  >
924
1051
  <LuTerminal className="h-3.5 w-3.5" />
925
1052
  <span>终端</span>
926
1053
  <span className="ml-auto text-[10px] text-agent-muted-foreground/70">
927
- {isMac ? '⌘T' : 'Ctrl+T'}
1054
+ {isMac ? "⌘T" : "Ctrl+T"}
928
1055
  </span>
929
1056
  </button>
930
1057
  ) : (
@@ -935,14 +1062,14 @@ export function AgentSidebar({
935
1062
  >
936
1063
  <button
937
1064
  type="button"
938
- onClick={() => onToggleRightPanel('terminal')}
1065
+ onClick={() => onToggleRightPanel("terminal")}
939
1066
  className={[
940
- 'flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors',
941
- rightPanel === 'terminal'
942
- ? 'bg-agent-foreground/10 text-agent-foreground'
943
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
944
- ].join(' ')}
945
- title={`${rightPanel === 'terminal' ? '关闭' : '打开'}终端面板`}
1067
+ "flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
1068
+ rightPanel === "terminal"
1069
+ ? "bg-agent-foreground/10 text-agent-foreground"
1070
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
1071
+ ].join(" ")}
1072
+ title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板`}
946
1073
  data-testid="sidebar-terminal"
947
1074
  >
948
1075
  <LuTerminal className="h-3.5 w-3.5" />
@@ -954,12 +1081,12 @@ export function AgentSidebar({
954
1081
  type="button"
955
1082
  onClick={() => onToggleRightPanel(slot.slotId)}
956
1083
  className={[
957
- 'flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors',
1084
+ "flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
958
1085
  rightPanel === slot.slotId
959
- ? 'bg-agent-foreground/10 text-agent-foreground'
960
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
961
- ].join(' ')}
962
- title={`${rightPanel === slot.slotId ? '关闭' : '打开'}${slot.title}`}
1086
+ ? "bg-agent-foreground/10 text-agent-foreground"
1087
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
1088
+ ].join(" ")}
1089
+ title={`${rightPanel === slot.slotId ? "关闭" : "打开"}${slot.title}`}
963
1090
  data-testid={`sidebar-slot-${slot.slotId}`}
964
1091
  >
965
1092
  <slot.Icon className="h-3.5 w-3.5" />
@@ -970,13 +1097,13 @@ export function AgentSidebar({
970
1097
  )}
971
1098
  <button
972
1099
  type="button"
973
- onClick={() => navigate('/settings')}
1100
+ onClick={() => navigate("/settings")}
974
1101
  className={[
975
- 'mt-0.5 flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
1102
+ "mt-0.5 flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
976
1103
  onGeneralSettings
977
- ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
978
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
979
- ].join(' ')}
1104
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1105
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
1106
+ ].join(" ")}
980
1107
  title="设置"
981
1108
  data-testid="sidebar-llm-settings"
982
1109
  >
@@ -984,6 +1111,64 @@ export function AgentSidebar({
984
1111
  <span>设置</span>
985
1112
  </button>
986
1113
  </div>
1114
+
1115
+ {projectMenu &&
1116
+ (() => {
1117
+ const project = projects.find((item) => item.id === projectMenu.id);
1118
+ if (!project) return null;
1119
+ const chatCount =
1120
+ projectGroups.find((group) => group.project.id === project.id)
1121
+ ?.items.length ?? 0;
1122
+ return (
1123
+ <ProjectOverflowMenu
1124
+ project={project}
1125
+ chatCount={chatCount}
1126
+ anchor={projectMenu.anchor}
1127
+ revealLabel={isMac ? "在访达中显示" : "在文件管理器中显示"}
1128
+ confirmDelete={confirmDeleteProjectId === project.id}
1129
+ deleting={deletingProjectId === project.id}
1130
+ onClose={closeProjectMenu}
1131
+ onRename={() => {
1132
+ setProjectMenu(null);
1133
+ setRenamingProjectId(project.id);
1134
+ setRenamingValue(project.name);
1135
+ }}
1136
+ onChangeFolder={() => {
1137
+ setProjectMenu(null);
1138
+ setEditingProjectId(project.id);
1139
+ }}
1140
+ onReveal={() => {
1141
+ setProjectMenu(null);
1142
+ void handleRevealProjectFolder(project.folderPath);
1143
+ }}
1144
+ onDelete={() => void handleDeleteProject(project.id)}
1145
+ />
1146
+ );
1147
+ })()}
1148
+
1149
+ <CreateProjectModal
1150
+ open={createProjectOpen}
1151
+ onClose={() => setCreateProjectOpen(false)}
1152
+ onCreate={handleCreateProject}
1153
+ />
1154
+ {editingProjectId &&
1155
+ (() => {
1156
+ const project = projects.find((item) => item.id === editingProjectId);
1157
+ if (!project) return null;
1158
+ return (
1159
+ <CreateProjectModal
1160
+ open
1161
+ mode="edit"
1162
+ initial={{
1163
+ name: project.name,
1164
+ sourceFolders: project.sourceFolders ?? [],
1165
+ }}
1166
+ onClose={() => setEditingProjectId(null)}
1167
+ onCreate={(input) => handleUpdateProject(project.id, input)}
1168
+ onDelete={() => removeProject(project.id)}
1169
+ />
1170
+ );
1171
+ })()}
987
1172
  </div>
988
1173
  );
989
1174
  }