@steerable/agent-shell-web 0.6.51 → 0.6.53

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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/brand.ts +7 -0
  3. package/src/components/AgentSidebar.tsx +284 -188
  4. package/src/components/ConfirmDialog.tsx +90 -0
  5. package/src/components/CreateProjectModal.tsx +32 -16
  6. package/src/components/chat/ApprovalModal.tsx +2 -5
  7. package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
  8. package/src/components/chat/AskUserPromptProvider.tsx +1 -32
  9. package/src/components/chat/ChatInput.tsx +129 -59
  10. package/src/components/chat/ChatProjectBadge.tsx +1 -1
  11. package/src/components/chat/EmptyChatHero.tsx +22 -0
  12. package/src/components/chat/ExecPolicyPicker.tsx +2 -2
  13. package/src/components/chat/ExecutedActionsCard.tsx +22 -12
  14. package/src/components/chat/LocalChatPanel.tsx +25 -19
  15. package/src/components/chat/MessageList.tsx +77 -5
  16. package/src/components/chat/ModelPicker.tsx +21 -1
  17. package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
  18. package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
  19. package/src/components/chat/SessionTodoList.tsx +12 -3
  20. package/src/components/chat/UserMessage.tsx +116 -25
  21. package/src/components/chat/composer-status-stack.tsx +79 -0
  22. package/src/components/chat/orchestration-flow-model.ts +108 -43
  23. package/src/components/chat/prompt-chat-scope.ts +33 -0
  24. package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
  25. package/src/components/settings/McpSettingsPanel.tsx +144 -48
  26. package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
  27. package/src/hooks/useProjects.ts +82 -0
  28. package/src/lib/attachments.ts +8 -0
  29. package/src/lib/electron-bridge.ts +16 -1
  30. package/src/lib/exec-policy.ts +1 -1
  31. package/src/lib/host-file-drop.ts +68 -0
  32. package/src/lib/host-paste.ts +38 -9
  33. package/src/lib/local-api.ts +1 -1
  34. package/src/lib/tauri-bridge.ts +5 -0
  35. package/src/pages/AgentPage.tsx +114 -64
  36. package/src/pages/SettingsPage.tsx +131 -46
  37. package/src/vite-env.d.ts +2 -0
  38. package/vite.base.js +1 -0
@@ -15,7 +15,7 @@ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
15
15
  * McpSettingsPanel — MCP 服务管理面板(JSON 导入 / 手动增改 / 启停 / 测试 /
16
16
  * 删除)。
17
17
  *
18
- * 渲染在 `/settings?section=mcp` 独立页(AgentLayout 右侧内容区)。
18
+ * 渲染在 `/settings?section=plugins` 的 MCP 分类(AgentLayout 右侧内容区)。
19
19
  *
20
20
  * 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
21
21
  * local-backend REST(`/api/v2/mcp/servers*`),注册表持久化在 main 进程的
