@steerable/agent-shell-web 0.6.27 → 0.6.29

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 (53) hide show
  1. package/package.json +4 -4
  2. package/src/components/AgentSidebar.tsx +27 -27
  3. package/src/components/BrandLockup.tsx +20 -4
  4. package/src/components/ChatHeader.tsx +5 -15
  5. package/src/components/LocalLlmSettingsModal.tsx +5 -5
  6. package/src/components/TerminalPanel.tsx +1 -1
  7. package/src/components/chat/ApprovalModal.tsx +5 -5
  8. package/src/components/chat/AskUserQuestionMenu.tsx +13 -13
  9. package/src/components/chat/AssistantMessage.tsx +122 -36
  10. package/src/components/chat/ChatInput.tsx +34 -26
  11. package/src/components/chat/ExecPolicyPicker.tsx +4 -4
  12. package/src/components/chat/ExecutedActionsCard.tsx +398 -29
  13. package/src/components/chat/FilePathCode.tsx +2 -1
  14. package/src/components/chat/LocalChatPanel.tsx +42 -11
  15. package/src/components/chat/Markdown.tsx +15 -14
  16. package/src/components/chat/MessageList.tsx +23 -5
  17. package/src/components/chat/ModelPicker.tsx +6 -6
  18. package/src/components/chat/OrchestrationChildrenCard.tsx +69 -5
  19. package/src/components/chat/SessionTodoList.tsx +234 -0
  20. package/src/components/chat/SessionTreeModal.tsx +3 -3
  21. package/src/components/chat/SuggestedReplies.tsx +14 -5
  22. package/src/components/chat/TaskPanelModal.tsx +3 -3
  23. package/src/components/chat/TaskProcessPanel.tsx +67 -21
  24. package/src/components/chat/TurnFilesCard.tsx +2 -2
  25. package/src/components/chat/TurnProcessGroup.tsx +252 -99
  26. package/src/components/chat/UserMessage.tsx +7 -7
  27. package/src/components/chat/executed-actions-model.ts +469 -0
  28. package/src/components/chat/next-steps-tags.ts +28 -0
  29. package/src/components/chat/orchestration-children-model.ts +120 -0
  30. package/src/components/chat/process-status.ts +125 -53
  31. package/src/components/chat/todo-list-model.ts +167 -0
  32. package/src/components/chat/turn-timeline.ts +123 -11
  33. package/src/components/settings/AgentsSettingsPanel.tsx +2 -2
  34. package/src/components/settings/AppearanceSettingsPanel.tsx +3 -3
  35. package/src/components/settings/DiagnoseSettingsPanel.tsx +2 -2
  36. package/src/components/settings/InsightsSettingsPanel.tsx +4 -4
  37. package/src/components/settings/LlmSettingsPanel.tsx +10 -10
  38. package/src/components/settings/McpSettingsPanel.tsx +3 -3
  39. package/src/components/settings/ModelIdCombobox.tsx +2 -2
  40. package/src/components/settings/SecuritySettingsPanel.tsx +1 -1
  41. package/src/components/settings/SettingsSaveButton.tsx +1 -1
  42. package/src/components/settings/SkillsSettingsPanel.tsx +2 -2
  43. package/src/components/settings/TelemetrySettingsPanel.tsx +2 -2
  44. package/src/components/settings/UsagePanel.tsx +14 -10
  45. package/src/components/settings/WebSearchSettingsPanel.tsx +2 -2
  46. package/src/layouts/AgentLayout.tsx +3 -3
  47. package/src/lib/chat-transport.ts +68 -12
  48. package/src/lib/local-api.ts +11 -0
  49. package/src/lib/show-thinking-content.ts +4 -4
  50. package/src/pages/AgentPage.tsx +82 -11
  51. package/src/pages/ChatPanelPreviewPage.tsx +26 -4
  52. package/src/pages/SettingsPage.tsx +39 -39
  53. package/src/styles/index.css +12 -2
@@ -1,9 +1,32 @@
1
1
  import type { ReactNode } from 'react';
2
- import { LuCircleCheck, LuCircleX } from 'react-icons/lu';
2
+ import { LuCircleCheck, LuCircleX, LuListTodo } from 'react-icons/lu';
3
3
  import { ToolExecutionCard } from '@steerable/agent-ui/cards';
