@steerable/agent-shell-web 0.6.26 → 0.6.28

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 (49) hide show
  1. package/package.json +4 -4
  2. package/src/components/AgentSidebar.tsx +24 -24
  3. package/src/components/BrandLockup.tsx +1 -1
  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 +41 -13
  10. package/src/components/chat/ChatInput.tsx +24 -24
  11. package/src/components/chat/ExecPolicyPicker.tsx +4 -4
  12. package/src/components/chat/ExecutedActionsCard.tsx +10 -3
  13. package/src/components/chat/FilePathCode.tsx +2 -1
  14. package/src/components/chat/LocalChatPanel.tsx +39 -4
  15. package/src/components/chat/Markdown.tsx +15 -14
  16. package/src/components/chat/MessageList.tsx +39 -16
  17. package/src/components/chat/ModelPicker.tsx +6 -6
  18. package/src/components/chat/SessionTodoList.tsx +137 -0
  19. package/src/components/chat/SessionTreeModal.tsx +3 -3
  20. package/src/components/chat/SuggestedReplies.tsx +5 -4
  21. package/src/components/chat/TaskPanelModal.tsx +3 -3
  22. package/src/components/chat/TaskProcessPanel.tsx +17 -11
  23. package/src/components/chat/TurnFilesCard.tsx +2 -2
  24. package/src/components/chat/TurnProcessGroup.tsx +281 -117
  25. package/src/components/chat/UserMessage.tsx +4 -4
  26. package/src/components/chat/next-steps-tags.ts +28 -0
  27. package/src/components/chat/process-status.ts +125 -32
  28. package/src/components/chat/todo-list-model.ts +167 -0
  29. package/src/components/chat/turn-timeline.ts +100 -11
  30. package/src/components/settings/AgentsSettingsPanel.tsx +2 -2
  31. package/src/components/settings/AppearanceSettingsPanel.tsx +43 -30
  32. package/src/components/settings/DiagnoseSettingsPanel.tsx +2 -2
  33. package/src/components/settings/InsightsSettingsPanel.tsx +4 -4
  34. package/src/components/settings/LlmSettingsPanel.tsx +10 -10
  35. package/src/components/settings/McpSettingsPanel.tsx +3 -3
  36. package/src/components/settings/ModelIdCombobox.tsx +2 -2
  37. package/src/components/settings/SecuritySettingsPanel.tsx +1 -1
  38. package/src/components/settings/SettingsSaveButton.tsx +1 -1
  39. package/src/components/settings/SkillsSettingsPanel.tsx +2 -2
  40. package/src/components/settings/TelemetrySettingsPanel.tsx +2 -2
  41. package/src/components/settings/UsagePanel.tsx +14 -10
  42. package/src/components/settings/WebSearchSettingsPanel.tsx +2 -2
  43. package/src/layouts/AgentLayout.tsx +3 -3
  44. package/src/lib/chat-transport.ts +68 -12
  45. package/src/lib/show-thinking-content.ts +40 -15
  46. package/src/pages/AgentPage.tsx +56 -6
  47. package/src/pages/ChatPanelPreviewPage.tsx +88 -8
  48. package/src/pages/SettingsPage.tsx +39 -39
  49. package/src/styles/index.css +12 -2
@@ -81,7 +81,7 @@ export type { ExecPolicy, McpToolItem, SkillItem };
81
81
  * - Compact-mode responsive layout.
82
82
  */
83
83
 
84
- const MIN_HEIGHT_PX = 44;
84
+ const MIN_HEIGHT_PX = 36;
85
85
  const MAX_HEIGHT_PX = 220;
86
86
  const MENTION_PATTERN = /(@[^\s@]+)/g;
87
87
  const MAX_MENTION_SUGGESTIONS = 8;
@@ -1512,7 +1512,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1512
1512
 