@@ -26,10 +26,15 @@ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
26
26
  export interface McpServerInfo {
27
27
  id: string;
28
28
  name: string;
29
- command: string;
30
- args: string[];
31
- env: Record<string, string>;
29
+ transport: 'stdio' | 'streamable-http';
30
+ command?: string;
31
+ args?: string[];
32
+ envKeys?: string[];
32
33
  cwd?: string;
34
+ url?: string;
35
+ headerNames?: string[];
36
+ headersFromEnv?: Record<string, string>;
37
+ bearerTokenEnvVar?: string;
33
38
  enabled: boolean;
34
39
  serverKey: string;
35
40
  toolCount: number;
@@ -46,11 +51,16 @@ export function McpSettingsPanel() {
46
51
  const [mcpImportStatus, setMcpImportStatus] = useState<string | null>(null);
47
52
  const [mcpFormOpen, setMcpFormOpen] = useState(false);
48
53
  const [mcpEditingId, setMcpEditingId] = useState<string | null>(null);
54
+ const [mcpFormTransport, setMcpFormTransport] = useState<'stdio' | 'streamable-http'>('stdio');
49
55
  const [mcpFormName, setMcpFormName] = useState('');
50
56
  const [mcpFormCommand, setMcpFormCommand] = useState('');
51
57
  const [mcpFormArgs, setMcpFormArgs] = useState('');
52
58
  const [mcpFormEnv, setMcpFormEnv] = useState('');
53
59
  const [mcpFormCwd, setMcpFormCwd] = useState('');
60
+ const [mcpFormUrl, setMcpFormUrl] = useState('');
61
+ const [mcpFormHeaders, setMcpFormHeaders] = useState('');
62
+ const [mcpFormHeadersFromEnv, setMcpFormHeadersFromEnv] = useState('');
63
+ const [mcpFormBearerEnv, setMcpFormBearerEnv] = useState('');
54
64
  const [mcpSaving, setMcpSaving] = useState(false);
55
65
  const [mcpTestingId, setMcpTestingId] = useState<string | null>(null);
56
66
  const [mcpDeletingId, setMcpDeletingId] = useState<string | null>(null);
@@ -80,11 +90,16 @@ export function McpSettingsPanel() {
80
90
 
81
91
  const resetMcpForm = () => {
82
92
  setMcpEditingId(null);
93
+ setMcpFormTransport('stdio');
83
94
  setMcpFormName('');
84
95
  setMcpFormCommand('');
85
96
  setMcpFormArgs('');
86
97
  setMcpFormEnv('');
87
98
  setMcpFormCwd('');
99
+ setMcpFormUrl('');
100
+ setMcpFormHeaders('');
101
+ setMcpFormHeadersFromEnv('');
102
+ setMcpFormBearerEnv('');
88
103
  };
89
104
 
90
105
  const parseEnvLines = (raw: string): Record<string, string> => {
@@ -104,13 +119,27 @@ export function McpSettingsPanel() {
104
119
  setMcpSaving(true);
105
120
  setError(null);
106
121
  try {
107
- const body = {
108
- name: mcpFormName.trim(),
109
- command: mcpFormCommand.trim(),
110
- args: mcpFormArgs.split('\n').map((s) => s.trim()).filter(Boolean),
111
- env: parseEnvLines(mcpFormEnv),
112
- cwd: mcpFormCwd.trim() || undefined,
113
- };
122
+ const body = mcpFormTransport === 'stdio'
123
+ ? {
124
+ name: mcpFormName.trim(),
125
+ transport: 'stdio',
126
+ command: mcpFormCommand.trim(),
127
+ args: mcpFormArgs.split('\n').map((s) => s.trim()).filter(Boolean),
128
+ ...(!mcpEditingId || mcpFormEnv.trim()
129
+ ? { env: parseEnvLines(mcpFormEnv) }
130
+ : {}),
131
+ cwd: mcpFormCwd.trim() || undefined,
132
+ }
133
+ : {
134
+ name: mcpFormName.trim(),
135
+ transport: 'streamable-http',
136
+ url: mcpFormUrl.trim(),
137
+ ...(!mcpEditingId || mcpFormHeaders.trim()
138
+ ? { headers: parseEnvLines(mcpFormHeaders) }
139
+ : {}),
140
+ headersFromEnv: parseEnvLines(mcpFormHeadersFromEnv),
141
+ bearerTokenEnvVar: mcpFormBearerEnv.trim() || undefined,
142
+ };
114
143
  if (mcpEditingId) {
115
144
  await getElectronBridge()!.localBackend.request({
116
145
  method: 'PUT',
@@ -136,15 +165,22 @@ export function McpSettingsPanel() {
136
165
 
137
166
  const handleEditMcpServer = (server: McpServerInfo) => {
138
167
  setMcpEditingId(server.id);
168
+ setMcpFormTransport(server.transport ?? 'stdio');
139
169
  setMcpFormName(server.name);
140
- setMcpFormCommand(server.command);
170
+ setMcpFormCommand(server.command ?? '');
141
171
  setMcpFormArgs((server.args || []).join('\n'));
142
- setMcpFormEnv(
143
- Object.entries(server.env || {})
144
- .map(([k, v]) => `${k}=${v}`)
172
+ // Stored values never return through the settings API. Blank preserves
173
+ // the existing env map; entering lines replaces it deliberately.
174
+ setMcpFormEnv('');
175
+ setMcpFormCwd(server.cwd || '');
176
+ setMcpFormUrl(server.url || '');
177
+ setMcpFormHeaders('');
178
+ setMcpFormHeadersFromEnv(
179
+ Object.entries(server.headersFromEnv ?? {})
180
+ .map(([header, variable]) => `${header}=${variable}`)
145
181
  .join('\n'),
146
182
  );
147
- setMcpFormCwd(server.cwd || '');
183
+ setMcpFormBearerEnv(server.bearerTokenEnvVar || '');
148
184
  setMcpFormOpen(true);
149
185
  };
150
186
 
@@ -275,6 +311,16 @@ export function McpSettingsPanel() {
275
311
  <h4 className="text-xs font-semibold text-agent-foreground">
276
312
  {mcpEditingId ? '编辑 MCP 服务' : '手动添加 MCP 服务'}
277
313
  </h4>
314
+ <select
315
+ value={mcpFormTransport}
316
+ onChange={(event) =>
317
+ setMcpFormTransport(event.target.value as 'stdio' | 'streamable-http')
318
+ }
319
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
320
+ >
321
+ <option value="stdio">本地进程(stdio)</option>
322
+ <option value="streamable-http">远程服务(Streamable HTTP)</option>
323
+ </select>
278
324
  <div className="grid grid-cols-2 gap-2">
279
325
  <input
280
326
  type="text"
@@ -283,35 +329,73 @@ export function McpSettingsPanel() {
283
329
  placeholder="名称,如 filesystem"
284
330
  className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
285
331
  />
286
- <input
287
- type="text"
288
- value={mcpFormCommand}
289
- onChange={(e) => setMcpFormCommand(e.target.value)}
290
- placeholder="启动命令,如 npx / uvx / node"
291
- className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
292
- />
332
+ {mcpFormTransport === 'stdio' ? (
333
+ <input
334
+ type="text"
335
+ value={mcpFormCommand}
336
+ onChange={(e) => setMcpFormCommand(e.target.value)}
337
+ placeholder="启动命令,如 npx / uvx / node"
338
+ className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
339
+ />
340
+ ) : (
341
+ <input
342
+ type="url"
343
+ value={mcpFormUrl}
344
+ onChange={(e) => setMcpFormUrl(e.target.value)}
345
+ placeholder="https://example.com/mcp"
346
+ className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
347
+ />
348
+ )}
293
349
  </div>
294
- <textarea
295
- value={mcpFormArgs}
296
- onChange={(e) => setMcpFormArgs(e.target.value)}
297
- placeholder="参数(每行一个),如:&#10;-y&#10;@modelcontextprotocol/server-filesystem&#10;C:/"
298
- rows={3}
299
- className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
300
- />
301
- <textarea
302
- value={mcpFormEnv}
303
- onChange={(e) => setMcpFormEnv(e.target.value)}
304
- placeholder="环境变量(每行一个 KEY=VALUE,可留空)"
305
- rows={2}
306
- className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
307
- />
308
- <input
309
- type="text"
310
- value={mcpFormCwd}
311
- onChange={(e) => setMcpFormCwd(e.target.value)}
312
- placeholder="工作目录(可选)"
313
- className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
314
- />
350
+ {mcpFormTransport === 'stdio' ? (
351
+ <>
352
+ <textarea
353
+ value={mcpFormArgs}
354
+ onChange={(e) => setMcpFormArgs(e.target.value)}
355
+ placeholder="参数(每行一个)"
356
+ rows={3}
357
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
358
+ />
359
+ <textarea
360
+ value={mcpFormEnv}
361
+ onChange={(e) => setMcpFormEnv(e.target.value)}
362
+ placeholder={mcpEditingId ? '环境变量;留空保留现有值' : '环境变量(每行 KEY=VALUE)'}
363
+ rows={2}
364
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
365
+ />
366
+ <input
367
+ type="text"
368
+ value={mcpFormCwd}
369
+ onChange={(e) => setMcpFormCwd(e.target.value)}
370
+ placeholder="工作目录(可选)"
371
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
372
+ />
373
+ </>
374
+ ) : (
375
+ <>
376
+ <textarea
377
+ value={mcpFormHeaders}
378
+ onChange={(e) => setMcpFormHeaders(e.target.value)}
379
+ placeholder={mcpEditingId ? '普通请求头;留空保留现有值' : '普通请求头(每行 Header=Value)'}
380
+ rows={2}
381
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
382
+ />
383
+ <textarea
384
+ value={mcpFormHeadersFromEnv}
385
+ onChange={(e) => setMcpFormHeadersFromEnv(e.target.value)}
386
+ placeholder="环境变量请求头(每行 Header=ENV_NAME)"
387
+ rows={2}
388
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
389
+ />
390
+ <input
391
+ type="text"
392
+ value={mcpFormBearerEnv}
393
+ onChange={(e) => setMcpFormBearerEnv(e.target.value)}
394
+ placeholder="Bearer token 环境变量名(可选)"
395
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
396
+ />
397
+ </>
398
+ )}
315
399
  <div className="flex justify-end gap-2">
316
400
  <button
317
401
  type="button"
@@ -323,9 +407,19 @@ export function McpSettingsPanel() {
323
407
  <button
324
408
  type="button"
325
409
  onClick={handleSaveMcpServer}
326
- disabled={mcpSaving || !mcpFormName.trim() || !mcpFormCommand.trim()}
410
+ disabled={
411
+ mcpSaving ||
412
+ !mcpFormName.trim() ||
413
+ (mcpFormTransport === 'stdio'
414
+ ? !mcpFormCommand.trim()
415
+ : !mcpFormUrl.trim())
416
+ }
327
417
  className={`h-8 px-4 rounded-full text-xs font-medium transition-all ${
328
- mcpSaving || !mcpFormName.trim() || !mcpFormCommand.trim()
418
+ mcpSaving ||
419
+ !mcpFormName.trim() ||
420
+ (mcpFormTransport === 'stdio'
421
+ ? !mcpFormCommand.trim()
422
+ : !mcpFormUrl.trim())
329
423
  ? 'bg-agent-muted text-agent-muted-foreground cursor-not-allowed'
330
424
  : 'bg-agent-foreground text-agent-canvas hover:opacity-90'
331
425
  }`}
@@ -386,7 +480,9 @@ export function McpSettingsPanel() {
386
480
  )}
387
481
  </div>
388
482
  <p className="text-[11px] text-agent-muted-foreground font-mono truncate">
389
- {server.command} {(server.args || []).join(' ')}
483
+ {server.transport === 'streamable-http'
484
+ ? server.url
485
+ : `${server.command ?? ''} ${(server.args ?? []).join(' ')}`}
390
486
  </p>
391
487
  {server.lastError && (
392
488
  <p className="text-[10px] text-agent-destructive line-clamp-2">
@@ -453,7 +549,7 @@ export function McpSettingsPanel() {
453
549
  )}
454
550
  </div>
455
551
  <p className="text-[10px] text-agent-muted-foreground">
456
- 已启用服务的工具会以 mcp__服务名__工具名 的形式直接提供给助手调用;新导入或修改后点「测试连接」即可生效。
552
+ 支持 stdio 与 Streamable HTTP。密钥请填写环境变量名,不要写进普通请求头;已启用服务的工具通过 tool_search 提供给助手。
457
553
  </p>
458
554
 
459
555
  {error && (
@@ -11,7 +11,7 @@ import { getPackHiddenSlashSkills } from '@/packs/registry';
11
11
  /**
12
12
  * SkillsSettingsPanel — 本地技能管理面板(导入目录 / 列表 / 卸载)。
13
13
  *
14
- * 渲染在 `/settings?section=skills` 独立页(AgentLayout 右侧内容区)。
14
+ * 渲染在 `/settings?section=plugins` 的 Skills 分类(AgentLayout 右侧内容区)。
15
15
  *
16
16
  * 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
17
17
  * local-backend REST(`GET/POST /api/v2/chat-agents/skills*`),无专用 IPC。
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 侧栏项目分组和输入框「关联到项目」共用这一份列表。
3
+ * 任一侧新建、改名或改目录后调用 refresh,其它订阅者一起更新。
4
+ */
5
+ import { useCallback, useEffect, useSyncExternalStore } from 'react';
6
+ import { isElectron } from '@/lib/electron-bridge';
7
+ import { hostToolChrome } from '@/lib/host-tools';
8
+ import { listProjects, type LocalProject } from '@/lib/local-api';
9
+
10
+ type ProjectsSnapshot = {
11
+ projects: LocalProject[];
12
+ error: string | null;
13
+ };
14
+
15
+ const EMPTY_SNAPSHOT: ProjectsSnapshot = { projects: [], error: null };
16
+
17
+ let snapshot: ProjectsSnapshot = EMPTY_SNAPSHOT;
18
+ let requestSeq = 0;
19
+ const listeners = new Set<() => void>();
20
+
21
+ function emit() {
22
+ for (const listener of listeners) listener();
23
+ }
24
+
25
+ export function subscribeProjects(listener: () => void): () => void {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ }
31
+
32
+ export function getProjectsSnapshot(): ProjectsSnapshot {
33
+ return snapshot;
34
+ }
35
+
36
+ /** 重新拉取项目列表并通知所有订阅者。非 Electron 或关掉项目入口时清空。 */
37
+ export async function refreshProjects(): Promise<void> {
38
+ const seq = ++requestSeq;
39
+ if (!isElectron() || !hostToolChrome('projects')) {
40
+ if (snapshot.projects.length > 0 || snapshot.error) {
41
+ snapshot = EMPTY_SNAPSHOT;
42
+ emit();
43
+ }
44
+ return;
45
+ }
46
+ try {
47
+ const res = await listProjects();
48
+ if (seq !== requestSeq) return;
49
+ snapshot = { projects: res.projects ?? [], error: null };
50
+ emit();
51
+ } catch (err) {
52
+ if (seq !== requestSeq) return;
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ console.error('获取项目列表失败:', err);
55
+ snapshot = { projects: snapshot.projects, error: message };
56
+ emit();
57
+ }
58
+ }
59
+
60
+ export function useProjects(): {
61
+ projects: LocalProject[];
62
+ error: string | null;
63
+ refresh: () => Promise<void>;
64
+ } {
65
+ const current = useSyncExternalStore(
66
+ subscribeProjects,
67
+ getProjectsSnapshot,
68
+ getProjectsSnapshot,
69
+ );
70
+ useEffect(() => {
71
+ void refreshProjects();
72
+ }, []);
73
+ const refresh = useCallback(() => refreshProjects(), []);
74
+ return { projects: current.projects, error: current.error, refresh };
75
+ }
76
+
77
+ /** 测试隔离:丢掉上一个用例留下的列表,并忽略还在飞的请求。 */
78
+ export function resetProjectsStoreForTests(): void {
79
+ requestSeq += 1;
80
+ snapshot = EMPTY_SNAPSHOT;
81
+ emit();
82
+ }
@@ -19,6 +19,14 @@ export interface AttachmentFile {
19
19
  file?: File;
20
20
  }
21
21
 
22
+ /** 宿主拖放 / 粘贴只给出绝对路径时,用最后一段作为展示名。 */
23
+ export function attachmentFromPath(path: string): AttachmentFile {
24
+ const trimmed = path.trim();
25
+ const parts = trimmed.split(/[/\\]/).filter((part) => part.length > 0);
26
+ const name = parts[parts.length - 1] ?? trimmed;
27
+ return { name, path: trimmed };
28
+ }
29
+
22
30
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
23
31
 
24
32
  /** 扩展名是否为图像(renderer 侧预过滤,后端 image-attachment 会二次校验)。 */
@@ -48,6 +48,7 @@ export interface ApprovalPromptRequest {
48
48
  mode: string;
49
49
  category: string;
50
50
  round: number;
51
+ /** 发起审批的对话。缺省时卡片不绑定会话(旧请求)。 */
51
52
  chatId?: string;
52
53
  }
53
54
 
@@ -196,10 +197,24 @@ export interface HostBridge {
196
197
  /**
197
198
  * App menu IPC — `Cmd+V` / 编辑 → 粘贴 fires `menu:paste`.
198
199
  * WKWebView does not expose that pasteboard to the page, so the renderer
199
- * reads it through `readClipboardText`.
200
+ * reads it through `readClipboard`.
200
201
  */
201
202
  onMenuPaste?: (callback: () => void) => void;
202
203
  offMenuPaste?: () => void;
204
+ /**
205
+ * OS pasteboard. `files` are copied paths or a screenshot PNG.
206
+ * Text is empty when the pasteboard holds files, so the path is not
207
+ * inserted into the composer as well.
208
+ */
209
+ readClipboard?: () => Promise<{
210
+ text: string;
211
+ files: Array<{
212
+ name: string;
213
+ path?: string;
214
+ dataBase64?: string;
215
+ mime?: string;
216
+ }>;
217
+ }>;
203
218
  /** Plain text from the OS pasteboard. Empty when the clipboard has no text. */
204
219
  readClipboardText?: () => Promise<string>;
205
220
  /**
@@ -2,7 +2,7 @@
2
2
  * 会话级命令执行沙箱(类 Codex 的 sandbox_mode)。
3
3
  *
4
4
  * 作用于下一轮 `chat.stream` 的 `execSandbox`,不是 sidecar 进程沙箱
5
- * (设置页那一层)。工作区 = 只能写入当前项目或本对话工作区;完整权限
5
+ * (设置页那一层)。工作区 = 只能写入项目家目录、源文件夹(含各自子目录)或本对话工作区;完整权限
6
6
  * = 关闭逐条命令沙箱。工具审批(允许/拒绝)仍按原策略生效。
7
7
  */
8
8
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tauri consumes OS file drags before they become HTML5 drop events.
3
+ * The webview reports paths through `onDragDropEvent` instead.
4
+ *
5
+ * macOS labels AppKit points as a physical position. Windows reports device
6
+ * pixels. Checking both against the composer rectangle accepts the drop on
7
+ * either scale.
8
+ */
9
+ import { getCurrentWebview, type DragDropEvent } from '@tauri-apps/api/webview';
10
+
11
+ export interface HostFileDrop {
12
+ type: 'enter' | 'over' | 'drop' | 'leave';
13
+ paths: string[];
14
+ x?: number;
15
+ y?: number;
16
+ }
17
+
18
+ export function hostFileDropAvailable(): boolean {
19
+ return typeof window !== 'undefined' && window.__TAURI_INTERNALS__ != null;
20
+ }
21
+
22
+ export function dropPointHitsRect(
23
+ position: { x: number; y: number },
24
+ rect: { left: number; right: number; top: number; bottom: number },
25
+ devicePixelRatio: number,
26
+ ): boolean {
27
+ const scale = devicePixelRatio > 0 ? devicePixelRatio : 1;
28
+ return (
29
+ contains(rect, position.x, position.y) ||
30
+ contains(rect, position.x / scale, position.y / scale)
31
+ );
32
+ }
33
+
34
+ /** Subscribe while a composer is mounted. The returned function removes it. */
35
+ export function listenHostFileDrop(onEvent: (event: HostFileDrop) => void): () => void {
36
+ if (!hostFileDropAvailable()) return () => {};
37
+ let unlisten: (() => void) | undefined;
38
+ let cancelled = false;
39
+ const pending = getCurrentWebview().onDragDropEvent((event) => {
40
+ onEvent(hostFileDropFrom(event.payload));
41
+ });
42
+ void pending.then((stop) => {
43
+ if (cancelled) stop();
44
+ else unlisten = stop;
45
+ });
46
+ return () => {
47
+ cancelled = true;
48
+ unlisten?.();
49
+ };
50
+ }
51
+
52
+ function contains(
53
+ rect: { left: number; right: number; top: number; bottom: number },
54
+ x: number,
55
+ y: number,
56
+ ): boolean {
57
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
58
+ }
59
+
60
+ function hostFileDropFrom(payload: DragDropEvent): HostFileDrop {
61
+ if (payload.type === 'leave') return { type: 'leave', paths: [] };
62
+ return {
63
+ type: payload.type,
64
+ paths: payload.type === 'over' ? [] : payload.paths,
65
+ x: payload.position.x,
66
+ y: payload.position.y,
67
+ };
68
+ }
@@ -1,8 +1,20 @@
1
1
  /**
2
- * WKWebView paste does not carry clipboard text into the page. The Edit menu
3
- * shortcut emits `menu:paste`; this module reads the OS pasteboard and writes
4
- * it into the focused field.
2
+ * WKWebView paste does not carry clipboard text or files into the page. The
3
+ * Edit menu shortcut emits `menu:paste`; this module reads the OS pasteboard
4
+ * and writes it into the focused field.
5
5
  */
6
+
7
+ export interface HostClipboardFile {
8
+ name: string;
9
+ path?: string;
10
+ dataBase64?: string;
11
+ mime?: string;
12
+ }
13
+
14
+ export interface HostClipboard {
15
+ text: string;
16
+ files: HostClipboardFile[];
17
+ }
6
18
  import { getHostBridge } from './electron-bridge';
7
19
 
8
20
  let pending = false;
@@ -25,12 +37,18 @@ export function installHostPaste(): void {
25
37
 
26
38
  /** Read the OS pasteboard and insert it into `target` or the focused field. */
27
39
  export function requestHostPaste(target?: HTMLElement | null): void {
28
- const read = getHostBridge()?.readClipboardText;
29
- if (!read || pending) return;
40
+ const bridge = getHostBridge();
41
+ const readClipboard = bridge?.readClipboard;
42
+ const readText = bridge?.readClipboardText;
43
+ if ((!readClipboard && !readText) || pending) return;
30
44
  pending = true;
31
- void read()
32
- .then((text) => {
33
- if (text) insertHostText(text, target ?? null);
45
+ const read = readClipboard
46
+ ? readClipboard()
47
+ : readText!().then((text) => ({ text, files: [] as HostClipboardFile[] }));
48
+ void read
49
+ .then((clip) => {
50
+ if (clip.files.length > 0) insertHostFiles(clip.files, target ?? null);
51
+ if (clip.text) insertHostText(clip.text, target ?? null);
34
52
  })
35
53
  .catch((error: unknown) => {
36
54
  console.warn('[host-paste] clipboard read failed', error);
@@ -41,7 +59,10 @@ export function requestHostPaste(target?: HTMLElement | null): void {
41
59
  }
42
60
 
43
61
  export function hostClipboardAvailable(): boolean {
44
- return typeof getHostBridge()?.readClipboardText === 'function';
62
+ const bridge = getHostBridge();
63
+ return (
64
+ typeof bridge?.readClipboard === 'function' || typeof bridge?.readClipboardText === 'function'
65
+ );
45
66
  }
46
67
 
47
68
  function isEditable(target: EventTarget | null): target is HTMLElement {
@@ -58,6 +79,14 @@ function focusedEditable(): HTMLElement | null {
58
79
  return null;
59
80
  }
60
81
 
82
+ function insertHostFiles(files: HostClipboardFile[], preferred: HTMLElement | null): void {
83
+ const el = preferred?.isConnected ? preferred : focusedEditable();
84
+ if (!el) return;
85
+ if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
86
+ el.dispatchEvent(new CustomEvent('hostpastefiles', { detail: files, cancelable: true }));
87
+ }
88
+ }
89
+
61
90
  function insertHostText(text: string, preferred: HTMLElement | null): void {
62
91
  const el = preferred?.isConnected ? preferred : focusedEditable();
63
92
  if (!el) return;
@@ -387,7 +387,7 @@ export interface LocalProject {
387
387
  id: string;
388
388
  name: string;
389
389
  folderPath: string;
390
- /** 附加源文件夹(只读)。缺省空。 */
390
+ /** 附加源文件夹(可读写,含其子目录)。缺省空。 */
391
391
  sourceFolders?: string[];
392
392
  /** W6-5: 信任后该项目目录里的规则文件才会注入模型上下文。缺省 false。 */
393
393
  trusted?: boolean;
@@ -92,6 +92,11 @@ export function createTauriBridge(): HostBridge {
92
92
  offMenuOpenTerminal: terminalMenu.off,
93
93
  onMenuPaste: pasteMenu.on,
94
94
  offMenuPaste: pasteMenu.off,
95
+ readClipboard: () =>
96
+ invoke<{
97
+ text: string;
98
+ files: Array<{ name: string; path?: string; dataBase64?: string; mime?: string }>;
99
+ }>('host_read_clipboard'),
95
100
  readClipboardText: () => invoke<string>('host_read_clipboard_text'),
96
101
  app: {
97
102
  snapshot: () => invoke<AppReleaseSnapshot>('app_release_snapshot'),