@steerable/agent-shell-web 0.6.20 → 0.6.21

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.
@@ -0,0 +1,65 @@
1
+ import { useEffect, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ import { openLocalPath, type ResolvedLocalPath } from '@/lib/local-api';
3
+ import { peekResolvedPath, subscribeResolvedPath } from './path-mentions';
4
+
5
+ /**
6
+ * 行内代码里的文件路径:后端确认存在后变成可点击,点击用系统默认应用打开。
7
+ *
8
+ * 未确认存在(或后端不可达)时渲染成与普通行内代码完全一致的外观——路径
9
+ * 靠形状判断挑出,误报难免,不能给点不开的假 affordance。
10
+ */
11
+
12
+ const INLINE_CODE_CLASS =
13
+ 'rounded bg-agent-muted px-1 py-0.5 font-mono text-[0.85em] text-agent-foreground';
14
+
15
+ interface FilePathCodeProps extends ComponentPropsWithoutRef<'code'> {
16
+ /** 行内代码的纯文本,即待解析的路径字面量。 */
17
+ candidate: string;
18
+ /** 当前对话(相对路径按其绑定项目根解析),落地页可缺省。 */
19
+ chatId?: string | null;
20
+ children: ReactNode;
21
+ }
22
+
23
+ export function FilePathCode({ candidate, chatId, children, ...rest }: FilePathCodeProps) {
24
+ const [resolved, setResolved] = useState<ResolvedLocalPath | null>(
25
+ () => peekResolvedPath(candidate, chatId) ?? null,
26
+ );
27
+ const [openError, setOpenError] = useState<string | null>(null);
28
+
29
+ useEffect(() => {
30
+ setOpenError(null);
31
+ return subscribeResolvedPath(candidate, chatId, (entry) => setResolved(entry));
32
+ }, [candidate, chatId]);
33
+
34
+ if (!resolved) {
35
+ return (
36
+ <code {...rest} className={INLINE_CODE_CLASS}>
37
+ {children}
38
+ </code>
39
+ );
40
+ }
41
+
42
+ const handleClick = async () => {
43
+ setOpenError(null);
44
+ try {
45
+ const result = await openLocalPath(resolved.path);
46
+ if (!result.success) setOpenError(result.error || '打开失败');
47
+ } catch (err) {
48
+ setOpenError(err instanceof Error ? err.message : '打开失败');
49
+ }
50
+ };
51
+
52
+ return (
53
+ <button
54
+ type="button"
55
+ data-file-path-chip=""
56
+ onClick={handleClick}
57
+ title={openError ? `${resolved.path}(${openError})` : `点击打开 ${resolved.path}`}
58
+ className={`${INLINE_CODE_CLASS} cursor-pointer underline decoration-dotted underline-offset-2 transition-colors hover:bg-agent-accent/15 hover:text-agent-accent ${
59
+ openError ? 'text-red-600 dark:text-red-400' : ''
60
+ }`}
61
+ >
62
+ {children}
63
+ </button>
64
+ );
65
+ }
@@ -15,6 +15,7 @@ import { MessageList } from './MessageList';
15
15
  import type { ExecutedAction } from './ExecutedActionsCard';
16
16
  import type { ChildInfo } from './OrchestrationChildrenCard';
17
17
  import type { TurnBlock } from './turn-timeline';
18
+ import type { TurnFile } from './turn-files';
18
19
  import {
19
20
  isImageFile,
20
21
  saveChatAttachments,
@@ -97,6 +98,10 @@ export interface LocalChatPanelProps {
97
98
  currentTurnTimeline?: TurnBlock[];
98
99
  currentTurnStartedAtMs?: number;
99
100
  durationByMessageId?: Record<string, number>;
101
+ /** 回合产物文件列表(按落库消息 id 键控),转发给 MessageList。 */
102
+ turnFilesByMessageId?: Record<string, TurnFile[]>;
103
+ /** 当轮产物文件(流尾声到达、尚未归档到落库 id 的尾部消息用)。 */
104
+ currentTurnFiles?: TurnFile[];
100
105
  /** P3.1: in-flight child agents + per-message reconciliation map. */
101
106
  currentTurnChildren?: ChildInfo[];
102
107
  orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
@@ -141,6 +146,11 @@ export interface LocalChatPanelProps {
141
146
  onDismissFinishedTask?: (taskId: string) => void;
142
147
  /** 分享当前对话(截图)。落到最近一条助手消息的时间戳行。 */
143
148
  onShare?: () => Promise<boolean>;
149
+ /**
150
+ * 最近一条助手回复下的下一轮输入建议。点击即作为新用户消息发出。
151
+ */
152
+ suggestedReplies?: string[];
153
+ onSelectSuggestion?: (text: string) => void;
144
154
  }
145
155
 
146
156
  export function LocalChatPanel({
@@ -166,6 +176,8 @@ export function LocalChatPanel({
166
176
  currentTurnTimeline,
167
177
  currentTurnStartedAtMs,
168
178
  durationByMessageId,
179
+ turnFilesByMessageId,
180
+ currentTurnFiles,
169
181
  currentTurnChildren,
170
182
  orchestrationChildrenByMessageId,
171
183
  currentRound,
@@ -189,6 +201,8 @@ export function LocalChatPanel({
189
201
  onInspectTask,
190
202
  onDismissFinishedTask,
191
203
  onShare,
204
+ suggestedReplies,
205
+ onSelectSuggestion,
192
206
  }: LocalChatPanelProps) {
193
207
  const [inputValue, setInputValue] = useState('');
194
208
  const [files, setFiles] = useState<AttachmentFile[]>([]);
@@ -374,6 +388,7 @@ export function LocalChatPanel({
374
388
  isStreaming={isStreaming}
375
389
  agents={agents}
376
390
  chats={chats}
391
+ chatId={chatId}
377
392
  currentAgent={currentAgent}
378
393
  executedActionsByMessageId={executedActionsByMessageId}
379
394
  currentTurnActions={currentTurnActions}
@@ -381,6 +396,8 @@ export function LocalChatPanel({
381
396
  currentTurnTimeline={currentTurnTimeline}
382
397
  currentTurnStartedAtMs={currentTurnStartedAtMs}
383
398
  durationByMessageId={durationByMessageId}
399
+ turnFilesByMessageId={turnFilesByMessageId}
400
+ currentTurnFiles={currentTurnFiles}
384
401
  currentTurnChildren={currentTurnChildren}
385
402
  orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
386
403
  currentRound={currentRound}
@@ -394,6 +411,8 @@ export function LocalChatPanel({
394
411
  onInspectTask={onInspectTask}
395
412
  onDismissFinishedTask={onDismissFinishedTask}
396
413
  onShare={onShare}
414
+ suggestedReplies={suggestedReplies}
415
+ onSelectSuggestion={onSelectSuggestion}
397
416
  />
398
417
  {inputBanner}
399
418
  {chatInputNode}
@@ -18,6 +18,8 @@ import {
18
18
  type SkillItem,
19
19
  } from '@/lib/slash-sources';
20
20
  import { PlanTodoList } from './PlanTodoList';
21
+ import { FilePathCode } from './FilePathCode';
22
+ import { looksLikeFilePath } from './path-mentions';
21
23
 
22
24
  /**
23
25
  * Markdown renderer shared by `UserMessage` and `AssistantMessage`.
@@ -146,7 +148,31 @@ const components: Components = {
146
148
  ),
147
149
 
148
150
  // ── Code (inline + block) ─────────────────────────────────────────────
149
- code: (props) => {
151
+ code: renderCode(null),
152
+ pre: (props: ComponentPropsWithoutRef<'pre'>) => {
153
+ // A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
154
+ // The code renderer above already swaps the inner node for PlanTodoList;
155
+ // here we drop the <pre> shell so the card isn't boxed in a code block.
156
+ if (containsPlanCode(props.children)) {
157
+ return <>{props.children}</>;
158
+ }
159
+ return (
160
+ <pre
161
+ {...props}
162
+ className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
163
+ />
164
+ );
165
+ },
166
+ };
167
+
168
+ /**
169
+ * 行内代码 / 代码块渲染器。`chatId` 只影响行内代码:形状上像本地路径的
170
+ * 字面量交给 FilePathCode,由它经后端确认存在后变成可点击(相对路径按
171
+ * 该对话绑定的项目根解析)。代码块内的路径不参与——整块代码是给人读/
172
+ * 复制的,逐 token 挑路径会把块结构打散。
173
+ */
174
+ function renderCode(chatId: string | null): Components['code'] {
175
+ return (props) => {
150
176
  const { children, className, ...rest } = props as ComponentPropsWithoutRef<'code'> & {
151
177
  // react-markdown v9 dropped the `inline` typed prop. Distinguish by
152
178
  // language fence (the only time class is set is for fenced blocks
@@ -167,6 +193,14 @@ const components: Components = {
167
193
  </code>
168
194
  );
169
195
  }
196
+ const text = childrenToText(children);
197
+ if (looksLikeFilePath(text)) {
198
+ return (
199
+ <FilePathCode {...rest} candidate={text.trim()} chatId={chatId}>
200
+ {children}
201
+ </FilePathCode>
202
+ );
203
+ }
170
204
  return (
171
205
  <code
172
206
  {...rest}
@@ -175,22 +209,8 @@ const components: Components = {
175
209
  {children}
176
210
  </code>
177
211
  );
178
- },
179
- pre: (props: ComponentPropsWithoutRef<'pre'>) => {
180
- // A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
181
- // The code renderer above already swaps the inner node for PlanTodoList;
182
- // here we drop the <pre> shell so the card isn't boxed in a code block.
183
- if (containsPlanCode(props.children)) {
184
- return <>{props.children}</>;
185
- }
186
- return (
187
- <pre
188
- {...props}
189
- className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
190
- />
191
- );
192
- },
193
- };
212
+ };
213
+ }
194
214
 
195
215
  function isPlanCodeClass(className: unknown): boolean {
196
216
  return typeof className === 'string' && className.split(/\s+/).includes('language-plan');
@@ -359,6 +379,11 @@ interface MarkdownProps {
359
379
  skills?: SkillItem[];
360
380
  /** MCP tools behind `/mcp__srv__tool` chips. */
361
381
  mcpTools?: McpToolItem[];
382
+ /**
383
+ * 当前对话。行内代码里的路径要按它绑定的项目根解析相对路径;缺省时
384
+ * 相对路径按 home 解析(同无项目对话里 exec 的缺省 cwd)。
385
+ */
386
+ chatId?: string | null;
362
387
  }
363
388
 
364
389
  /**
@@ -401,15 +426,19 @@ export function Markdown({
401
426
  chats = [],
402
427
  skills = [],
403
428
  mcpTools = [],
429
+ chatId = null,
404
430
  }: MarkdownProps) {
405
- const baseComponents: Components = inlineParagraph
406
- ? {
407
- ...components,
408
- p: (props: ComponentPropsWithoutRef<'p'>) => (
409
- <span style={{ whiteSpace: 'pre-line' }} {...props} />
410
- ),
411
- }
412
- : components;
431
+ const baseComponents: Components = {
432
+ ...components,
433
+ code: renderCode(chatId),
434
+ ...(inlineParagraph
435
+ ? {
436
+ p: (props: ComponentPropsWithoutRef<'p'>) => (
437
+ <span style={{ whiteSpace: 'pre-line' }} {...props} />
438
+ ),
439
+ }
440
+ : {}),
441
+ };
413
442
 
414
443
  const finalComponents = withTokens(baseComponents, (node) =>
415
444
  renderTextWithMentions(node, agents, chats, skills, mcpTools),
@@ -13,10 +13,12 @@ import { UserMessage } from './UserMessage';
13
13
  import { AssistantMessage } from './AssistantMessage';
14
14
  import { InterruptedTurnCard } from './InterruptedTurnCard';
15
15
  import { TaskOutcomeCards } from './TaskOutcomeCards';
16
+ import { SuggestedReplies } from './SuggestedReplies';
16
17
  import type { ExecutedAction } from './ExecutedActionsCard';
17
18
  import type { ChildInfo } from './OrchestrationChildrenCard';
18
19
  import type { ChatMode } from './ChatInput';
19
20
  import type { TurnBlock } from './turn-timeline';
21
+ import type { TurnFile } from './turn-files';
20
22
  import { inferDurationMs, readPersistedDurationMs } from './elapsed';
21
23
 
22
24
  /**
@@ -55,6 +57,11 @@ interface MessageListProps {
55
57
  emptyState?: ReactNode;
56
58
  agents: LocalChatAgent[];
57
59
  chats?: LocalChat[];
60
+ /**
61
+ * 当前对话。正文行内代码里的路径按其绑定项目根解析后,确认存在的变成
62
+ * 可点击(见 FilePathCode)。
63
+ */
64
+ chatId?: string | null;
58
65
  currentAgent: LocalChatAgent | null;
59
66
  /**
60
67
  * Tool calls keyed by the persisted message id (post-stream). Used for
@@ -73,6 +80,17 @@ interface MessageListProps {
73
80
  currentTurnStartedAtMs?: number;
74
81
  /** Frozen duration keyed by assistant message id (live freeze + history). */
75
82
  durationByMessageId?: Record<string, number>;
83
+ /**
84
+ * 回合产物文件列表,按落库消息 id 键控(live 回合在 message_id 事件时
85
+ * 归档;历史回合从 messageMetadata.turnFiles 水合)。
86
+ */
87
+ turnFilesByMessageId?: Record<string, TurnFile[]>;
88
+ /**
89
+ * 当轮产物文件:turn_files 事件在流尾声到达,此时尾部助手消息仍挂着
90
+ * 占位 id(框架不会在 message_id 后改写它),归档 map 按键查不到——
91
+ * 与 currentTurnActions 同款尾部回退。
92
+ */
93
+ currentTurnFiles?: TurnFile[];
76
94
  /**
77
95
  * Tool calls accumulated for the in-flight assistant message that the
78
96
  * backend hasn't assigned a DB id to yet. Rendered under the latest
@@ -116,6 +134,11 @@ interface MessageListProps {
116
134
  onDismissFinishedTask?: (taskId: string) => void;
117
135
  /** 分享当前对话(截图)。只画在最近一条助手消息的时间戳行上。 */
118
136
  onShare?: () => Promise<boolean>;
137
+ /**
138
+ * 最近一条助手回复下的下一轮输入建议(WorkBuddy 式)。只在非流式时渲染。
139
+ */
140
+ suggestedReplies?: string[];
141
+ onSelectSuggestion?: (text: string) => void;
119
142
  }
120
143
 
121
144
  export function MessageList({
@@ -124,6 +147,7 @@ export function MessageList({
124
147
  emptyState,
125
148
  agents,
126
149
  chats = [],
150
+ chatId = null,
127
151
  currentAgent,
128
152
  executedActionsByMessageId,
129
153
  currentTurnActions,
@@ -131,6 +155,8 @@ export function MessageList({
131
155
  currentTurnTimeline,
132
156
  currentTurnStartedAtMs,
133
157
  durationByMessageId,
158
+ turnFilesByMessageId,
159
+ currentTurnFiles,
134
160
  currentTurnChildren,
135
161
  orchestrationChildrenByMessageId,
136
162
  currentRound,
@@ -143,6 +169,8 @@ export function MessageList({
143
169
  onInspectTask,
144
170
  onDismissFinishedTask,
145
171
  onShare,
172
+ suggestedReplies,
173
+ onSelectSuggestion,
146
174
  }: MessageListProps) {
147
175
  const containerRef = useRef<HTMLDivElement>(null);
148
176
  const endRef = useRef<HTMLDivElement>(null);
@@ -196,10 +224,11 @@ export function MessageList({
196
224
  const lastTimelineSig = currentTurnTimeline
197
225
  ?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
198
226
  .join('|') ?? '';
227
+ const suggestedSig = suggestedReplies?.join('\0') ?? '';
199
228
  useEffect(() => {
200
229
  if (!isAtBottom) return;
201
230
  scrollToBottom('smooth');
202
- }, [lastMessageId, lastContentLen, lastTimelineSig, isAtBottom, scrollToBottom]);
231
+ }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, isAtBottom, scrollToBottom]);
203
232
 
204
233
  return (
205
234
  <div className="relative flex-1 overflow-hidden">
@@ -253,6 +282,12 @@ export function MessageList({
253
282
  const turnTimeline = persistedTimeline
254
283
  ?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
255
284
 
285
+ const persistedTurnFiles = turnFilesByMessageId?.[message.id];
286
+ const isTurnFilesTail =
287
+ isLast && currentTurnFiles !== undefined && currentTurnFiles.length > 0;
288
+ const turnFiles = persistedTurnFiles
289
+ ?? (isTurnFilesTail ? currentTurnFiles : undefined);
290
+
256
291
  const metadataJson =
257
292
  typeof message.messageMetadata === 'string'
258
293
  ? message.messageMetadata
@@ -271,23 +306,36 @@ export function MessageList({
271
306
  ?? inferDurationMs(previousUserCreatedAt, message.createdAt);
272
307
 
273
308
  return (
274
- <AssistantMessage
275
- key={message.id}
276
- message={message}
277
- isStreaming={isStreamingTail}
278
- agents={agents}
279
- chats={chats}
280
- currentAgent={currentAgent}
281
- executedActions={actions}
282
- timeline={turnTimeline}
283
- orchestrationChildren={childList}
284
- currentRound={isStreamingTail ? currentRound : undefined}
285
- isPlanMode={isPlanMode}
286
- onRegenerate={onRegenerate}
287
- startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
288
- durationMs={durationMs}
289
- onShare={message.id === lastAssistantId ? onShare : undefined}
290
- />
309
+ <div key={message.id}>
310
+ <AssistantMessage
311
+ message={message}
312
+ isStreaming={isStreamingTail}
313
+ agents={agents}
314
+ chats={chats}
315
+ chatId={chatId}
316
+ currentAgent={currentAgent}
317
+ executedActions={actions}
318
+ timeline={turnTimeline}
319
+ orchestrationChildren={childList}
320
+ currentRound={isStreamingTail ? currentRound : undefined}
321
+ isPlanMode={isPlanMode}
322
+ onRegenerate={onRegenerate}
323
+ startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
324
+ durationMs={durationMs}
325
+ turnFiles={turnFiles}
326
+ onShare={message.id === lastAssistantId ? onShare : undefined}
327
+ />
328
+ {!isStreaming &&
329
+ message.id === lastAssistantId &&
330
+ suggestedReplies &&
331
+ suggestedReplies.length > 0 &&
332
+ onSelectSuggestion ? (
333
+ <SuggestedReplies
334
+ suggestions={suggestedReplies}
335
+ onSelect={onSelectSuggestion}
336
+ />
337
+ ) : null}
338
+ </div>
291
339
  );
292
340
  })}
293
341
  {/* W7-1: 中断提示卡在消息列尾部、与最后一条用户消息同列——
@@ -104,7 +104,9 @@ export function ModelPicker({
104
104
 
105
105
  return (
106
106
  <div ref={rootRef} className="flex min-w-0 items-center gap-1">
107
- <div className="relative">
107
+ {/* flex + min-w-0 截断链:窄容器下模型 id 省略号收缩,而不是把
108
+ 旁边的圆形图标按钮压扁。 */}
109
+ <div className="relative flex min-w-0">
108
110
  <button
109
111
  type="button"
110
112
  onClick={() => {
@@ -115,7 +117,7 @@ export function ModelPicker({
115
117
  if (next && status !== 'live' && !loading) void refresh();
116
118
  }}
117
119
  disabled={disabled}
118
- className="inline-flex h-7 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
120
+ className="inline-flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
119
121
  title={
120
122
  status === 'offline'
121
123
  ? `模型目录不可用${catalog?.error ? `:${catalog.error}` : ''}(仍可手动指定模型)`
@@ -124,7 +126,7 @@ export function ModelPicker({
124
126
  aria-haspopup="menu"
125
127
  aria-expanded={modelMenuOpen}
126
128
  >
127
- <span className="truncate">{effectiveModel || '选择模型'}</span>
129
+ <span className="min-w-0 truncate">{effectiveModel || '选择模型'}</span>
128
130
  {model != null && (
129
131
  <span className="inline-flex shrink-0 items-center rounded bg-agent-foreground/10 px-1 py-0.5 text-[9px] font-medium text-agent-foreground">
130
132
  本会话
@@ -0,0 +1,34 @@
1
+ /**
2
+ * WorkBuddy-style follow-up chips: three next-turn user inputs rendered
3
+ * under the latest assistant reply. Clicking one sends that text immediately.
4
+ */
5
+ export function SuggestedReplies({
6
+ suggestions,
7
+ onSelect,
8
+ }: {
9
+ suggestions: string[];
10
+ onSelect: (text: string) => void;
11
+ }) {
12
+ if (suggestions.length === 0) return null;
13
+
14
+ return (
15
+ <div
16
+ className="mx-auto flex w-full max-w-[var(--chat-input-box-width)] flex-col items-start gap-1.5 px-1 pt-1"
17
+ data-testid="suggested-replies"
18
+ >
19
+ {suggestions.map((text) => (
20
+ <button
21
+ key={text}
22
+ type="button"
23
+ data-testid="suggested-reply"
24
+ onClick={() => onSelect(text)}
25
+ className="max-w-full rounded-full border border-agent-border bg-agent-muted/40 px-3 py-1.5 text-left text-[13px] leading-snug text-agent-foreground transition-colors hover:bg-agent-foreground/5"
26
+ >
27
+ {text}
28
+ </button>
29
+ ))}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ export default SuggestedReplies;
@@ -0,0 +1,113 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ LuFilePen,
4
+ LuFilePlus,
5
+ LuFiles,
6
+ LuLoaderCircle,
7
+ } from 'react-icons/lu';
8
+ import { openLocalPath } from '@/lib/local-api';
9
+ import {
10
+ formatFileSize,
11
+ splitTurnFilePath,
12
+ type TurnFile,
13
+ } from './turn-files';
14
+
15
+ /**
16
+ * TurnFilesCard — 回合产物文件列表(Codex 式「本轮写了哪些文件」)。
17
+ *
18
+ * 渲染在助手回合的末尾(回答气泡之下、时间戳行之上),回合内新建/修改的
19
+ * 文件逐行列出;点击行用系统默认应用打开该文件(PPT → PowerPoint/WPS,
20
+ * 报告 → 对应阅读器)。打开失败在行内给出错误提示,不打断会话。
21
+ *
22
+ * 数据来自 local-backend 的 `turn_files` SSE 事件 / messageMetadata 里的
23
+ * `turnFiles`(见 `src/local-backend/turn-files.ts` 的收集逻辑)。
24
+ */
25
+
26
+ interface TurnFilesCardProps {
27
+ files: TurnFile[];
28
+ }
29
+
30
+ export function TurnFilesCard({ files }: TurnFilesCardProps) {
31
+ const [openingPath, setOpeningPath] = useState<string | null>(null);
32
+ const [openError, setOpenError] = useState<{ path: string; message: string } | null>(null);
33
+
34
+ if (files.length === 0) return null;
35
+
36
+ const handleOpen = async (file: TurnFile) => {
37
+ if (openingPath) return;
38
+ setOpeningPath(file.path);
39
+ setOpenError(null);
40
+ try {
41
+ const result = await openLocalPath(file.path);
42
+ if (!result.success) {
43
+ setOpenError({ path: file.path, message: result.error || '打开失败' });
44
+ }
45
+ } catch (err) {
46
+ setOpenError({
47
+ path: file.path,
48
+ message: err instanceof Error ? err.message : String(err),
49
+ });
50
+ } finally {
51
+ setOpeningPath(null);
52
+ }
53
+ };
54
+
55
+ return (
56
+ <div
57
+ className="rounded-agent-lg border border-agent-border bg-agent-canvas shadow-sm"
58
+ data-turn-files=""
59
+ >
60
+ <div className="flex items-center gap-1.5 border-b border-agent-border/60 px-3 py-1.5 text-[11px] text-agent-muted-foreground">
61
+ <LuFiles className="h-3.5 w-3.5" />
62
+ <span>本轮产生了 {files.length} 个文件,点击打开</span>
63
+ </div>
64
+ <ul className="max-h-64 overflow-y-auto py-1">
65
+ {files.map((file) => {
66
+ const { dir, name } = splitTurnFilePath(file.path);
67
+ const size = formatFileSize(file.size);
68
+ const busy = openingPath === file.path;
69
+ const error = openError?.path === file.path ? openError.message : null;
70
+ return (
71
+ <li key={file.path}>
72
+ <button
73
+ type="button"
74
+ onClick={() => void handleOpen(file)}
75
+ disabled={openingPath !== null}
76
+ title={file.path}
77
+ className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-agent-foreground/5 disabled:cursor-wait"
78
+ data-turn-file=""
79
+ data-kind={file.kind}
80
+ >
81
+ {busy ? (
82
+ <LuLoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-agent-muted-foreground" />
83
+ ) : file.kind === 'created' ? (
84
+ <LuFilePlus className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
85
+ ) : (
86
+ <LuFilePen className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
87
+ )}
88
+ <span className="min-w-0 flex-1 truncate">
89
+ <span className="font-medium text-agent-foreground">{name}</span>
90
+ {dir && (
91
+ <span className="ml-1.5 text-agent-muted-foreground">{dir}</span>
92
+ )}
93
+ </span>
94
+ {size && (
95
+ <span className="shrink-0 tabular-nums text-[11px] text-agent-muted-foreground">
96
+ {size}
97
+ </span>
98
+ )}
99
+ </button>
100
+ {error && (
101
+ <div className="px-3 pb-1 text-[11px] text-agent-destructive" role="status">
102
+ 打开失败:{error}
103
+ </div>
104
+ )}
105
+ </li>
106
+ );
107
+ })}
108
+ </ul>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ export default TurnFilesCard;
@@ -60,11 +60,13 @@ function ProcessBlocks({
60
60
  isStreaming,
61
61
  agents,
62
62
  chats,
63
+ chatId,
63
64
  }: {
64
65
  blocks: TurnBlock[];
65
66
  isStreaming: boolean;
66
67
  agents: LocalChatAgent[];
67
68
  chats: LocalChat[];
69
+ chatId?: string | null;
68
70
  }) {
69
71
  const lastIndex = blocks.length - 1;
70
72
  return (
@@ -77,7 +79,7 @@ function ProcessBlocks({
77
79
  key={`reasoning-${index}`}
78
80
  className="text-xs leading-relaxed text-agent-muted-foreground"
79
81
  >
80
- <Markdown agents={agents} chats={chats}>{block.content}</Markdown>
82
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
81
83
  {isStreaming && isLast && (
82
84
  <span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
83
85
  )}
@@ -92,7 +94,7 @@ function ProcessBlocks({
92
94
  key={`text-${index}`}
93
95
  className="markdown-content text-xs leading-relaxed text-agent-muted-foreground"
94
96
  >
95
- <Markdown agents={agents} chats={chats}>{block.content}</Markdown>
97
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
96
98
  </div>
97
99
  );
98
100
  })}
@@ -105,6 +107,7 @@ export function TurnProcessGroup({
105
107
  isStreaming,
106
108
  agents,
107
109
  chats,
110
+ chatId,
108
111
  emptyFallback,
109
112
  streamingHint,
110
113
  startedAtMs,
@@ -115,6 +118,7 @@ export function TurnProcessGroup({
115
118
  isStreaming: boolean;
116
119
  agents: LocalChatAgent[];
117
120
  chats: LocalChat[];
121
+ chatId?: string | null;
118
122
  emptyFallback: ReactNode;
119
123
  /** Shown while streaming after a tools row and before the summary lands. */
120
124
  streamingHint?: ReactNode;
@@ -180,6 +184,7 @@ export function TurnProcessGroup({
180
184
  isStreaming={isStreaming && answer.length === 0}
181
185
  agents={agents}
182
186
  chats={chats}
187
+ chatId={chatId}
183
188
  />
184
189
  )}
185
190
  {showStreamingHint ? streamingHint : null}