@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.
@@ -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,269 @@
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 } from '@/lib/electron-bridge';
10
+
11
+ export interface ProjectFormValues {
12
+ name: string;
13
+ sourceFolders: string[];
14
+ }
15
+
16
+ export interface CreateProjectModalProps {
17
+ open: boolean;
18
+ onClose: () => void;
19
+ onCreate: (input: ProjectFormValues) => Promise<void>;
20
+ mode?: 'create' | 'edit';
21
+ initial?: ProjectFormValues;
22
+ onDelete?: () => Promise<void> | void;
23
+ }
24
+
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('');
37
+ const [submitting, setSubmitting] = useState(false);
38
+ const [deleting, setDeleting] = useState(false);
39
+ const [confirmDelete, setConfirmDelete] = useState(false);
40
+ const [error, setError] = useState<string | null>(null);
41
+ const initialFoldersKey = (initial?.sourceFolders ?? []).join('\0');
42
+
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ setName(initial?.name ?? '');
46
+ setSourceFolders(initial?.sourceFolders ?? []);
47
+ setFolderDraft('');
48
+ setSubmitting(false);
49
+ setDeleting(false);
50
+ setConfirmDelete(false);
51
+ setError(null);
52
+ const onKey = (event: KeyboardEvent) => {
53
+ if (event.key === 'Escape') onClose();
54
+ };
55
+ window.addEventListener('keydown', onKey);
56
+ return () => window.removeEventListener('keydown', onKey);
57
+ }, [open, onClose, initial?.name, initialFoldersKey]);
58
+
59
+ if (!open) return null;
60
+ if (typeof document === 'undefined') return null;
61
+
62
+ const trimmed = name.trim();
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
+ };
73
+
74
+ const handleAddFolder = async () => {
75
+ const typed = folderDraft.trim();
76
+ if (typed) {
77
+ addSourceFolder(typed);
78
+ return;
79
+ }
80
+ const result = await getElectronBridge()?.local?.selectDirectory({
81
+ title: '添加源文件夹',
82
+ });
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]);
90
+ };
91
+
92
+ const handleSubmit = async () => {
93
+ if (!trimmed || submitting || deleting) return;
94
+ setSubmitting(true);
95
+ setError(null);
96
+ try {
97
+ await onCreate({ name: trimmed, sourceFolders });
98
+ onClose();
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : String(err));
101
+ setSubmitting(false);
102
+ }
103
+ };
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
+
122
+ return createPortal(
123
+ <div
124
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
125
+ onMouseDown={(event) => {
126
+ if (event.target === event.currentTarget) onClose();
127
+ }}
128
+ role="dialog"
129
+ aria-modal="true"
130
+ aria-labelledby={titleId}
131
+ data-testid={isEdit ? 'edit-project-dialog' : 'create-project-dialog'}
132
+ >
133
+ <div className="flex w-[440px] max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-agent-border bg-agent-canvas shadow-2xl">
134
+ <div className="flex items-center justify-between px-5 pb-1 pt-4">
135
+ <h2 id={titleId} className="text-base font-semibold text-agent-foreground">
136
+ {isEdit ? '编辑项目' : '新建项目'}
137
+ </h2>
138
+ <button
139
+ type="button"
140
+ onClick={onClose}
141
+ className="rounded-full p-1 text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground"
142
+ aria-label="关闭"
143
+ >
144
+ <LuX className="h-4 w-4" />
145
+ </button>
146
+ </div>
147
+
148
+ <div className="space-y-4 px-5 py-3">
149
+ <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">
150
+ <LuFolder className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
151
+ <input
152
+ type="text"
153
+ value={name}
154
+ onChange={(event) => setName(event.target.value)}
155
+ onKeyDown={(event) => {
156
+ if (event.key === 'Enter') void handleSubmit();
157
+ }}
158
+ placeholder="项目名称"
159
+ autoFocus
160
+ className="h-full min-w-0 flex-1 bg-transparent text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground"
161
+ data-testid={isEdit ? 'edit-project-name' : 'create-project-name'}
162
+ />
163
+ </label>
164
+ {!isEdit && (
165
+ <p className="px-1 text-[11px] text-agent-muted-foreground">
166
+ 默认位置:{homePreview}
167
+ </p>
168
+ )}
169
+
170
+ <div>
171
+ <div className="mb-2 text-sm text-agent-foreground">源文件夹</div>
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}`}
189
+ >
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>
219
+ </div>
220
+ </div>
221
+
222
+ {error && (
223
+ <p className="text-xs text-agent-destructive" role="alert">
224
+ {error}
225
+ </p>
226
+ )}
227
+ </div>
228
+
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
+ )}
246
+ <button
247
+ type="button"
248
+ onClick={onClose}
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
+ }`}
252
+ >
253
+ 取消
254
+ </button>
255
+ <button
256
+ type="button"
257
+ onClick={() => void handleSubmit()}
258
+ disabled={!trimmed || submitting || deleting}
259
+ className="h-9 rounded-full bg-agent-foreground px-4 text-sm font-medium text-agent-canvas transition-opacity disabled:opacity-40"
260
+ data-testid={isEdit ? 'edit-project-submit' : 'create-project-submit'}
261
+ >
262
+ {submitting ? (isEdit ? '保存中…' : '创建中…') : isEdit ? '保存' : '创建项目'}
263
+ </button>
264
+ </div>
265
+ </div>
266
+ </div>,
267
+ document.body,
268
+ );
269
+ }
@@ -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
  </>
@@ -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
@@ -313,13 +383,13 @@ export function ProjectPickerButton({
313
383
  title={
314
384
  selected
315
385
  ? `${selected.folderPath}\n新对话将绑定到此项目`
316
- : '为新对话选择项目(可不选)'
386
+ : '为新对话选择项目。不选则在文稿/<应用名>/conversations/ 下建立本对话工作区'
317
387
  }
318
388
  />
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
  }
@@ -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',
@@ -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
  <>
@@ -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[] }>({
@@ -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',
@@ -483,7 +489,7 @@ export async function createProject(input: { name: string; folderPath: string })
483
489
 
484
490
  export async function updateProject(
485
491
  projectId: string,
486
- updates: { name?: string; folderPath?: string },
492
+ updates: { name?: string; folderPath?: string; sourceFolders?: string[] },
487
493
  ) {
488
494
  return bridge().localBackend.request<{ success: boolean; project: LocalProject }>({
489
495
  method: 'PUT',
@@ -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
  }