1513
1513
  if (approvalPrompt?.current) {
1514
1514
  return (
1515
- <div className="chat-input-container @container relative px-3 pb-3 pt-1">
1515
+ <div className="chat-input-container @container relative px-2.5 pb-2 pt-0.5">
1516
1516
  <ApprovalPromptMenu
1517
1517
  key={approvalPrompt.current.requestId}
1518
1518
  request={approvalPrompt.current}
@@ -1527,7 +1527,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1527
1527
  const request = askUserPrompt.current;
1528
1528
  return (
1529
1529
  <div
1530
- className="chat-input-container @container relative px-3 pb-3 pt-1"
1530
+ className="chat-input-container @container relative px-2.5 pb-2 pt-0.5"
1531
1531
  data-testid="ask-user-composer"
1532
1532
  >
1533
1533
  <AskUserQuestionMenu
@@ -1549,7 +1549,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1549
1549
  return (
1550
1550
  // @container:底栏元素的显隐/宽度用容器查询(@sm = 384px)而不是
1551
1551
  // 视口断点——侧边栏占宽后,视口够宽但输入框本身可能已经很窄。
1552
- <div className="chat-input-container @container relative px-3 pb-3 pt-1">
1552
+ <div className="chat-input-container @container relative px-2.5 pb-2 pt-0.5">
1553
1553
  {(leadingChrome || selectedAgent) && (
1554
1554
  <div
1555
1555
  className="relative mb-1 flex min-w-0 items-center gap-1.5"
@@ -1712,7 +1712,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1712
1712
  onDrop={handleDrop}
1713
1713
  >
1714
1714
  {pendingFollowUps.length > 0 && (
1715
- <div className="flex flex-col gap-1 border-b border-agent-border bg-agent-muted/30 px-3 py-2">
1715
+ <div className="flex flex-col gap-1 border-b border-agent-border bg-agent-muted/30 px-2.5 py-1.5">
1716
1716
  <div className="text-[11px] font-medium text-agent-muted-foreground">
1717
1717
  排队中({pendingFollowUps.length})· 本轮结束后自动发出 · 停止将丢弃
1718
1718
  </div>
@@ -1739,14 +1739,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1739
1739
  {steerNotice && (
1740
1740
  <div
1741
1741
  role="status"
1742
- className="border-b border-agent-border bg-agent-muted/20 px-3 py-1.5 text-[11px] text-agent-muted-foreground"
1742
+ className="border-b border-agent-border bg-agent-muted/20 px-2.5 py-1 text-[11px] text-agent-muted-foreground"
1743
1743
  >
1744
1744
  {steerNotice}
1745
1745
  </div>
1746
1746
  )}
1747
1747
  <div className="chat-input-surface relative overflow-hidden">
1748
1748
  <div
1749
- className="chat-input-placeholder pointer-events-none absolute inset-x-0 top-0 px-3 pt-3 text-sm text-agent-muted-foreground"
1749
+ className="chat-input-placeholder pointer-events-none absolute inset-x-0 top-0 px-2.5 pt-2 text-xs text-agent-muted-foreground"
1750
1750
  style={{ visibility: value ? 'hidden' : undefined }}
1751
1751
  >
1752
1752
  {placeholder}
@@ -1769,7 +1769,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1769
1769
  onCompositionStart={handleCompositionStart}
1770
1770
  onCompositionEnd={handleCompositionEnd}
1771
1771
  onScroll={handleEditorScroll}
1772
- className="chat-input-editor relative z-10 block w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-transparent px-3 pt-3 text-sm text-agent-foreground caret-agent-foreground outline-none selection:bg-agent-foreground/20"
1772
+ className="chat-input-editor relative z-10 block w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-transparent px-2.5 pt-2 text-xs text-agent-foreground caret-agent-foreground outline-none selection:bg-agent-foreground/20"
1773
1773
  style={{
1774
1774
  minHeight: MIN_HEIGHT_PX,
1775
1775
  maxHeight: MAX_HEIGHT_PX,
@@ -1777,7 +1777,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1777
1777
  />
1778
1778
  </div>
1779
1779
  {actualFiles.length > 0 && (
1780
- <div className="flex flex-wrap gap-1.5 px-3 pb-2 pt-0.5">
1780
+ <div className="flex flex-wrap gap-1.5 px-2.5 pb-1.5 pt-0.5">
1781
1781
  {actualFiles.map((file, idx) => (
1782
1782
  <div
1783
1783
  key={idx}
@@ -1802,7 +1802,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1802
1802
  )}
1803
1803
  <div
1804
1804
  ref={toolbarRowRef}
1805
- className="flex items-center justify-between gap-2 px-2 pb-2 pt-1"
1805
+ className="flex items-center justify-between gap-1.5 px-2 py-1 text-[12px] leading-[1.45]"
1806
1806
  >
1807
1807
  {/* Left toolbar: mode + exec sandbox + host extras + settings.
1808
1808
  Agent picker lives in the meta row above the box, next to
@@ -1827,11 +1827,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1827
1827
  type="button"
1828
1828
  onClick={handlePickFiles}
1829
1829
  disabled={disabled || isStreaming}
1830
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
1830
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
1831
1831
  title="上传文件(可多选)"
1832
1832
  aria-label="上传文件"
1833
1833
  >
1834
- <LuPaperclip className="h-3.5 w-3.5" />
1834
+ <LuPaperclip className="h-3 w-3" />
1835
1835
  </button>
1836
1836
  {/* 隐藏的原生多选文件输入:点击回形针按钮触发系统选择框。 */}
1837
1837
  <input
@@ -1847,12 +1847,12 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1847
1847
  <button
1848
1848
  type="button"
1849
1849
  onClick={onOpenSettings}
1850
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
1850
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
1851
1851
  title="LLM 设置"
1852
1852
  aria-label="LLM 设置"
1853
1853
  data-testid="chat-llm-settings"
1854
1854
  >
1855
- <LuSettings className="h-3.5 w-3.5" />
1855
+ <LuSettings className="h-3 w-3" />
1856
1856
  </button>
1857
1857
  )}
1858
1858
  </div>
@@ -1921,10 +1921,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1921
1921
  disabled={!isStreaming && !canSend}
1922
1922
  className={
1923
1923
  isStreaming
1924
- ? 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
1924
+ ? 'flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
1925
1925
  : canSend
1926
- ? 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
1927
- : 'flex h-8 w-8 shrink-0 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
1926
+ ? 'flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
1927
+ : 'flex h-6 w-6 shrink-0 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
1928
1928
  }
1929
1929
  title={
1930
1930
  isStreaming
@@ -1937,9 +1937,9 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1937
1937
  data-testid="chat-send"
1938
1938
  >
1939
1939
  {isStreaming ? (
1940
- <LuSquare className="h-3.5 w-3.5 fill-current" />
1940
+ <LuSquare className="h-3 w-3 fill-current" />
1941
1941
  ) : (
1942
- <LuArrowUp className="h-4 w-4" />
1942
+ <LuArrowUp className="h-3.5 w-3.5" />
1943
1943
  )}
1944
1944
  </button>
1945
1945
  </div>
@@ -1971,7 +1971,7 @@ function ModeToggle({
1971
1971
  // shrink-0:分段控件被 flex 压缩会压扁文字;极窄容器(<@sm)退化为
1972
1972
  // 纯图标(title 兜底语义),把收缩量让给可截断的选择器。
1973
1973
  <div
1974
- className="inline-flex h-7 shrink-0 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
1974
+ className="inline-flex h-6 shrink-0 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
1975
1975
  role="radiogroup"
1976
1976
  aria-label="对话模式"
1977
1977
  data-testid="mode-toggle"
@@ -1985,13 +1985,13 @@ function ModeToggle({
1985
1985
  title="Agent 模式:直接执行任务"
1986
1986
  data-testid="mode-agent"
1987
1987
  className={[
1988
- 'inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-70',
1988
+ 'inline-flex h-5 items-center gap-1 rounded-full px-1.5 text-[12px] leading-[1.45] transition-colors disabled:cursor-not-allowed disabled:opacity-70',
1989
1989
  mode === 'agent'
1990
1990
  ? 'bg-agent-foreground/10 text-agent-foreground'
1991
1991
  : 'text-agent-muted-foreground hover:text-agent-foreground',
1992
1992
  ].join(' ')}
1993
1993
  >
1994
- <LuInfinity className="h-3.5 w-3.5" />
1994
+ <LuInfinity className="h-3 w-3" />
1995
1995
  <span className="@max-sm:hidden">Agent</span>
1996
1996
  </button>
1997
1997
  <button
@@ -2003,13 +2003,13 @@ function ModeToggle({
2003
2003
  title="Plan 模式:先制定计划,只读不执行"
2004
2004
  data-testid="mode-plan"
2005
2005
  className={[
2006
- 'inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-70',
2006
+ 'inline-flex h-5 items-center gap-1 rounded-full px-1.5 text-[12px] leading-[1.45] transition-colors disabled:cursor-not-allowed disabled:opacity-70',
2007
2007
  mode === 'plan'
2008
2008
  ? 'bg-amber-400/20 text-amber-600 dark:text-amber-400'
2009
2009
  : 'text-agent-muted-foreground hover:text-agent-foreground',
2010
2010
  ].join(' ')}
2011
2011
  >
2012
- <LuListChecks className="h-3.5 w-3.5" />
2012
+ <LuListChecks className="h-3 w-3" />
2013
2013
  <span className="@max-sm:hidden">Plan</span>
2014
2014
  </button>
2015
2015
  </div>
@@ -64,16 +64,16 @@ export function ExecPolicyPicker({
64
64
  title={current.description}
65
65
  onClick={() => setOpen((next) => !next)}
66
66
  className={[
67
- 'inline-flex h-7 max-w-[140px] items-center gap-1 rounded-full border px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-70',
67
+ 'inline-flex h-6 max-w-[140px] items-center gap-1 rounded-full border px-1.5 text-[12px] leading-[1.45] transition-colors disabled:cursor-not-allowed disabled:opacity-70',
68
68
  isFull
69
69
  ? 'border-amber-400/50 bg-amber-400/10 text-amber-700 dark:text-amber-400'
70
70
  : 'border-agent-border bg-agent-canvas text-agent-foreground hover:bg-agent-foreground/5',
71
71
  ].join(' ')}
72
72
  >
73
73
  {isFull ? (
74
- <LuLockOpen className="h-3.5 w-3.5 shrink-0" />
74
+ <LuLockOpen className="h-3 w-3 shrink-0" />
75
75
  ) : (
76
- <LuFolderLock className="h-3.5 w-3.5 shrink-0" />
76
+ <LuFolderLock className="h-3 w-3 shrink-0" />
77
77
  )}
78
78
  <span className="truncate">{current.label}</span>
79
79
  <LuChevronDown className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
@@ -104,7 +104,7 @@ export function ExecPolicyPicker({
104
104
  : 'hover:bg-agent-foreground/5',
105
105
  ].join(' ')}
106
106
  >
107
- <span className="text-xs font-medium text-agent-foreground">
107
+ <span className="text-[12px] font-medium leading-[1.45] text-agent-foreground">
108
108
  {option.label}
109
109
  </span>
110
110
  <span className="text-[11px] leading-snug text-agent-muted-foreground">
@@ -42,6 +42,8 @@ interface ExecutedActionsCardProps {
42
42
  actions: ExecutedAction[];
43
43
  /** Drop the outer card chrome when nested in a turn-process group. */
44
44
  compact?: boolean;
45
+ /** Show args/output instead of a one-line summary. Defaults to collapsed. */
46
+ defaultExpanded?: boolean;
45
47
  }
46
48
 
47
49
  function deriveStatus(action: ExecutedAction): ToolExecutionPayload['status'] {
@@ -145,7 +147,11 @@ function renderActionOutput(output: unknown): ReactNode {
145
147
  }
146
148
 
147
149
  /** Inline tool rows for a mixed think→act timeline (no summary banner). */
148
- export function ToolsFlow({ actions, compact = false }: ExecutedActionsCardProps) {
150
+ export function ToolsFlow({
151
+ actions,
152
+ compact = false,
153
+ defaultExpanded = false,
154
+ }: ExecutedActionsCardProps) {
149
155
  if (!actions || actions.length === 0) return null;
150
156
  const expanded = expandRunCodeActions(actions);
151
157
  return (
@@ -161,6 +167,7 @@ export function ToolsFlow({ actions, compact = false }: ExecutedActionsCardProps
161
167
  <ToolExecutionCard
162
168
  key={action.id ?? `${action.tool}-${i}`}
163
169
  payload={actionToTool(action, i)}
170
+ defaultExpanded={defaultExpanded}
164
171
  renderOutput={renderActionOutput}
165
172
  className="rounded-none border-0 border-t border-agent-border first:border-t-0"
166
173
  />
@@ -179,8 +186,8 @@ export function ExecutedActionsCard({ actions }: ExecutedActionsCardProps) {
179
186
  const runningCount = expanded.filter((a) => deriveStatus(a) === 'running').length;
180
187
 
181
188
  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">
189
+ <div className="my-1.5 overflow-hidden rounded-agent-md border border-agent-border bg-agent-canvas">
190
+ <div className="flex items-center justify-between border-b border-agent-border bg-agent-muted/30 px-2.5 py-1">
184
191
  <div className="flex items-center gap-1.5 text-xs">
185
192
  {failureCount === 0 ? (
186
193
  <LuCircleCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
@@ -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';
@@ -16,6 +16,9 @@ import type { ExecutedAction } from './ExecutedActionsCard';
16
16
  import type { ChildInfo } from './OrchestrationChildrenCard';
17
17
  import type { TurnBlock } from './turn-timeline';
18
18
  import type { TurnFile } from './turn-files';
19
+ import type { LlmSpeedSnapshot } from './process-status';
20
+ import { SessionTodoList } from './SessionTodoList';
21
+ import { resolveLatestSessionTodos } from './todo-list-model';
19
22
  import {
20
23
  isImageFile,
21
24
  saveChatAttachments,
@@ -98,6 +101,8 @@ export interface LocalChatPanelProps {
98
101
  currentTurnTimeline?: TurnBlock[];
99
102
  currentTurnStartedAtMs?: number;
100
103
  durationByMessageId?: Record<string, number>;
104
+ currentLlmSpeed?: LlmSpeedSnapshot;
105
+ llmSpeedByMessageId?: Record<string, LlmSpeedSnapshot>;
101
106
  /** 回合产物文件列表(按落库消息 id 键控),转发给 MessageList。 */
102
107
  turnFilesByMessageId?: Record<string, TurnFile[]>;
103
108
  /** 当轮产物文件(流尾声到达、尚未归档到落库 id 的尾部消息用)。 */
@@ -176,6 +181,8 @@ export function LocalChatPanel({
176
181
  currentTurnTimeline,
177
182
  currentTurnStartedAtMs,
178
183
  durationByMessageId,
184
+ currentLlmSpeed,
185
+ llmSpeedByMessageId,
179
186
  turnFilesByMessageId,
180
187
  currentTurnFiles,
181
188
  currentTurnChildren,
@@ -285,6 +292,30 @@ export function LocalChatPanel({
285
292
  // 空会话 hero:messages 为空且未在流式时整面板切到居中首屏布局。
286
293
  const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
287
294
 
295
+ const sessionTodos = useMemo(
296
+ () =>
297
+ resolveLatestSessionTodos({
298
+ messages,
299
+ executedActionsByMessageId,
300
+ currentTurnActions,
301
+ timelineByMessageId,
302
+ currentTurnTimeline,
303
+ }),
304
+ [
305
+ messages,
306
+ executedActionsByMessageId,
307
+ currentTurnActions,
308
+ timelineByMessageId,
309
+ currentTurnTimeline,
310
+ ],
311
+ );
312
+
313
+ const sessionTodoBanner = sessionTodos ? (
314
+ <div className="px-2.5">
315
+ <SessionTodoList todos={sessionTodos} />
316
+ </div>
317
+ ) : null;
318
+
288
319
  // Sync `chat-input-box-width` CSS var so bubbles match the input width
289
320
  // (cloud product uses 95% of the input box; we mirror that here so message
290
321
  // bubbles + input visually align in a column).
@@ -356,20 +387,21 @@ export function LocalChatPanel({
356
387
  >
357
388
  <ChatPanel.Root className="flex h-full flex-col overflow-hidden" unstyled>
358
389
  {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">
390
+ <div className="flex min-h-0 flex-1 items-center justify-center p-3 sm:p-5">
391
+ <div className="flex w-full max-w-2xl flex-col items-center gap-4">
361
392
  <div className="text-center">
362
393
  <h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
363
394
  {emptyHero?.title}
364
395
  </h1>
365
396
  {emptyHero?.subtitle && (
366
- <p className="mt-1.5 text-sm text-agent-muted-foreground">
397
+ <p className="mt-1.5 text-xs text-agent-muted-foreground">
367
398
  {emptyHero.subtitle}
368
399
  </p>
369
400
  )}
370
401
  </div>
371
402
  <div className="w-full">
372
403
  {inputBanner}
404
+ {sessionTodoBanner}
373
405
  {chatInputNode}
374
406
  </div>
375
407
  </div>
@@ -396,6 +428,8 @@ export function LocalChatPanel({
396
428
  currentTurnTimeline={currentTurnTimeline}
397
429
  currentTurnStartedAtMs={currentTurnStartedAtMs}
398
430
  durationByMessageId={durationByMessageId}
431
+ currentLlmSpeed={currentLlmSpeed}
432
+ llmSpeedByMessageId={llmSpeedByMessageId}
399
433
  turnFilesByMessageId={turnFilesByMessageId}
400
434
  currentTurnFiles={currentTurnFiles}
401
435
  currentTurnChildren={currentTurnChildren}
@@ -415,6 +449,7 @@ export function LocalChatPanel({
415
449
  onSelectSuggestion={onSelectSuggestion}
416
450
  />
417
451
  {inputBanner}
452
+ {sessionTodoBanner}
418
453
  {chatInputNode}
419
454
  </div>
420
455
  </>
@@ -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
  }
@@ -20,6 +20,7 @@ import type { ChatMode } from './ChatInput';
20
20
  import type { TurnBlock } from './turn-timeline';
21
21
  import type { TurnFile } from './turn-files';
22
22
  import { inferDurationMs, readPersistedDurationMs } from './elapsed';
23
+ import type { LlmSpeedSnapshot } from './process-status';
23
24
 
24
25
  /**
25
26
  * MessageList — scrolling viewport that renders user/assistant message
@@ -80,6 +81,10 @@ interface MessageListProps {
80
81
  currentTurnStartedAtMs?: number;
81
82
  /** Frozen duration keyed by assistant message id (live freeze + history). */
82
83
  durationByMessageId?: Record<string, number>;
84
+ /** Live model-request speed for the in-flight assistant turn. */
85
+ currentLlmSpeed?: LlmSpeedSnapshot;
86
+ /** Frozen model-request speed keyed by assistant message id. */
87
+ llmSpeedByMessageId?: Record<string, LlmSpeedSnapshot>;
83
88
  /**
84
89
  * 回合产物文件列表,按落库消息 id 键控(live 回合在 message_id 事件时
85
90
  * 归档;历史回合从 messageMetadata.turnFiles 水合)。
@@ -155,6 +160,8 @@ export function MessageList({
155
160
  currentTurnTimeline,
156
161
  currentTurnStartedAtMs,
157
162
  durationByMessageId,
163
+ currentLlmSpeed,
164
+ llmSpeedByMessageId,
158
165
  turnFilesByMessageId,
159
166
  currentTurnFiles,
160
167
  currentTurnChildren,
@@ -173,8 +180,8 @@ export function MessageList({
173
180
  onSelectSuggestion,
174
181
  }: MessageListProps) {
175
182
  const containerRef = useRef<HTMLDivElement>(null);
176
- const endRef = useRef<HTMLDivElement>(null);
177
183
  const [isAtBottom, setIsAtBottom] = useState(true);
184
+ const isAtBottomRef = useRef(true);
178
185
 
179
186
  const visibleMessages = messages.filter(
180
187
  (m) => m.role === 'user' || m.role === 'assistant',
@@ -192,13 +199,18 @@ export function MessageList({
192
199
  if (!el) return;
193
200
  const atBottom =
194
201
  el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
202
+ isAtBottomRef.current = atBottom;
195
203
  setIsAtBottom(atBottom);
196
204
  }, []);
197
205
 
198
- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
199
- const el = endRef.current;
206
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
207
+ const el = containerRef.current;
200
208
  if (!el) return;
201
- el.scrollIntoView({ behavior, block: 'end' });
209
+ if (behavior === 'smooth') {
210
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
211
+ return;
212
+ }
213
+ el.scrollTop = el.scrollHeight;
202
214
  }, []);
203
215
 
204
216
  useEffect(() => {
@@ -213,33 +225,35 @@ export function MessageList({
213
225
  // Snap to bottom on first paint, regardless of whether the user "was" at
214
226
  // the bottom — there's no "before" on mount.
215
227
  useLayoutEffect(() => {
216
- scrollToBottom('instant' as ScrollBehavior);
228
+ isAtBottomRef.current = true;
229
+ scrollToBottom('auto');
217
230
  setIsAtBottom(true);
218
231
  }, [scrollToBottom]);
219
232
 
220
- // Subsequent auto-scroll only when the user is already near the bottom,
221
- // so reading old messages doesn't get yanked.
233
+ // Stick to the bottom *before paint* while the user is still anchored.
234
+ // Smooth `scrollIntoView` on every reasoning token paints a frame mid-list
235
+ // then animates down — on Windows the classic scrollbar visibly jumps.
222
236
  const lastMessageId = visibleMessages[visibleMessages.length - 1]?.id;
223
237
  const lastContentLen = visibleMessages[visibleMessages.length - 1]?.content?.length ?? 0;
224
238
  const lastTimelineSig = currentTurnTimeline
225
239
  ?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
226
240
  .join('|') ?? '';
227
241
  const suggestedSig = suggestedReplies?.join('\0') ?? '';
228
- useEffect(() => {
229
- if (!isAtBottom) return;
230
- scrollToBottom('smooth');
231
- }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, isAtBottom, scrollToBottom]);
242
+ useLayoutEffect(() => {
243
+ if (!isAtBottomRef.current) return;
244
+ scrollToBottom('auto');
245
+ }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, scrollToBottom]);
232
246
 
233
247
  return (
234
248
  <div className="relative flex-1 overflow-hidden">
235
249
  <div
236
250
  ref={containerRef}
237
- className="h-full overflow-y-auto px-3 py-3"
251
+ className="h-full overflow-y-auto overflow-anchor-none px-2.5 py-2"
238
252
  >
239
253
  {visibleMessages.length === 0 ? (
240
254
  emptyState ?? null
241
255
  ) : (
242
- <div className="mx-auto w-full space-y-1.5">
256
+ <div className="mx-auto w-full space-y-1">
243
257
  {visibleMessages.map((message, index) => {
244
258
  const isLast = index === visibleMessages.length - 1;
245
259
  if (message.role === 'user') {
@@ -304,6 +318,11 @@ export function MessageList({
304
318
  : durationByMessageId?.[message.id]
305
319
  ?? readPersistedDurationMs(metadataJson)
306
320
  ?? inferDurationMs(previousUserCreatedAt, message.createdAt);
321
+ const persistedSpeed = llmSpeedByMessageId?.[message.id];
322
+ const isSpeedTail = isLast && currentLlmSpeed !== undefined;
323
+ const llmSpeed =
324
+ persistedSpeed
325
+ ?? (isStreamingTail || isSpeedTail ? currentLlmSpeed : undefined);
307
326
 
308
327
  return (
309
328
  <div key={message.id}>
@@ -322,6 +341,7 @@ export function MessageList({
322
341
  onRegenerate={onRegenerate}
323
342
  startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
324
343
  durationMs={durationMs}
344
+ llmSpeed={llmSpeed}
325
345
  turnFiles={turnFiles}
326
346
  onShare={message.id === lastAssistantId ? onShare : undefined}
327
347
  />
@@ -359,14 +379,17 @@ export function MessageList({
359
379
  ) : null}
360
380
  </div>
361
381
  )}
362
- <div ref={endRef} />
363
382
  </div>
364
383
 
365
384
  {!isAtBottom && visibleMessages.length > 0 && (
366
385
  <button
367
386
  type="button"
368
- onClick={() => scrollToBottom('smooth')}
369
- 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"
387
+ onClick={() => {
388
+ isAtBottomRef.current = true;
389
+ setIsAtBottom(true);
390
+ scrollToBottom('smooth');
391
+ }}
392
+ 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"
370
393
  title="回到底部"
371
394
  aria-label="回到底部"
372
395
  >