@steerable/agent-shell-web 0.6.30 → 0.6.31

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.30",
3
+ "version": "0.6.31",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -46,8 +46,8 @@
46
46
  "react-router-dom": "^6.30.0",
47
47
  "rehype-raw": "^7.0.0",
48
48
  "remark-gfm": "^4.0.1",
49
- "@steerable/agent-ui": "0.6.30",
50
- "@steerable/agent-protocol": "0.6.30"
49
+ "@steerable/agent-protocol": "0.6.31",
50
+ "@steerable/agent-ui": "0.6.31"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@tailwindcss/vite": "^4.3.0",
@@ -60,7 +60,7 @@
60
60
  "typescript": "^5.8.3",
61
61
  "vite": "^5.4.21",
62
62
  "vitest": "^3.2.4",
63
- "@steerable/pack-sdk": "0.6.30"
63
+ "@steerable/pack-sdk": "0.6.31"
64
64
  },
65
65
  "scripts": {
66
66
  "gen": "echo agent-shell-web",
Binary file
Binary file
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
+ <circle cx="24" cy="24" r="17" stroke="#5c6bc0" stroke-width="3.5"/>
3
+ <circle cx="24" cy="24" r="5" fill="#5c6bc0"/>
4
+ <g stroke="#5c6bc0" stroke-width="3.5" stroke-linecap="round">
5
+ <line x1="24" y1="2.5" x2="24" y2="12"/>
6
+ <line x1="24" y1="36" x2="24" y2="45.5"/>
7
+ <line x1="2.5" y1="24" x2="12" y2="24"/>
8
+ <line x1="36" y1="24" x2="45.5" y2="24"/>
9
+ <line x1="8.8" y1="8.8" x2="15.5" y2="15.5"/>
10
+ <line x1="32.5" y1="32.5" x2="39.2" y2="39.2"/>
11
+ <line x1="39.2" y1="8.8" x2="32.5" y2="15.5"/>
12
+ <line x1="15.5" y1="32.5" x2="8.8" y2="39.2"/>
13
+ </g>
14
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
+ <circle cx="24" cy="24" r="17" stroke="#5c6bc0" stroke-width="3.5"/>
3
+ <circle cx="24" cy="24" r="5" fill="#5c6bc0"/>
4
+ <g stroke="#5c6bc0" stroke-width="3.5" stroke-linecap="round">
5
+ <line x1="24" y1="2.5" x2="24" y2="12"/>
6
+ <line x1="24" y1="36" x2="24" y2="45.5"/>
7
+ <line x1="2.5" y1="24" x2="12" y2="24"/>
8
+ <line x1="36" y1="24" x2="45.5" y2="24"/>
9
+ <line x1="8.8" y1="8.8" x2="15.5" y2="15.5"/>
10
+ <line x1="32.5" y1="32.5" x2="39.2" y2="39.2"/>
11
+ <line x1="39.2" y1="8.8" x2="32.5" y2="15.5"/>
12
+ <line x1="15.5" y1="32.5" x2="8.8" y2="39.2"/>
13
+ </g>
14
+ </svg>
package/src/brand.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  * (VITE_BRAND_* define:product.json brand 优先,其次激活包 pack.json
4
4
  * brand,3.1);logo 资产由激活包的 web 模块在注册时经
5
5
  * setBrandLogoUrl() 注入(资产静态 import 在包内,只进本产品 bundle)。
6
- * 未注入时一律回落到 shell 中性默认(Steerable Shell / generic logo)。
6
+ * 未注入时一律回落到 shell 默认(Steerable Shell / 舵轮标)。
7
7
  * 主进程侧对应物见 src/brand.ts。
8
8
  */
9
- import shellLogoUrl from '@/assets/logo-generic.png';
9
+ import shellLogoUrl from '@/assets/logo-steerable.svg';
10
10
 
11
11
  /** 当前构建的 flavor(开放字符串,0.3g 起不再是二值联合)。 */
12
12
  export const APP_FLAVOR: string = import.meta.env.VITE_APP_FLAVOR ?? 'generic';
@@ -23,7 +23,7 @@ export const BRAND_NAME: string = import.meta.env.VITE_BRAND_NAME ?? 'Steerable
23
23
  export const BRAND_TITLE: string =
24
24
  import.meta.env.VITE_BRAND_TITLE ?? import.meta.env.VITE_BRAND_NAME ?? 'Steerable Shell';
25
25
 
26
- /** shell 默认 logo(中性通用图标);包品牌 logo 由包 web 模块注册覆盖。 */
26
+ /** shell 默认 logo(Steerable 舵轮标);包品牌 logo 由包 web 模块注册覆盖。 */
27
27
  let brandLogoUrl: string = shellLogoUrl;
28
28
 
29
29
  /**
@@ -63,7 +63,7 @@
63
63
  * │ ⬡ 智能体管理 │ ← /settings?section=agents(独立页)
64
64
  * │ ⬡ Skill 设置 │ ← /settings?section=skills(独立页)
65
65
  * │ 🔌 MCP 设置 │ ← /settings?section=mcp(独立页)
66
- * │ v 会话 · 12 📁+ │ ← 📁+ 新建项目
66
+ * │ 会话 v 📁+ │ ← 📁+ 打开新建项目弹窗
67
67
  * │ v 📁 项目A · 3 (hover: +✏📂🗑)│ ← 项目组:折叠/新建/重命名/换文件夹/删
68
68
  * │ ...(项目内对话) │
69
69
  * │ 今天 │
@@ -73,19 +73,12 @@
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 { useLocation, useNavigate, useParams } from "react-router-dom";
89
82
  import {
90
83
  LuChevronDown,
91
84
  LuChevronUp,
@@ -104,10 +97,10 @@ import {
104
97
  LuFolderPlus,
105
98
  LuPencil,
106
99
  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';
100
+ } from "react-icons/lu";
101
+ import { parseChatTitle } from "@/lib/chat-title";
102
+ import { getDateGroupLabel, getDateGroupPriority } from "@/lib/date-groups";
103
+ import { getElectronBridge, isElectron } from "@/lib/electron-bridge";
111
104
  import {
112
105
  createProject,
113
106
  deleteProject,
@@ -116,17 +109,18 @@ import {
116
109
  type LocalChat,
117
110
  type LocalChatAgent,
118
111
  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';
112
+ } from "@/lib/local-api";
113
+ import type { UseChatsAndAgentsResult } from "@/hooks/useChatsAndAgents";
114
+ import type { PackChatSlotContribution } from "@/packs/registry";
115
+ import type { RightPanelState } from "@/layouts/AgentLayout";
116
+ import { BrandLockup } from "@/components/BrandLockup";
117
+ import { CreateProjectModal } from "@/components/CreateProjectModal";
124
118
 
125
- const DEFAULT_DOT_COLOR = '#7c3aed';
119
+ const DEFAULT_DOT_COLOR = "#7c3aed";
126
120
 
127
121
  function agentInitial(agent: LocalChatAgent | null): string {
128
- const name = (agent?.name || '').trim();
129
- return name ? name[0].toUpperCase() : 'A';
122
+ const name = (agent?.name || "").trim();
123
+ return name ? name[0].toUpperCase() : "A";
130
124
  }
131
125
 
132
126
  function AgentDot({
@@ -170,17 +164,17 @@ export function AgentSidebar({
170
164
  const location = useLocation();
171
165
  const { chatId: currentChatId } = useParams<{ chatId?: string }>();
172
166
  const bridge = getElectronBridge();
173
- const onSettingsPage = location.pathname === '/settings';
167
+ const onSettingsPage = location.pathname === "/settings";
174
168
  // /settings?section=skills|mcp|agents|(缺省 = 综合设置)—— 各自高亮。
175
169
  const settingsSection = useMemo(
176
- () => new URLSearchParams(location.search).get('section'),
170
+ () => new URLSearchParams(location.search).get("section"),
177
171
  [location.search],
178
172
  );
179
173
  const onGeneralSettings =
180
174
  onSettingsPage &&
181
- settingsSection !== 'skills' &&
182
- settingsSection !== 'mcp' &&
183
- settingsSection !== 'agents';
175
+ settingsSection !== "skills" &&
176
+ settingsSection !== "mcp" &&
177
+ settingsSection !== "agents";
184
178
  const onNewChatHome = !currentChatId && !onSettingsPage;
185
179
 
186
180
  const {
@@ -198,7 +192,7 @@ export function AgentSidebar({
198
192
  const [isMac, setIsMac] = useState(false);
199
193
 
200
194
  useEffect(() => {
201
- if (typeof navigator !== 'undefined') {
195
+ if (typeof navigator !== "undefined") {
202
196
  setIsMac(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
203
197
  }
204
198
  }, []);
@@ -217,11 +211,18 @@ export function AgentSidebar({
217
211
  const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
218
212
  () => new Set(),
219
213
  );
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);
214
+ const [renamingProjectId, setRenamingProjectId] = useState<string | null>(
215
+ null,
216
+ );
217
+ const [renamingValue, setRenamingValue] = useState("");
218
+ const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<
219
+ string | null
220
+ >(null);
221
+ const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
222
+ null,
223
+ );
224
224
  const [projectError, setProjectError] = useState<string | null>(null);
225
+ const [createProjectOpen, setCreateProjectOpen] = useState(false);
225
226
 
226
227
  const fetchProjects = useCallback(async () => {
227
228
  if (!isElectron()) return;
@@ -229,7 +230,7 @@ export function AgentSidebar({
229
230
  const res = await listProjects();
230
231
  setProjects(res.projects || []);
231
232
  } catch (err) {
232
- console.error('获取项目列表失败:', err);
233
+ console.error("获取项目列表失败:", err);
233
234
  setProjectError(err instanceof Error ? err.message : String(err));
234
235
  }
235
236
  }, []);
@@ -238,24 +239,25 @@ export function AgentSidebar({
238
239
  void fetchProjects();
239
240
  }, [fetchProjects]);
240
241
 
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]);
242
+ const handleCreateProject = useCallback(
243
+ async (input: { name: string; sourceFolders: string[] }) => {
244
+ setProjectError(null);
245
+ try {
246
+ await createProject({
247
+ name: input.name,
248
+ ...(input.sourceFolders.length > 0
249
+ ? { sourceFolders: input.sourceFolders }
250
+ : {}),
251
+ });
252
+ await fetchProjects();
253
+ } catch (err) {
254
+ const message = err instanceof Error ? err.message : String(err);
255
+ setProjectError(message);
256
+ throw err instanceof Error ? err : new Error(message);
257
+ }
258
+ },
259
+ [fetchProjects],
260
+ );
259
261
 
260
262
  const handleRenameProject = useCallback(
261
263
  async (projectId: string) => {
@@ -278,7 +280,7 @@ export function AgentSidebar({
278
280
  setProjectError(null);
279
281
  try {
280
282
  const result = await bridge?.local?.selectDirectory({
281
- title: '重新选择项目文件夹',
283
+ title: "重新选择项目文件夹",
282
284
  });
283
285
  if (!result || result.canceled || result.filePaths.length === 0) return;
284
286
  await updateProject(projectId, { folderPath: result.filePaths[0] });
@@ -350,7 +352,7 @@ export function AgentSidebar({
350
352
  navigate(`/agent?projectId=${encodeURIComponent(projectId)}`);
351
353
  return;
352
354
  }
353
- navigate('/agent');
355
+ navigate("/agent");
354
356
  },
355
357
  [navigate],
356
358
  );
@@ -369,7 +371,7 @@ export function AgentSidebar({
369
371
  const { displayTitle, isAutomation } = parseChatTitle(chat.title);
370
372
  return {
371
373
  id: chat.id,
372
- title: displayTitle || '新会话',
374
+ title: displayTitle || "新会话",
373
375
  isAutomation,
374
376
  isPinned: chat.isPinned,
375
377
  sortDate,
@@ -420,7 +422,7 @@ export function AgentSidebar({
420
422
  // 分组,「5 天前置顶的会话」会排在「今天」的普通会话之后,置顶语义
421
423
  // 就只剩组内有效——与用户点图钉时的预期不符。
422
424
  noProjectChats.forEach((chat) => {
423
- const label = chat.isPinned ? '置顶' : getDateGroupLabel(chat.sortDate);
425
+ const label = chat.isPinned ? "置顶" : getDateGroupLabel(chat.sortDate);
424
426
  if (!map.has(label)) {
425
427
  map.set(label, {
426
428
  label,
@@ -448,7 +450,7 @@ export function AgentSidebar({
448
450
  const ok = await deleteChat(id);
449
451
  if (ok) {
450
452
  setConfirmDeleteChatId(null);
451
- if (id === currentChatId) navigate('/agent');
453
+ if (id === currentChatId) navigate("/agent");
452
454
  }
453
455
  } finally {
454
456
  setDeletingChatId(null);
@@ -477,22 +479,17 @@ export function AgentSidebar({
477
479
  navigate(`/agent/${chat.id}`);
478
480
  }}
479
481
  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',
482
+ "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
483
  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(' ')}
484
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
485
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
486
+ ].join(" ")}
485
487
  title={
486
- chat.isAutomation
487
- ? `[由自动化触发] ${chat.title}`
488
- : chat.title
488
+ chat.isAutomation ? `[由自动化触发] ${chat.title}` : chat.title
489
489
  }
490
490
  >
491
491
  {chat.isPinned && (
492
- <span
493
- className="shrink-0 text-[10px]"
494
- aria-label="已置顶"
495
- >
492
+ <span className="shrink-0 text-[10px]" aria-label="已置顶">
496
493
  📌
497
494
  </span>
498
495
  )}
@@ -503,9 +500,7 @@ export function AgentSidebar({
503
500
  />
504
501
  )}
505
502
  <AgentDot agent={chatAgent} size={16} />
506
- <span className="min-w-0 truncate leading-none">
507
- {chat.title}
508
- </span>
503
+ <span className="min-w-0 truncate leading-none">{chat.title}</span>
509
504
  </button>
510
505
  {/* Gradient mask so the trash button doesn't paint
511
506
  over the chat title — fade matches the row's
@@ -514,15 +509,15 @@ export function AgentSidebar({
514
509
  <div
515
510
  aria-hidden="true"
516
511
  className={[
517
- 'pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity duration-200',
512
+ "pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity duration-200",
518
513
  isConfirmingDelete
519
514
  ? 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'
515
+ ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-100"
516
+ : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-100"
522
517
  : 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(' ')}
518
+ ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-0 group-hover/item:opacity-100"
519
+ : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-0 group-hover/item:opacity-100",
520
+ ].join(" ")}
526
521
  />
527
522
  <button
528
523
  type="button"
@@ -532,29 +527,20 @@ export function AgentSidebar({
532
527
  }}
533
528
  disabled={isDeleting}
534
529
  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',
536
- 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={
530
+ "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",
547
531
  isConfirmingDelete
548
- ? '再次点击确认删除'
549
- : '删除会话'
550
- }
532
+ ? "bg-agent-destructive/10 text-agent-destructive opacity-100 hover:bg-agent-destructive/20"
533
+ : "text-agent-muted-foreground opacity-0 hover:bg-agent-foreground/5 hover:text-agent-destructive group-hover/item:opacity-100 focus:opacity-100",
534
+ "disabled:cursor-not-allowed disabled:opacity-100",
535
+ ].join(" ")}
536
+ title={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
537
+ aria-label={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
551
538
  data-testid="sidebar-chat-delete"
552
539
  >
553
540
  <LuTrash2
554
- className={[
555
- 'h-3.5 w-3.5',
556
- isDeleting ? 'animate-pulse' : '',
557
- ].join(' ')}
541
+ className={["h-3.5 w-3.5", isDeleting ? "animate-pulse" : ""].join(
542
+ " ",
543
+ )}
558
544
  />
559
545
  </button>
560
546
  </div>
@@ -587,10 +573,10 @@ export function AgentSidebar({
587
573
  container.scrollHeight - 60;
588
574
  if (nearBottom) void loadMoreChats();
589
575
  };
590
- container.addEventListener('scroll', maybeLoadMore, { passive: true });
576
+ container.addEventListener("scroll", maybeLoadMore, { passive: true });
591
577
  const tickId = window.requestAnimationFrame(maybeLoadMore);
592
578
  return () => {
593
- container.removeEventListener('scroll', maybeLoadMore);
579
+ container.removeEventListener("scroll", maybeLoadMore);
594
580
  window.cancelAnimationFrame(tickId);
595
581
  };
596
582
  }, [
@@ -608,7 +594,7 @@ export function AgentSidebar({
608
594
  useEffect(() => {
609
595
  if (!bridge?.onMenuOpenTerminal) return;
610
596
  bridge.onMenuOpenTerminal(() => {
611
- onToggleRightPanel('terminal');
597
+ onToggleRightPanel("terminal");
612
598
  });
613
599
  return () => {
614
600
  bridge.offMenuOpenTerminal?.();
@@ -620,7 +606,7 @@ export function AgentSidebar({
620
606
  return (
621
607
  <div className="flex h-full w-full flex-col border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
622
608
  {/* ───── Brand + actions ───── */}