4
4
  import type { ToolExecutionPayload } from '@steerable/agent-protocol';
5
+ import type { LocalChatAgent } from '@/lib/local-api';
5
6
  import { UnifiedDiff } from './UnifiedDiff';
6
- import { summarizeRunCodeAction, summarizeWebAction, expandRunCodeActions } from './executed-actions-model';
7
+ import {
8
+ summarizeRunCodeAction,
9
+ summarizeWebAction,
10
+ expandRunCodeActions,
11
+ parseDelegateSubagent,
12
+ summarizeDelegateSubagent,
13
+ delegateSubagentDisplayName,
14
+ parseTaskRun,
15
+ parseTaskToolOutput,
16
+ parseToolEnvelope,
17
+ humanizeDelegateError,
18
+ summarizeTaskAction,
19
+ taskToolLabel,
20
+ isTaskFamilyTool,
21
+ inspectableTaskFromAction,
22
+ shortTaskId,
23
+ type InspectTaskInput,
24
+ } from './executed-actions-model';
25
+ import {
26
+ findAgentForProfile,
27
+ matchChildForDelegation,
28
+ type ChildInfo,
29
+ } from './orchestration-children-model';
7
30
 
8
31
  /**
9
32
  * ExecutedActionsCard — visual surface for the `executed_actions` SSE event
@@ -42,15 +65,26 @@ interface ExecutedActionsCardProps {
42
65
  actions: ExecutedAction[];
43
66
  /** Drop the outer card chrome when nested in a turn-process group. */
44
67
  compact?: boolean;
68
+ /** Show args/output instead of a one-line summary. Defaults to collapsed. */
69
+ defaultExpanded?: boolean;
70
+ /** Used to map `delegate_subagent` 画像名 to the agent's display name + color. */
71
+ agents?: ReadonlyArray<Pick<LocalChatAgent, 'id' | 'slug' | 'name' | 'color'>>;
72
+ chatId?: string | null;
73
+ onInspectTask?: (task: InspectTaskInput) => void;
74
+ /**
75
+ * 本回合的子代理生命周期。委派行据此找到子代理自己的 record,点一下就能
76
+ * 在右侧看它的推理过程。
77
+ */
78
+ orchestrationChildren?: ChildInfo[];
45
79
  }
46
80
 
47
81
  function deriveStatus(action: ExecutedAction): ToolExecutionPayload['status'] {
48
82
  const result = action.result;
49
83
  if (result === undefined || result === null) return 'running';
84
+ const env = parseToolEnvelope(result);
85
+ if (env.success === false || env.error) return 'failed';
86
+ if (env.success === true) return 'succeeded';
50
87
  if (typeof result !== 'object') return 'succeeded';
51
- if ('success' in result) {
52
- return (result as { success?: unknown }).success ? 'succeeded' : 'failed';
53
- }
54
88
  if ('error' in result) return 'failed';
55
89
  return 'succeeded';
56
90
  }
