@steerable/agent-shell-web 0.6.31 → 0.6.33

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.
@@ -1,56 +1,98 @@
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';
41
65
 
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];
66
+ const addSourceFolder = (folder: string) => {
67
+ const next = folder.trim();
68
+ if (!next) return;
49
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 picker = getElectronBridge()?.local?.selectDirectory;
81
+ if (!picker) {
82
+ setError('请输入文件夹路径');
83
+ return;
84
+ }
85
+ try {
86
+ const result = await picker({ title: '添加源文件夹' });
87
+ if (!result || result.canceled || result.filePaths.length === 0) return;
88
+ addSourceFolder(result.filePaths[0]);
89
+ } catch (err) {
90
+ setError(err instanceof Error ? err.message : String(err));
91
+ }
50
92
  };
51
93
 
52
94
  const handleSubmit = async () => {
53
- if (!trimmed || submitting) return;
95
+ if (!trimmed || submitting || deleting) return;
54
96
  setSubmitting(true);
55
97
  setError(null);
56
98
  try {
@@ -62,6 +104,23 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
62
104
  }
63
105
  };
64
106
 
107
+ const handleDelete = async () => {
108
+ if (!onDelete || deleting || submitting) return;
109
+ if (!confirmDelete) {
110
+ setConfirmDelete(true);
111
+ return;
112
+ }
113
+ setDeleting(true);
114
+ setError(null);
115
+ try {
116
+ await onDelete();
117
+ onClose();
118
+ } catch (err) {
119
+ setError(err instanceof Error ? err.message : String(err));
120
+ setDeleting(false);
121
+ }
122
+ };
123
+
65
124
  return createPortal(
66
125
  <div
67
126
  className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@@ -70,13 +129,13 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
70
129
  }}
71
130
  role="dialog"
72
131
  aria-modal="true"
73
- aria-labelledby="create-project-title"
74
- data-testid="create-project-dialog"
132
+ aria-labelledby={titleId}
133
+ data-testid={isEdit ? 'edit-project-dialog' : 'create-project-dialog'}
75
134
  >
76
135
  <div className="flex w-[440px] max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-agent-border bg-agent-canvas shadow-2xl">
77
136
  <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
- 新建项目
137
+ <h2 id={titleId} className="text-base font-semibold text-agent-foreground">
138
+ {isEdit ? '编辑项目' : '新建项目'}
80
139
  </h2>
81
140
  <button
82
141
  type="button"
@@ -101,52 +160,64 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
101
160
  placeholder="项目名称"
102
161
  autoFocus
103
162
  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"
163
+ data-testid={isEdit ? 'edit-project-name' : 'create-project-name'}
105
164
  />
106
165
  </label>
107
- <p className="px-1 text-[11px] text-agent-muted-foreground">
108
- 默认位置:{homePreview}
109
- </p>
166
+ {!isEdit && (
167
+ <p className="px-1 text-[11px] text-agent-muted-foreground">
168
+ 默认位置:{homePreview}
169
+ </p>
170
+ )}
110
171
 
111
172
  <div>
112
173
  <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"
174
+ <div className="overflow-hidden rounded-2xl border border-agent-border">
175
+ {sourceFolders.map((folder) => (
176
+ <div
177
+ key={folder}
178
+ className="flex items-center gap-2 border-b border-agent-border/60 px-3 py-2 text-xs text-agent-foreground"
179
+ >
180
+ <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
181
+ <span className="min-w-0 flex-1 truncate font-mono" title={folder}>
182
+ {folder}
183
+ </span>
184
+ <button
185
+ type="button"
186
+ onClick={() =>
187
+ setSourceFolders((prev) => prev.filter((item) => item !== folder))
188
+ }
189
+ className="rounded-full p-0.5 text-agent-muted-foreground hover:text-agent-foreground"
190
+ aria-label={`移除 ${folder}`}
119
191
  >
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>
192
+ <LuX className="h-3.5 w-3.5" />
193
+ </button>
194
+ </div>
195
+ ))}
196
+ <div className="flex items-center gap-2 px-3 py-2">
197
+ <LuFolderPlus className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
198
+ <input
199
+ type="text"
200
+ value={folderDraft}
201
+ onChange={(event) => setFolderDraft(event.target.value)}
202
+ onKeyDown={(event) => {
203
+ if (event.key === 'Enter') {
204
+ event.preventDefault();
205
+ void handleAddFolder();
206
+ }
207
+ }}
208
+ placeholder="输入路径或点添加浏览"
209
+ className="h-7 min-w-0 flex-1 bg-transparent text-xs text-agent-foreground outline-none placeholder:text-agent-muted-foreground"
210
+ data-testid={isEdit ? 'edit-project-folder-path' : 'create-project-folder-path'}
211
+ />
212
+ <button
213
+ type="button"
214
+ onClick={() => void handleAddFolder()}
215
+ className="shrink-0 rounded-full px-2 py-1 text-xs font-medium text-agent-foreground hover:bg-agent-muted"
216
+ data-testid={isEdit ? 'edit-project-add-folder' : 'create-project-add-folder'}
217
+ >
218
+ 添加
219
+ </button>
220
+ </div>
150
221
  </div>
151
222
  </div>
152
223
 
@@ -157,22 +228,40 @@ export function CreateProjectModal({ open, onClose, onCreate }: CreateProjectMod
157
228
  )}
158
229
  </div>
159
230
 
160
- <div className="flex items-center justify-end gap-2 px-5 pb-4 pt-1">
231
+ <div className="flex items-center gap-2 px-5 pb-4 pt-1">
232
+ {onDelete && (
233
+ <button
234
+ type="button"
235
+ onClick={() => void handleDelete()}
236
+ disabled={deleting || submitting}
237
+ title={
238
+ confirmDelete
239
+ ? '再次点击确认删除(会话会保留为无项目对话)'
240
+ : '删除项目(会话保留为无项目对话)'
241
+ }
242
+ className="mr-auto h-9 rounded-full px-3 text-sm text-agent-destructive transition-colors hover:bg-agent-destructive/10 disabled:opacity-40"
243
+ data-testid="edit-project-delete"
244
+ >
245
+ {confirmDelete ? '再次点击确认删除' : '删除本地项目'}
246
+ </button>
247
+ )}
161
248
  <button
162
249
  type="button"
163
250
  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"
251
+ className={`h-9 rounded-full px-4 text-sm text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground ${
252
+ onDelete ? '' : 'ml-auto'
253
+ }`}
165
254
  >
166
255
  取消
167
256
  </button>
168
257
  <button
169
258
  type="button"
170
259
  onClick={() => void handleSubmit()}
171
- disabled={!trimmed || submitting}
260
+ disabled={!trimmed || submitting || deleting}
172
261
  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"
262
+ data-testid={isEdit ? 'edit-project-submit' : 'create-project-submit'}
174
263
  >
175
- {submitting ? '创建中…' : '创建项目'}
264
+ {submitting ? (isEdit ? '保存中…' : '创建中…') : isEdit ? '保存' : '创建项目'}
176
265
  </button>
177
266
  </div>
178
267
  </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
  </>
@@ -32,6 +32,7 @@ import {
32
32
  } from 'react-icons/lu';
33
33
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
34
34
  import type { ExecPolicy } from '@/lib/exec-policy';
35
+ import { getWebChatModes, hostToolChrome, settingsChrome } from '@/lib/host-tools';
35
36
  import type { AttachmentFile } from '@/lib/attachments';
36
37
  import type { SteerOutcome } from '@steerable/agent-ui';