623
- <div className="flex h-9 flex-shrink-0 items-center justify-between px-2.5">
609
+ <div className="flex h-11 flex-shrink-0 items-center justify-between px-2.5">
624
610
  <BrandLockup onClick={() => handleOpenNewChat()} />
625
611
  <div className="flex items-center gap-1">
626
612
  <button
@@ -643,11 +629,11 @@ export function AgentSidebar({
643
629
  type="button"
644
630
  onClick={() => handleOpenNewChat()}
645
631
  className={[
646
- 'flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
632
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
647
633
  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(' ')}
634
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
635
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
636
+ ].join(" ")}
651
637
  title="新建对话"
652
638
  data-testid="sidebar-new-chat"
653
639
  >
@@ -656,13 +642,13 @@ export function AgentSidebar({
656
642
  </button>
657
643
  <button
658
644
  type="button"
659
- onClick={() => navigate('/settings?section=agents')}
645
+ onClick={() => navigate("/settings?section=agents")}
660
646
  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(' ')}
647
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
648
+ onSettingsPage && settingsSection === "agents"
649
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
650
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
651
+ ].join(" ")}
666
652
  title="智能体管理"
667
653
  data-testid="sidebar-agent-settings"
668
654
  >
@@ -671,13 +657,13 @@ export function AgentSidebar({
671
657
  </button>
672
658
  <button
673
659
  type="button"
674
- onClick={() => navigate('/settings?section=skills')}
660
+ onClick={() => navigate("/settings?section=skills")}
675
661
  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(' ')}
662
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
663
+ onSettingsPage && settingsSection === "skills"
664
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
665
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
666
+ ].join(" ")}
681
667
  title="Skill 设置"
