@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
@@ -20,6 +20,7 @@ import {
20
20
  import { PlanTodoList } from './PlanTodoList';
21
21
  import { FilePathCode } from './FilePathCode';
22
22
  import { looksLikeFilePath } from './path-mentions';
23
+ import { stripNextStepsTags } from './next-steps-tags';
23
24
 
24
25
  /**
25
26
  * Markdown renderer shared by `UserMessage` and `AssistantMessage`.
@@ -59,25 +60,25 @@ const components: Components = {
59
60
  h1: (props: ComponentPropsWithoutRef<'h1'>) => (
60
61
  <h1
61
62
  {...props}
62
- className="mb-2 mt-3 text-base font-semibold text-agent-foreground first:mt-0"
63
+ className="mb-0.5 mt-1.5 text-xs font-semibold text-agent-foreground first:mt-0"
63
64
  />
64
65
  ),
65
66
  h2: (props: ComponentPropsWithoutRef<'h2'>) => (
66
67
  <h2
67
68
  {...props}
68
- className="mb-1.5 mt-3 text-[14px] font-semibold text-agent-foreground first:mt-0"
69
+ className="mb-0.5 mt-1.5 text-xs font-semibold text-agent-foreground first:mt-0"
69
70
  />
70
71
  ),
71
72
  h3: (props: ComponentPropsWithoutRef<'h3'>) => (
72
73
  <h3
73
74
  {...props}
74
- className="mb-1 mt-2 text-[13px] font-semibold text-agent-foreground first:mt-0"
75
+ className="mb-0.5 mt-1.5 text-xs font-semibold text-agent-foreground first:mt-0"
75
76
  />
76
77
  ),
77
78
  h4: (props: ComponentPropsWithoutRef<'h4'>) => (
78
79
  <h4
79
80
  {...props}
80
- className="mb-1 mt-2 text-[13px] font-semibold text-agent-foreground first:mt-0"
81
+ className="mb-0.5 mt-1.5 text-xs font-semibold text-agent-foreground first:mt-0"
81
82
  />
82
83
  ),
83
84
 
@@ -85,16 +86,16 @@ const components: Components = {
85
86
  <p
86
87
  style={{ whiteSpace: 'pre-line' }}
87
88
  {...props}
88
- className="my-1 leading-relaxed"
89
+ className="my-0.5 leading-relaxed"
89
90
  />
90
91
  ),
91
92
 
92
93
  // ── Lists ─────────────────────────────────────────────────────────────
93
94
  ul: (props: ComponentPropsWithoutRef<'ul'>) => (
94
- <ul {...props} className="my-1.5 list-disc space-y-0.5 pl-5" />
95
+ <ul {...props} className="my-1 list-disc space-y-0.5 pl-4" />
95
96
  ),
96
97
  ol: (props: ComponentPropsWithoutRef<'ol'>) => (
97
- <ol {...props} className="my-1.5 list-decimal space-y-0.5 pl-5" />
98
+ <ol {...props} className="my-1 list-decimal space-y-0.5 pl-4" />
98
99
  ),
99
100
  li: (props: ComponentPropsWithoutRef<'li'>) => (
100
101
  <li
@@ -115,18 +116,18 @@ const components: Components = {
115
116
  blockquote: (props: ComponentPropsWithoutRef<'blockquote'>) => (
116
117
  <blockquote
117
118
  {...props}
118
- className="my-1.5 border-l-2 border-agent-border bg-agent-muted/40 px-3 py-1 text-[13px] text-agent-muted-foreground [&>p]:my-0"
119
+ className="my-1 border-l-2 border-agent-border bg-agent-muted/40 px-2 py-0.5 text-xs text-agent-muted-foreground [&>p]:my-0"
119
120
  />
120
121
  ),
121
122
 
122
123
  // ── Horizontal rule ──────────────────────────────────────────────────
123
124
  hr: (props: ComponentPropsWithoutRef<'hr'>) => (
124
- <hr {...props} className="my-3 border-agent-border" />
125
+ <hr {...props} className="my-2 border-agent-border" />
125
126
  ),
126
127
 
127
128
  // ── Tables ───────────────────────────────────────────────────────────
128
129
  table: (props: ComponentPropsWithoutRef<'table'>) => (
129
- <div className="my-2 w-full overflow-x-auto rounded-md border border-agent-border">
130
+ <div className="my-1.5 w-full overflow-x-auto rounded-md border border-agent-border">
130
131
  <table {...props} className="w-full border-collapse text-[12px]" />
131
132
  </div>
132
133
  ),
@@ -136,13 +137,13 @@ const components: Components = {
136
137
  th: (props: ComponentPropsWithoutRef<'th'>) => (
137
138
  <th
138
139
  {...props}
139
- className="border-b border-agent-border px-2 py-1.5 text-left font-medium text-agent-foreground"
140
+ className="border-b border-agent-border px-2 py-1 text-left font-medium text-agent-foreground"
140
141
  />
141
142
  ),
142
143
  td: (props: ComponentPropsWithoutRef<'td'>) => (
143
144
  <td
144
145
  {...props}
145
- className="border-b border-agent-border px-2 py-1.5 align-top break-words"
146
+ className="border-b border-agent-border px-2 py-1 align-top break-words"
146
147
  style={{ maxWidth: '300px' }}
147
148
  />
148
149
  ),
@@ -159,7 +160,7 @@ const components: Components = {
159
160
  return (
160
161
  <pre
161
162
  {...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
+ className="my-1.5 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2 text-[12px] leading-relaxed text-agent-foreground"
163
164
  />
164
165
  );
165
166
  },
@@ -453,7 +454,7 @@ export function Markdown({
453
454
  rehypePlugins={[rehypeRaw]}
454
455
  components={finalComponents}
455
456
  >
456
- {children}
457
+ {stripNextStepsTags(children)}
457
458
  </ReactMarkdown>
458
459
  );
459
460
  }
@@ -15,11 +15,13 @@ import { InterruptedTurnCard } from './InterruptedTurnCard';
15
15
  import { TaskOutcomeCards } from './TaskOutcomeCards';
16
16
  import { SuggestedReplies } from './SuggestedReplies';
17
17
  import type { ExecutedAction } from './ExecutedActionsCard';
18
+ import type { InspectTaskInput } from './executed-actions-model';
18
19
  import type { ChildInfo } from './OrchestrationChildrenCard';
19
20
  import type { ChatMode } from './ChatInput';
20
21
  import type { TurnBlock } from './turn-timeline';
21
22
  import type { TurnFile } from './turn-files';
22
23
  import { inferDurationMs, readPersistedDurationMs } from './elapsed';
24
+ import type { LlmSpeedSnapshot } from './process-status';
23
25
 
24
26
  /**
25
27
  * MessageList — scrolling viewport that renders user/assistant message
@@ -80,6 +82,10 @@ interface MessageListProps {
80
82
  currentTurnStartedAtMs?: number;
81
83
  /** Frozen duration keyed by assistant message id (live freeze + history). */
82
84
  durationByMessageId?: Record<string, number>;
85
+ /** Live model-request speed for the in-flight assistant turn. */
86
+ currentLlmSpeed?: LlmSpeedSnapshot;
87
+ /** Frozen model-request speed keyed by assistant message id. */
88
+ llmSpeedByMessageId?: Record<string, LlmSpeedSnapshot>;
83
89
  /**
84
90
  * 回合产物文件列表,按落库消息 id 键控(live 回合在 message_id 事件时
85
91
  * 归档;历史回合从 messageMetadata.turnFiles 水合)。
@@ -129,7 +135,7 @@ interface MessageListProps {
129
135
  */
130
136
  finishedTasks?: LocalTask[];
131
137
  /** 点「查看过程」:在右侧栏打开该任务的推理过程。 */
132
- onInspectTask?: (task: LocalTask) => void;
138
+ onInspectTask?: (task: InspectTaskInput) => void;
133
139
  /** 点「忽略」:隐藏这条终态通知(本次挂载内,不落库)。 */
134
140
  onDismissFinishedTask?: (taskId: string) => void;
135
141
  /** 分享当前对话(截图)。只画在最近一条助手消息的时间戳行上。 */
@@ -155,6 +161,8 @@ export function MessageList({
155
161
  currentTurnTimeline,
156
162
  currentTurnStartedAtMs,
157
163
  durationByMessageId,
164
+ currentLlmSpeed,
165
+ llmSpeedByMessageId,
158
166
  turnFilesByMessageId,
159
167
  currentTurnFiles,
160
168
  currentTurnChildren,
@@ -241,12 +249,12 @@ export function MessageList({
241
249
  <div className="relative flex-1 overflow-hidden">
242
250
  <div
243
251
  ref={containerRef}
244
- className="h-full overflow-y-auto overflow-anchor-none px-3 py-3"
252
+ className="h-full overflow-y-auto overflow-anchor-none px-3 pt-3 pb-6"
245
253
  >
246
254
  {visibleMessages.length === 0 ? (
247
255
  emptyState ?? null
248
256
  ) : (
249
- <div className="mx-auto w-full space-y-1.5">
257
+ <div className="mx-auto w-full max-w-3xl space-y-4">
250
258
  {visibleMessages.map((message, index) => {
251
259
  const isLast = index === visibleMessages.length - 1;
252
260
  if (message.role === 'user') {
@@ -299,10 +307,12 @@ export function MessageList({
299
307
  typeof message.messageMetadata === 'string'
300
308
  ? message.messageMetadata
301
309
  : undefined;
310
+ let previousUser: ChatMessage | undefined;
302
311
  let previousUserCreatedAt: string | undefined;
303
312
  for (let i = index - 1; i >= 0; i -= 1) {
304
313
  if (visibleMessages[i].role === 'user') {
305
- previousUserCreatedAt = visibleMessages[i].createdAt;
314
+ previousUser = visibleMessages[i];
315
+ previousUserCreatedAt = previousUser.createdAt;
306
316
  break;
307
317
  }
308
318
  }
@@ -311,6 +321,11 @@ export function MessageList({
311
321
  : durationByMessageId?.[message.id]
312
322
  ?? readPersistedDurationMs(metadataJson)
313
323
  ?? inferDurationMs(previousUserCreatedAt, message.createdAt);
324
+ const persistedSpeed = llmSpeedByMessageId?.[message.id];
325
+ const isSpeedTail = isLast && currentLlmSpeed !== undefined;
326
+ const llmSpeed =
327
+ persistedSpeed
328
+ ?? (isStreamingTail || isSpeedTail ? currentLlmSpeed : undefined);
314
329
 
315
330
  return (
316
331
  <div key={message.id}>
@@ -324,13 +339,16 @@ export function MessageList({
324
339
  executedActions={actions}
325
340
  timeline={turnTimeline}
326
341
  orchestrationChildren={childList}
342
+ previousUser={previousUser}
327
343
  currentRound={isStreamingTail ? currentRound : undefined}
328
344
  isPlanMode={isPlanMode}
329
345
  onRegenerate={onRegenerate}
330
346
  startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
331
347
  durationMs={durationMs}
348
+ llmSpeed={llmSpeed}
332
349
  turnFiles={turnFiles}
333
350
  onShare={message.id === lastAssistantId ? onShare : undefined}
351
+ onInspectTask={onInspectTask}
334
352
  />
335
353
  {!isStreaming &&
336
354
  message.id === lastAssistantId &&
@@ -376,7 +394,7 @@ export function MessageList({
376
394
  setIsAtBottom(true);
377
395
  scrollToBottom('smooth');
378
396
  }}
379
- className="absolute bottom-3 right-3 flex h-8 w-8 items-center justify-center rounded-full border border-agent-border bg-agent-canvas text-agent-foreground shadow-md transition-colors hover:bg-agent-foreground/5"
397
+ className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-full border border-agent-border bg-agent-canvas text-agent-foreground shadow-md transition-colors hover:bg-agent-foreground/5"
380
398
  title="回到底部"
381
399
  aria-label="回到底部"
382
400
  >
@@ -117,7 +117,7 @@ export function ModelPicker({
117
117
  if (next && status !== 'live' && !loading) void refresh();
118
118
  }}
119
119
  disabled={disabled}
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]"
120
+ className="inline-flex h-6 min-w-0 max-w-[160px] items-center gap-1 rounded-full border border-agent-border bg-agent-canvas px-1.5 text-[12px] leading-[1.45] text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
121
121
  title={
122
122
  status === 'offline'
123
123
  ? `模型目录不可用${catalog?.error ? `:${catalog.error}` : ''}(仍可手动指定模型)`
@@ -165,7 +165,7 @@ export function ModelPicker({
165
165
  setModelMenuOpen(false);
166
166
  }}
167
167
  className={[
168
- 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors',
168
+ 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
169
169
  model == null
170
170
  ? 'bg-agent-foreground/10 text-agent-foreground'
171
171
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -192,7 +192,7 @@ export function ModelPicker({
192
192
  setModelMenuOpen(false);
193
193
  }}
194
194
  className={[
195
- 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors',
195
+ 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
196
196
  isActive
197
197
  ? 'bg-agent-foreground/10 text-agent-foreground'
198
198
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -232,7 +232,7 @@ export function ModelPicker({
232
232
  setModelMenuOpen(false);
233
233
  }}
234
234
  disabled={disabled}
235
- className="inline-flex h-7 items-center gap-1 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"
235
+ className="inline-flex h-6 items-center gap-1 rounded-full border border-agent-border bg-agent-canvas px-1.5 text-[12px] leading-[1.45] text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70"
236
236
  title="推理档位(随每轮请求下发;模型不支持会直接报错)"
237
237
  aria-haspopup="menu"
238
238
  aria-expanded={effortMenuOpen}
@@ -253,7 +253,7 @@ export function ModelPicker({
253
253
  setEffortMenuOpen(false);
254
254
  }}
255
255
  className={[
256
- 'flex w-full rounded px-2 py-1.5 text-left text-xs transition-colors',
256
+ 'flex w-full rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
257
257
  reasoningEffort == null
258
258
  ? 'bg-agent-foreground/10 text-agent-foreground'
259
259
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -271,7 +271,7 @@ export function ModelPicker({
271
271
  setEffortMenuOpen(false);
272
272
  }}
273
273
  className={[
274
- 'flex w-full rounded px-2 py-1.5 text-left text-xs transition-colors',
274
+ 'flex w-full rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
275
275
  reasoningEffort === level
276
276
  ? 'bg-agent-foreground/10 text-agent-foreground'
277
277
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -1,9 +1,16 @@
1
1
  import { useMemo } from 'react';
2
- import { OrchestrationPlanCard } from '@steerable/agent-ui/cards';
2
+ import {
3
+ OrchestrationPlanCard,
4
+ ORCHESTRATION_DOT_CLASS,
5
+ ORCHESTRATION_STATUS_LABEL,
6
+ } from '@steerable/agent-ui/cards';
3
7
  import {
4
8
  childrenToCardModel,
9
+ findAgentForProfile,
10
+ shouldShowOrchestrationBoard,
5
11
  type ChildInfo,
6
12
  } from './orchestration-children-model';
13
+ import type { LocalChatAgent } from '@/lib/local-api';
7
14
 
8
15
  /**
9
16
  * OrchestrationChildrenCard — the desktop surface of the framework's P3.1
@@ -13,25 +20,82 @@ import {
13
20
  * payload is synthesized from spawn events, so there is no separate "plan"
14
21
  * step: the plan IS the set of spawned children.
15
22
  *
23
+ * Rows are overridden (`renderTaskRow`) to add the agent's colored initial
24
+ * next to its display name, matching the `@提及` chip in the composer and in
25
+ * user bubbles. Status labels and dot colors come from the card's own
26
+ * exported tables so the two surfaces cannot drift.
27
+ *
16
28
  * The mapping logic lives in `./orchestration-children-model` (pure,
17
29
  * runtime-dependency-free) so node-side tests do not pull the agent-ui
18
- * dist build.
30
+ * dist build. A single delegate child is not rendered here: the tool row
31
+ * already shows 「委派 ·」.
32
+ *
33
+ * **Currently unmounted.** Nothing renders this card: every child is a
34
+ * `delegate_subagent` call, and that tool row carries the same progress
35
+ * (执行中 → 已完成) plus the expandable task brief and the child's report.
36
+ * `ChildInfo` and the event fold stay in use — they feed the turn's agent
37
+ * badges — so putting the board back is one JSX line in `AssistantMessage`.
19
38
  */
20
39
 
21
40
  export type { ChildInfo } from './orchestration-children-model';
22
41
 
23
- export function OrchestrationChildrenCard({ children }: { children: ChildInfo[] }) {
42
+ export function OrchestrationChildrenCard({
43
+ children,
44
+ agents = [],
45
+ }: {
46
+ children: ChildInfo[];
47
+ agents?: ReadonlyArray<Pick<LocalChatAgent, 'id' | 'slug' | 'name' | 'color'>>;
48
+ }) {
24
49
  const { payload, taskStatuses } = useMemo(
25
50
  () => childrenToCardModel(children),
26
51
  [children],
27
52
  );
53
+ const delegateOnly = children.every((child) => Boolean(child.profile));
28
54
 
29
- if (children.length === 0) return null;
55
+ if (!shouldShowOrchestrationBoard(children)) return null;
30
56
  return (
31
57
  <OrchestrationPlanCard
32
58
  payload={payload}
33
59
  taskStatuses={taskStatuses}
34
- agentNameFor={(id) => id}
60
+ headerLabel={delegateOnly ? '子代理' : undefined}
61
+ hideMode={delegateOnly}
62
+ renderTaskRow={(task, status) => {
63
+ // 没有匹配的智能体:编排六件套的子代理(只有 lineage id)或被删掉的
64
+ // 智能体,显示画像原文。
65
+ const agent = findAgentForProfile(task.agentId, agents);
66
+ const displayName = agent?.name ?? task.agentId;
67
+ return (
68
+ <li className="flex items-start gap-2 text-sm">
69
+ <span
70
+ className={`mt-1.5 h-2 w-2 flex-shrink-0 rounded-full ${ORCHESTRATION_DOT_CLASS[status]}`}
71
+ aria-hidden
72
+ />
73
+ <div className="min-w-0 flex-1">
74
+ <div className="flex items-center gap-2">
75
+ <span
76
+ data-agent-color-dot=""
77
+ className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full text-[8px] font-semibold text-white"
78
+ style={{ backgroundColor: agent?.color || '#7c3aed' }}
79
+ aria-hidden
80
+ >
81
+ {displayName.trim()[0]?.toUpperCase() ?? 'A'}
82
+ </span>
83
+ <span className="font-medium text-[var(--agent-foreground,#111827)]">
84
+ {displayName}
85
+ </span>
86
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
87
+ {ORCHESTRATION_STATUS_LABEL[status]}
88
+ </span>
89
+ </div>
90
+ {task.prompt ? (
91
+ <p className="mt-0.5 line-clamp-2 text-xs text-[var(--agent-muted-foreground,#6b7280)]">
92
+ {task.prompt}
93
+ </p>
94
+ ) : null}
95
+ </div>
96
+ </li>
97
+ );
98
+ }}
35
99
  defaultExpanded
36
100
  />
37
101
  );
@@ -0,0 +1,234 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ LuCircle,
4
+ LuCircleCheck,
5
+ LuCircleDot,
6
+ LuChevronDown,
7
+ LuListChecks,
8
+ LuLoaderCircle,
9
+ LuX,
10
+ } from 'react-icons/lu';
11
+ import {
12
+ summarizeTodos,
13
+ type SessionTodo,
14
+ type TodoStatus,
15
+ } from './todo-list-model';
16
+
17
+ function StatusIcon({
18
+ status,
19
+ className,
20
+ }: {
21
+ status: TodoStatus;
22
+ className?: string;
23
+ }) {
24
+ const cls = ['h-3.5 w-3.5 shrink-0', className].filter(Boolean).join(' ');
25
+ if (status === 'completed') {
26
+ return <LuCircleCheck className={`${cls} text-emerald-600 dark:text-emerald-400`} />;
27
+ }
28
+ if (status === 'in_progress') {
29
+ return <LuCircleDot className={`${cls} text-blue-600 dark:text-blue-400`} />;
30
+ }
31
+ return <LuCircle className={`${cls} text-agent-muted-foreground/70`} />;
32
+ }
33
+
34
+ export function currentTodoStep(todos: SessionTodo[]): SessionTodo | null {
35
+ return (
36
+ todos.find((todo) => todo.status === 'in_progress') ??
37
+ todos.find((todo) => todo.status === 'pending') ??
38
+ todos[todos.length - 1] ??
39
+ null
40
+ );
41
+ }
42
+
43
+ export function todoProgressCopy(todos: SessionTodo[]): string {
44
+ const summary = summarizeTodos(todos);
45
+ if (summary.total === 0) return '';
46
+ if (summary.completed === summary.total) {
47
+ return `${summary.completed}/${summary.total} 已完成`;
48
+ }
49
+ const currentIndex = todos.findIndex((todo) => todo.status === 'in_progress');
50
+ const step =
51
+ currentIndex >= 0 ? currentIndex + 1 : Math.min(summary.completed + 1, summary.total);
52
+ return `执行到 ${step}/${summary.total}`;
53
+ }
54
+
55
+ export function TodoItems({
56
+ todos,
57
+ compact = false,
58
+ }: {
59
+ todos: SessionTodo[];
60
+ compact?: boolean;
61
+ }) {
62
+ return (
63
+ <ol
64
+ className={`m-0 list-none ${compact ? 'max-h-48 space-y-1 overflow-y-auto px-2.5 py-1.5' : 'space-y-1 px-2.5 py-1.5'}`}
65
+ >
66
+ {todos.map((todo) => {
67
+ const done = todo.status === 'completed';
68
+ const active = todo.status === 'in_progress';
69
+ return (
70
+ <li
71
+ key={todo.id}
72
+ data-testid={`todo-item-${todo.id}`}
73
+ data-status={todo.status}
74
+ className="flex items-start gap-2 text-[11px] leading-relaxed"
75
+ >
76
+ <StatusIcon status={todo.status} className="mt-[2px]" />
77
+ <span
78
+ className={
79
+ done
80
+ ? 'text-agent-muted-foreground line-through'
81
+ : active
82
+ ? 'font-medium text-agent-foreground'
83
+ : 'text-agent-muted-foreground/85'
84
+ }
85
+ >
86
+ {todo.content}
87
+ </span>
88
+ </li>
89
+ );
90
+ })}
91
+ </ol>
92
+ );
93
+ }
94
+
95
+ export function SessionTodoList({ todos }: { todos: SessionTodo[] }) {
96
+ const containerRef = useRef<HTMLDivElement>(null);
97
+ const current = useMemo(() => currentTodoStep(todos), [todos]);
98
+ const progressLabel = useMemo(() => todoProgressCopy(todos), [todos]);
99
+
100
+ const hasActive = useMemo(
101
+ () => todos.some((t) => t.status === 'in_progress'),
102
+ [todos],
103
+ );
104
+ const isAllCompleted = useMemo(() => {
105
+ const summary = summarizeTodos(todos);
106
+ return summary.total > 0 && summary.completed === summary.total;
107
+ }, [todos]);
108
+
109
+ // 执行中默认展开,全部完成默认折叠;允许用户手动点击收起或展开
110
+ const [userToggled, setUserToggled] = useState<boolean | null>(null);
111
+ const expanded = userToggled ?? hasActive;
112
+
113
+ // 当任务全部执行完毕时,自动收起为紧凑胶囊
114
+ useEffect(() => {
115
+ if (isAllCompleted) {
116
+ setUserToggled(false);
117
+ }
118
+ }, [isAllCompleted]);
119
+
120
+ // 当新出现执行中的任务时,恢复默认展开
121
+ useEffect(() => {
122
+ if (hasActive) {
123
+ setUserToggled(null);
124
+ }
125
+ }, [hasActive]);
126
+
127
+ // 任务全部完成后,如果展开了浮层,点击外部区域时自动收起
128
+ useEffect(() => {
129
+ if (!expanded || !isAllCompleted) return;
130
+ const handlePointerDown = (event: PointerEvent) => {
131
+ if (
132
+ containerRef.current &&
133
+ !containerRef.current.contains(event.target as Node)
134
+ ) {
135
+ setUserToggled(false);
136
+ }
137
+ };
138
+ document.addEventListener('pointerdown', handlePointerDown);
139
+ return () => document.removeEventListener('pointerdown', handlePointerDown);
140
+ }, [expanded, isAllCompleted]);
141
+
142
+ return (
143
+ <div
144
+ ref={containerRef}
145
+ className="relative inline-flex items-center"
146
+ data-testid="session-todo-list"
147
+ >
148
+ <button
149
+ type="button"
150
+ onClick={() => setUserToggled(!expanded)}
151
+ className={[
152
+ 'flex h-6 max-w-[240px] sm:max-w-[320px] items-center gap-1.5 rounded-full px-2 text-left text-[11px] transition-all duration-200 select-none shadow-2xs',
153
+ isAllCompleted
154
+ ? 'border border-emerald-500/25 bg-emerald-500/[0.04] text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/[0.08]'
155
+ : hasActive
156
+ ? 'border border-blue-500/30 bg-blue-500/[0.06] text-blue-700 dark:text-blue-300 hover:bg-blue-500/10'
157
+ : 'border border-agent-border/80 bg-agent-canvas text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
158
+ ].join(' ')}
159
+ aria-expanded={expanded}
160
+ >
161
+ {isAllCompleted ? (
162
+ <LuCircleCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
163
+ ) : hasActive ? (
164
+ <LuLoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
165
+ ) : current ? (
166
+ <StatusIcon status={current.status} />
167
+ ) : (
168
+ <LuListChecks className="h-3.5 w-3.5 shrink-0 text-agent-foreground" />
169
+ )}
170
+ <span className="min-w-0 flex-1 truncate font-medium">
171
+ {isAllCompleted ? '任务清单' : (current?.content ?? '任务清单')}
172
+ </span>
173
+ {progressLabel ? (
174
+ <span
175
+ className={`shrink-0 rounded-full px-1.5 py-0.2 text-[10px] font-medium ${
176
+ isAllCompleted
177
+ ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
178
+ : hasActive
179
+ ? 'bg-blue-500/15 text-blue-700 dark:text-blue-300'
180
+ : 'bg-agent-muted text-agent-muted-foreground'
181
+ }`}
182
+ data-testid="todo-progress"
183
+ >
184
+ {progressLabel}
185
+ </span>
186
+ ) : null}
187
+ <LuChevronDown
188
+ className={`h-3 w-3 shrink-0 text-current transition-transform duration-200 ${
189
+ expanded ? 'rotate-180' : ''
190
+ }`}
191
+ />
192
+ </button>
193
+
194
+ {expanded && (
195
+ <div
196
+ className="absolute bottom-full right-0 mb-1.5 w-72 sm:w-80 overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas/98 backdrop-blur-md shadow-xl z-40 animate-in fade-in slide-in-from-bottom-1 duration-150"
197
+ >
198
+ <div className="flex items-center justify-between border-b border-agent-border/60 bg-agent-muted/30 px-2.5 py-1.5">
199
+ <div className="flex items-center gap-1.5 text-[11px] font-medium text-agent-foreground">
200
+ <LuListChecks className="h-3.5 w-3.5 text-agent-foreground" />
201
+ <span>任务清单</span>
202
+ </div>
203
+ <div className="flex items-center gap-1.5">
204
+ {progressLabel && (
205
+ <span
206
+ className={`rounded-full px-1.5 py-0.2 text-[10px] font-medium ${
207
+ isAllCompleted
208
+ ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
209
+ : 'bg-blue-500/10 text-blue-600 dark:text-blue-400'
210
+ }`}
211
+ >
212
+ {progressLabel}
213
+ </span>
214
+ )}
215
+ <button
216
+ type="button"
217
+ onClick={() => setUserToggled(false)}
218
+ className="flex h-5 w-5 items-center justify-center rounded text-agent-muted-foreground transition-colors hover:bg-agent-foreground/10 hover:text-agent-foreground"
219
+ title="收起清单"
220
+ aria-label="收起清单"
221
+ >
222
+ <LuX className="h-3 w-3" />
223
+ </button>
224
+ </div>
225
+ </div>
226
+ <TodoItems todos={todos} compact />
227
+ </div>
228
+ )}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ export default SessionTodoList;
234
+
@@ -136,9 +136,9 @@ export function SessionTreeModal({ chatId, onClose, onBranchSwitched }: SessionT
136
136
  data-session-tree-modal
137
137
  >
138
138
  <div className="flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
139
- <div className="flex items-center gap-2 border-b border-agent-border px-4 py-2.5">
139
+ <div className="flex items-center gap-2 border-b border-agent-border px-3 py-2">
140
140
  <LuGitBranch className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
141
- <span className="min-w-0 flex-1 truncate text-sm font-medium text-agent-foreground">
141
+ <span className="min-w-0 flex-1 truncate text-xs font-medium text-agent-foreground">
142
142
  会话分支树
143
143
  {data && data.nodeCount > 1 && (
144
144
  <span className="ml-1.5 text-xs font-normal text-agent-muted-foreground">
@@ -206,7 +206,7 @@ export function SessionTreeModal({ chatId, onClose, onBranchSwitched }: SessionT
206
206
  </div>
207
207
 
208
208
  {!loading && !isEmpty && (
209
- <div className="border-t border-agent-border px-4 py-2 text-[10px] text-agent-muted-foreground/80">
209
+ <div className="border-t border-agent-border px-3 py-1.5 text-[10px] text-agent-muted-foreground/80">
210
210
  {data?.truncated
211
211
  ? '分支过多,树已被截断(仅显示部分节点)。'
212
212
  : '点击切换分支;↑↓ 移动,Enter 切换,Esc 关闭。'}