@steerable/agent-shell-web 0.6.27 → 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 +16 -3
  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 +5 -4
  23. package/src/components/chat/TurnFilesCard.tsx +2 -2
  24. package/src/components/chat/TurnProcessGroup.tsx +226 -99
  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 -53
  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 +3 -3
  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 +4 -4
  46. package/src/pages/AgentPage.tsx +56 -6
  47. package/src/pages/ChatPanelPreviewPage.tsx +26 -4
  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,
@@ -241,12 +248,12 @@ export function MessageList({
241
248
  <div className="relative flex-1 overflow-hidden">
242
249
  <div
243
250
  ref={containerRef}
244
- className="h-full overflow-y-auto overflow-anchor-none px-3 py-3"
251
+ className="h-full overflow-y-auto overflow-anchor-none px-2.5 py-2"
245
252
  >
246
253
  {visibleMessages.length === 0 ? (
247
254
  emptyState ?? null
248
255
  ) : (
249
- <div className="mx-auto w-full space-y-1.5">
256
+ <div className="mx-auto w-full space-y-1">
250
257
  {visibleMessages.map((message, index) => {
251
258
  const isLast = index === visibleMessages.length - 1;
252
259
  if (message.role === 'user') {
@@ -311,6 +318,11 @@ export function MessageList({
311
318
  : durationByMessageId?.[message.id]
312
319
  ?? readPersistedDurationMs(metadataJson)
313
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);
314
326
 
315
327
  return (
316
328
  <div key={message.id}>
@@ -329,6 +341,7 @@ export function MessageList({
329
341
  onRegenerate={onRegenerate}
330
342
  startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
331
343
  durationMs={durationMs}
344
+ llmSpeed={llmSpeed}
332
345
  turnFiles={turnFiles}
333
346
  onShare={message.id === lastAssistantId ? onShare : undefined}
334
347
  />
@@ -376,7 +389,7 @@ export function MessageList({
376
389
  setIsAtBottom(true);
377
390
  scrollToBottom('smooth');
378
391
  }}
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"
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"
380
393
  title="回到底部"
381
394
  aria-label="回到底部"
382
395
  >
@@ -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',