682
668
  data-testid="sidebar-skill-settings"
683
669
  >
@@ -686,13 +672,13 @@ export function AgentSidebar({
686
672
  </button>
687
673
  <button
688
674
  type="button"
689
- onClick={() => navigate('/settings?section=mcp')}
675
+ onClick={() => navigate("/settings?section=mcp")}
690
676
  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(' ')}
677
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
678
+ onSettingsPage && settingsSection === "mcp"
679
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
680
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
681
+ ].join(" ")}
696
682
  title="MCP 设置"
697
683
  data-testid="sidebar-mcp-settings"
698
684
  >
@@ -706,28 +692,22 @@ export function AgentSidebar({
706
692
  onClick={() => setChatsExpanded((v) => !v)}
707
693
  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
694
  >
709
- <span className="mr-1">
695
+ 会话
696
+ <span className="ml-1">
710
697
  {chatsExpanded ? (
711
698
  <LuChevronUp className="h-3 w-3" />
712
699
  ) : (
713
700
  <LuChevronDown className="h-3 w-3" />
714
701
  )}
715
702
  </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
703
  </button>
723
704
  <div className="flex items-center gap-1">
724
- {/* 新建项目:选文件夹 → 以文件夹名建项目,之后可在组头重命名 */}
725
705
  {hasElectron && (
726
706
  <button
727
707
  type="button"
728
- onClick={() => void handleCreateProject()}
708
+ onClick={() => setCreateProjectOpen(true)}
729
709
  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="新建项目(绑定文件夹,项目内对话的文件操作被限制在该文件夹)"
710
+ title="新建项目"
731
711
  aria-label="新建项目"
732
712
  >
733
713
  <LuFolderPlus className="h-3.5 w-3.5" />
@@ -768,10 +748,13 @@ export function AgentSidebar({
768
748
  <input
769
749
  autoFocus
770
750
  value={renamingValue}
771
- onChange={(event) => setRenamingValue(event.target.value)}
751
+ onChange={(event) =>
752
+ setRenamingValue(event.target.value)
753
+ }
772
754
  onBlur={() => void handleRenameProject(project.id)}
773
755
  onKeyDown={(event) => {
774
- if (event.key === 'Escape') setRenamingProjectId(null);
756
+ if (event.key === "Escape")
757
+ setRenamingProjectId(null);
775
758
  }}
776
759
  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
760
  />
@@ -824,7 +807,9 @@ export function AgentSidebar({
824
807
  </button>
825
808
  <button
826
809
  type="button"
827
- onClick={() => void handleChangeProjectFolder(project.id)}
810
+ onClick={() =>
811
+ void handleChangeProjectFolder(project.id)
812
+ }
828
813
  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
814
  title="更换绑定文件夹"
830
815
  >
@@ -832,21 +817,23 @@ export function AgentSidebar({
832
817
  </button>
833
818
  <button
834
819
  type="button"
835
- onClick={() => void handleDeleteProject(project.id)}
820
+ onClick={() =>
821
+ void handleDeleteProject(project.id)
822
+ }
836
823
  disabled={deletingProjectId === project.id}
837
824
  className={`flex h-5 w-5 items-center justify-center rounded-full transition-colors ${
838
825
  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'
826
+ ? "bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20"
827
+ : "text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-destructive"
841
828
  }`}
842
829
  title={
843
830
  confirmDeleteProjectId === project.id
844
- ? '再次点击确认删除(会话会保留为无项目对话)'
845
- : '删除项目(会话保留为无项目对话)'
831
+ ? "再次点击确认删除(会话会保留为无项目对话)"
832
+ : "删除项目(会话保留为无项目对话)"
846
833
  }
847
834
  >
848
835
  <LuTrash2
849
- className={`h-3 w-3 ${deletingProjectId === project.id ? 'animate-pulse' : ''}`}
836
+ className={`h-3 w-3 ${deletingProjectId === project.id ? "animate-pulse" : ""}`}
850
837
  />
851
838
  </button>
852
839
  </div>
@@ -887,7 +874,7 @@ export function AgentSidebar({
887
874
  加载更多...
888
875
  </span>
889
876
  ) : hasMoreChats ? (
890
- '继续下滑加载更多'
877
+ "继续下滑加载更多"
891
878
  ) : (
892
879
  `共 ${normalizedChats.length} 个会话`
893
880
  )}
@@ -911,20 +898,20 @@ export function AgentSidebar({
911
898
  {chatSlots.length === 0 ? (
912
899
  <button
913
900
  type="button"
914
- onClick={() => onToggleRightPanel('terminal')}
901
+ onClick={() => onToggleRightPanel("terminal")}
915
902
  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'})`}
903
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
904
+ rightPanel === "terminal"
905
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
906
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground",
907
+ ].join(" ")}
908
+ title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板 (${isMac ? "⌘T" : "Ctrl+T"})`}
922
909
  data-testid="sidebar-terminal"
923
910
  >
924
911
  <LuTerminal className="h-3.5 w-3.5" />
925
912
  <span>终端</span>
926
913
  <span className="ml-auto text-[10px] text-agent-muted-foreground/70">
927
- {isMac ? '⌘T' : 'Ctrl+T'}
914
+ {isMac ? "⌘T" : "Ctrl+T"}
928
915
  </span>
929
916
  </button>
930
917
  ) : (
@@ -935,14 +922,14 @@ export function AgentSidebar({
935
922
  >
936
923
  <button
937
924
  type="button"
938
- onClick={() => onToggleRightPanel('terminal')}
925
+ onClick={() => onToggleRightPanel("terminal")}
939
926
  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' ? '关闭' : '打开'}终端面板`}
927
+ "flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
928
+ rightPanel === "terminal"
929
+ ? "bg-agent-foreground/10 text-agent-foreground"
930
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
931
+ ].join(" ")}
932
+ title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板`}
946
933
  data-testid="sidebar-terminal"
947
934
  >
948
935
  <LuTerminal className="h-3.5 w-3.5" />
@@ -954,12 +941,12 @@ export function AgentSidebar({
954
941
  type="button"
955
942
  onClick={() => onToggleRightPanel(slot.slotId)}
956
943
  className={[
957
- 'flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors',
944
+ "flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
958
945
  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}`}
946
+ ? "bg-agent-foreground/10 text-agent-foreground"
947
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
948
+ ].join(" ")}
949
+ title={`${rightPanel === slot.slotId ? "关闭" : "打开"}${slot.title}`}
963
950
  data-testid={`sidebar-slot-${slot.slotId}`}
964
951
  >
965
952
  <slot.Icon className="h-3.5 w-3.5" />
@@ -970,13 +957,13 @@ export function AgentSidebar({
970
957
  )}
971
958
  <button
972
959
  type="button"
973
- onClick={() => navigate('/settings')}
960
+ onClick={() => navigate("/settings")}
974
961
  className={[
975
- 'mt-0.5 flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
962
+ "mt-0.5 flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
976
963
  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(' ')}
964
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
965
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
966
+ ].join(" ")}
980
967
  title="设置"
981
968
  data-testid="sidebar-llm-settings"
982
969
  >
@@ -984,6 +971,12 @@ export function AgentSidebar({
984
971
  <span>设置</span>
985
972
  </button>
986
973
  </div>
974
+
975
+ <CreateProjectModal
976
+ open={createProjectOpen}
977
+ onClose={() => setCreateProjectOpen(false)}
978
+ onCreate={handleCreateProject}
979
+ />
987
980
  </div>
988
981
  );
989
982
  }
@@ -30,7 +30,7 @@ export function BrandLockup({ className, onClick }: BrandLockupProps = {}) {
30
30
  draggable={false}
31
31
  />
32
32
  {title ? (
33
- <span className="truncate text-xs font-semibold tracking-tight text-agent-foreground leading-none">
33
+ <span className="truncate text-sm font-semibold tracking-tight text-agent-foreground leading-none">
34
34
  {title}
35
35
  </span>
36
36
  ) : null}
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 新建项目弹窗(Codex 式):项目是带名字的容器,不是「选中的那个文件夹」。
3
+ * 家目录由后端建在 Documents/<应用名>/<项目名>/;这里可选附加源文件夹。
4
+ */
5
+ import { useEffect, useState } from 'react';
6
+ import { createPortal } from 'react-dom';
7
+ import { LuFolder, LuFolderPlus, LuX } from 'react-icons/lu';
8
+ import { BRAND_NAME } from '@/brand';
9
+ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
10
+
11
+ export interface CreateProjectModalProps {
12
+ open: boolean;
13
+ onClose: () => void;
14
+ onCreate: (input: { name: string; sourceFolders: string[] }) => Promise<void>;
15
+ }
16
+
17
+ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectModalProps) {
18
+ const [name, setName] = useState('');
19
+ const [sourceFolders, setSourceFolders] = useState<string[]>([]);
20
+ const [submitting, setSubmitting] = useState(false);
21
+ const [error, setError] = useState<string | null>(null);
22
+
23
+ useEffect(() => {
24
+ if (!open) return;
25
+ setName('');
26
+ setSourceFolders([]);
27
+ setSubmitting(false);
28
+ setError(null);
29
+ const onKey = (event: KeyboardEvent) => {
30
+ if (event.key === 'Escape') onClose();
31
+ };
32
+ window.addEventListener('keydown', onKey);
33
+ return () => window.removeEventListener('keydown', onKey);
34
+ }, [open, onClose]);
35
+
36
+ if (!open) return null;
37
+ if (typeof document === 'undefined') return null;
38
+
39
+ const trimmed = name.trim();
40
+ const homePreview = `~/Documents/${BRAND_NAME}/${trimmed || '项目名'}`;
41
+
42
+ const handleAddFolder = async () => {
43
+ if (!isElectron()) return;
44
+ const result = await getElectronBridge()?.local?.selectDirectory({
45
+ title: '添加源文件夹',
46
+ });
47
+ if (!result || result.canceled || result.filePaths.length === 0) return;
48
+ const next = result.filePaths[0];
49
+ setSourceFolders((prev) => (prev.includes(next) ? prev : [...prev, next]));
50
+ };
51
+
52
+ const handleSubmit = async () => {
53
+ if (!trimmed || submitting) return;
54
+ setSubmitting(true);
55
+ setError(null);
56
+ try {
57
+ await onCreate({ name: trimmed, sourceFolders });
58
+ onClose();
59
+ } catch (err) {
60
+ setError(err instanceof Error ? err.message : String(err));
61
+ setSubmitting(false);
62
+ }
63
+ };
64
+
65
+ return createPortal(
66
+ <div
67
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
68
+ onMouseDown={(event) => {
69
+ if (event.target === event.currentTarget) onClose();
70
+ }}
71
+ role="dialog"
72
+ aria-modal="true"
73
+ aria-labelledby="create-project-title"
74
+ data-testid="create-project-dialog"
75
+ >
76
+ <div className="flex w-[440px] max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-agent-border bg-agent-canvas shadow-2xl">
77
+ <div className="flex items-center justify-between px-5 pb-1 pt-4">
78
+ <h2 id="create-project-title" className="text-base font-semibold text-agent-foreground">
79
+ 新建项目
80
+ </h2>
81
+ <button
82
+ type="button"
83
+ onClick={onClose}
84
+ className="rounded-full p-1 text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground"
85
+ aria-label="关闭"
86
+ >
87
+ <LuX className="h-4 w-4" />
88
+ </button>
89
+ </div>
90
+
91
+ <div className="space-y-4 px-5 py-3">
92
+ <label className="flex h-10 items-center gap-2 rounded-full border border-agent-border bg-agent-canvas px-3 focus-within:border-agent-foreground/40">
93
+ <LuFolder className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
94
+ <input
95
+ type="text"
96
+ value={name}
97
+ onChange={(event) => setName(event.target.value)}
98
+ onKeyDown={(event) => {
99
+ if (event.key === 'Enter') void handleSubmit();
100
+ }}
101
+ placeholder="项目名称"
102
+ autoFocus
103
+ className="h-full min-w-0 flex-1 bg-transparent text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground"
104
+ data-testid="create-project-name"
105
+ />
106
+ </label>
107
+ <p className="px-1 text-[11px] text-agent-muted-foreground">
108
+ 默认位置:{homePreview}
109
+ </p>
110
+
111
+ <div>
112
+ <div className="mb-2 text-sm text-agent-foreground">源文件夹</div>
113
+ {sourceFolders.length > 0 && (
114
+ <ul className="mb-2 space-y-1">
115
+ {sourceFolders.map((folder) => (
116
+ <li
117
+ key={folder}
118
+ className="flex items-center gap-2 rounded-lg bg-agent-muted/40 px-2.5 py-1.5 text-xs text-agent-foreground"
119
+ >
120
+ <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
121
+ <span className="min-w-0 flex-1 truncate font-mono" title={folder}>
122
+ {folder}
123
+ </span>
124
+ <button
125
+ type="button"
126
+ onClick={() =>
127
+ setSourceFolders((prev) => prev.filter((item) => item !== folder))
128
+ }
129
+ className="rounded-full p-0.5 text-agent-muted-foreground hover:text-agent-foreground"
130
+ aria-label={`移除 ${folder}`}
131
+ >
132
+ <LuX className="h-3.5 w-3.5" />
133
+ </button>
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ )}
138
+ <div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-agent-border px-4 py-8">
139
+ <span className="text-sm text-agent-muted-foreground">添加此电脑上的文件夹</span>
140
+ <button
141
+ type="button"
142
+ onClick={() => void handleAddFolder()}
143
+ disabled={!isElectron()}
144
+ className="flex h-8 items-center gap-1.5 rounded-full border border-agent-border px-4 text-xs font-medium text-agent-foreground transition-colors hover:bg-agent-muted disabled:cursor-not-allowed disabled:opacity-50"
145
+ data-testid="create-project-add-folder"
146
+ >
147
+ <LuFolderPlus className="h-3.5 w-3.5" />
148
+ 添加
149
+ </button>
150
+ </div>
151
+ </div>
152
+
153
+ {error && (
154
+ <p className="text-xs text-agent-destructive" role="alert">
155
+ {error}
156
+ </p>
157
+ )}
158
+ </div>
159
+
160
+ <div className="flex items-center justify-end gap-2 px-5 pb-4 pt-1">
161
+ <button
162
+ type="button"
163
+ onClick={onClose}
164
+ className="h-9 rounded-full px-4 text-sm text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground"
165
+ >
166
+ 取消
167
+ </button>
168
+ <button
169
+ type="button"
170
+ onClick={() => void handleSubmit()}
171
+ disabled={!trimmed || submitting}
172
+ className="h-9 rounded-full bg-agent-foreground px-4 text-sm font-medium text-agent-canvas transition-opacity disabled:opacity-40"
173
+ data-testid="create-project-submit"
174
+ >
175
+ {submitting ? '创建中…' : '创建项目'}
176
+ </button>
177
+ </div>
178
+ </div>
179
+ </div>,
180
+ document.body,
181
+ );
182
+ }
@@ -7,9 +7,12 @@ import {
7
7
  LuFolderPlus,
8
8
  LuFolderX,
9
9
  LuLoaderCircle,
10
+ LuPlus,
10
11
  } from 'react-icons/lu';
12
+ import { CreateProjectModal } from '@/components/CreateProjectModal';
11
13
  import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
12
14
  import {
15
+ createProject,
13
16
  updateChatProject,
14
17
  updateProject,
15
18
  type LocalProject,
@@ -93,14 +96,47 @@ function MenuShell({
93
96
  );
94
97
  }
95
98
 
96
- function MenuSectionLabel({ children }: { children: ReactNode }) {
99
+ function MenuSectionLabel({
100
+ children,
101
+ onAdd,
102
+ }: {
103
+ children: ReactNode;
104
+ onAdd?: () => void;
105
+ }) {
97
106
  return (
98
- <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-agent-muted-foreground">
99
- {children}
107
+ <div className="flex items-center justify-between gap-1 px-2 py-1">
108
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-agent-muted-foreground">
109
+ {children}
110
+ </div>
111
+ {onAdd && (
112
+ <button
113
+ type="button"
114
+ onClick={onAdd}
115
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/10 hover:text-agent-foreground"
116
+ title="新建项目"
117
+ aria-label="新建项目"
118
+ >
119
+ <LuPlus className="h-3 w-3" />
120
+ </button>
121
+ )}
100
122
  </div>
101
123
  );
102
124
  }
103
125
 
126
+ function CreateProjectMenuItem({ onClick }: { onClick: () => void }) {
127
+ return (
128
+ <button
129
+ type="button"
130
+ role="menuitem"
131
+ onClick={onClick}
132
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5"
133
+ >
134
+ <LuFolderPlus className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
135
+ 新建项目
136
+ </button>
137
+ );
138
+ }
139
+
104
140
  function ProjectMenuRow({
105
141
  project,
106
142
  selected,
@@ -147,9 +183,15 @@ export function ChatProjectBadge({
147
183
  onChatProjectChanged: () => void | Promise<void>;
148
184
  }) {
149
185
  const [open, setOpen] = useState(false);
186
+ const [createOpen, setCreateOpen] = useState(false);
150
187
  const [busy, setBusy] = useState(false);
151
188
  const [error, setError] = useState<string | null>(null);
152
189
 
190
+ const openCreate = () => {
191
+ setOpen(false);
192
+ setCreateOpen(true);
193
+ };
194
+
153
195
  const otherProjects = project
154
196
  ? projects.filter((p) => p.id !== project.id)
155
197
  : projects;
@@ -226,7 +268,7 @@ export function ChatProjectBadge({
226
268
  </div>
227
269
  </>
228
270
  ) : (
229
- <MenuSectionLabel>关联到项目</MenuSectionLabel>
271
+ <MenuSectionLabel onAdd={openCreate}>关联到项目</MenuSectionLabel>
230
272
  )}
231
273
 
232
274
  {otherProjects.length > 0 && (
@@ -247,9 +289,12 @@ export function ChatProjectBadge({
247
289
  </>
248
290
  )}
249
291
  {!project && projects.length === 0 && (
250
- <div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
251
- 还没有项目——先在侧边栏「会话」旁点 📁+ 新建。
252
- </div>
292
+ <>
293
+ <div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
294
+ 还没有项目
295
+ </div>
296
+ <CreateProjectMenuItem onClick={openCreate} />
297
+ </>
253
298
  )}
254
299
 
255
300
  {project && (
@@ -283,6 +328,22 @@ export function ChatProjectBadge({
283
328
  )}
284
329
  </MenuShell>
285
330
  )}
331
+
332
+ <CreateProjectModal
333
+ open={createOpen}
334
+ onClose={() => setCreateOpen(false)}
335
+ onCreate={async (input) => {
336
+ const res = await createProject({
337
+ name: input.name,
338
+ ...(input.sourceFolders.length > 0 ? { sourceFolders: input.sourceFolders } : {}),
339
+ });
340
+ await onProjectsChanged();
341
+ if (res.project?.id) {
342
+ await updateChatProject(chatId, res.project.id);
343
+ await onChatProjectChanged();
344
+ }
345
+ }}
346
+ />
286
347
  </div>
287
348
  );
288
349
  }
@@ -293,15 +354,24 @@ export function ProjectPickerButton({
293
354
  projects,
294
355
  value,
295
356
  onChange,
357
+ onProjectsChanged,
296
358
  }: {
297
359
  projects: LocalProject[];
298
360
  /** 当前选中的 projectId;null = 无项目。 */
299
361
  value: string | null;
300
362
  onChange: (projectId: string | null) => void;
363
+ /** 新建项目后刷新列表。 */
364
+ onProjectsChanged: () => void | Promise<void>;
301
365
  }) {
302
366
  const [open, setOpen] = useState(false);
367
+ const [createOpen, setCreateOpen] = useState(false);
303
368
  const selected = value ? (projects.find((p) => p.id === value) ?? null) : null;
304
369
 
370
+ const openCreate = () => {
371
+ setOpen(false);
372
+ setCreateOpen(true);
373
+ };
374
+
305
375
  return (
306
376
  <div className="relative">
307
377
  <BadgeButton
@@ -319,7 +389,7 @@ export function ProjectPickerButton({
319
389
 
320
390
  {open && (
321
391
  <MenuShell onClose={() => setOpen(false)}>
322
- <MenuSectionLabel>新对话所属项目</MenuSectionLabel>
392
+ <MenuSectionLabel onAdd={openCreate}>新对话所属项目</MenuSectionLabel>
323
393
  <button
324
394
  type="button"
325
395
  role="menuitem"
@@ -349,12 +419,28 @@ export function ProjectPickerButton({
349
419
  ))}
350
420
  </div>
351
421
  {projects.length === 0 && (
352
- <div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
353
- 还没有项目——先在侧边栏「会话」旁点 📁+ 新建。
354
- </div>
422
+ <>
423
+ <div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
424
+ 还没有项目
425
+ </div>
426
+ <CreateProjectMenuItem onClick={openCreate} />
427
+ </>
355
428
  )}
356
429
  </MenuShell>
357
430
  )}
431
+
432
+ <CreateProjectModal
433
+ open={createOpen}
434
+ onClose={() => setCreateOpen(false)}
435
+ onCreate={async (input) => {
436
+ const res = await createProject({
437
+ name: input.name,
438
+ ...(input.sourceFolders.length > 0 ? { sourceFolders: input.sourceFolders } : {}),
439
+ });
440
+ await onProjectsChanged();
441
+ if (res.project?.id) onChange(res.project.id);
442
+ }}
443
+ />
358
444
  </div>
359
445
  );
360
446
  }
@@ -450,16 +450,18 @@ function AgentLayoutContent() {
450
450
  <div className="relative flex h-full w-full bg-agent-muted/30">
451
451
  {sidebarCollapsed ? (
452
452
  // 收起态:窄 rail 只放展开按钮。新建对话仍可用 Cmd+N / 菜单触发。
453
- <div className="flex h-full w-10 flex-shrink-0 flex-col items-center border-r border-agent-border/60 bg-agent-muted/70 py-2 backdrop-blur-md">
454
- <button
455
- type="button"
456
- onClick={toggleSidebarCollapsed}
457
- 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"
458
- title="展开侧边栏"
459
- aria-label="展开侧边栏"
460
- >
461
- <LuPanelLeftOpen className="h-3.5 w-3.5" />
462
- </button>
453
+ <div className="flex h-full w-10 flex-shrink-0 flex-col items-center border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
454
+ <div className="flex h-11 w-full items-center justify-center">
455
+ <button
456
+ type="button"
457
+ onClick={toggleSidebarCollapsed}
458
+ 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"
459
+ title="展开侧边栏"
460
+ aria-label="展开侧边栏"
461
+ >
462
+ <LuPanelLeftOpen className="h-3.5 w-3.5" />
463
+ </button>
464
+ </div>
463
465
  </div>
464
466
  ) : (
465
467
  <>
@@ -369,6 +369,8 @@ export interface LocalProject {
369
369
  id: string;
370
370
  name: string;
371
371
  folderPath: string;
372
+ /** 附加源文件夹(只读)。缺省空。 */
373
+ sourceFolders?: string[];
372
374
  /** W6-5: 信任后该项目目录里的规则文件才会注入模型上下文。缺省 false。 */
373
375
  trusted?: boolean;
374
376
  createdAt: string;
@@ -473,7 +475,11 @@ export async function listProjects() {
473
475
  });
474
476
  }
475
477
 
476
- export async function createProject(input: { name: string; folderPath: string }) {
478
+ export async function createProject(input: {
479
+ name: string;
480
+ folderPath?: string;
481
+ sourceFolders?: string[];
482
+ }) {
477
483
  return bridge().localBackend.request<{ success: boolean; project: LocalProject }>({
478
484
  method: 'POST',
479
485
  path: '/api/v2/projects',
@@ -1234,18 +1234,18 @@ function EmptyChatGate() {
1234
1234
  useEffect(() => {
1235
1235
  setSelectedProjectId(projectIdFromUrl);
1236
1236
  }, [projectIdFromUrl]);
1237
- useEffect(() => {
1237
+ const fetchProjects = useCallback(async () => {
1238
1238
  if (!isElectron()) return;
1239
- let cancelled = false;
1240
- listProjects()
1241
- .then((res) => {
1242
- if (!cancelled) setProjects(res.projects ?? []);
1243
- })
1244
- .catch(() => {});
1245
- return () => {
1246
- cancelled = true;
1247
- };
1239
+ try {
1240
+ const res = await listProjects();
1241
+ setProjects(res.projects ?? []);
1242
+ } catch {
1243
+ /* 列表失败保持旧数据 */
1244
+ }
1248
1245
  }, []);
1246
+ useEffect(() => {
1247
+ void fetchProjects();
1248
+ }, [fetchProjects]);
1249
1249
 
1250
1250
  useEffect(() => {
1251
1251
  inputRef.current?.focusAtEnd();
@@ -1382,6 +1382,7 @@ function EmptyChatGate() {
1382
1382
  projects={projects}
1383
1383
  value={selectedProjectId}
1384
1384
  onChange={setSelectedProjectId}
1385
+ onProjectsChanged={fetchProjects}
1385
1386
  />
1386
1387
  ) : undefined
1387
1388
  }
Binary file