@steerable/agent-shell-web 0.6.48 → 0.6.49

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,53 +1,77 @@
1
- import { useState } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  import {
3
- LuFilePen,
4
- LuFilePlus,
5
- LuFiles,
3
+ LuCheck,
4
+ LuChevronDown,
5
+ LuCopy,
6
+ LuExternalLink,
7
+ LuFile,
8
+ LuFileDiff,
9
+ LuFileSpreadsheet,
10
+ LuFileText,
11
+ LuFolderOpen,
12
+ LuImage,
6
13
  LuLoaderCircle,
14
+ LuPresentation,
15
+ LuUndo2,
7
16
  } from 'react-icons/lu';
8
17
  import { openLocalPath } from '@/lib/local-api';
9
18
  import { hostToolChrome } from '@/lib/host-tools';
10
19
  import {
11
20
  formatFileSize,
21
+ formatIntermediateDisplayPath,
22
+ getDeliverableMeta,
23
+ groupTurnFiles,
12
24
  splitTurnFilePath,
25
+ type DeliverableFileMeta,
13
26
  type TurnFile,
14
27
  } from './turn-files';
15
28
 
16
29
  /**
17
- * TurnFilesCard — 回合产物文件列表(Codex 式「本轮写了哪些文件」)。
30
+ * TurnFilesCard — 回合产物文件展示(Codex 样式)。
18
31
  *
19
- * 渲染在助手回合的末尾(回答气泡之下、时间戳行之上),回合内新建/修改的
20
- * 文件逐行列出;点击行用系统默认应用打开该文件(PPT → PowerPoint/WPS,
21
- * 报告 → 对应阅读器)。打开失败在行内给出错误提示,不打断会话。
22
- *
23
- * 数据来自 local-backend 的 `turn_files` SSE 事件 / messageMetadata 里的
24
- * `turnFiles`(见 `src/local-backend/turn-files.ts` 的收集逻辑)。
32
+ * 拆分两层呈现:
33
+ * 1. 最终文件(Deliverables):如生成的 Excel、PPT、PDF、图片等,展示为独立的大卡片,
34
+ * 带文件类型图标、分类标签(Spreadsheet · XLSX)、以及「Open in ⌵」下拉打开操作。
35
+ * 2. 中间修改文件(Edited files):如脚本、代码等修改,展示为「Edited N files (+X -Y)」
36
+ * 可折叠卡片,包含 Review 按钮与细粒度行变动列表。
25
37
  */
26
38
 
39
+ export interface ExecutedActionLike {
40
+ tool?: string;
41
+ arguments?: unknown;
42
+ result?: unknown;
43
+ }
44
+
27
45
  interface TurnFilesCardProps {
28
46
  files: TurnFile[];
47
+ executedActions?: ExecutedActionLike[];
29
48
  }
30
49
 
