@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,228 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Terminal } from '@xterm/xterm';
3
+ import { FitAddon } from '@xterm/addon-fit';
4
+ import { WebLinksAddon } from '@xterm/addon-web-links';
5
+ import { LuListTodo } from 'react-icons/lu';
6
+ import '@xterm/xterm/css/xterm.css';
7
+ import { DockHeaderButton, DockPanelHeader } from '@/components/DockPanelHeader';
8
+ import { getElectronBridge } from '@/lib/electron-bridge';
9
+ import { BRAND_NAME } from '@/brand';
10
+
11
+ /**
12
+ * Renders an xterm.js terminal wired up to the main-process PTY via the
13
+ * `terminal.*` bridge. Ported from `deeppath/apps/web/src/app/agent/terminal/
14
+ * TerminalView.tsx`, with these adaptations:
15
+ *
16
+ * - `window.electron` direct access → `getElectronBridge()` helper so the
17
+ * "no bridge" path is type-safe.
18
+ * - Removed `'use client'` directive (not a Next.js project).
19
+ * - Container is `h-full` instead of `h-screen` so the component is
20
+ * embeddable in any parent — currently the embedded `TerminalPanel`
21
+ * inside AgentLayout's split view.
22
+ * - Optional `onClose` renders a header close button (panel chrome).
23
+ * - Header chrome comes from the shared `DockPanelHeader`, so switching
24
+ * between terminal and 后台推理 doesn't shift the title bar.
25
+ *
26
+ * No business logic changes: same xterm config, same Cmd+C/V handling,
27
+ * same context-menu copy-paste, same resize observer.
28
+ */
29
+ export function TerminalView({
30
+ onClose,
31
+ onShowTaskProcess,
32
+ taskProcessTitle,
33
+ }: {
34
+ onClose?: () => void;
35
+ /** 缺省时不渲染「后台任务」按钮(本次会话还没看过任何任务)。 */
36
+ onShowTaskProcess?: () => void;
37
+ /** 记住的那个任务的标题,只用于按钮 tooltip。 */
38
+ taskProcessTitle?: string;
39
+ }) {
40
+ const containerRef = useRef<HTMLDivElement | null>(null);
41
+ const sessionIdRef = useRef<string | null>(null);
42
+ const [status, setStatus] = useState<'starting' | 'ready' | 'error'>('starting');
43
+ const [statusMsg, setStatusMsg] = useState<string>('');
44
+
45
+ useEffect(() => {
46
+ if (!containerRef.current) return;
47
+ const bridge = getElectronBridge();
48
+ const terminal = bridge?.terminal;
49
+ if (!terminal) {
50
+ setStatus('error');
51
+ setStatusMsg('window.electron.terminal 不可用');
52
+ return;
53
+ }
54
+
55
+ const term = new Terminal({
56
+ fontFamily:
57
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
58
+ fontSize: 13,
59
+ lineHeight: 1.2,
60
+ cursorBlink: true,
61
+ cursorStyle: 'bar',
62
+ convertEol: true,
63
+ scrollback: 5000,
64
+ // macOS 上让 Option 当 Meta(习惯用 alt+f/b 在词间跳转)
65
+ macOptionIsMeta: true,
66
+ // 鼠标右键弹系统菜单时会偷走 selection,关掉
67
+ rightClickSelectsWord: true,
68
+ theme: {
69
+ background: '#0b0b0c',
70
+ foreground: '#e6e6e6',
71
+ cursor: '#e6e6e6',
72
+ selectionBackground: '#3a3d41',
73
+ },
74
+ allowProposedApi: true,
75
+ });
76
+ const fit = new FitAddon();
77
+ term.loadAddon(fit);
78
+ term.loadAddon(new WebLinksAddon());
79
+ term.open(containerRef.current);
80
+ fit.fit();
81
+
82
+ // xterm 渲染在 canvas 上,应用菜单的 copy/paste role 抓不到选区。自己拦截:
83
+ // Cmd/Ctrl+C → 有选区就复制,否则放行(让 xterm 走默认 ^C 发 SIGINT)
84
+ // Cmd+V / Ctrl+Shift+V → 从剪贴板读出来 paste 到 PTY
85
+ // Cmd/Ctrl+A → 全选
86
+ const isMac = navigator.platform.toUpperCase().includes('MAC');
87
+ term.attachCustomKeyEventHandler((event) => {
88
+ if (event.type !== 'keydown') return true;
89
+ const mod = isMac ? event.metaKey : event.ctrlKey;
90
+ const key = event.key.toLowerCase();
91
+
92
+ if (mod && key === 'c') {
93
+ const sel = term.getSelection();
94
+ if (sel) {
95
+ void navigator.clipboard.writeText(sel).catch(() => {});
96
+ term.clearSelection();
97
+ return false;
98
+ }
99
+ return true;
100
+ }
101
+ if (
102
+ (isMac && mod && key === 'v') ||
103
+ (!isMac && event.ctrlKey && event.shiftKey && key === 'v')
104
+ ) {
105
+ void navigator.clipboard
106
+ .readText()
107
+ .then((text) => {
108
+ if (text) term.paste(text);
109
+ })
110
+ .catch(() => {});
111
+ return false;
112
+ }
113
+ if (mod && key === 'a' && !event.shiftKey) {
114
+ term.selectAll();
115
+ return false;
116
+ }
117
+ return true;
118
+ });
119
+
120
+ const onContextMenu = (e: MouseEvent) => {
121
+ e.preventDefault();
122
+ const sel = term.getSelection();
123
+ if (sel) {
124
+ void navigator.clipboard.writeText(sel).catch(() => {});
125
+ term.clearSelection();
126
+ } else {
127
+ void navigator.clipboard
128
+ .readText()
129
+ .then((text) => {
130
+ if (text) term.paste(text);
131
+ })
132
+ .catch(() => {});
133
+ }
134
+ };
135
+ containerRef.current.addEventListener('contextmenu', onContextMenu);
136
+
137
+ let cancelled = false;
138
+ const offData = terminal.onData(({ sessionId, chunk }) => {
139
+ if (sessionIdRef.current && sessionId === sessionIdRef.current) {
140
+ term.write(chunk);
141
+ }
142
+ });
143
+ const offExit = terminal.onExit(({ sessionId, code }) => {
144
+ if (sessionIdRef.current === sessionId) {
145
+ term.writeln(`\r\n\x1b[33m[process exited with code ${code}]\x1b[0m`);
146
+ }
147
+ });
148
+
149
+ (async () => {
150
+ try {
151
+ const dims = fit.proposeDimensions();
152
+ const session = await terminal.ensure({
153
+ cols: dims?.cols,
154
+ rows: dims?.rows,
155
+ });
156
+ if (cancelled) return;
157
+ sessionIdRef.current = session.id;
158
+ setStatus('ready');
159
+ term.writeln(
160
+ `\x1b[90m[agent-shell] ${session.shell} pid=${session.pid} cwd=${session.cwd}\x1b[0m`,
161
+ );
162
+ } catch (err) {
163
+ setStatus('error');
164
+ setStatusMsg(err instanceof Error ? err.message : String(err));
165
+ }
166
+ })();
167
+
168
+ const inputDisposable = term.onData((data) => {
169
+ if (sessionIdRef.current) {
170
+ void terminal.write(sessionIdRef.current, data);
171
+ }
172
+ });
173
+
174
+ const handleResize = () => {
175
+ try {
176
+ fit.fit();
177
+ if (sessionIdRef.current) {
178
+ void terminal.resize(sessionIdRef.current, term.cols, term.rows);
179
+ }
180
+ } catch {
181
+ // ignore
182
+ }
183
+ };
184
+ window.addEventListener('resize', handleResize);
185
+ const ro = new ResizeObserver(handleResize);
186
+ ro.observe(containerRef.current);
187
+
188
+ const containerEl = containerRef.current;
189
+ return () => {
190
+ cancelled = true;
191
+ window.removeEventListener('resize', handleResize);
192
+ containerEl?.removeEventListener('contextmenu', onContextMenu);
193
+ ro.disconnect();
194
+ offData?.();
195
+ offExit?.();
196
+ inputDisposable.dispose();
197
+ term.dispose();
198
+ };
199
+ }, []);
200
+
201
+ const statusLabel =
202
+ status === 'starting' ? '启动中…' : status === 'ready' ? '在线' : `错误:${statusMsg}`;
203
+
204
+ return (
205
+ <div className="flex h-full w-full flex-col bg-[#0b0b0c]">
206
+ <DockPanelHeader
207
+ title={`${BRAND_NAME} · 终端 · ${statusLabel}`}
208
+ onClose={onClose}
209
+ closeLabel="关闭终端面板"
210
+ actions={
211
+ onShowTaskProcess && (
212
+ <DockHeaderButton
213
+ icon={<LuListTodo className="h-3 w-3" />}
214
+ label="后台任务"
215
+ title={
216
+ taskProcessTitle
217
+ ? `切换到后台推理:${taskProcessTitle}`
218
+ : '切换到后台推理'
219
+ }
220
+ onClick={onShowTaskProcess}
221
+ />
222
+ )
223
+ }
224
+ />
225
+ <div ref={containerRef} className="flex-1 overflow-hidden p-2" />
226
+ </div>
227
+ );
228
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ApprovalModalHost 的网络出口(network_egress)分支契约(W-egress-ask):
3
+ * - category=network_egress 的请示以「请求访问外网」标题 + host:port
4
+ * 展示,并带仿冒域名警示;
5
+ * - 「始终」变体对网络出口隐藏(代理白名单是会话寿命的进程级状态,
6
+ * 持久放行要走设置的出网白名单,不是这个模态);
7
+ * - 普通工具调用的 7 变体行为不回归。
8
+ */
9
+ import { act, cleanup, render, screen } from '@testing-library/react';
10
+ import { afterEach, describe, expect, it, vi } from 'vitest';
11
+ import type { ApprovalPromptRequest } from '@/lib/electron-bridge';
12
+ import { ApprovalModalHost } from './ApprovalModal';
13
+
14
+ afterEach(() => {
15
+ cleanup();
16
+ delete (window as { electron?: unknown }).electron;
17
+ });
18
+
19
+ function installBridge() {
20
+ const listeners = new Set<(request: ApprovalPromptRequest) => void>();
21
+ const decide = vi.fn().mockResolvedValue(undefined);
22
+ (window as { electron?: unknown }).electron = {
23
+ approval: {
24
+ onRequest: (callback: (request: ApprovalPromptRequest) => void) => {
25
+ listeners.add(callback);
26
+ return () => listeners.delete(callback);
27
+ },
28
+ decide,
29
+ },
30
+ };
31
+ return {
32
+ decide,
33
+ emit: (request: ApprovalPromptRequest) => {
34
+ for (const callback of Array.from(listeners)) callback(request);
35
+ },
36
+ };
37
+ }
38
+
39
+ const EGRESS_REQUEST: ApprovalPromptRequest = {
40
+ requestId: 'req-egress',
41
+ toolName: 'network_egress',
42
+ arguments: { host: 'cdn.example.com', port: 443, url: 'https://cdn.example.com/x.js' },
43
+ mode: 'other',
44
+ category: 'network_egress',
45
+ round: 0,
46
+ };
47
+
48
+ const SHELL_REQUEST: ApprovalPromptRequest = {
49
+ requestId: 'req-shell',
50
+ toolName: 'bash',
51
+ arguments: { command: 'rm -rf /tmp/x' },
52
+ mode: 'destructive',
53
+ category: 'bash',
54
+ round: 0,
55
+ };
56
+
57
+ describe('ApprovalModalHost 网络出口分支(W-egress-ask)', () => {
58
+ it('以 host:port 标题 + 仿冒警示展示,并隐藏「始终」变体', () => {
59
+ const bridge = installBridge();
60
+ render(<ApprovalModalHost />);
61
+ act(() => bridge.emit(EGRESS_REQUEST));
62
+
63
+ expect(screen.getByText('Agent 请求访问外网')).toBeTruthy();
64
+ expect(screen.getByText('cdn.example.com:443')).toBeTruthy();
65
+ expect(screen.getByText(/仿冒域名/)).toBeTruthy();
66
+ // 会话档保留,「始终」档隐藏。
67
+ expect(screen.getByText('允许一次')).toBeTruthy();
68
+ expect(screen.getByText('本次会话允许')).toBeTruthy();
69
+ expect(screen.queryByText('始终允许')).toBeNull();
70
+ expect(screen.queryByText('始终拒绝')).toBeNull();
71
+ });
72
+
73
+ it('普通工具调用的 7 变体不回归', () => {
74
+ const bridge = installBridge();
75
+ render(<ApprovalModalHost />);
76
+ act(() => bridge.emit(SHELL_REQUEST));
77
+
78
+ expect(screen.getByText('Agent 请求执行')).toBeTruthy();
79
+ expect(screen.getByText('始终允许')).toBeTruthy();
80
+ expect(screen.getByText('始终拒绝')).toBeTruthy();
81
+ expect(screen.getByText(/工作区沙箱/)).toBeTruthy();
82
+ });
83
+ });
@@ -0,0 +1,229 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { LuShieldAlert, LuTriangleAlert } from 'react-icons/lu';
3
+ import {
4
+ getElectronBridge,
5
+ type ApprovalDecisionKind,
6
+ type ApprovalPromptRequest,
7
+ } from '@/lib/electron-bridge';
8
+
9
+ /**
10
+ * ApprovalModalHost — W4-1 审批代数的 Electron UI 半侧。
11
+ *
12
+ * 挂载一次(AgentPage),订阅主进程广播的 `approval:request`,弹出模态
13
+ * 让用户对工具调用做 7 变体决策(允许/拒绝 × 一次性/会话/持久 + 中止)。
14
+ * 决定经 `approval:decide` IPC 回到主进程,再应答 sidecar 的反向调用。
15
+ *
16
+ * 持久化语义(与框架 ApprovalExecutor 对齐):
17
+ * - 一次性:只作用于本次调用,不缓存。
18
+ * - 会话:按 category(默认=工具名)缓存在本 chat 的会话级 cache。
19
+ * - 持久:写入 ~/.steerable/approvals.json,跨会话生效。
20
+ *
21
+ * 队列为 FIFO:模型一批并发多个工具调用时逐个请示(sidecar 串行等待
22
+ * 每个应答)。组件卸载/无窗口时主进程 fail-closed 为 deny_once。
23
+ */
24
+
25
+ const MODE_LABEL: Record<string, string> = {
26
+ read: '只读',
27
+ safe_write: '写入',
28
+ destructive: '危险',
29
+ other: '其他',
30
+ };
31
+
32
+ function summarizeArguments(args: Record<string, unknown>): string {
33
+ // 'url' 必须在前列:批准一次 web_fetch 就是批准一次出网请求,
34
+ // 用户必须看清目标地址(W5-2)。
35
+ for (const key of ['url', 'command', 'cmd', 'path', 'file', 'target', 'query', 'content']) {
36
+ const v = args[key];
37
+ if (typeof v === 'string' && v.length > 0) {
38
+ return v.length > 300 ? `${v.slice(0, 300)}…` : v;
39
+ }
40
+ }
41
+ const json = JSON.stringify(args);
42
+ return json.length > 300 ? `${json.slice(0, 300)}…` : json;
43
+ }
44
+
45
+ interface DecisionButton {
46
+ kind: ApprovalDecisionKind;
47
+ label: string;
48
+ tone: 'allow' | 'deny' | 'abort';
49
+ }
50
+
51
+ const ALLOW_BUTTONS: DecisionButton[] = [
52
+ { kind: 'allow_once', label: '允许一次', tone: 'allow' },
53
+ { kind: 'allow_for_session', label: '本次会话允许', tone: 'allow' },
54
+ { kind: 'allow_always', label: '始终允许', tone: 'allow' },
55
+ ];
56
+
57
+ const DENY_BUTTONS: DecisionButton[] = [
58
+ { kind: 'deny_once', label: '拒绝一次', tone: 'deny' },
59
+ { kind: 'deny_for_session', label: '本次会话拒绝', tone: 'deny' },
60
+ { kind: 'deny_always', label: '始终拒绝', tone: 'deny' },
61
+ ];
62
+
63
+ function DecisionRow({
64
+ buttons,
65
+ onPick,
66
+ }: {
67
+ buttons: DecisionButton[];
68
+ onPick: (kind: ApprovalDecisionKind) => void;
69
+ }) {
70
+ return (
71
+ <div className="flex flex-wrap gap-2">
72
+ {buttons.map((b) => (
73
+ <button
74
+ key={b.kind}
75
+ type="button"
76
+ data-testid={`approval-${b.kind}`}
77
+ onClick={() => onPick(b.kind)}
78
+ className={
79
+ b.tone === 'allow'
80
+ ? 'rounded-agent-md bg-agent-foreground px-3 py-1.5 text-xs font-medium text-agent-canvas transition hover:opacity-90'
81
+ : 'rounded-agent-md border border-agent-border px-3 py-1.5 text-xs font-medium text-agent-foreground transition hover:bg-agent-muted'
82
+ }
83
+ >
84
+ {b.label}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ );
89
+ }
90
+
91
+ export function ApprovalModalHost() {
92
+ const [queue, setQueue] = useState<ApprovalPromptRequest[]>([]);
93
+
94
+ useEffect(() => {
95
+ const bridge = getElectronBridge();
96
+ if (!bridge?.approval) return;
97
+ return bridge.approval.onRequest((request) => {
98
+ setQueue((prev) => [...prev, request]);
99
+ });
100
+ }, []);
101
+
102
+ const current = queue[0] ?? null;
103
+ if (!current) return null;
104
+
105
+ const decide = (kind: ApprovalDecisionKind) => {
106
+ const bridge = getElectronBridge();
107
+ setQueue((prev) => prev.slice(1));
108
+ void bridge?.approval?.decide({ requestId: current.requestId, kind });
109
+ };
110
+
111
+ const destructive = current.mode === 'destructive';
112
+ // 网络出口拓宽(W-egress-ask):sidecar 的 web 工具被 egress 代理 403
113
+ // 后发起。白名单是代理进程级的(会话寿命),所以「始终」变体对网络
114
+ // 出口没有意义——只给一次性/会话两档,且默认焦点在拒绝(安全默认:
115
+ // 用户能认出 api.github.com,认不出 cdn.jsdelivr.net.evil.com)。
116
+ const isEgress = current.category === 'network_egress';
117
+ const egressHost =
118
+ typeof current.arguments.host === 'string' ? current.arguments.host : '';
119
+ const egressPort =
120
+ typeof current.arguments.port === 'number' ? current.arguments.port : null;
121
+
122
+ return (
123
+ <div
124
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
125
+ role="dialog"
126
+ aria-modal="true"
127
+ aria-label="工具调用审批"
128
+ data-testid="approval-dialog"
129
+ >
130
+ <div className="w-full max-w-lg rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
131
+ <div className="flex items-center gap-2 border-b border-agent-border px-4 py-3">
132
+ {destructive ? (
133
+ <LuTriangleAlert className="h-4 w-4 shrink-0 text-agent-destructive" />
134
+ ) : (
135
+ <LuShieldAlert className="h-4 w-4 shrink-0 text-amber-500" />
136
+ )}
137
+ <div className="min-w-0 flex-1">
138
+ <div className="text-sm font-medium text-agent-foreground">
139
+ {isEgress ? (
140
+ <>
141
+ Agent 请求访问外网
142
+ <span className="mx-1 font-mono">
143
+ {egressHost}
144
+ {egressPort !== null ? `:${egressPort}` : ''}
145
+ </span>
146
+ </>
147
+ ) : (
148
+ <>
149
+ Agent 请求执行
150
+ <span className="mx-1 font-mono">{current.toolName}</span>
151
+ <span className="text-agent-muted-foreground">
152
+ ({MODE_LABEL[current.mode] ?? current.mode}操作)
153
+ </span>
154
+ </>
155
+ )}
156
+ </div>
157
+ {queue.length > 1 && (
158
+ <div className="mt-0.5 text-[11px] text-agent-muted-foreground">
159
+ 还有 {queue.length - 1} 个待审批
160
+ </div>
161
+ )}
162
+ </div>
163
+ </div>
164
+
165
+ <div className="max-h-48 overflow-auto px-4 py-3">
166
+ <pre className="whitespace-pre-wrap break-all rounded-agent-md bg-agent-muted/50 px-3 py-2 font-mono text-xs text-agent-foreground">
167
+ {summarizeArguments(current.arguments)}
168
+ </pre>
169
+ {isEgress ? (
170
+ <p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
171
+ 该域名不在出网白名单内。放行前请核对完整域名拼写(仿冒域名常
172
+ 用相似拼写,如 cdn.jsdelivr.net.evil.com)。放行仅对本次会话
173
+ 生效;要持久放行请把域名加入设置的出网白名单。
174
+ </p>
175
+ ) : (
176
+ <>
177
+ {current.category !== current.toolName && (
178
+ <p className="mt-2 text-[11px] text-agent-muted-foreground">
179
+ 持久化类别:{current.category}(会话/始终决定对该类别所有调用生效)
180
+ </p>
181
+ )}
182
+ <p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
183
+ 默认在工作区沙箱中执行(只能写入当前项目)。写入 Downloads
184
+ 等项目外路径会被系统拒绝;需要时在输入框把沙箱切到「完整权限」后再让
185
+ Agent 重试。
186
+ </p>
187
+ </>
188
+ )}
189
+ </div>
190
+
191
+ <div className="space-y-2 border-t border-agent-border px-4 py-3">
192
+ <DecisionRow
193
+ buttons={
194
+ isEgress
195
+ ? ALLOW_BUTTONS.filter((b) => b.kind !== 'allow_always')
196
+ : ALLOW_BUTTONS
197
+ }
198
+ onPick={decide}
199
+ />
200
+ <div className="flex items-center justify-between gap-2">
201
+ <DecisionRow
202
+ buttons={
203
+ isEgress
204
+ ? DENY_BUTTONS.filter((b) => b.kind !== 'deny_always')
205
+ : DENY_BUTTONS
206
+ }
207
+ onPick={decide}
208
+ />
209
+ <button
210
+ type="button"
211
+ onClick={() => decide('abort')}
212
+ className="shrink-0 rounded-agent-md bg-agent-destructive px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
213
+ data-testid="approval-abort"
214
+ >
215
+ 中止本轮
216
+ </button>
217
+ </div>
218
+ <p className="text-[11px] leading-relaxed text-agent-muted-foreground">
219
+ {isEgress
220
+ ? '「会话」决定在本对话内记住(代理进程退出即失效)。不操作约 3 分钟后按拒绝处理。'
221
+ : '「会话」决定在本对话内记住;「始终」决定写入本机 ~/.steerable/approvals.json,跨对话生效。不操作约 2 分钟后按拒绝处理。'}
222
+ </p>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ export default ApprovalModalHost;