37
38
  import {
@@ -1112,8 +1113,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1112
1113
  const trimmed = value.trim();
1113
1114
  const canSend = (trimmed.length > 0 || actualFiles.length > 0) && !disabled && !isStreaming;
1114
1115
 
1116
+ const allowFileAttach = hostToolChrome('local-fs');
1117
+
1115
1118
  const handleDragOver = (event: React.DragEvent) => {
1116
1119
  event.preventDefault();
1120
+ if (!allowFileAttach) return;
1117
1121
  if (event.dataTransfer.types.includes('Files') && !disabled) {
1118
1122
  setIsDragging(true);
1119
1123
  }
@@ -1143,7 +1147,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1143
1147
  // 按钮点击 → 触发隐藏的 <input type="file" multiple>,由浏览器/Electron
1144
1148
  // 直接弹出系统文件选择框(不依赖 IPC,避免「点击无反应」)。
1145
1149
  const handlePickFiles = () => {
1146
- if (disabled) return;
1150
+ if (disabled || !allowFileAttach) return;
1147
1151
  fileInputRef.current?.click();
1148
1152
  };
1149
1153
  const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -1158,7 +1162,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1158
1162
  event.preventDefault();
1159
1163
  setIsDragging(false);
1160
1164
 
1161
- if (disabled) return;
1165
+ if (disabled || !allowFileAttach) return;
1162
1166
 
1163
1167
  const droppedFiles = Array.from(event.dataTransfer.files);
1164
1168
  if (droppedFiles.length > 0) {
@@ -1816,7 +1820,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1816
1820
  Agent picker lives in the meta row above the box, next to
1817
1821
  the project badge. min-w-0 允许窄屏时胶囊截断收缩。 */}
1818
1822
  <div ref={toolbarLeftRef} className="flex min-w-0 items-center gap-1">
1819
- {mode && onModeChange && (
1823
+ {mode && onModeChange && getWebChatModes().length > 1 && (
1820
1824
  <ModeToggle
1821
1825
  mode={mode}
1822
1826
  disabled={disabled || isStreaming}
@@ -1831,6 +1835,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1831
1835
  />
1832
1836
  )}
1833
1837
  {toolbarExtras}
1838
+ {allowFileAttach && (
1839
+ <>
1834
1840
  <button
1835
1841
  type="button"
1836
1842
  onClick={handlePickFiles}
@@ -1851,6 +1857,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1851
1857
  tabIndex={-1}
1852
1858
  onChange={handleFileInputChange}
1853
1859
  />
1860
+ </>
1861
+ )}
1854
1862
  {onOpenSettings && (
1855
1863
  <button
1856
1864
  type="button"
@@ -2142,6 +2150,7 @@ function AgentSelect({
2142
2150
  </button>
2143
2151
  );
2144
2152
  })}
2153
+ {settingsChrome('agents') && (
2145
2154
  <button
2146
2155
  type="button"
2147
2156
  role="menuitem"
@@ -2155,6 +2164,7 @@ function AgentSelect({
2155
2164
  <LuBot className="h-3.5 w-3.5 shrink-0" />
2156
2165
  <span className="text-xs font-medium">管理智能体</span>
2157
2166
  </button>
2167
+ )}
2158
2168
  </div>
2159
2169
  )}
2160
2170
  </div>
@@ -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',
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import { openLocalPath, type ResolvedLocalPath } from '@/lib/local-api';
3
+ import { hostToolChrome } from '@/lib/host-tools';
3
4
  import { peekResolvedPath, subscribeResolvedPath } from './path-mentions';
4
5
  import { splitTurnFilePath } from './turn-files';
5
6
 
@@ -27,12 +28,15 @@ export function FilePathCode({ candidate, chatId, children, ...rest }: FilePathC
27
28
  );
28
29
  const [openError, setOpenError] = useState<string | null>(null);
29
30
 
31
+ const allowOpenPath = hostToolChrome('local-fs');
32
+
30
33
  useEffect(() => {
31
34
  setOpenError(null);
35
+ if (!allowOpenPath) return;
32
36
  return subscribeResolvedPath(candidate, chatId, (entry) => setResolved(entry));
33
- }, [candidate, chatId]);
37
+ }, [allowOpenPath, candidate, chatId]);
34
38
 
35
- if (!resolved) {
39
+ if (!resolved || !allowOpenPath) {
36
40
  return (
37
41
  <code {...rest} className={INLINE_CODE_CLASS}>
38
42
  {children}
@@ -6,6 +6,7 @@ import {
6
6
  LuLoaderCircle,
7
7
  } from 'react-icons/lu';
8
8
  import { openLocalPath } from '@/lib/local-api';
9
+ import { hostToolChrome } from '@/lib/host-tools';
9
10
  import {
10
11
  formatFileSize,
11
12
  splitTurnFilePath,
@@ -30,10 +31,12 @@ interface TurnFilesCardProps {
30
31
  export function TurnFilesCard({ files }: TurnFilesCardProps) {
31
32
  const [openingPath, setOpeningPath] = useState<string | null>(null);
32
33
  const [openError, setOpenError] = useState<{ path: string; message: string } | null>(null);
34
+ const allowOpenPath = hostToolChrome('local-fs');
33
35
 
34
36
  if (files.length === 0) return null;
35
37
 
36
38
  const handleOpen = async (file: TurnFile) => {
39
+ if (!allowOpenPath) return;
37
40
  if (openingPath) return;
38
41
  setOpeningPath(file.path);
39
42
  setOpenError(null);
@@ -59,7 +62,11 @@ export function TurnFilesCard({ files }: TurnFilesCardProps) {
59
62
  >
60
63
  <div className="flex items-center gap-1.5 border-b border-agent-border/60 px-2.5 py-1 text-[11px] text-agent-muted-foreground">
61
64
  <LuFiles className="h-3.5 w-3.5" />
62
- <span>本轮产生了 {files.length} 个文件,点击打开</span>
65
+ <span>
66
+ {allowOpenPath
67
+ ? `本轮产生了 ${files.length} 个文件,点击打开`
68
+ : `本轮产生了 ${files.length} 个文件`}
69
+ </span>
63
70
  </div>
64
71
  <ul className="max-h-64 overflow-y-auto py-1">
65
72
  {files.map((file) => {
@@ -72,9 +79,13 @@ export function TurnFilesCard({ files }: TurnFilesCardProps) {
72
79
  <button
73
80
  type="button"
74
81
  onClick={() => void handleOpen(file)}
75
- disabled={openingPath !== null}
82
+ disabled={!allowOpenPath || openingPath !== null}
76
83
  title={file.path}
77
- className="flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors hover:bg-agent-foreground/5 disabled:cursor-wait"
84
+ className={`flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors ${
85
+ allowOpenPath
86
+ ? 'hover:bg-agent-foreground/5 disabled:cursor-wait'
87
+ : 'cursor-default'
88
+ }`}
78
89
  data-turn-file=""
79
90
  data-kind={file.kind}
80
91
  >
@@ -15,6 +15,7 @@ import {
15
15
  useChatsAndAgents,
16
16
  type UseChatsAndAgentsResult,
17
17
  } from '@/hooks/useChatsAndAgents';
18
+ import { hostToolChrome, sanitizeRightPanelKind, settingsChrome } from '@/lib/host-tools';
18
19
 
19
20
  /**
20
21
  * AgentLayout wraps `/agent`, `/agent/:chatId`, and `/` (default) with a
@@ -272,14 +273,16 @@ function AgentLayoutContent() {
272
273
  try {
273
274
  const saved = window.localStorage.getItem(RIGHT_PANEL_KEY);
274
275
  if (saved !== null) {
275
- return saved === 'terminal' || packChatSlots.some((s) => s.slotId === saved)
276
- ? saved
277
- : null;
276
+ const raw =
277
+ saved === 'terminal' || packChatSlots.some((s) => s.slotId === saved)
278
+ ? saved
279
+ : null;
280
+ return sanitizeRightPanelKind(raw);
278
281
  }
279
282
  // 新 key 不存在时才迁移老版本仅持久化终端的旧 key。
280
- return window.localStorage.getItem(TERMINAL_OPEN_KEY) === '1'
281
- ? 'terminal'
282
- : null;
283
+ return sanitizeRightPanelKind(
284
+ window.localStorage.getItem(TERMINAL_OPEN_KEY) === '1' ? 'terminal' : null,
285
+ );
283
286
  } catch {
284
287
  return null;
285
288
  }
@@ -311,7 +314,8 @@ function AgentLayoutContent() {
311
314
  打开任一面板即离开任务 dock(栏位同一时刻只呈现一个内容)。 */
312
315
  const toggleRightPanel = useCallback(
313
316
  (kind: string) => {
314
- const next: RightPanelState = rightPanelRef.current === kind ? null : kind;
317
+ const raw: RightPanelState = rightPanelRef.current === kind ? null : kind;
318
+ const next = sanitizeRightPanelKind(raw);
315
319
  rightPanelRef.current = next;
316
320
  if (next !== null) setInspectedTask(null);
317
321
  setRightPanelState(next);
@@ -363,6 +367,7 @@ function AgentLayoutContent() {
363
367
  }, []);
364
368
 
365
369
  const showTerminalFromTask = useCallback(() => {
370
+ if (!hostToolChrome('terminal')) return;
366
371
  setInspectedTask(null);
367
372
  rightPanelRef.current = 'terminal';
368
373
  setRightPanelState('terminal');
@@ -499,7 +504,7 @@ function AgentLayoutContent() {
499
504
  } satisfies AgentOutletContext}
500
505
  />
501
506
  </div>
502
- <InsightsConsentBanner />
507
+ {settingsChrome('insights') && <InsightsConsentBanner />}
503
508
  </div>
504
509
  {(rightPanel !== null || inspectedTask !== null) && (
505
510
  <>
@@ -523,7 +528,9 @@ function AgentLayoutContent() {
523
528
  <TaskProcessPanel
524
529
  inspected={inspectedTask}
525
530
  onClose={closeTaskProcess}
526
- onShowTerminal={showTerminalFromTask}
531
+ onShowTerminal={
532
+ hostToolChrome('terminal') ? showTerminalFromTask : undefined
533
+ }
527
534
  />
528
535
  ) : rightPanel === 'terminal' ? (
529
536
  <TerminalPanel
@@ -2,10 +2,12 @@
2
2
  * 会话级命令执行沙箱(类 Codex 的 sandbox_mode)。
3
3
  *
4
4
  * 作用于下一轮 `chat.stream` 的 `execSandbox`,不是 sidecar 进程沙箱
5
- * (设置页那一层)。工作区 = 只能写入当前项目;完整权限 = 关闭逐条命令
6
- * 沙箱。工具审批(允许/拒绝)仍按原策略生效。
5
+ * (设置页那一层)。工作区 = 只能写入当前项目或本对话工作区;完整权限
6
+ * = 关闭逐条命令沙箱。工具审批(允许/拒绝)仍按原策略生效。
7
7
  */
8
8
 
9
+ import { hostToolChrome } from './host-tools';
10
+
9
11
  export type ExecPolicy = 'workspace' | 'full';
10
12
 
11
13
  export const EXEC_POLICY_STORAGE_KEY = 'agent-exec-policy';
@@ -16,6 +18,7 @@ export function parseExecPolicy(value: unknown): ExecPolicy {
16
18
 
17
19
  export function readStoredExecPolicy(): ExecPolicy {
18
20
  if (typeof localStorage === 'undefined') return 'workspace';
21
+ if (!hostToolChrome('local-fs')) return 'workspace';
19
22
  return parseExecPolicy(localStorage.getItem(EXEC_POLICY_STORAGE_KEY));
20
23
  }
21
24