31
- export function TurnFilesCard({ files }: TurnFilesCardProps) {
50
+ export function TurnFilesCard({ files, executedActions }: TurnFilesCardProps) {
32
51
  const [openingPath, setOpeningPath] = useState<string | null>(null);
33
52
  const [openError, setOpenError] = useState<{ path: string; message: string } | null>(null);
34
53
  const allowOpenPath = hostToolChrome('local-fs');
35
54
 
36
- if (files.length === 0) return null;
55
+ const { deliverables, intermediates, totalAdditions, totalDeletions } = groupTurnFiles(
56
+ files,
57
+ executedActions,
58
+ );
59
+
60
+ if (deliverables.length === 0 && intermediates.length === 0) return null;
37
61
 
38
- const handleOpen = async (file: TurnFile) => {
62
+ const handleOpen = async (filePath: string) => {
39
63
  if (!allowOpenPath) return;
40
64
  if (openingPath) return;
41
- setOpeningPath(file.path);
65
+ setOpeningPath(filePath);
42
66
  setOpenError(null);
43
67
  try {
44
- const result = await openLocalPath(file.path);
68
+ const result = await openLocalPath(filePath);
45
69
  if (!result.success) {
46
- setOpenError({ path: file.path, message: result.error || '打开失败' });
70
+ setOpenError({ path: filePath, message: result.error || '打开失败' });
47
71
  }
48
72
  } catch (err) {
49
73
  setOpenError({
50
- path: file.path,
74
+ path: filePath,
51
75
  message: err instanceof Error ? err.message : String(err),
52
76
  });
53
77
  } finally {
@@ -55,68 +79,392 @@ export function TurnFilesCard({ files }: TurnFilesCardProps) {
55
79
  }
56
80
  };
57
81
 
82
+ const handleReveal = async (filePath: string) => {
83
+ const { dir } = splitTurnFilePath(filePath);
84
+ const targetDir = dir || filePath;
85
+ await handleOpen(targetDir);
86
+ };
87
+
88
+ return (
89
+ <div className="space-y-2.5" data-turn-files="">
90
+ {/* 1. 最终文件卡片区 (Deliverables) */}
91
+ {deliverables.length > 0 && (
92
+ <div className="space-y-2" data-turn-deliverables="">
93
+ {deliverables.map((file) => (
94
+ <DeliverableCard
95
+ key={file.path}
96
+ file={file}
97
+ onOpen={() => void handleOpen(file.path)}
98
+ onReveal={() => void handleReveal(file.path)}
99
+ busy={openingPath === file.path}
100
+ error={openError?.path === file.path ? openError.message : null}
101
+ allowOpen={allowOpenPath}
102
+ />
103
+ ))}
104
+ </div>
105
+ )}
106
+
107
+ {/* 2. 中间修改文件区 (Edited Files) */}
108
+ {intermediates.length > 0 && (
109
+ <EditedFilesCard
110
+ files={intermediates}
111
+ totalAdditions={totalAdditions}
112
+ totalDeletions={totalDeletions}
113
+ onOpen={(f) => void handleOpen(f.path)}
114
+ openingPath={openingPath}
115
+ openError={openError}
116
+ allowOpen={allowOpenPath}
117
+ />
118
+ )}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ // ─────────────────────────────────────────────────────────────
124
+ // 最终交付文件卡片 (Codex Deliverable Card)
125
+ // ─────────────────────────────────────────────────────────────
126
+
127
+ interface DeliverableCardProps {
128
+ file: TurnFile;
129
+ onOpen: () => void;
130
+ onReveal: () => void;
131
+ busy: boolean;
132
+ error: string | null;
133
+ allowOpen: boolean;
134
+ }
135
+
136
+ function DeliverableCard({
137
+ file,
138
+ onOpen,
139
+ onReveal,
140
+ busy,
141
+ error,
142
+ allowOpen,
143
+ }: DeliverableCardProps) {
144
+ const [dropdownOpen, setDropdownOpen] = useState(false);
145
+ const [copied, setCopied] = useState(false);
146
+ const menuRef = useRef<HTMLDivElement>(null);
147
+ const { name } = splitTurnFilePath(file.path);
148
+ const size = formatFileSize(file.size);
149
+ const meta = getDeliverableMeta(file.path);
150
+
151
+ useEffect(() => {
152
+ if (!dropdownOpen) return;
153
+ const handleClickOutside = (e: MouseEvent) => {
154
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
155
+ setDropdownOpen(false);
156
+ }
157
+ };
158
+ document.addEventListener('mousedown', handleClickOutside);
159
+ return () => document.removeEventListener('mousedown', handleClickOutside);
160
+ }, [dropdownOpen]);
161
+
162
+ const handleCopyPath = async () => {
163
+ try {
164
+ await navigator.clipboard.writeText(file.path);
165
+ setCopied(true);
166
+ setTimeout(() => setCopied(false), 2000);
167
+ } catch {
168
+ // ignore
169
+ }
170
+ };
171
+
58
172
  return (
59
173
  <div
60
- className="rounded-agent-lg border border-agent-border bg-agent-canvas shadow-sm"
61
- data-turn-files=""
174
+ className="rounded-agent-lg border border-agent-border bg-agent-canvas p-2.5 shadow-xs transition-all hover:border-agent-border/80"
175
+ data-deliverable-card=""
176
+ data-turn-file=""
177
+ data-kind={file.kind}
62
178
  >
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">
64
- <LuFiles className="h-3.5 w-3.5" />
65
- <span>
66
- {allowOpenPath
67
- ? `本轮产生了 ${files.length} 个文件,点击打开`
68
- : `本轮产生了 ${files.length} 个文件`}
69
- </span>
70
- </div>
71
- <ul className="max-h-64 overflow-y-auto py-1">
72
- {files.map((file) => {
73
- const { dir, name } = splitTurnFilePath(file.path);
74
- const size = formatFileSize(file.size);
75
- const busy = openingPath === file.path;
76
- const error = openError?.path === file.path ? openError.message : null;
77
- return (
78
- <li key={file.path}>
179
+ <div className="flex items-center justify-between gap-2.5">
180
+ {/* 左侧:文件图标 */}
181
+ <div className="shrink-0">
182
+ <DeliverableIcon meta={meta} />
183
+ </div>
184
+
185
+ {/* 中间:文件名与类型信息 */}
186
+ <div className="min-w-0 flex-1">
187
+ <button
188
+ type="button"
189
+ onClick={onOpen}
190
+ disabled={!allowOpen || busy}
191
+ title={file.path}
192
+ className={`block w-full truncate text-left text-xs font-medium text-agent-foreground ${
193
+ allowOpen ? 'hover:underline cursor-pointer' : 'cursor-default'
194
+ }`}
195
+ >
196
+ {name}
197
+ </button>
198
+ <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] text-agent-muted-foreground">
199
+ <span className="truncate" title={file.description}>
200
+ {file.description ?? `${meta.label} · ${meta.extBadge}`}
201
+ </span>
202
+ {size && (
203
+ <>
204
+ <span className="shrink-0 opacity-40">·</span>
205
+ <span className="shrink-0 tabular-nums">{size}</span>
206
+ </>
207
+ )}
208
+ </div>
209
+ </div>
210
+
211
+ {/* 右侧:Open in ⌵ 动作按钮 */}
212
+ <div className="relative shrink-0" ref={menuRef}>
213
+ <div className="flex items-center rounded-agent-md border border-agent-border bg-agent-canvas shadow-xs hover:border-agent-border/80 transition-colors">
214
+ <button
215
+ type="button"
216
+ onClick={onOpen}
217
+ disabled={!allowOpen || busy}
218
+ title={file.path}
219
+ className="flex items-center gap-1.5 rounded-l-agent-md px-2.5 py-1 text-xs font-medium text-agent-foreground hover:bg-agent-muted/50 transition-colors disabled:cursor-wait"
220
+ >
221
+ {busy ? (
222
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin text-agent-muted-foreground" />
223
+ ) : null}
224
+ <span>打开方式</span>
225
+ </button>
226
+ <button
227
+ type="button"
228
+ onClick={() => setDropdownOpen(!dropdownOpen)}
229
+ disabled={!allowOpen}
230
+ aria-label="更多操作"
231
+ className="border-l border-agent-border px-1.5 py-1 text-agent-muted-foreground hover:bg-agent-muted/50 hover:text-agent-foreground rounded-r-agent-md transition-colors"
232
+ >
233
+ <LuChevronDown className="h-3.5 w-3.5" />
234
+ </button>
235
+ </div>
236
+
237
+ {/* 下拉菜单 */}
238
+ {dropdownOpen && (
239
+ <div className="absolute right-0 top-full z-30 mt-1.5 w-44 rounded-agent-md border border-agent-border bg-agent-canvas py-1 shadow-lg text-xs">
240
+ <button
241
+ type="button"
242
+ onClick={() => {
243
+ setDropdownOpen(false);
244
+ onOpen();
245
+ }}
246
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-agent-foreground hover:bg-agent-foreground/5"
247
+ >
248
+ <LuExternalLink className="h-3.5 w-3.5 text-agent-muted-foreground" />
249
+ <span>用默认应用打开</span>
250
+ </button>
251
+ <button
252
+ type="button"
253
+ onClick={() => {
254
+ setDropdownOpen(false);
255
+ onReveal();
256
+ }}
257
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-agent-foreground hover:bg-agent-foreground/5"
258
+ >
259
+ <LuFolderOpen className="h-3.5 w-3.5 text-agent-muted-foreground" />
260
+ <span>在文件夹中显示</span>
261
+ </button>
79
262
  <button
80
263
  type="button"
81
- onClick={() => void handleOpen(file)}
82
- disabled={!allowOpenPath || openingPath !== null}
83
- title={file.path}
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
- }`}
89
- data-turn-file=""
90
- data-kind={file.kind}
264
+ onClick={() => {
265
+ void handleCopyPath();
266
+ setTimeout(() => setDropdownOpen(false), 800);
267
+ }}
268
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-agent-foreground hover:bg-agent-foreground/5"
91
269
  >
92
- {busy ? (
93
- <LuLoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-agent-muted-foreground" />
94
- ) : file.kind === 'created' ? (
95
- <LuFilePlus className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
270
+ {copied ? (
271
+ <LuCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
96
272
  ) : (
97
- <LuFilePen className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
273
+ <LuCopy className="h-3.5 w-3.5 text-agent-muted-foreground" />
98
274
  )}
99
- <span className="min-w-0 flex-1 truncate">
100
- <span className="font-medium text-agent-foreground">{name}</span>
101
- {dir && (
102
- <span className="ml-1.5 text-agent-muted-foreground">{dir}</span>
103
- )}
275
+ <span>{copied ? '已复制完整路径' : '复制文件路径'}</span>
276
+ </button>
277
+ </div>
278
+ )}
279
+ </div>
280
+ </div>
281
+
282
+ {error && (
283
+ <div className="mt-2 px-1 text-[11px] text-agent-destructive" role="status">
284
+ 打开失败:{error}
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ }
290
+
291
+ function DeliverableIcon({ meta }: { meta: DeliverableFileMeta }) {
292
+ switch (meta.themeColor) {
293
+ case 'emerald':
294
+ return (
295
+ <div className="flex h-8 w-8 items-center justify-center rounded-agent-md border border-emerald-500/35 text-emerald-600 dark:text-emerald-400">
296
+ <LuFileSpreadsheet className="h-4 w-4" />
297
+ </div>
298
+ );
299
+ case 'amber':
300
+ return (
301
+ <div className="flex h-8 w-8 items-center justify-center rounded-agent-md border border-amber-500/35 text-amber-600 dark:text-amber-400">
302
+ <LuPresentation className="h-4 w-4" />
303
+ </div>
304
+ );
305
+ case 'rose':
306
+ return (
307
+ <div className="flex h-8 w-8 items-center justify-center rounded-agent-md border border-rose-500/35 text-rose-600 dark:text-rose-400">
308
+ <LuFileText className="h-4 w-4" />
309
+ </div>
310
+ );
311
+ case 'blue':
312
+ return (
313
+ <div className="flex h-8 w-8 items-center justify-center rounded-agent-md border border-blue-500/35 text-blue-600 dark:text-blue-400">
314
+ <LuFileText className="h-4 w-4" />
315
+ </div>
316
+ );
317
+ case 'purple':
318
+ return (
319
+ <div className="flex h-8 w-8 items-center justify-center rounded-agent-md border border-purple-500/35 text-purple-600 dark:text-purple-400">
320
+ <LuImage className="h-4 w-4" />
321
+ </div>
322
+ );
323
+ default:
324
+ return (
325
+ <div className="flex h-8 w-8 items-center justify-center rounded-agent-md border border-agent-border text-agent-muted-foreground">
326
+ <LuFile className="h-4 w-4" />
327
+ </div>
328
+ );
329
+ }
330
+ }
331
+
332
+ // ─────────────────────────────────────────────────────────────
333
+ // 中间修改文件卡片 (Codex Edited Files Card)
334
+ // ─────────────────────────────────────────────────────────────
335
+
336
+ interface EditedFilesCardProps {
337
+ files: TurnFile[];
338
+ totalAdditions: number;
339
+ totalDeletions: number;
340
+ onOpen: (file: TurnFile) => void;
341
+ openingPath: string | null;
342
+ openError: { path: string; message: string } | null;
343
+ allowOpen: boolean;
344
+ }
345
+
346
+ function EditedFilesCard({
347
+ files,
348
+ totalAdditions,
349
+ totalDeletions,
350
+ onOpen,
351
+ openingPath,
352
+ openError,
353
+ allowOpen,
354
+ }: EditedFilesCardProps) {
355
+ const [expanded, setExpanded] = useState(false);
356
+
357
+ return (
358
+ <div
359
+ className="rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xs overflow-hidden"
360
+ data-edited-files-card=""
361
+ >
362
+ {/* 头部标题与统计(默认折叠,点击标题栏或 Review 展开) */}
363
+ <div
364
+ className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-agent-foreground/[0.03] transition-colors select-none"
365
+ onClick={() => setExpanded(!expanded)}
366
+ >
367
+ <div className="flex items-center gap-2 min-w-0">
368
+ <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-agent-sm border border-agent-border bg-agent-muted/30 text-agent-foreground/80">
369
+ <LuFileDiff className="h-3.5 w-3.5" />
370
+ </div>
371
+ <div className="min-w-0">
372
+ <div className="truncate text-xs font-medium text-agent-foreground">
373
+ 修改了 {files.length} 个文件
374
+ </div>
375
+ {(totalAdditions > 0 || totalDeletions > 0) && (
376
+ <div className="flex items-center gap-1.5 font-mono text-[11px] tabular-nums text-agent-muted-foreground">
377
+ <span className="font-medium text-emerald-600 dark:text-emerald-400">
378
+ +{totalAdditions}
104
379
  </span>
105
- {size && (
106
- <span className="shrink-0 tabular-nums text-[11px] text-agent-muted-foreground">
107
- {size}
380
+ <span>-{totalDeletions}</span>
381
+ </div>
382
+ )}
383
+ </div>
384
+ </div>
385
+
386
+ {/* 右侧动作按钮:Undo 与 查看详情 */}
387
+ <div className="flex items-center gap-1.5 shrink-0" onClick={(e) => e.stopPropagation()}>
388
+ <button
389
+ type="button"
390
+ disabled
391
+ title="撤销本轮文件改动"
392
+ className="flex items-center gap-1 px-1.5 py-0.5 text-xs text-agent-muted-foreground/50 cursor-default"
393
+ >
394
+ <span>撤销</span>
395
+ <LuUndo2 className="h-3 w-3" />
396
+ </button>
397
+ <button
398
+ type="button"
399
+ aria-expanded={expanded}
400
+ onClick={() => setExpanded(!expanded)}
401
+ className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-0.5 text-xs font-medium text-agent-foreground hover:bg-agent-muted/50 transition-colors"
402
+ >
403
+ 查看详情
404
+ </button>
405
+ </div>
406
+ </div>
407
+
408
+ {/* 文件列表 */}
409
+ {expanded && (
410
+ <ul className="max-h-60 overflow-y-auto border-t border-agent-border/60 divide-y divide-agent-border/40 py-0.5">
411
+ {files.map((file) => {
412
+ const displayPath = formatIntermediateDisplayPath(file.path);
413
+ const size = formatFileSize(file.size);
414
+ const busy = openingPath === file.path;
415
+ const error = openError?.path === file.path ? openError.message : null;
416
+ const hasDiffStats =
417
+ typeof file.additions === 'number' || typeof file.deletions === 'number';
418
+
419
+ return (
420
+ <li key={file.path}>
421
+ <button
422
+ type="button"
423
+ onClick={() => onOpen(file)}
424
+ disabled={!allowOpen || openingPath !== null}
425
+ title={file.path}
426
+ className={`group flex w-full items-center justify-between px-3 py-1.5 text-left text-xs transition-colors ${
427
+ allowOpen
428
+ ? 'hover:bg-agent-foreground/5 cursor-pointer disabled:cursor-wait'
429
+ : 'cursor-default'
430
+ }`}
431
+ data-turn-file=""
432
+ data-kind={file.kind}
433
+ >
434
+ <span className="min-w-0 flex-1 truncate text-xs text-agent-foreground/90 group-hover:text-agent-foreground">
435
+ {displayPath}
108
436
  </span>
437
+
438
+ <span className="shrink-0 pl-2">
439
+ {busy ? (
440
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin text-agent-muted-foreground" />
441
+ ) : hasDiffStats ? (
442
+ <span className="flex items-center gap-1.5 font-mono text-[11px] tabular-nums">
443
+ <span className="font-medium text-emerald-600 dark:text-emerald-400">
444
+ +{file.additions ?? 0}
445
+ </span>
446
+ <span className="text-agent-muted-foreground">
447
+ -{file.deletions ?? 0}
448
+ </span>
449
+ </span>
450
+ ) : size ? (
451
+ <span className="text-[11px] text-agent-muted-foreground tabular-nums">
452
+ {size}
453
+ </span>
454
+ ) : null}
455
+ </span>
456
+ </button>
457
+
458
+ {error && (
459
+ <div className="px-3 pb-1 text-[11px] text-agent-destructive" role="status">
460
+ 打开失败:{error}
461
+ </div>
109
462
  )}
110
- </button>
111
- {error && (
112
- <div className="px-3 pb-1 text-[11px] text-agent-destructive" role="status">
113
- 打开失败:{error}
114
- </div>
115
- )}
116
- </li>
117
- );
118
- })}
119
- </ul>
463
+ </li>
464
+ );
465
+ })}
466
+ </ul>
467
+ )}
120
468
  </div>
121
469
  );
122
470
  }