@@ -83,15 +117,22 @@ function sandboxBadge(sandbox: ExecutedAction['sandbox']): string | null {
83
117
 
84
118
  function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload {
85
119
  const status = deriveStatus(action);
120
+ const env = parseToolEnvelope(action.result);
86
121
  const errorText =
87
- status === 'failed' && action.result && typeof action.result === 'object'
88
- ? typeof (action.result as { error?: unknown }).error === 'string'
89
- ? ((action.result as { error: string }).error)
90
- : null
122
+ status === 'failed'
123
+ ? action.tool === 'delegate_subagent'
124
+ ? humanizeDelegateError(env.error)
125
+ : env.error
91
126
  : null;
92
127
  // W5-2: web_search/web_fetch 用结构化中文摘要(查询词/URL + 结果计数/
93
128
  // 状态码),其余工具走通用参数摘要。
94
129
  const summary =
130
+ (action.tool === 'delegate_subagent'
131
+ ? summarizeDelegateSubagent(action.arguments)
132
+ : null) ??
133
+ (isTaskFamilyTool(action.tool)
134
+ ? summarizeTaskAction(action.tool, action.arguments, action.result)
135
+ : null) ??
95
136
  summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
96
137
  summarizeWebAction(action.tool, action.arguments, action.result) ??
97
138
  summarizeArguments(action.arguments);
@@ -144,10 +185,312 @@ function renderActionOutput(output: unknown): ReactNode {
144
185
  );
145
186
  }
146
187
 
188
+ const BUILTIN_DELEGATE_COLORS: Record<string, string> = {
189
+ explore: '#0ea5e9',
190
+ research: '#2563eb',
191
+ coder: '#16a34a',
192
+ 'general-purpose': '#7c3aed',
193
+ };
194
+
195
+ function delegateColor(
196
+ profile: string | null,
197
+ agents: ReadonlyArray<Pick<LocalChatAgent, 'id' | 'slug' | 'name' | 'color'>>,
198
+ ): string {
199
+ if (profile) {
200
+ const agent = findAgentForProfile(profile, agents);
201
+ if (agent?.color) return agent.color;
202
+ if (BUILTIN_DELEGATE_COLORS[profile]) return BUILTIN_DELEGATE_COLORS[profile];
203
+ }
204
+ return '#7c3aed';
205
+ }
206
+
207
+ function DelegateLead({
208
+ name,
209
+ color,
210
+ }: {
211
+ name: string;
212
+ color: string;
213
+ }) {
214
+ return (
215
+ <span
216
+ data-agent-color-dot=""
217
+ className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full text-[8px] font-semibold text-white"
218
+ style={{ backgroundColor: color }}
219
+ aria-hidden
220
+ >
221
+ {name.trim()[0]?.toUpperCase() ?? 'A'}
222
+ </span>
223
+ );
224
+ }
225
+
226
+ function renderDelegateArgs(
227
+ args: unknown,
228
+ agents: ReadonlyArray<Pick<LocalChatAgent, 'id' | 'slug' | 'name' | 'color'>>,
229
+ ): ReactNode {
230
+ const { profile, task } = parseDelegateSubagent(args);
231
+ if (!profile && !task) {
232
+ return (
233
+ <pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5 text-[11px] text-agent-foreground">
234
+ {typeof args === 'string' ? args : JSON.stringify(args, null, 2)}
235
+ </pre>
236
+ );
237
+ }
238
+ // 自建智能体没有 slug 时画像名是 `agent-<id 片段>`,对用户无意义——出中文名。
239
+ const displayName = delegateSubagentDisplayName(profile, agents);
240
+ return (
241
+ <div className="space-y-1.5 text-[11px] text-agent-foreground">
242
+ {profile ? (
243
+ <section>
244
+ <div className="mb-0.5 text-[10px] uppercase tracking-wider text-agent-muted-foreground">
245
+ 子代理
246
+ </div>
247
+ <div className="text-[11px]">{displayName}</div>
248
+ </section>
249
+ ) : null}
250
+ {task ? (
251
+ <section>
252
+ <div className="mb-0.5 text-[10px] uppercase tracking-wider text-agent-muted-foreground">
253
+ 任务
254
+ </div>
255
+ <pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5">
256
+ {task}
257
+ </pre>
258
+ </section>
259
+ ) : null}
260
+ </div>
261
+ );
262
+ }
263
+
264
+ function renderDelegateOutput(output: unknown): ReactNode {
265
+ const env = parseToolEnvelope(output);
266
+ if (env.message) {
267
+ return (
268
+ <div className="space-y-1.5">
269
+ {kvBlock(
270
+ '回报',
271
+ <pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5">
272
+ {env.message}
273
+ </pre>,
274
+ )}
275
+ </div>
276
+ );
277
+ }
278
+ if (env.error || env.success === false) {
279
+ return <div className="text-[11px] text-agent-muted-foreground">没有文字回报</div>;
280
+ }
281
+ return renderActionOutput(output);
282
+ }
283
+
284
+ function TaskLead() {
285
+ return (
286
+ <span
287
+ data-task-lead=""
288
+ className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded bg-amber-100/80 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
289
+ aria-hidden
290
+ >
291
+ <LuListTodo className="h-2.5 w-2.5" />
292
+ </span>
293
+ );
294
+ }
295
+
296
+ const TASK_STATUS_ZH: Record<string, string> = {
297
+ running: '已启动',
298
+ blocked: '等待依赖',
299
+ completed: '已完成',
300
+ failed: '失败',
301
+ };
302
+
303
+ function kvBlock(label: string, value: ReactNode): ReactNode {
304
+ return (
305
+ <section>
306
+ <div className="mb-0.5 text-[10px] uppercase tracking-wider text-agent-muted-foreground">
307
+ {label}
308
+ </div>
309
+ <div className="text-[11px] text-agent-foreground">{value}</div>
310
+ </section>
311
+ );
312
+ }
313
+
314
+ function renderTaskArgs(tool: string, args: unknown): ReactNode {
315
+ if (tool === 'task_run') {
316
+ const parsed = parseTaskRun(args);
317
+ if (!parsed.task && parsed.dependsOn.length === 0 && !parsed.worktree) {
318
+ return renderActionOutput(args);
319
+ }
320
+ return (
321
+ <div className="space-y-1.5">
322
+ {parsed.task
323
+ ? kvBlock(
324
+ '任务',
325
+ <pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5">
326
+ {parsed.task}
327
+ </pre>,
328
+ )
329
+ : null}
330
+ {parsed.dependsOn.length > 0
331
+ ? kvBlock('依赖', parsed.dependsOn.map(shortTaskId).join('、'))
332
+ : null}
333
+ {parsed.worktree
334
+ ? kvBlock('工作区', parsed.worktreeName ? `隔离 · ${parsed.worktreeName}` : '隔离')
335
+ : null}
336
+ </div>
337
+ );
338
+ }
339
+ const obj = args && typeof args === 'object' ? (args as Record<string, unknown>) : null;
340
+ const taskId = typeof obj?.taskId === 'string' ? obj.taskId : null;
341
+ const message = typeof obj?.message === 'string' ? obj.message : null;
342
+ if (!taskId && !message) return renderActionOutput(args);
343
+ return (
344
+ <div className="space-y-1.5">
345
+ {taskId ? kvBlock('编号', <span className="font-mono">{shortTaskId(taskId)}</span>) : null}
346
+ {message
347
+ ? kvBlock(
348
+ '消息',
349
+ <pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5">
350
+ {message}
351
+ </pre>,
352
+ )
353
+ : null}
354
+ </div>
355
+ );
356
+ }
357
+
358
+ function renderTaskOutput(output: unknown): ReactNode {
359
+ const parsed = parseTaskToolOutput(output);
360
+ if (parsed.items.length === 0 && !parsed.hint) {
361
+ return renderActionOutput(output);
362
+ }
363
+ return (
364
+ <div className="space-y-2">
365
+ {parsed.items.map((item, index) => (
366
+ <div key={item.taskId ?? `task-${index}`} className="space-y-1.5">
367
+ {item.taskId
368
+ ? kvBlock('编号', <span className="font-mono">{shortTaskId(item.taskId)}</span>)
369
+ : null}
370
+ {item.status ? kvBlock('状态', TASK_STATUS_ZH[item.status] ?? item.status) : null}
371
+ {item.task
372
+ ? kvBlock(
373
+ '任务',
374
+ <pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5">
375
+ {item.task}
376
+ </pre>,
377
+ )
378
+ : null}
379
+ {item.worktreeLabel ? kvBlock('工作区', item.worktreeLabel) : null}
380
+ {item.hint ? kvBlock('说明', item.hint) : null}
381
+ {item.answer
382
+ ? kvBlock(
383
+ '结果',
384
+ <pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5">
385
+ {item.answer}
386
+ </pre>,
387
+ )
388
+ : null}
389
+ {item.error ? kvBlock('原因', item.error) : null}
390
+ </div>
391
+ ))}
392
+ {parsed.hint && parsed.items.every((item) => item.hint !== parsed.hint)
393
+ ? kvBlock('说明', parsed.hint)
394
+ : null}
395
+ </div>
396
+ );
397
+ }
398
+
399
+ function toolCardExtras(
400
+ action: ExecutedAction,
401
+ agents: ReadonlyArray<Pick<LocalChatAgent, 'id' | 'slug' | 'name' | 'color'>>,
402
+ ): {
403
+ label?: string;
404
+ lead?: ReactNode;
405
+ renderArgs?: (args: unknown) => ReactNode;
406
+ renderOutput?: (output: unknown) => ReactNode;
407
+ } {
408
+ if (action.tool === 'delegate_subagent') {
409
+ const { profile } = parseDelegateSubagent(action.arguments);
410
+ const displayName = delegateSubagentDisplayName(profile, agents);
411
+ return {
412
+ label: `委派 · ${displayName}`,
413
+ lead: <DelegateLead name={displayName} color={delegateColor(profile, agents)} />,
414
+ renderArgs: (args) => renderDelegateArgs(args, agents),
415
+ renderOutput: renderDelegateOutput,
416
+ };
417
+ }
418
+ if (isTaskFamilyTool(action.tool)) {
419
+ return {
420
+ label: taskToolLabel(action.tool, action.arguments) ?? undefined,
421
+ lead: <TaskLead />,
422
+ renderArgs: (args) => renderTaskArgs(action.tool, args),
423
+ renderOutput: renderTaskOutput,
424
+ };
425
+ }
426
+ return { renderOutput: renderActionOutput };
427
+ }
428
+
429
+ function inspectActivate(
430
+ action: ExecutedAction,
431
+ chatId: string | null | undefined,
432
+ onInspectTask?: (task: InspectTaskInput) => void,
433
+ child?: ChildInfo,
434
+ ): { onActivate?: () => void; activateTitle?: string } {
435
+ if (!chatId || !onInspectTask) return {};
436
+ if (action.tool === 'delegate_subagent') {
437
+ if (!child?.recordId) return {};
438
+ const { task } = parseDelegateSubagent(action.arguments);
439
+ return {
440
+ onActivate: () =>
441
+ onInspectTask({
442
+ id: child.childId,
443
+ chatId,
444
+ recordId: child.recordId,
445
+ live: child.status === 'running',
446
+ title: task ?? '子代理任务',
447
+ }),
448
+ activateTitle: '查看子代理执行过程',
449
+ };
450
+ }
451
+ const target = inspectableTaskFromAction(action.tool, action.arguments, action.result);
452
+ if (!target) return {};
453
+ return {
454
+ onActivate: () => onInspectTask({ id: target.id, chatId, title: target.title }),
455
+ activateTitle: '查看后台执行过程',
456
+ };
457
+ }
458
+
459
+ /** 逐行配对委派与子代理:同画像多份活时按出现顺序对上。 */
460
+ function delegationChildren(
461
+ actions: readonly ExecutedAction[],
462
+ children: readonly ChildInfo[],
463
+ ): Map<number, ChildInfo> {
464
+ const paired = new Map<number, ChildInfo>();
465
+ if (children.length === 0) return paired;
466
+ const taken = new Set<string>();
467
+ actions.forEach((action, index) => {
468
+ if (action.tool !== 'delegate_subagent') return;
469
+ const match = matchChildForDelegation(
470
+ parseDelegateSubagent(action.arguments),
471
+ children,
472
+ taken,
473
+ );
474
+ if (!match) return;
475
+ taken.add(match.childId);
476
+ paired.set(index, match);
477
+ });
478
+ return paired;
479
+ }
480
+
147
481
  /** Inline tool rows for a mixed think→act timeline (no summary banner). */
148
- export function ToolsFlow({ actions, compact = false }: ExecutedActionsCardProps) {
482
+ export function ToolsFlow({
483
+ actions,
484
+ compact = false,
485
+ defaultExpanded = false,
486
+ agents = [],
487
+ chatId = null,
488
+ onInspectTask,
489
+ orchestrationChildren = [],
490
+ }: ExecutedActionsCardProps) {
149
491
  if (!actions || actions.length === 0) return null;
150
492
  const expanded = expandRunCodeActions(actions);
493
+ const childByIndex = delegationChildren(expanded, orchestrationChildren);
151
494
  return (
152
495
  <div
153
496
  className={
@@ -157,30 +500,47 @@ export function ToolsFlow({ actions, compact = false }: ExecutedActionsCardProps
157
500
  }
158
501
  >
159
502
  <div className="space-y-px">
160
- {expanded.map((action, i) => (
161
- <ToolExecutionCard
162
- key={action.id ?? `${action.tool}-${i}`}
163
- payload={actionToTool(action, i)}
164
- renderOutput={renderActionOutput}
165
- className="rounded-none border-0 border-t border-agent-border first:border-t-0"
166
- />
167
- ))}
503
+ {expanded.map((action, i) => {
504
+ const extras = toolCardExtras(action, agents);
505
+ const inspect = inspectActivate(action, chatId, onInspectTask, childByIndex.get(i));
506
+ return (
507
+ <ToolExecutionCard
508
+ key={action.id ?? `${action.tool}-${i}`}
509
+ payload={actionToTool(action, i)}
510
+ defaultExpanded={defaultExpanded}
511
+ label={extras.label}
512
+ lead={extras.lead}
513
+ renderArgs={extras.renderArgs}
514
+ renderOutput={extras.renderOutput ?? renderActionOutput}
515
+ onActivate={inspect.onActivate}
516
+ activateTitle={inspect.activateTitle}
517
+ className="rounded-none border-0 border-t border-agent-border first:border-t-0"
518
+ />
519
+ );
520
+ })}
168
521
  </div>
169
522
  </div>
170
523
  );
171
524
  }
172
525
 
173
- export function ExecutedActionsCard({ actions }: ExecutedActionsCardProps) {
526
+ export function ExecutedActionsCard({
527
+ actions,
528
+ agents = [],
529
+ chatId = null,
530
+ onInspectTask,
531
+ orchestrationChildren = [],
532
+ }: ExecutedActionsCardProps) {
174
533
  if (!actions || actions.length === 0) return null;
175
534
 
176
535
  const expanded = expandRunCodeActions(actions);
536
+ const childByIndex = delegationChildren(expanded, orchestrationChildren);
177
537
  const successCount = expanded.filter((a) => deriveStatus(a) === 'succeeded').length;
178
538
  const failureCount = expanded.filter((a) => deriveStatus(a) === 'failed').length;
179
539
  const runningCount = expanded.filter((a) => deriveStatus(a) === 'running').length;
180
540
 
181
541
  return (
182
- <div className="my-2 overflow-hidden rounded-agent-md border border-agent-border bg-agent-canvas">
183
- <div className="flex items-center justify-between border-b border-agent-border bg-agent-muted/30 px-3 py-1.5">
542
+ <div className="my-1.5 overflow-hidden rounded-agent-md border border-agent-border bg-agent-canvas">
543
+ <div className="flex items-center justify-between border-b border-agent-border bg-agent-muted/30 px-2.5 py-1">
184
544
  <div className="flex items-center gap-1.5 text-xs">
185
545
  {failureCount === 0 ? (
186
546
  <LuCircleCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
@@ -200,14 +560,23 @@ export function ExecutedActionsCard({ actions }: ExecutedActionsCardProps) {
200
560
  </span>
201
561
  </div>
202
562
  <div className="space-y-px">
203
- {expanded.map((action, i) => (
204
- <ToolExecutionCard
205
- key={action.id ?? `${action.tool}-${i}`}
206
- payload={actionToTool(action, i)}
207
- renderOutput={renderActionOutput}
208
- className="rounded-none border-0 border-t border-agent-border first:border-t-0"
209
- />
210
- ))}
563
+ {expanded.map((action, i) => {
564
+ const extras = toolCardExtras(action, agents);
565
+ const inspect = inspectActivate(action, chatId, onInspectTask, childByIndex.get(i));
566
+ return (
567
+ <ToolExecutionCard
568
+ key={action.id ?? `${action.tool}-${i}`}
569
+ payload={actionToTool(action, i)}
570
+ label={extras.label}
571
+ lead={extras.lead}
572
+ renderArgs={extras.renderArgs}
573
+ renderOutput={extras.renderOutput ?? renderActionOutput}
574
+ onActivate={inspect.onActivate}
575
+ activateTitle={inspect.activateTitle}
576
+ className="rounded-none border-0 border-t border-agent-border first:border-t-0"
577
+ />
578
+ );
579
+ })}
211
580
  </div>
212
581
  </div>
213
582
  );
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import { openLocalPath, type ResolvedLocalPath } from '@/lib/local-api';
3
3
  import { peekResolvedPath, subscribeResolvedPath } from './path-mentions';
4
+ import { splitTurnFilePath } from './turn-files';
4
5
 
5
6
  /**
6
7
  * 行内代码里的文件路径:后端确认存在后变成可点击,点击用系统默认应用打开。
@@ -59,7 +60,7 @@ export function FilePathCode({ candidate, chatId, children, ...rest }: FilePathC
59
60
  openError ? 'text-red-600 dark:text-red-400' : ''
60
61
  }`}
61
62
  >
62
- {children}
63
+ {splitTurnFilePath(resolved.path).name || children}
63
64
  </button>
64
65
  );
65
66
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import type { ChatMessage } from '@steerable/agent-protocol';
3
3
  import { ChatPanel } from '@steerable/agent-ui';
4
4
  import type { SteerOutcome } from '@steerable/agent-ui';
@@ -13,9 +13,13 @@ import {
13
13
  import { EmptyChat } from './EmptyChat';
14
14
  import { MessageList } from './MessageList';
15
15
  import type { ExecutedAction } from './ExecutedActionsCard';
16
+ import type { InspectTaskInput } from './executed-actions-model';
16
17
  import type { ChildInfo } from './OrchestrationChildrenCard';
17
18
  import type { TurnBlock } from './turn-timeline';
18
19
  import type { TurnFile } from './turn-files';
20
+ import type { LlmSpeedSnapshot } from './process-status';
21
+ import { SessionTodoList } from './SessionTodoList';
22
+ import { resolveLatestSessionTodos } from './todo-list-model';
19
23
  import {
20
24
  isImageFile,
21
25
  saveChatAttachments,
@@ -98,6 +102,8 @@ export interface LocalChatPanelProps {
98
102
  currentTurnTimeline?: TurnBlock[];
99
103
  currentTurnStartedAtMs?: number;
100
104
  durationByMessageId?: Record<string, number>;
105
+ currentLlmSpeed?: LlmSpeedSnapshot;
106
+ llmSpeedByMessageId?: Record<string, LlmSpeedSnapshot>;
101
107
  /** 回合产物文件列表(按落库消息 id 键控),转发给 MessageList。 */
102
108
  turnFilesByMessageId?: Record<string, TurnFile[]>;
103
109
  /** 当轮产物文件(流尾声到达、尚未归档到落库 id 的尾部消息用)。 */
@@ -141,7 +147,7 @@ export interface LocalChatPanelProps {
141
147
  /** 本次挂载期间跑完的后台任务——消息列尾部的终态通知卡。 */
142
148
  finishedTasks?: LocalTask[];
143
149
  /** 终态卡「查看过程」:在右侧栏打开该任务的推理过程。 */
144
- onInspectTask?: (task: LocalTask) => void;
150
+ onInspectTask?: (task: InspectTaskInput) => void;
145
151
  /** 终态卡「忽略」(本次挂载内隐藏,不落库)。 */
146
152
  onDismissFinishedTask?: (taskId: string) => void;
147
153
  /** 分享当前对话(截图)。落到最近一条助手消息的时间戳行。 */
@@ -176,6 +182,8 @@ export function LocalChatPanel({
176
182
  currentTurnTimeline,
177
183
  currentTurnStartedAtMs,
178
184
  durationByMessageId,
185
+ currentLlmSpeed,
186
+ llmSpeedByMessageId,
179
187
  turnFilesByMessageId,
180
188
  currentTurnFiles,
181
189
  currentTurnChildren,
@@ -225,7 +233,8 @@ export function LocalChatPanel({
225
233
  // 把上传的文件持久化到会话空间:成功项用落盘路径(稳定、可被 agent
226
234
  // 读回),失败项退回原源路径。落地页(无 chatId)由调用方创建会话后
227
235
  // 自行持久化,这里 chatId 为空时原样用源路径。
228
- const resolvedFiles = chatId ? await saveChatAttachments(chatId, files) : files;
236
+ const resolvedFiles =
237
+ chatId && files.length > 0 ? await saveChatAttachments(chatId, files) : files;
229
238
 
230
239
  // Append file references to the user message content
231
240
  if (resolvedFiles.length > 0) {
@@ -285,9 +294,26 @@ export function LocalChatPanel({
285
294
  // 空会话 hero:messages 为空且未在流式时整面板切到居中首屏布局。
286
295
  const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
287
296
 
297
+ const sessionTodos = useMemo(
298
+ () =>
299
+ resolveLatestSessionTodos({
300
+ messages,
301
+ executedActionsByMessageId,
302
+ currentTurnActions,
303
+ timelineByMessageId,
304
+ currentTurnTimeline,
305
+ }),
306
+ [
307
+ messages,
308
+ executedActionsByMessageId,
309
+ currentTurnActions,
310
+ timelineByMessageId,
311
+ currentTurnTimeline,
312
+ ],
313
+ );
314
+
288
315
  // Sync `chat-input-box-width` CSS var so bubbles match the input width
289
- // (cloud product uses 95% of the input box; we mirror that here so message
290
- // bubbles + input visually align in a column).
316
+ // (mirrors the rendered input box width so message bubbles + input visually align in a column).
291
317
  const containerRef = useRef<HTMLDivElement>(null);
292
318
  useEffect(() => {
293
319
  const containerEl = containerRef.current;
@@ -300,7 +326,7 @@ export function LocalChatPanel({
300
326
  if (width > 0) {
301
327
  containerEl.style.setProperty(
302
328
  '--chat-input-box-width',
303
- `${Math.round(width * 0.95)}px`,
329
+ `${Math.round(width)}px`,
304
330
  );
305
331
  }
306
332
  };
@@ -339,6 +365,7 @@ export function LocalChatPanel({
339
365
  onOpenSettings={onOpenSettings}
340
366
  toolbarExtras={inputToolbarExtras}
341
367
  leadingChrome={inputLeadingChrome}
368
+ trailingChrome={sessionTodos ? <SessionTodoList todos={sessionTodos} /> : undefined}
342
369
  mode={mode}
343
370
  onModeChange={onModeChange}
344
371
  execPolicy={execPolicy}
@@ -356,14 +383,14 @@ export function LocalChatPanel({
356
383
  >
357
384
  <ChatPanel.Root className="flex h-full flex-col overflow-hidden" unstyled>
358
385
  {showEmptyHero ? (
359
- <div className="flex min-h-0 flex-1 items-center justify-center p-4 sm:p-8">
360
- <div className="flex w-full max-w-2xl flex-col items-center gap-6">
386
+ <div className="flex min-h-0 flex-1 items-center justify-center p-3 sm:p-5">
387
+ <div className="flex w-full max-w-3xl flex-col items-center gap-4">
361
388
  <div className="text-center">
362
389
  <h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
363
390
  {emptyHero?.title}
364
391
  </h1>
365
392
  {emptyHero?.subtitle && (
366
- <p className="mt-1.5 text-sm text-agent-muted-foreground">
393
+ <p className="mt-1.5 text-xs text-agent-muted-foreground">
367
394
  {emptyHero.subtitle}
368
395
  </p>
369
396
  )}
@@ -396,6 +423,8 @@ export function LocalChatPanel({
396
423
  currentTurnTimeline={currentTurnTimeline}
397
424
  currentTurnStartedAtMs={currentTurnStartedAtMs}
398
425
  durationByMessageId={durationByMessageId}
426
+ currentLlmSpeed={currentLlmSpeed}
427
+ llmSpeedByMessageId={llmSpeedByMessageId}
399
428
  turnFilesByMessageId={turnFilesByMessageId}
400
429
  currentTurnFiles={currentTurnFiles}
401
430
  currentTurnChildren={currentTurnChildren}
@@ -414,8 +443,10 @@ export function LocalChatPanel({
414
443
  suggestedReplies={suggestedReplies}
415
444
  onSelectSuggestion={onSelectSuggestion}
416
445
  />
417
- {inputBanner}
418
- {chatInputNode}
446
+ <div className="mx-auto w-full max-w-3xl">
447
+ {inputBanner}
448
+ {chatInputNode}
449
+ </div>
419
450
  </div>
420
451
  </>
421
452
  )}