@steerable/agent-shell-web 0.6.31 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.31",
3
+ "version": "0.6.32",
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-protocol": "0.6.31",
50
- "@steerable/agent-ui": "0.6.31"
49
+ "@steerable/agent-protocol": "0.6.32",
50
+ "@steerable/agent-ui": "0.6.32"
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.31"
63
+ "@steerable/pack-sdk": "0.6.32"
64
64
  },
65
65
  "scripts": {
66
66
  "gen": "echo agent-shell-web",
@@ -64,7 +64,7 @@
64
64
  * │ ⬡ Skill 设置 │ ← /settings?section=skills(独立页)
65
65
  * │ 🔌 MCP 设置 │ ← /settings?section=mcp(独立页)
66
66
  * │ 会话 v 📁+ │ ← 📁+ 打开新建项目弹窗
67
- * │ v 📁 项目A · 3 (hover: +✏📂🗑)│ ← 项目组:折叠/新建/重命名/换文件夹/删
67
+ * │ v 📁 项目A · 3 (hover: +··)│ ← + 新建对话;·· 菜单:重命名/换目录/访达/删
68
68
  * │ ...(项目内对话) │
69
69
  * │ 今天 │
70
70
  * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
@@ -78,6 +78,7 @@
78
78
  */
79
79
 
80
80
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
81
+ import { createPortal } from "react-dom";
81
82
  import { useLocation, useNavigate, useParams } from "react-router-dom";
82
83
  import {
83
84
  LuChevronDown,
@@ -97,6 +98,7 @@ import {
97
98
  LuFolderPlus,
98
99
  LuPencil,
99
100
  LuPlug,
101
+ LuEllipsis,
100
102
  } from "react-icons/lu";
101
103
  import { parseChatTitle } from "@/lib/chat-title";
102
104
  import { getDateGroupLabel, getDateGroupPriority } from "@/lib/date-groups";
@@ -105,6 +107,7 @@ import {
105
107
  createProject,
106
108
  deleteProject,
107
109
  listProjects,
110
+ openLocalPath,
108
111
  updateProject,
109
112
  type LocalChat,
110
113
  type LocalChatAgent,
@@ -141,6 +144,125 @@ function AgentDot({
141
144
  );
142
145
  }
143
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
+
144
266
  interface AgentSidebarProps {
145
267
  data: UseChatsAndAgentsResult;
146
268
  /** 右侧栏当前打开的面板:null / 'terminal' / 包槽位 id(互斥)。 */
@@ -205,8 +327,8 @@ export function AgentSidebar({
205
327
 
206
328
  // ───── 项目模式 ─────
207
329
  // 项目列表从 local-backend 拉取(electron-store 持久化)。会话按
208
- // projectId 分组:项目分组在上(每个项目一个可折叠分组,组头支持内联
209
- // 管理:新建对话 / 重命名 / 换文件夹 / 删除),无项目对话在下(日期分组)。
330
+ // projectId 分组:项目分组在上(组头 hover:+ 新建对话 / ·· 菜单),
331
+ // 无项目对话在下(日期分组)。
210
332
  const [projects, setProjects] = useState<LocalProject[]>([]);
211
333
  const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
212
334
  () => new Set(),
@@ -223,6 +345,11 @@ export function AgentSidebar({
223
345
  );
224
346
  const [projectError, setProjectError] = useState<string | null>(null);
225
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);
226
353
 
227
354
  const fetchProjects = useCallback(async () => {
228
355
  if (!isElectron()) return;
@@ -274,48 +401,80 @@ export function AgentSidebar({
274
401
  [renamingValue, fetchProjects],
275
402
  );
276
403
 
277
- const handleChangeProjectFolder = useCallback(
278
- async (projectId: string) => {
279
- if (!isElectron()) return;
404
+ const handleUpdateProject = useCallback(
405
+ async (
406
+ projectId: string,
407
+ input: { name: string; sourceFolders: string[] },
408
+ ) => {
280
409
  setProjectError(null);
281
410
  try {
282
- const result = await bridge?.local?.selectDirectory({
283
- title: "重新选择项目文件夹",
411
+ await updateProject(projectId, {
412
+ name: input.name,
413
+ sourceFolders: input.sourceFolders,
284
414
  });
285
- if (!result || result.canceled || result.filePaths.length === 0) return;
286
- await updateProject(projectId, { folderPath: result.filePaths[0] });
287
415
  await fetchProjects();
288
416
  } catch (err) {
289
- 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);
290
420
  }
291
421
  },
292
- [bridge, fetchProjects],
422
+ [fetchProjects],
293
423
  );
294
424
 
295
- const handleDeleteProject = useCallback(
425
+ const removeProject = useCallback(
296
426
  async (projectId: string) => {
297
427
  if (deletingProjectId === projectId) return;
298
- // 与会话删除同款两段确认:第一次点击武装红色按钮,第二次才真删。
299
- if (confirmDeleteProjectId !== projectId) {
300
- setConfirmDeleteProjectId(projectId);
301
- return;
302
- }
303
428
  try {
304
429
  setDeletingProjectId(projectId);
305
- // 后端会把该项目下的会话降级为无项目对话(不删会话)。
306
430
  await deleteProject(projectId);
307
431
  setConfirmDeleteProjectId(null);
432
+ setProjectMenu(null);
433
+ setEditingProjectId(null);
308
434
  await fetchProjects();
309
435
  await data.refreshChats();
310
436
  } catch (err) {
311
- 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);
312
440
  } finally {
313
441
  setDeletingProjectId(null);
314
442
  }
315
443
  },
316
- [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],
317
461
  );
318
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
+
319
478
  const toggleProjectCollapsed = useCallback((projectId: string) => {
320
479
  setCollapsedProjectIds((prev) => {
321
480
  const next = new Set(prev);
@@ -733,7 +892,7 @@ export function AgentSidebar({
733
892
  </div>
734
893
  ) : (
735
894
  <>
736
- {/* 项目分组在前:组头可折叠,hover 出内联管理动作 */}
895
+ {/* 项目分组在前:组头可折叠,hover 出 + 新建对话 / ·· 菜单 */}
737
896
  {projectGroups.map(({ project, items }) => (
738
897
  <div key={project.id} className="mb-1">
739
898
  <div className="group/proj relative">
@@ -767,25 +926,30 @@ export function AgentSidebar({
767
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"
768
927
  title={`${project.name}\n${project.folderPath}`}
769
928
  >
770
- <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">
771
934
  {collapsedProjectIds.has(project.id) ? (
772
935
  <LuChevronDown className="h-3 w-3" />
773
936
  ) : (
774
937
  <LuChevronUp className="h-3 w-3" />
775
938
  )}
776
939
  </span>
777
- <LuFolder className="mr-1 h-3 w-3 shrink-0" />
778
- <span className="min-w-0 truncate normal-case">
779
- {project.name}
780
- </span>
781
940
  {items.length > 0 && (
782
941
  <span className="ml-1 shrink-0 font-normal">
783
942
  · {items.length}
784
943
  </span>
785
944
  )}
786
945
  </button>
787
- {/* 内联管理:新建对话 / 重命名 / 换文件夹 / 删除(两段确认) */}
788
- <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
+ >
789
953
  <button
790
954
  type="button"
791
955
  onClick={() => handleOpenNewChat(project.id)}
@@ -796,45 +960,21 @@ export function AgentSidebar({
796
960
  </button>
797
961
  <button
798
962
  type="button"
799
- onClick={() => {
800
- setRenamingProjectId(project.id);
801
- 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
+ );
802
973
  }}
803
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"
804
- title="重命名项目"
975
+ title="项目菜单"
805
976
  >
806
- <LuPencil className="h-3 w-3" />
807
- </button>
808
- <button
809
- type="button"
810
- onClick={() =>
811
- void handleChangeProjectFolder(project.id)
812
- }
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"
814
- title="更换绑定文件夹"
815
- >
816
- <LuFolderOpen className="h-3 w-3" />
817
- </button>
818
- <button
819
- type="button"
820
- onClick={() =>
821
- void handleDeleteProject(project.id)
822
- }
823
- disabled={deletingProjectId === project.id}
824
- className={`flex h-5 w-5 items-center justify-center rounded-full transition-colors ${
825
- confirmDeleteProjectId === project.id
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"
828
- }`}
829
- title={
830
- confirmDeleteProjectId === project.id
831
- ? "再次点击确认删除(会话会保留为无项目对话)"
832
- : "删除项目(会话保留为无项目对话)"
833
- }
834
- >
835
- <LuTrash2
836
- className={`h-3 w-3 ${deletingProjectId === project.id ? "animate-pulse" : ""}`}
837
- />
977
+ <LuEllipsis className="h-3 w-3" />
838
978
  </button>
839
979
  </div>
840
980
  </>
@@ -972,11 +1112,63 @@ export function AgentSidebar({
972
1112
  </button>
973
1113
  </div>
974
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
+
975
1149
  <CreateProjectModal
976
1150
  open={createProjectOpen}
977
1151
  onClose={() => setCreateProjectOpen(false)}
978
1152
  onCreate={handleCreateProject}
979
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
+ })()}
980
1172
  </div>
981
1173
  );
982
1174
  }
@@ -1,56 +1,96 @@
1
1
  /**
2
- * 新建项目弹窗(Codex 式):项目是带名字的容器,不是「选中的那个文件夹」。
3
- * 家目录由后端建在 Documents/<应用名>/<项目名>/;这里可选附加源文件夹。
2
+ * 新建 / 编辑项目弹窗(Codex 式):项目是带名字的容器,可附加多个源文件夹。
3
+ * 家目录由后端建在 Documents/<应用名>/<项目名>/;源文件夹只放宽读取。
4
4
  */
5
5
  import { useEffect, useState } from 'react';
6
6
  import { createPortal } from 'react-dom';
7
7
  import { LuFolder, LuFolderPlus, LuX } from 'react-icons/lu';
8
8
  import { BRAND_NAME } from '@/brand';
9
- import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
9
+ import { getElectronBridge } from '@/lib/electron-bridge';
10
+
11
+ export interface ProjectFormValues {
12
+ name: string;
13
+ sourceFolders: string[];
14
+ }
10
15
 
11
16
  export interface CreateProjectModalProps {
12
17
  open: boolean;
13
18
  onClose: () => void;
14
- onCreate: (input: { name: string; sourceFolders: string[] }) => Promise<void>;
19
+ onCreate: (input: ProjectFormValues) => Promise<void>;
20
+ mode?: 'create' | 'edit';
21
+ initial?: ProjectFormValues;
22
+ onDelete?: () => Promise<void> | void;
15
23
  }
16
24
 
17
- export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectModalProps) {
18
- const [name, setName] = useState('');
19
- const [sourceFolders, setSourceFolders] = useState<string[]>([]);
25
+ export function CreateProjectModal({
26
+ open,
27
+ onClose,
28
+ onCreate,
29
+ mode = 'create',
30
+ initial,
31
+ onDelete,
32
+ }: CreateProjectModalProps) {
33
+ const isEdit = mode === 'edit';
34
+ const [name, setName] = useState(initial?.name ?? '');
35
+ const [sourceFolders, setSourceFolders] = useState(initial?.sourceFolders ?? []);
36
+ const [folderDraft, setFolderDraft] = useState('');
20
37
  const [submitting, setSubmitting] = useState(false);
38
+ const [deleting, setDeleting] = useState(false);
39
+ const [confirmDelete, setConfirmDelete] = useState(false);
21
40
  const [error, setError] = useState<string | null>(null);
41
+ const initialFoldersKey = (initial?.sourceFolders ?? []).join('\0');
22
42
 
23
43
  useEffect(() => {
24
44
  if (!open) return;
25
- setName('');
26
- setSourceFolders([]);
45
+ setName(initial?.name ?? '');
46
+ setSourceFolders(initial?.sourceFolders ?? []);
47
+ setFolderDraft('');
27
48
  setSubmitting(false);
49
+ setDeleting(false);
50
+ setConfirmDelete(false);
28
51
  setError(null);
29
52
  const onKey = (event: KeyboardEvent) => {
30
53
  if (event.key === 'Escape') onClose();
31
54
  };
32
55
  window.addEventListener('keydown', onKey);
33
56
  return () => window.removeEventListener('keydown', onKey);
34
- }, [open, onClose]);
57
+ }, [open, onClose, initial?.name, initialFoldersKey]);
35
58
 
36
59
  if (!open) return null;
37
60
  if (typeof document === 'undefined') return null;
38
61
 
39
62
  const trimmed = name.trim();
40
63
  const homePreview = `~/Documents/${BRAND_NAME}/${trimmed || '项目名'}`;
64
+ const titleId = isEdit ? 'edit-project-title' : 'create-project-title';
65
+
66
+ const addSourceFolder = (folder: string) => {
67
+ const next = folder.trim();
68
+ if (!next) return;
69
+ setSourceFolders((prev) => (prev.includes(next) ? prev : [...prev, next]));
70
+ setFolderDraft('');
71
+ setError(null);
72
+ };
41
73
 
42
74
  const handleAddFolder = async () => {
43
- if (!isElectron()) return;
75
+ const typed = folderDraft.trim();
76
+ if (typed) {
77
+ addSourceFolder(typed);
78
+ return;
79
+ }
44
80
  const result = await getElectronBridge()?.local?.selectDirectory({
45
81
  title: '添加源文件夹',
46
82
  });
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]));
83
+ if (!result || result.canceled || result.filePaths.length === 0) {
84
+ if (!getElectronBridge()?.local?.selectDirectory) {
85
+ setError('请输入文件夹路径');
86
+ }
87
+ return;
88
+ }
89
+ addSourceFolder(result.filePaths[0]);
50
90
  };
51
91
 
52
92
  const handleSubmit = async () => {
53
- if (!trimmed || submitting) return;
93
+ if (!trimmed || submitting || deleting) return;
54
94
  setSubmitting(true);
55
95
  setError(null);
56
96
  try {
@@ -62,6 +102,23 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
62
102
  }
63
103
  };
64
104
 
105
+ const handleDelete = async () => {
106
+ if (!onDelete || deleting || submitting) return;
107
+ if (!confirmDelete) {
108
+ setConfirmDelete(true);
109
+ return;
110
+ }
111
+ setDeleting(true);
112
+ setError(null);
113
+ try {
114
+ await onDelete();
115
+ onClose();
116
+ } catch (err) {
117
+ setError(err instanceof Error ? err.message : String(err));
118
+ setDeleting(false);
119
+ }
120
+ };
121
+
65
122
  return createPortal(
66
123
  <div
67
124
  className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@@ -70,13 +127,13 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
70
127
  }}
71
128
  role="dialog"
72
129
  aria-modal="true"
73
- aria-labelledby="create-project-title"
74
- data-testid="create-project-dialog"
130
+ aria-labelledby={titleId}
131
+ data-testid={isEdit ? 'edit-project-dialog' : 'create-project-dialog'}
75
132
  >
76
133
  <div className="flex w-[440px] max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-agent-border bg-agent-canvas shadow-2xl">
77
134
  <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
- 新建项目
135
+ <h2 id={titleId} className="text-base font-semibold text-agent-foreground">
136
+ {isEdit ? '编辑项目' : '新建项目'}
80
137
  </h2>
81
138
  <button
82
139
  type="button"
@@ -101,52 +158,64 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
101
158
  placeholder="项目名称"
102
159
  autoFocus
103
160
  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"
161
+ data-testid={isEdit ? 'edit-project-name' : 'create-project-name'}
105
162
  />
106
163
  </label>
107
- <p className="px-1 text-[11px] text-agent-muted-foreground">
108
- 默认位置:{homePreview}
109
- </p>
164
+ {!isEdit && (
165
+ <p className="px-1 text-[11px] text-agent-muted-foreground">
166
+ 默认位置:{homePreview}
167
+ </p>
168
+ )}
110
169
 
111
170
  <div>
112
171
  <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"
172
+ <div className="overflow-hidden rounded-2xl border border-agent-border">
173
+ {sourceFolders.map((folder) => (
174
+ <div
175
+ key={folder}
176
+ className="flex items-center gap-2 border-b border-agent-border/60 px-3 py-2 text-xs text-agent-foreground"
177
+ >
178
+ <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
179
+ <span className="min-w-0 flex-1 truncate font-mono" title={folder}>
180
+ {folder}
181
+ </span>
182
+ <button
183
+ type="button"
184
+ onClick={() =>
185
+ setSourceFolders((prev) => prev.filter((item) => item !== folder))
186
+ }
187
+ className="rounded-full p-0.5 text-agent-muted-foreground hover:text-agent-foreground"
188
+ aria-label={`移除 ${folder}`}
119
189
  >
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>
190
+ <LuX className="h-3.5 w-3.5" />
191
+ </button>
192
+ </div>
193
+ ))}
194
+ <div className="flex items-center gap-2 px-3 py-2">
195
+ <LuFolderPlus className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
196
+ <input
197
+ type="text"
198
+ value={folderDraft}
199
+ onChange={(event) => setFolderDraft(event.target.value)}
200
+ onKeyDown={(event) => {
201
+ if (event.key === 'Enter') {
202
+ event.preventDefault();
203
+ void handleAddFolder();
204
+ }
205
+ }}
206
+ placeholder="输入路径或点添加浏览"
207
+ className="h-7 min-w-0 flex-1 bg-transparent text-xs text-agent-foreground outline-none placeholder:text-agent-muted-foreground"
208
+ data-testid={isEdit ? 'edit-project-folder-path' : 'create-project-folder-path'}
209
+ />
210
+ <button
211
+ type="button"
212
+ onClick={() => void handleAddFolder()}
213
+ className="shrink-0 rounded-full px-2 py-1 text-xs font-medium text-agent-foreground hover:bg-agent-muted"
214
+ data-testid={isEdit ? 'edit-project-add-folder' : 'create-project-add-folder'}
215
+ >
216
+ 添加
217
+ </button>
218
+ </div>
150
219
  </div>
151
220
  </div>
152
221
 
@@ -157,22 +226,40 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
157
226
  )}
158
227
  </div>
159
228
 
160
- <div className="flex items-center justify-end gap-2 px-5 pb-4 pt-1">
229
+ <div className="flex items-center gap-2 px-5 pb-4 pt-1">
230
+ {onDelete && (
231
+ <button
232
+ type="button"
233
+ onClick={() => void handleDelete()}
234
+ disabled={deleting || submitting}
235
+ title={
236
+ confirmDelete
237
+ ? '再次点击确认删除(会话会保留为无项目对话)'
238
+ : '删除项目(会话保留为无项目对话)'
239
+ }
240
+ className="mr-auto h-9 rounded-full px-3 text-sm text-agent-destructive transition-colors hover:bg-agent-destructive/10 disabled:opacity-40"
241
+ data-testid="edit-project-delete"
242
+ >
243
+ {confirmDelete ? '再次点击确认删除' : '删除本地项目'}
244
+ </button>
245
+ )}
161
246
  <button
162
247
  type="button"
163
248
  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"
249
+ className={`h-9 rounded-full px-4 text-sm text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground ${
250
+ onDelete ? '' : 'ml-auto'
251
+ }`}
165
252
  >
166
253
  取消
167
254
  </button>
168
255
  <button
169
256
  type="button"
170
257
  onClick={() => void handleSubmit()}
171
- disabled={!trimmed || submitting}
258
+ disabled={!trimmed || submitting || deleting}
172
259
  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"
260
+ data-testid={isEdit ? 'edit-project-submit' : 'create-project-submit'}
174
261
  >
175
- {submitting ? '创建中…' : '创建项目'}
262
+ {submitting ? (isEdit ? '保存中…' : '创建中…') : isEdit ? '保存' : '创建项目'}
176
263
  </button>
177
264
  </div>
178
265
  </div>
@@ -161,8 +161,8 @@ export function ApprovalPromptMenu({
161
161
  </p>
162
162
  )}
163
163
  <p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
164
- 默认在工作区沙箱中执行(只能写入当前项目)。写入 Downloads
165
- 等项目外路径会被系统拒绝;需要时在输入框把沙箱切到「完整权限」后再让
164
+ 默认在工作区沙箱中执行(只能写入当前项目或本对话工作区)。写入
165
+ Downloads 等工作区外路径会被系统拒绝;需要时在输入框把沙箱切到「完整权限」后再让
166
166
  Agent 重试。
167
167
  </p>
168
168
  </>
@@ -383,7 +383,7 @@ export function ProjectPickerButton({
383
383
  title={
384
384
  selected
385
385
  ? `${selected.folderPath}\n新对话将绑定到此项目`
386
- : '为新对话选择项目(可不选)'
386
+ : '为新对话选择项目。不选则在文稿/<应用名>/conversations/ 下建立本对话工作区'
387
387
  }
388
388
  />
389
389
 
@@ -6,8 +6,8 @@ import type { ExecPolicy } from '@/lib/exec-policy';
6
6
  * ExecPolicyPicker — 输入框上的命令沙箱切换(类 Codex 底部权限档)。
7
7
  *
8
8
  * 切档立刻记住,下一轮才生效:当前回合的 `execSandbox` 已经随流发出。
9
- * 工作区 = Seatbelt/bwrap 只允许写项目根;完整权限 = 不下发命令沙箱,
10
- * 本机路径(如 Downloads)不再被 Operation not permitted 拦住。
9
+ * 工作区 = Seatbelt/bwrap 只允许写项目家目录或本对话工作区;完整权限
10
+ * = 不下发命令沙箱,本机路径(如 Downloads)不再被 Operation not permitted 拦住。
11
11
  */
12
12
 
13
13
  const OPTIONS: Array<{
@@ -18,7 +18,8 @@ const OPTIONS: Array<{
18
18
  {
19
19
  id: 'workspace',
20
20
  label: '工作区',
21
- description: '命令只能写入当前项目;项目外路径会被系统拒绝。',
21
+ description:
22
+ '命令只能写入当前项目或本对话工作区(文稿/<应用名>/conversations/);工作区外路径会被系统拒绝。',
22
23
  },
23
24
  {
24
25
  id: 'full',
@@ -2,8 +2,8 @@
2
2
  * 会话级命令执行沙箱(类 Codex 的 sandbox_mode)。
3
3
  *
4
4
  * 作用于下一轮 `chat.stream` 的 `execSandbox`,不是 sidecar 进程沙箱
5
- * (设置页那一层)。工作区 = 只能写入当前项目;完整权限 = 关闭逐条命令
6
- * 沙箱。工具审批(允许/拒绝)仍按原策略生效。
5
+ * (设置页那一层)。工作区 = 只能写入当前项目或本对话工作区;完整权限
6
+ * = 关闭逐条命令沙箱。工具审批(允许/拒绝)仍按原策略生效。
7
7
  */
8
8
 
9
9
  export type ExecPolicy = 'workspace' | 'full';
@@ -44,6 +44,15 @@ function authHeaders(): Record<string, string> {
44
44
  return token ? { Authorization: `Bearer ${token}` } : {};
45
45
  }
46
46
 
47
+ function httpErrorMessage(data: unknown, status: number): string {
48
+ if (data && typeof data === 'object') {
49
+ const rec = data as { error?: unknown; detail?: unknown };
50
+ if (typeof rec.error === 'string' && rec.error.trim()) return rec.error;
51
+ if (typeof rec.detail === 'string' && rec.detail.trim()) return rec.detail;
52
+ }
53
+ return `Request failed (${status})`;
54
+ }
55
+
47
56
  async function http<T>(method: string, path: string, body?: unknown): Promise<T> {
48
57
  const res = await fetch(path, {
49
58
  method,
@@ -53,11 +62,9 @@ async function http<T>(method: string, path: string, body?: unknown): Promise<T>
53
62
  },
54
63
  body: body !== undefined ? JSON.stringify(body) : undefined,
55
64
  });
56
- const data = (await res.json().catch(() => null)) as T & { detail?: string };
65
+ const data = (await res.json().catch(() => null)) as T;
57
66
  if (!res.ok) {
58
- const err = new Error(
59
- (data as { detail?: string } | null)?.detail || `Request failed (${res.status})`,
60
- ) as Error & { status?: number };
67
+ const err = new Error(httpErrorMessage(data, res.status)) as Error & { status?: number };
61
68
  err.status = res.status;
62
69
  throw err;
63
70
  }
@@ -197,9 +204,15 @@ export function createHttpBridge(): ElectronBridge {
197
204
  platform: boot.platform,
198
205
 
199
206
  local: {
200
- // 浏览器没有系统目录选择器(showDirectoryPicker 不给路径)。返回
201
- // canceled 让 UI 走"手动输入路径"的既有分支。
202
- selectDirectory: async () => ({ canceled: true, filePaths: [] }),
207
+ // 浏览器 File System Access API 不给真实路径。改走宿主
208
+ // `/host/local/select-directory`(本机系统选择器);取消或无 GUI
209
+ // 时返回 canceled,UI 再走手动输入路径。
210
+ selectDirectory: async (options) =>
211
+ http<{ canceled: boolean; filePaths: string[] }>(
212
+ 'POST',
213
+ '/host/local/select-directory',
214
+ options ?? {},
215
+ ),
203
216
  saveTextFile: async (options) => {
204
217
  const blob = new Blob([options.content], { type: 'application/json' });
205
218
  const url = URL.createObjectURL(blob);
@@ -353,7 +353,7 @@ export interface ResolvedLocalPath {
353
353
 
354
354
  /**
355
355
  * 批量确认正文里提到的路径是否真实存在(用于决定行内代码要不要变成可点击)。
356
- * 相对路径由后端按对话绑定的项目根解析,无项目时按 home;不存在的候选不回。
356
+ * 相对路径由后端按对话绑定的项目根解析,无项目时按对话工作区;不存在的候选不回。
357
357
  */
358
358
  export async function resolveLocalPaths(candidates: string[], chatId?: string | null) {
359
359
  return bridge().localBackend.request<{ resolved: ResolvedLocalPath[] }>({
@@ -489,7 +489,7 @@ export async function createProject(input: {
489
489
 
490
490
  export async function updateProject(
491
491
  projectId: string,
492
- updates: { name?: string; folderPath?: string },
492
+ updates: { name?: string; folderPath?: string; sourceFolders?: string[] },
493
493
  ) {
494
494
  return bridge().localBackend.request<{ success: boolean; project: LocalProject }>({
495
495
  method: 'PUT',