@steerable/agent-shell-web 0.6.15

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.
Files changed (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,362 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import {
3
+ LuCheck,
4
+ LuChevronDown,
5
+ LuFolder,
6
+ LuFolderPen,
7
+ LuFolderPlus,
8
+ LuFolderX,
9
+ LuLoaderCircle,
10
+ } from 'react-icons/lu';
11
+ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
12
+ import {
13
+ updateChatProject,
14
+ updateProject,
15
+ type LocalProject,
16
+ } from '@/lib/local-api';
17
+
18
+ /**
19
+ * 项目徽章 / 选择器(Codex 式 cwd 指示),渲染在输入框上方的 meta 行里,
20
+ * 与专家选择器并排(项目在前)。
21
+ *
22
+ * 两个导出组件共用同一套按钮 + 上开菜单视觉:
23
+ * - ChatProjectBadge — 会话内:显示当前项目(无项目时显示"选择项目"),
24
+ * 可关联/移动到其他项目、修改项目目录、移出项目。
25
+ * - ProjectPickerButton — 落地页(还没有 chat):受控选择器,选中的
26
+ * projectId 在首次发消息建会话时一并传入。
27
+ *
28
+ * 菜单用透明全屏 backdrop 关外击(一次性菜单,比 click-outside 管线简单)。
29
+ */
30
+
31
+ /* ---------------- 共享内部件 ---------------- */
32
+
33
+ function BadgeButton({
34
+ label,
35
+ active,
36
+ open,
37
+ busy,
38
+ onClick,
39
+ title,
40
+ }: {
41
+ label: string;
42
+ /** true = 已关联/已选中项目(实心图标);false = 未选择(虚线感 + Plus 图标)。 */
43
+ active: boolean;
44
+ open: boolean;
45
+ busy: boolean;
46
+ onClick: () => void;
47
+ title?: string;
48
+ }) {
49
+ return (
50
+ <button
51
+ type="button"
52
+ onClick={onClick}
53
+ disabled={busy}
54
+ title={title}
55
+ className={`flex max-w-full items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] transition-colors disabled:opacity-60 ${
56
+ active
57
+ ? 'text-agent-muted-foreground/80 hover:bg-agent-foreground/5 hover:text-agent-foreground'
58
+ : 'border border-dashed border-agent-border text-agent-muted-foreground/70 hover:border-agent-muted-foreground/50 hover:text-agent-foreground'
59
+ }`}
60
+ >
61
+ {busy ? (
62
+ <LuLoaderCircle className="h-3 w-3 shrink-0 animate-spin" />
63
+ ) : active ? (
64
+ <LuFolder className="h-3 w-3 shrink-0" />
65
+ ) : (
66
+ <LuFolderPlus className="h-3 w-3 shrink-0" />
67
+ )}
68
+ <span className="max-w-[220px] truncate font-medium">{label}</span>
69
+ <LuChevronDown
70
+ className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
71
+ />
72
+ </button>
73
+ );
74
+ }
75
+
76
+ function MenuShell({
77
+ onClose,
78
+ children,
79
+ }: {
80
+ onClose: () => void;
81
+ children: ReactNode;
82
+ }) {
83
+ return (
84
+ <>
85
+ <div className="fixed inset-0 z-40 cursor-default" onClick={onClose} />
86
+ <div
87
+ role="menu"
88
+ className="absolute bottom-full left-0 z-50 mb-1 w-64 overflow-hidden rounded-agent-md border border-agent-border bg-agent-canvas p-1 shadow-lg"
89
+ >
90
+ {children}
91
+ </div>
92
+ </>
93
+ );
94
+ }
95
+
96
+ function MenuSectionLabel({ children }: { children: ReactNode }) {
97
+ return (
98
+ <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-agent-muted-foreground">
99
+ {children}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ function ProjectMenuRow({
105
+ project,
106
+ selected,
107
+ onClick,
108
+ }: {
109
+ project: LocalProject;
110
+ selected?: boolean;
111
+ onClick: () => void;
112
+ }) {
113
+ return (
114
+ <button
115
+ type="button"
116
+ role="menuitem"
117
+ onClick={onClick}
118
+ 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"
119
+ title={project.folderPath}
120
+ >
121
+ <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
122
+ <span className="min-w-0 flex-1 truncate">{project.name}</span>
123
+ {selected && (
124
+ <LuCheck className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
125
+ )}
126
+ </button>
127
+ );
128
+ }
129
+
130
+ /* ---------------- 会话内徽章(chat-bound) ---------------- */
131
+
132
+ export function ChatProjectBadge({
133
+ chatId,
134
+ project,
135
+ projects,
136
+ onProjectsChanged,
137
+ onChatProjectChanged,
138
+ }: {
139
+ chatId: string;
140
+ /** 当前会话绑定的项目;null = 无项目会话(徽章变为"选择项目")。 */
141
+ project: LocalProject | null;
142
+ /** 全部项目(用于关联列表)。 */
143
+ projects: LocalProject[];
144
+ /** 项目本身被修改(改目录)后回调,让父组件刷新项目列表。 */
145
+ onProjectsChanged: () => void | Promise<void>;
146
+ /** 会话归属变化后回调(通常是 refreshChats)。 */
147
+ onChatProjectChanged: () => void | Promise<void>;
148
+ }) {
149
+ const [open, setOpen] = useState(false);
150
+ const [busy, setBusy] = useState(false);
151
+ const [error, setError] = useState<string | null>(null);
152
+
153
+ const otherProjects = project
154
+ ? projects.filter((p) => p.id !== project.id)
155
+ : projects;
156
+
157
+ const run = async (action: () => Promise<void>) => {
158
+ if (busy) return;
159
+ setBusy(true);
160
+ setError(null);
161
+ try {
162
+ await action();
163
+ setOpen(false);
164
+ } catch (err) {
165
+ setError(err instanceof Error ? err.message : String(err));
166
+ } finally {
167
+ setBusy(false);
168
+ }
169
+ };
170
+
171
+ const moveTo = (projectId: string | null) =>
172
+ run(async () => {
173
+ await updateChatProject(chatId, projectId);
174
+ await onChatProjectChanged();
175
+ });
176
+
177
+ const changeFolder = () =>
178
+ run(async () => {
179
+ if (!isElectron() || !project) return;
180
+ const result = await getElectronBridge()!.local?.selectDirectory({
181
+ title: `重新选择「${project.name}」绑定的文件夹`,
182
+ });
183
+ if (!result || result.canceled || result.filePaths.length === 0) return;
184
+ await updateProject(project.id, { folderPath: result.filePaths[0] });
185
+ await onProjectsChanged();
186
+ });
187
+
188
+ return (
189
+ <div className="relative">
190
+ <BadgeButton
191
+ label={project ? project.name : '选择项目'}
192
+ active={Boolean(project)}
193
+ open={open}
194
+ busy={busy}
195
+ onClick={() => {
196
+ setError(null);
197
+ setOpen((v) => !v);
198
+ }}
199
+ title={
200
+ project
201
+ ? `${project.folderPath}\n点击管理项目归属 / 修改绑定目录`
202
+ : '把当前对话关联到一个项目(Agent 的文件操作将限制在项目目录内)'
203
+ }
204
+ />
205
+
206
+ {open && (
207
+ <MenuShell onClose={() => setOpen(false)}>
208
+ {project ? (
209
+ <>
210
+ <MenuSectionLabel>当前项目</MenuSectionLabel>
211
+ <div
212
+ className="flex items-center gap-2 rounded px-2 py-1.5 text-xs text-agent-foreground"
213
+ title={project.folderPath}
214
+ >
215
+ <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
216
+ <span className="min-w-0 flex-1 truncate font-medium">
217
+ {project.name}
218
+ </span>
219
+ <LuCheck className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
220
+ </div>
221
+ <div
222
+ className="truncate px-2 pb-1 text-[10px] font-mono text-agent-muted-foreground/70"
223
+ title={project.folderPath}
224
+ >
225
+ {project.folderPath}
226
+ </div>
227
+ </>
228
+ ) : (
229
+ <MenuSectionLabel>关联到项目</MenuSectionLabel>
230
+ )}
231
+
232
+ {otherProjects.length > 0 && (
233
+ <>
234
+ {project && (
235
+ <div className="mx-1 my-1 border-t border-agent-border/60" />
236
+ )}
237
+ {project && <MenuSectionLabel>移动到</MenuSectionLabel>}
238
+ <div className="max-h-40 overflow-y-auto">
239
+ {otherProjects.map((p) => (
240
+ <ProjectMenuRow
241
+ key={p.id}
242
+ project={p}
243
+ onClick={() => void moveTo(p.id)}
244
+ />
245
+ ))}
246
+ </div>
247
+ </>
248
+ )}
249
+ {!project && projects.length === 0 && (
250
+ <div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
251
+ 还没有项目——先在侧边栏「会话」旁点 📁+ 新建。
252
+ </div>
253
+ )}
254
+
255
+ {project && (
256
+ <>
257
+ <div className="mx-1 my-1 border-t border-agent-border/60" />
258
+ <button
259
+ type="button"
260
+ role="menuitem"
261
+ onClick={() => void changeFolder()}
262
+ 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"
263
+ >
264
+ <LuFolderPen className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
265
+ 修改项目目录…
266
+ </button>
267
+ <button
268
+ type="button"
269
+ role="menuitem"
270
+ onClick={() => void moveTo(null)}
271
+ 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"
272
+ >
273
+ <LuFolderX className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
274
+ 移出项目(变为无项目对话)
275
+ </button>
276
+ </>
277
+ )}
278
+
279
+ {error && (
280
+ <div className="mx-1 mt-1 rounded bg-agent-destructive/10 px-2 py-1 text-[11px] text-agent-destructive">
281
+ {error}
282
+ </div>
283
+ )}
284
+ </MenuShell>
285
+ )}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ /* ---------------- 落地页选择器(受控,无 chat) ---------------- */
291
+
292
+ export function ProjectPickerButton({
293
+ projects,
294
+ value,
295
+ onChange,
296
+ }: {
297
+ projects: LocalProject[];
298
+ /** 当前选中的 projectId;null = 无项目。 */
299
+ value: string | null;
300
+ onChange: (projectId: string | null) => void;
301
+ }) {
302
+ const [open, setOpen] = useState(false);
303
+ const selected = value ? (projects.find((p) => p.id === value) ?? null) : null;
304
+
305
+ return (
306
+ <div className="relative">
307
+ <BadgeButton
308
+ label={selected ? selected.name : '选择项目'}
309
+ active={Boolean(selected)}
310
+ open={open}
311
+ busy={false}
312
+ onClick={() => setOpen((v) => !v)}
313
+ title={
314
+ selected
315
+ ? `${selected.folderPath}\n新对话将绑定到此项目`
316
+ : '为新对话选择项目(可不选)'
317
+ }
318
+ />
319
+
320
+ {open && (
321
+ <MenuShell onClose={() => setOpen(false)}>
322
+ <MenuSectionLabel>新对话所属项目</MenuSectionLabel>
323
+ <button
324
+ type="button"
325
+ role="menuitem"
326
+ onClick={() => {
327
+ onChange(null);
328
+ setOpen(false);
329
+ }}
330
+ 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"
331
+ >
332
+ <LuFolderX className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
333
+ <span className="min-w-0 flex-1 truncate">不关联项目</span>
334
+ {!selected && (
335
+ <LuCheck className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
336
+ )}
337
+ </button>
338
+ <div className="max-h-40 overflow-y-auto">
339
+ {projects.map((p) => (
340
+ <ProjectMenuRow
341
+ key={p.id}
342
+ project={p}
343
+ selected={p.id === value}
344
+ onClick={() => {
345
+ onChange(p.id);
346
+ setOpen(false);
347
+ }}
348
+ />
349
+ ))}
350
+ </div>
351
+ {projects.length === 0 && (
352
+ <div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
353
+ 还没有项目——先在侧边栏「会话」旁点 📁+ 新建。
354
+ </div>
355
+ )}
356
+ </MenuShell>
357
+ )}
358
+ </div>
359
+ );
360
+ }
361
+
362
+ export default ChatProjectBadge;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * EmptyChat — empty-state component shown when a chat has no messages.
3
+ * Currently returns null to remove suggestion prompts per user request.
4
+ */
5
+
6
+ interface EmptyChatProps {
7
+ /**
8
+ * Called when the user clicks one of the prompt chips. The enclosing chat
9
+ * panel should set its input value to `prompt` and move focus there.
10
+ */
11
+ onSelectPrompt: (prompt: string) => void;
12
+ }
13
+
14
+ export function EmptyChat(_props: EmptyChatProps) {
15
+ return null;
16
+ }
17
+
18
+ export default EmptyChat;
@@ -0,0 +1,26 @@
1
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { ExecPolicyPicker } from './ExecPolicyPicker';
4
+
5
+ afterEach(() => cleanup());
6
+
7
+ describe('ExecPolicyPicker', () => {
8
+ it('shows the current policy and switches to full access', () => {
9
+ const onChange = vi.fn();
10
+ render(
11
+ <ExecPolicyPicker policy="workspace" disabled={false} onChange={onChange} />,
12
+ );
13
+
14
+ fireEvent.click(screen.getByTestId('exec-policy-picker'));
15
+ fireEvent.click(screen.getByTestId('exec-policy-full'));
16
+ expect(onChange).toHaveBeenCalledWith('full');
17
+ });
18
+
19
+ it('does not open when disabled', () => {
20
+ render(
21
+ <ExecPolicyPicker policy="workspace" disabled onChange={vi.fn()} />,
22
+ );
23
+ fireEvent.click(screen.getByTestId('exec-policy-picker'));
24
+ expect(screen.queryByTestId('exec-policy-full')).toBeNull();
25
+ });
26
+ });
@@ -0,0 +1,120 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { LuChevronDown, LuFolderLock, LuLockOpen } from 'react-icons/lu';
3
+ import type { ExecPolicy } from '@/lib/exec-policy';
4
+
5
+ /**
6
+ * ExecPolicyPicker — 输入框上的命令沙箱切换(类 Codex 底部权限档)。
7
+ *
8
+ * 切档立刻记住,下一轮才生效:当前回合的 `execSandbox` 已经随流发出。
9
+ * 工作区 = Seatbelt/bwrap 只允许写项目根;完整权限 = 不下发命令沙箱,
10
+ * 本机路径(如 Downloads)不再被 Operation not permitted 拦住。
11
+ */
12
+
13
+ const OPTIONS: Array<{
14
+ id: ExecPolicy;
15
+ label: string;
16
+ description: string;
17
+ }> = [
18
+ {
19
+ id: 'workspace',
20
+ label: '工作区',
21
+ description: '命令只能写入当前项目;项目外路径会被系统拒绝。',
22
+ },
23
+ {
24
+ id: 'full',
25
+ label: '完整权限',
26
+ description: '关闭命令沙箱,可写本机任意路径。工具审批仍然生效。',
27
+ },
28
+ ];
29
+
30
+ export function ExecPolicyPicker({
31
+ policy,
32
+ disabled,
33
+ onChange,
34
+ }: {
35
+ policy: ExecPolicy;
36
+ disabled: boolean;
37
+ onChange: (policy: ExecPolicy) => void;
38
+ }) {
39
+ const [open, setOpen] = useState(false);
40
+ const rootRef = useRef<HTMLDivElement>(null);
41
+ const current = OPTIONS.find((option) => option.id === policy) ?? OPTIONS[0];
42
+ const isFull = policy === 'full';
43
+
44
+ useEffect(() => {
45
+ if (!open) return;
46
+ const onPointerDown = (event: MouseEvent) => {
47
+ if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
48
+ setOpen(false);
49
+ }
50
+ };
51
+ document.addEventListener('mousedown', onPointerDown);
52
+ return () => document.removeEventListener('mousedown', onPointerDown);
53
+ }, [open]);
54
+
55
+ return (
56
+ <div ref={rootRef} className="relative">
57
+ <button
58
+ type="button"
59
+ disabled={disabled}
60
+ aria-haspopup="listbox"
61
+ aria-expanded={open}
62
+ aria-label="命令沙箱"
63
+ data-testid="exec-policy-picker"
64
+ title={current.description}
65
+ onClick={() => setOpen((next) => !next)}
66
+ className={[
67
+ 'inline-flex h-7 max-w-[140px] items-center gap-1 rounded-full border px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-70',
68
+ isFull
69
+ ? 'border-amber-400/50 bg-amber-400/10 text-amber-700 dark:text-amber-400'
70
+ : 'border-agent-border bg-agent-canvas text-agent-foreground hover:bg-agent-foreground/5',
71
+ ].join(' ')}
72
+ >
73
+ {isFull ? (
74
+ <LuLockOpen className="h-3.5 w-3.5 shrink-0" />
75
+ ) : (
76
+ <LuFolderLock className="h-3.5 w-3.5 shrink-0" />
77
+ )}
78
+ <span className="truncate">{current.label}</span>
79
+ <LuChevronDown className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
80
+ </button>
81
+ {open && (
82
+ <div
83
+ role="listbox"
84
+ aria-label="命令沙箱"
85
+ className="absolute bottom-full left-0 z-30 mb-1 w-64 overflow-hidden rounded-agent-md border border-agent-border bg-agent-canvas py-1 shadow-sm"
86
+ >
87
+ {OPTIONS.map((option) => {
88
+ const selected = option.id === policy;
89
+ return (
90
+ <button
91
+ key={option.id}
92
+ type="button"
93
+ role="option"
94
+ aria-selected={selected}
95
+ data-testid={`exec-policy-${option.id}`}
96
+ onClick={() => {
97
+ onChange(option.id);
98
+ setOpen(false);
99
+ }}
100
+ className={[
101
+ 'flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left transition-colors',
102
+ selected
103
+ ? 'bg-agent-foreground/10'
104
+ : 'hover:bg-agent-foreground/5',
105
+ ].join(' ')}
106
+ >
107
+ <span className="text-xs font-medium text-agent-foreground">
108
+ {option.label}
109
+ </span>
110
+ <span className="text-[11px] leading-snug text-agent-muted-foreground">
111
+ {option.description}
112
+ </span>
113
+ </button>
114
+ );
115
+ })}
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }