@steerable/agent-shell-web 0.6.19 → 0.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +15 -8
  2. package/src/components/chat/ApprovalModal.tsx +21 -41
  3. package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
  4. package/src/components/chat/AskUserPromptProvider.tsx +99 -0
  5. package/src/components/chat/AssistantMessage.tsx +25 -2
  6. package/src/components/chat/ChatInput.tsx +116 -13
  7. package/src/components/chat/FilePathCode.tsx +65 -0
  8. package/src/components/chat/LocalChatPanel.tsx +19 -0
  9. package/src/components/chat/Markdown.tsx +54 -25
  10. package/src/components/chat/MessageList.tsx +66 -18
  11. package/src/components/chat/ModelPicker.tsx +5 -3
  12. package/src/components/chat/SuggestedReplies.tsx +34 -0
  13. package/src/components/chat/TurnFilesCard.tsx +113 -0
  14. package/src/components/chat/TurnProcessGroup.tsx +7 -2
  15. package/src/components/chat/path-mentions.ts +148 -0
  16. package/src/components/chat/suggested-replies-model.ts +40 -0
  17. package/src/components/chat/turn-files.ts +50 -0
  18. package/src/layouts/AgentLayout.tsx +15 -26
  19. package/src/lib/browser-dev-electron-mock.ts +15 -0
  20. package/src/lib/electron-bridge.ts +13 -7
  21. package/src/lib/http-bridge.ts +8 -2
  22. package/src/lib/local-api.ts +31 -0
  23. package/src/pages/AgentPage.tsx +126 -1
  24. package/src/pages/ChatPanelPreviewPage.tsx +4 -0
  25. package/vite.base.d.ts +7 -0
  26. package/vite.base.js +83 -0
  27. package/src/brand.test.ts +0 -22
  28. package/src/components/AgentSidebar.test.tsx +0 -791
  29. package/src/components/ChatHeader.test.tsx +0 -230
  30. package/src/components/LocalLlmSettingsModal.test.tsx +0 -372
  31. package/src/components/chat/ApprovalModal.test.tsx +0 -83
  32. package/src/components/chat/AskUserModalHost.test.tsx +0 -176
  33. package/src/components/chat/AskUserModalHost.tsx +0 -61
  34. package/src/components/chat/AskUserQuestionMenu.test.tsx +0 -190
  35. package/src/components/chat/AssistantMessage.test.tsx +0 -68
  36. package/src/components/chat/ChatInput.test.tsx +0 -609
  37. package/src/components/chat/ExecPolicyPicker.test.tsx +0 -26
  38. package/src/components/chat/Markdown.test.tsx +0 -170
  39. package/src/components/chat/ModelPicker.test.tsx +0 -149
  40. package/src/components/chat/SessionTreeModal.test.tsx +0 -161
  41. package/src/components/chat/TaskOutcomeCards.test.tsx +0 -97
  42. package/src/components/chat/TaskPanelModal.test.tsx +0 -198
  43. package/src/components/chat/TaskProcessPanel.test.tsx +0 -111
  44. package/src/components/chat/TurnProcessGroup.test.tsx +0 -161
  45. package/src/components/chat/elapsed.test.ts +0 -42
  46. package/src/components/chat/executed-actions-model.test.ts +0 -134
  47. package/src/components/chat/orchestration-children-model.test.ts +0 -115
  48. package/src/components/chat/turn-timeline.test.ts +0 -97
  49. package/src/components/chat/useChatTasks.test.ts +0 -136
  50. package/src/components/settings/AgentsSettingsPanel.test.tsx +0 -229
  51. package/src/components/settings/InsightsSettingsPanel.test.tsx +0 -132
  52. package/src/components/settings/LlmSettingsPanel.test.tsx +0 -461
  53. package/src/components/settings/ModelIdCombobox.test.tsx +0 -88
  54. package/src/components/settings/SecuritySettingsPanel.test.tsx +0 -122
  55. package/src/components/settings/SkillsSettingsPanel.test.tsx +0 -62
  56. package/src/components/settings/WebSearchSettingsPanel.test.tsx +0 -90
  57. package/src/components/settings/compat-flags-model.test.ts +0 -71
  58. package/src/components/settings/llm-vendors.test.ts +0 -171
  59. package/src/components/settings/model-capabilities.test.ts +0 -88
  60. package/src/components/settings/preset-choice-model.test.ts +0 -96
  61. package/src/hooks/useChatsAndAgents.test.ts +0 -165
  62. package/src/lib/attachments.test.ts +0 -127
  63. package/src/lib/chat-title.test.ts +0 -53
  64. package/src/lib/chat-transport.test.ts +0 -282
  65. package/src/lib/date-groups.test.ts +0 -62
  66. package/src/lib/demo-flag.test.ts +0 -17
  67. package/src/lib/electron-bridge.test.ts +0 -54
  68. package/src/lib/exec-policy.test.ts +0 -71
  69. package/src/lib/http-bridge.test.ts +0 -283
  70. package/src/lib/insights.test.ts +0 -56
  71. package/src/lib/local-api.test.ts +0 -353
  72. package/src/lib/pending-first-message.test.ts +0 -17
  73. package/src/lib/slash-sources.test.ts +0 -161
  74. package/src/pages/AgentPage.test.tsx +0 -627
  75. package/src/pages/SettingsPage.test.tsx +0 -102
  76. package/vite.base.ts +0 -94
@@ -42,6 +42,10 @@ import {
42
42
  type McpToolItem,
43
43
  type SkillItem,
44
44
  } from '@/lib/slash-sources';
45
+ import { useAskUserPrompt } from './AskUserPromptProvider';
46
+ import { AskUserQuestionMenu } from './AskUserQuestionMenu';
47
+ import { useApprovalPrompt } from './ApprovalPromptProvider';
48
+ import { ApprovalPromptMenu } from './ApprovalModal';
45
49
  import { ExecPolicyPicker } from './ExecPolicyPicker';
46
50
 
47
51
  export type ChatMode = 'agent' | 'plan';
@@ -704,6 +708,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
704
708
  },
705
709
  ref,
706
710
  ) {
711
+ const askUserPrompt = useAskUserPrompt();
712
+ const approvalPrompt = useApprovalPrompt();
707
713
  const editorRef = useRef<HTMLDivElement>(null);
708
714
  const agentMenuRef = useRef<HTMLDivElement>(null);
709
715
  const skillOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
@@ -731,6 +737,12 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
731
737
  const compositionEndAtRef = useRef<number>(0);
732
738
 
733
739
  const fileInputRef = useRef<HTMLInputElement>(null);
740
+ // 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
741
+ const toolbarRowRef = useRef<HTMLDivElement>(null);
742
+ const toolbarLeftRef = useRef<HTMLDivElement>(null);
743
+ const hintRef = useRef<HTMLDivElement>(null);
744
+ const sendButtonRef = useRef<HTMLButtonElement>(null);
745
+ const [hintsSuppressed, setHintsSuppressed] = useState(false);
734
746
  const [localFiles, setLocalFiles] = useState<AttachmentFile[]>([]);
735
747
  const actualFiles = files ?? localFiles;
736
748
  const actualOnFilesChange = onFilesChange ?? setLocalFiles;
@@ -1050,6 +1062,50 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1050
1062
  return () => window.removeEventListener('keydown', onKey);
1051
1063
  }, [isStreaming, onCancel]);
1052
1064
 
1065
+ // 快捷键提示是工具栏里优先级最低的元素:空间不够时第一个隐藏(先于
1066
+ // 左侧选择器截断)。容器查询只看输入盒宽度、看不到左侧内容(模型 id
1067
+ // 长度可变)的实际占用,所以直接测量:左组自然宽度 + 提示 + 发送键
1068
+ // 放不下就收起提示。测量时临时取消左组 flex 收缩并强制显示提示,
1069
+ // 拿到的都是不依赖当前收起状态的自然宽度——结果稳定,不会来回闪烁。
1070
+ useLayoutEffect(() => {
1071
+ const row = toolbarRowRef.current;
1072
+ const left = toolbarLeftRef.current;
1073
+ if (!row || !left) return;
1074
+
1075
+ const check = () => {
1076
+ if (row.clientWidth === 0) return; // 无布局环境(happy-dom)不干预
1077
+ const hint = hintRef.current;
1078
+ const send = sendButtonRef.current;
1079
+ const prevShrink = left.style.flexShrink;
1080
+ left.style.flexShrink = '0';
1081
+ const prevHintDisplay = hint?.style.display ?? '';
1082
+ if (hint) hint.style.display = 'block';
1083
+ const needed =
1084
+ left.getBoundingClientRect().width +
1085
+ (hint?.getBoundingClientRect().width ?? 0) +
1086
+ (send?.getBoundingClientRect().width ?? 0) +
1087
+ 16; // gap-2 ×2:左右组之间 + 提示与发送键之间
1088
+ left.style.flexShrink = prevShrink;
1089
+ if (hint) hint.style.display = prevHintDisplay;
1090
+ const available = row.clientWidth - 16; // px-2 两侧
1091
+ const suppress = needed > available + 1;
1092
+ setHintsSuppressed((prev) => (prev === suppress ? prev : suppress));
1093
+ };
1094
+
1095
+ check();
1096
+ const resizeObs = new ResizeObserver(check);
1097
+ resizeObs.observe(row);
1098
+ // 左组内容变化(切换模型/沙箱档、设置齿轮挂载)不改变行宽,
1099
+ // ResizeObserver 观察不到,用 MutationObserver 兜底重新测量。
1100
+ const mutationObs = new MutationObserver(check);
1101
+ mutationObs.observe(left, { childList: true, subtree: true, characterData: true });
1102
+ return () => {
1103
+ resizeObs.disconnect();
1104
+ mutationObs.disconnect();
1105
+ };
1106
+ // isStreaming 切换提示文案(kbd 标签不同、宽度不同),需要重测。
1107
+ }, [isStreaming]);
1108
+
1053
1109
  const trimmed = value.trim();
1054
1110
  const canSend = (trimmed.length > 0 || actualFiles.length > 0) && !disabled && !isStreaming;
1055
1111
 
@@ -1454,6 +1510,42 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1454
1510
  editorRef.current?.focus();
1455
1511
  };
1456
1512
 
1513
+ if (approvalPrompt?.current) {
1514
+ return (
1515
+ <div className="chat-input-container @container relative px-3 pb-3 pt-1">
1516
+ <ApprovalPromptMenu
1517
+ key={approvalPrompt.current.requestId}
1518
+ request={approvalPrompt.current}
1519
+ pendingCount={approvalPrompt.pendingCount}
1520
+ onDecide={approvalPrompt.decide}
1521
+ />
1522
+ </div>
1523
+ );
1524
+ }
1525
+
1526
+ if (askUserPrompt?.current) {
1527
+ const request = askUserPrompt.current;
1528
+ return (
1529
+ <div
1530
+ className="chat-input-container @container relative px-3 pb-3 pt-1"
1531
+ data-testid="ask-user-composer"
1532
+ >
1533
+ <AskUserQuestionMenu
1534
+ key={request.requestId}
1535
+ intro={request.intro}
1536
+ questions={request.questions}
1537
+ onSubmit={askUserPrompt.answer}
1538
+ onAutoContinue={() => askUserPrompt.answer({})}
1539
+ bottomHint={
1540
+ askUserPrompt.pendingCount > 0
1541
+ ? `还有 ${askUserPrompt.pendingCount} 组问题待回答`
1542
+ : undefined
1543
+ }
1544
+ />
1545
+ </div>
1546
+ );
1547
+ }
1548
+
1457
1549
  return (
1458
1550
  // @container:底栏元素的显隐/宽度用容器查询(@sm = 384px)而不是
1459
1551
  // 视口断点——侧边栏占宽后,视口够宽但输入框本身可能已经很窄。
@@ -1708,11 +1800,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1708
1800
  ))}
1709
1801
  </div>
1710
1802
  )}
1711
- <div className="flex items-center justify-between gap-2 px-2 pb-2 pt-1">
1803
+ <div
1804
+ ref={toolbarRowRef}
1805
+ className="flex items-center justify-between gap-2 px-2 pb-2 pt-1"
1806
+ >
1712
1807
  {/* Left toolbar: mode + exec sandbox + host extras + settings.
1713
1808
  Agent picker lives in the meta row above the box, next to
1714
1809
  the project badge. min-w-0 允许窄屏时胶囊截断收缩。 */}
1715
- <div className="flex min-w-0 items-center gap-1">
1810
+ <div ref={toolbarLeftRef} className="flex min-w-0 items-center gap-1">
1716
1811
  {mode && onModeChange && (
1717
1812
  <ModeToggle
1718
1813
  mode={mode}
@@ -1732,7 +1827,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1732
1827
  type="button"
1733
1828
  onClick={handlePickFiles}
1734
1829
  disabled={disabled || isStreaming}
1735
- className="flex h-7 w-7 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-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"
1736
1831
  title="上传文件(可多选)"
1737
1832
  aria-label="上传文件"
1738
1833
  >
@@ -1752,7 +1847,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1752
1847
  <button
1753
1848
  type="button"
1754
1849
  onClick={onOpenSettings}
1755
- className="flex h-7 w-7 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-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"
1756
1851
  title="LLM 设置"
1757
1852
  aria-label="LLM 设置"
1758
1853
  data-testid="chat-llm-settings"
@@ -1761,9 +1856,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1761
1856
  </button>
1762
1857
  )}
1763
1858
  </div>
1764
- <div className="flex items-center gap-2">
1765
- {/* 快捷键提示只在输入盒足够宽时出现(容器查询,非视口) */}
1766
- <div className="hidden text-[11px] text-agent-muted-foreground/80 @min-[520px]:block">
1859
+ {/* shrink-0:发送键是固定尺寸圆钮,窄容器下不允许被压扁;
1860
+ 收缩量由左侧可截断的选择器吸收。 */}
1861
+ <div className="flex shrink-0 items-center gap-2">
1862
+ {/* 快捷键提示:空间不足时优先隐藏(测量逻辑见上方 effect) */}
1863
+ <div
1864
+ ref={hintRef}
1865
+ className={`${hintsSuppressed ? 'hidden' : 'block'} text-[11px] text-agent-muted-foreground/80`}
1866
+ >
1767
1867
  {isStreaming ? (
1768
1868
  <>
1769
1869
  <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
@@ -1815,15 +1915,16 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1815
1915
  )}
1816
1916
  </div>
1817
1917
  <button
1918
+ ref={sendButtonRef}
1818
1919
  type="button"
1819
1920
  onClick={handleSendClick}
1820
1921
  disabled={!isStreaming && !canSend}
1821
1922
  className={
1822
1923
  isStreaming
1823
- ? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
1924
+ ? 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
1824
1925
  : canSend
1825
- ? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
1826
- : 'flex h-8 w-8 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
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'
1827
1928
  }
1828
1929
  title={
1829
1930
  isStreaming
@@ -1867,8 +1968,10 @@ function ModeToggle({
1867
1968
  onChange: (mode: ChatMode) => void;
1868
1969
  }) {
1869
1970
  return (
1971
+ // shrink-0:分段控件被 flex 压缩会压扁文字;极窄容器(<@sm)退化为
1972
+ // 纯图标(title 兜底语义),把收缩量让给可截断的选择器。
1870
1973
  <div
1871
- className="inline-flex h-7 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
1974
+ className="inline-flex h-7 shrink-0 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
1872
1975
  role="radiogroup"
1873
1976
  aria-label="对话模式"
1874
1977
  data-testid="mode-toggle"
@@ -1889,7 +1992,7 @@ function ModeToggle({
1889
1992
  ].join(' ')}
1890
1993
  >
1891
1994
  <LuInfinity className="h-3.5 w-3.5" />
1892
- <span>Agent</span>
1995
+ <span className="@max-sm:hidden">Agent</span>
1893
1996
  </button>
1894
1997
  <button
1895
1998
  type="button"
@@ -1907,7 +2010,7 @@ function ModeToggle({
1907
2010
  ].join(' ')}
1908
2011
  >
1909
2012
  <LuListChecks className="h-3.5 w-3.5" />
1910
- <span>Plan</span>
2013
+ <span className="@max-sm:hidden">Plan</span>
1911
2014
  </button>
1912
2015
  </div>
1913
2016
  );
@@ -0,0 +1,65 @@
1
+ import { useEffect, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ import { openLocalPath, type ResolvedLocalPath } from '@/lib/local-api';
3
+ import { peekResolvedPath, subscribeResolvedPath } from './path-mentions';
4
+
5
+ /**
6
+ * 行内代码里的文件路径:后端确认存在后变成可点击,点击用系统默认应用打开。
7
+ *
8
+ * 未确认存在(或后端不可达)时渲染成与普通行内代码完全一致的外观——路径
9
+ * 靠形状判断挑出,误报难免,不能给点不开的假 affordance。
10
+ */
11
+
12
+ const INLINE_CODE_CLASS =
13
+ 'rounded bg-agent-muted px-1 py-0.5 font-mono text-[0.85em] text-agent-foreground';
14
+
15
+ interface FilePathCodeProps extends ComponentPropsWithoutRef<'code'> {
16
+ /** 行内代码的纯文本,即待解析的路径字面量。 */
17
+ candidate: string;
18
+ /** 当前对话(相对路径按其绑定项目根解析),落地页可缺省。 */
19
+ chatId?: string | null;
20
+ children: ReactNode;
21
+ }
22
+
23
+ export function FilePathCode({ candidate, chatId, children, ...rest }: FilePathCodeProps) {
24
+ const [resolved, setResolved] = useState<ResolvedLocalPath | null>(
25
+ () => peekResolvedPath(candidate, chatId) ?? null,
26
+ );
27
+ const [openError, setOpenError] = useState<string | null>(null);
28
+
29
+ useEffect(() => {
30
+ setOpenError(null);
31
+ return subscribeResolvedPath(candidate, chatId, (entry) => setResolved(entry));
32
+ }, [candidate, chatId]);
33
+
34
+ if (!resolved) {
35
+ return (
36
+ <code {...rest} className={INLINE_CODE_CLASS}>
37
+ {children}
38
+ </code>
39
+ );
40
+ }
41
+
42
+ const handleClick = async () => {
43
+ setOpenError(null);
44
+ try {
45
+ const result = await openLocalPath(resolved.path);
46
+ if (!result.success) setOpenError(result.error || '打开失败');
47
+ } catch (err) {
48
+ setOpenError(err instanceof Error ? err.message : '打开失败');
49
+ }
50
+ };
51
+
52
+ return (
53
+ <button
54
+ type="button"
55
+ data-file-path-chip=""
56
+ onClick={handleClick}
57
+ title={openError ? `${resolved.path}(${openError})` : `点击打开 ${resolved.path}`}
58
+ className={`${INLINE_CODE_CLASS} cursor-pointer underline decoration-dotted underline-offset-2 transition-colors hover:bg-agent-accent/15 hover:text-agent-accent ${
59
+ openError ? 'text-red-600 dark:text-red-400' : ''
60
+ }`}
61
+ >
62
+ {children}
63
+ </button>
64
+ );
65
+ }
@@ -15,6 +15,7 @@ import { MessageList } from './MessageList';
15
15
  import type { ExecutedAction } from './ExecutedActionsCard';
16
16
  import type { ChildInfo } from './OrchestrationChildrenCard';
17
17
  import type { TurnBlock } from './turn-timeline';
18
+ import type { TurnFile } from './turn-files';
18
19
  import {
19
20
  isImageFile,
20
21
  saveChatAttachments,
@@ -97,6 +98,10 @@ export interface LocalChatPanelProps {
97
98
  currentTurnTimeline?: TurnBlock[];
98
99
  currentTurnStartedAtMs?: number;
99
100
  durationByMessageId?: Record<string, number>;
101
+ /** 回合产物文件列表(按落库消息 id 键控),转发给 MessageList。 */
102
+ turnFilesByMessageId?: Record<string, TurnFile[]>;
103
+ /** 当轮产物文件(流尾声到达、尚未归档到落库 id 的尾部消息用)。 */
104
+ currentTurnFiles?: TurnFile[];
100
105
  /** P3.1: in-flight child agents + per-message reconciliation map. */
101
106
  currentTurnChildren?: ChildInfo[];
102
107
  orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
@@ -141,6 +146,11 @@ export interface LocalChatPanelProps {
141
146
  onDismissFinishedTask?: (taskId: string) => void;
142
147
  /** 分享当前对话(截图)。落到最近一条助手消息的时间戳行。 */
143
148
  onShare?: () => Promise<boolean>;
149
+ /**
150
+ * 最近一条助手回复下的下一轮输入建议。点击即作为新用户消息发出。
151
+ */
152
+ suggestedReplies?: string[];
153
+ onSelectSuggestion?: (text: string) => void;
144
154
  }
145
155
 
146
156
  export function LocalChatPanel({
@@ -166,6 +176,8 @@ export function LocalChatPanel({
166
176
  currentTurnTimeline,
167
177
  currentTurnStartedAtMs,
168
178
  durationByMessageId,
179
+ turnFilesByMessageId,
180
+ currentTurnFiles,
169
181
  currentTurnChildren,
170
182
  orchestrationChildrenByMessageId,
171
183
  currentRound,
@@ -189,6 +201,8 @@ export function LocalChatPanel({
189
201
  onInspectTask,
190
202
  onDismissFinishedTask,
191
203
  onShare,
204
+ suggestedReplies,
205
+ onSelectSuggestion,
192
206
  }: LocalChatPanelProps) {
193
207
  const [inputValue, setInputValue] = useState('');
194
208
  const [files, setFiles] = useState<AttachmentFile[]>([]);
@@ -374,6 +388,7 @@ export function LocalChatPanel({
374
388
  isStreaming={isStreaming}
375
389
  agents={agents}
376
390
  chats={chats}
391
+ chatId={chatId}
377
392
  currentAgent={currentAgent}
378
393
  executedActionsByMessageId={executedActionsByMessageId}
379
394
  currentTurnActions={currentTurnActions}
@@ -381,6 +396,8 @@ export function LocalChatPanel({
381
396
  currentTurnTimeline={currentTurnTimeline}
382
397
  currentTurnStartedAtMs={currentTurnStartedAtMs}
383
398
  durationByMessageId={durationByMessageId}
399
+ turnFilesByMessageId={turnFilesByMessageId}
400
+ currentTurnFiles={currentTurnFiles}
384
401
  currentTurnChildren={currentTurnChildren}
385
402
  orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
386
403
  currentRound={currentRound}
@@ -394,6 +411,8 @@ export function LocalChatPanel({
394
411
  onInspectTask={onInspectTask}
395
412
  onDismissFinishedTask={onDismissFinishedTask}
396
413
  onShare={onShare}
414
+ suggestedReplies={suggestedReplies}
415
+ onSelectSuggestion={onSelectSuggestion}
397
416
  />
398
417
  {inputBanner}
399
418
  {chatInputNode}
@@ -18,6 +18,8 @@ import {
18
18
  type SkillItem,
19
19
  } from '@/lib/slash-sources';
20
20
  import { PlanTodoList } from './PlanTodoList';
21
+ import { FilePathCode } from './FilePathCode';
22
+ import { looksLikeFilePath } from './path-mentions';
21
23
 
22
24
  /**
23
25
  * Markdown renderer shared by `UserMessage` and `AssistantMessage`.
@@ -146,7 +148,31 @@ const components: Components = {
146
148
  ),
147
149
 
148
150
  // ── Code (inline + block) ─────────────────────────────────────────────
149
- code: (props) => {
151
+ code: renderCode(null),
152
+ pre: (props: ComponentPropsWithoutRef<'pre'>) => {
153
+ // A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
154
+ // The code renderer above already swaps the inner node for PlanTodoList;
155
+ // here we drop the <pre> shell so the card isn't boxed in a code block.
156
+ if (containsPlanCode(props.children)) {
157
+ return <>{props.children}</>;
158
+ }
159
+ return (
160
+ <pre
161
+ {...props}
162
+ className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
163
+ />
164
+ );
165
+ },
166
+ };
167
+
168
+ /**
169
+ * 行内代码 / 代码块渲染器。`chatId` 只影响行内代码:形状上像本地路径的
170
+ * 字面量交给 FilePathCode,由它经后端确认存在后变成可点击(相对路径按
171
+ * 该对话绑定的项目根解析)。代码块内的路径不参与——整块代码是给人读/
172
+ * 复制的,逐 token 挑路径会把块结构打散。
173
+ */
174
+ function renderCode(chatId: string | null): Components['code'] {
175
+ return (props) => {
150
176
  const { children, className, ...rest } = props as ComponentPropsWithoutRef<'code'> & {
151
177
  // react-markdown v9 dropped the `inline` typed prop. Distinguish by
152
178
  // language fence (the only time class is set is for fenced blocks
@@ -167,6 +193,14 @@ const components: Components = {
167
193
  </code>
168
194
  );
169
195
  }
196
+ const text = childrenToText(children);
197
+ if (looksLikeFilePath(text)) {
198
+ return (
199
+ <FilePathCode {...rest} candidate={text.trim()} chatId={chatId}>
200
+ {children}
201
+ </FilePathCode>
202
+ );
203
+ }
170
204
  return (
171
205
  <code
172
206
  {...rest}
@@ -175,22 +209,8 @@ const components: Components = {
175
209
  {children}
176
210
  </code>
177
211
  );
178
- },
179
- pre: (props: ComponentPropsWithoutRef<'pre'>) => {
180
- // A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
181
- // The code renderer above already swaps the inner node for PlanTodoList;
182
- // here we drop the <pre> shell so the card isn't boxed in a code block.
183
- if (containsPlanCode(props.children)) {
184
- return <>{props.children}</>;
185
- }
186
- return (
187
- <pre
188
- {...props}
189
- className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
190
- />
191
- );
192
- },
193
- };
212
+ };
213
+ }
194
214
 
195
215
  function isPlanCodeClass(className: unknown): boolean {
196
216
  return typeof className === 'string' && className.split(/\s+/).includes('language-plan');
@@ -359,6 +379,11 @@ interface MarkdownProps {
359
379
  skills?: SkillItem[];
360
380
  /** MCP tools behind `/mcp__srv__tool` chips. */
361
381
  mcpTools?: McpToolItem[];
382
+ /**
383
+ * 当前对话。行内代码里的路径要按它绑定的项目根解析相对路径;缺省时
384
+ * 相对路径按 home 解析(同无项目对话里 exec 的缺省 cwd)。
385
+ */
386
+ chatId?: string | null;
362
387
  }
363
388
 
364
389
  /**
@@ -401,15 +426,19 @@ export function Markdown({
401
426
  chats = [],
402
427
  skills = [],
403
428
  mcpTools = [],
429
+ chatId = null,
404
430
  }: MarkdownProps) {
405
- const baseComponents: Components = inlineParagraph
406
- ? {
407
- ...components,
408
- p: (props: ComponentPropsWithoutRef<'p'>) => (
409
- <span style={{ whiteSpace: 'pre-line' }} {...props} />
410
- ),
411
- }
412
- : components;
431
+ const baseComponents: Components = {
432
+ ...components,
433
+ code: renderCode(chatId),
434
+ ...(inlineParagraph
435
+ ? {
436
+ p: (props: ComponentPropsWithoutRef<'p'>) => (
437
+ <span style={{ whiteSpace: 'pre-line' }} {...props} />
438
+ ),
439
+ }
440
+ : {}),
441
+ };
413
442
 
414
443
  const finalComponents = withTokens(baseComponents, (node) =>
415
444
  renderTextWithMentions(node, agents, chats, skills, mcpTools),
@@ -13,10 +13,12 @@ import { UserMessage } from './UserMessage';
13
13
  import { AssistantMessage } from './AssistantMessage';
14
14
  import { InterruptedTurnCard } from './InterruptedTurnCard';
15
15
  import { TaskOutcomeCards } from './TaskOutcomeCards';
16
+ import { SuggestedReplies } from './SuggestedReplies';
16
17
  import type { ExecutedAction } from './ExecutedActionsCard';
17
18
  import type { ChildInfo } from './OrchestrationChildrenCard';
18
19
  import type { ChatMode } from './ChatInput';
19
20
  import type { TurnBlock } from './turn-timeline';
21
+ import type { TurnFile } from './turn-files';
20
22
  import { inferDurationMs, readPersistedDurationMs } from './elapsed';
21
23
 
22
24
  /**
@@ -55,6 +57,11 @@ interface MessageListProps {
55
57
  emptyState?: ReactNode;
56
58
  agents: LocalChatAgent[];
57
59
  chats?: LocalChat[];
60
+ /**
61
+ * 当前对话。正文行内代码里的路径按其绑定项目根解析后,确认存在的变成
62
+ * 可点击(见 FilePathCode)。
63
+ */
64
+ chatId?: string | null;
58
65
  currentAgent: LocalChatAgent | null;
59
66
  /**
60
67
  * Tool calls keyed by the persisted message id (post-stream). Used for
@@ -73,6 +80,17 @@ interface MessageListProps {
73
80
  currentTurnStartedAtMs?: number;
74
81
  /** Frozen duration keyed by assistant message id (live freeze + history). */
75
82
  durationByMessageId?: Record<string, number>;
83
+ /**
84
+ * 回合产物文件列表,按落库消息 id 键控(live 回合在 message_id 事件时
85
+ * 归档;历史回合从 messageMetadata.turnFiles 水合)。
86
+ */
87
+ turnFilesByMessageId?: Record<string, TurnFile[]>;
88
+ /**
89
+ * 当轮产物文件:turn_files 事件在流尾声到达,此时尾部助手消息仍挂着
90
+ * 占位 id(框架不会在 message_id 后改写它),归档 map 按键查不到——
91
+ * 与 currentTurnActions 同款尾部回退。
92
+ */
93
+ currentTurnFiles?: TurnFile[];
76
94
  /**
77
95
  * Tool calls accumulated for the in-flight assistant message that the
78
96
  * backend hasn't assigned a DB id to yet. Rendered under the latest
@@ -116,6 +134,11 @@ interface MessageListProps {
116
134
  onDismissFinishedTask?: (taskId: string) => void;
117
135
  /** 分享当前对话(截图)。只画在最近一条助手消息的时间戳行上。 */
118
136
  onShare?: () => Promise<boolean>;
137
+ /**
138
+ * 最近一条助手回复下的下一轮输入建议(WorkBuddy 式)。只在非流式时渲染。
139
+ */
140
+ suggestedReplies?: string[];
141
+ onSelectSuggestion?: (text: string) => void;
119
142
  }
120
143
 
121
144
  export function MessageList({
@@ -124,6 +147,7 @@ export function MessageList({
124
147
  emptyState,
125
148
  agents,
126
149
  chats = [],
150
+ chatId = null,
127
151
  currentAgent,
128
152
  executedActionsByMessageId,
129
153
  currentTurnActions,
@@ -131,6 +155,8 @@ export function MessageList({
131
155
  currentTurnTimeline,
132
156
  currentTurnStartedAtMs,
133
157
  durationByMessageId,
158
+ turnFilesByMessageId,
159
+ currentTurnFiles,
134
160
  currentTurnChildren,
135
161
  orchestrationChildrenByMessageId,
136
162
  currentRound,
@@ -143,6 +169,8 @@ export function MessageList({
143
169
  onInspectTask,
144
170
  onDismissFinishedTask,
145
171
  onShare,
172
+ suggestedReplies,
173
+ onSelectSuggestion,
146
174
  }: MessageListProps) {
147
175
  const containerRef = useRef<HTMLDivElement>(null);
148
176
  const endRef = useRef<HTMLDivElement>(null);
@@ -196,10 +224,11 @@ export function MessageList({
196
224
  const lastTimelineSig = currentTurnTimeline
197
225
  ?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
198
226
  .join('|') ?? '';
227
+ const suggestedSig = suggestedReplies?.join('\0') ?? '';
199
228
  useEffect(() => {
200
229
  if (!isAtBottom) return;
201
230
  scrollToBottom('smooth');
202
- }, [lastMessageId, lastContentLen, lastTimelineSig, isAtBottom, scrollToBottom]);
231
+ }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, isAtBottom, scrollToBottom]);
203
232
 
204
233
  return (
205
234
  <div className="relative flex-1 overflow-hidden">
@@ -253,6 +282,12 @@ export function MessageList({
253
282
  const turnTimeline = persistedTimeline
254
283
  ?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
255
284
 
285
+ const persistedTurnFiles = turnFilesByMessageId?.[message.id];
286
+ const isTurnFilesTail =
287
+ isLast && currentTurnFiles !== undefined && currentTurnFiles.length > 0;
288
+ const turnFiles = persistedTurnFiles
289
+ ?? (isTurnFilesTail ? currentTurnFiles : undefined);
290
+
256
291
  const metadataJson =
257
292
  typeof message.messageMetadata === 'string'
258
293
  ? message.messageMetadata
@@ -271,23 +306,36 @@ export function MessageList({
271
306
  ?? inferDurationMs(previousUserCreatedAt, message.createdAt);
272
307
 
273
308
  return (
274
- <AssistantMessage
275
- key={message.id}
276
- message={message}
277
- isStreaming={isStreamingTail}
278
- agents={agents}
279
- chats={chats}
280
- currentAgent={currentAgent}
281
- executedActions={actions}
282
- timeline={turnTimeline}
283
- orchestrationChildren={childList}
284
- currentRound={isStreamingTail ? currentRound : undefined}
285
- isPlanMode={isPlanMode}
286
- onRegenerate={onRegenerate}
287
- startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
288
- durationMs={durationMs}
289
- onShare={message.id === lastAssistantId ? onShare : undefined}
290
- />
309
+ <div key={message.id}>
310
+ <AssistantMessage
311
+ message={message}
312
+ isStreaming={isStreamingTail}
313
+ agents={agents}
314
+ chats={chats}
315
+ chatId={chatId}
316
+ currentAgent={currentAgent}
317
+ executedActions={actions}
318
+ timeline={turnTimeline}
319
+ orchestrationChildren={childList}
320
+ currentRound={isStreamingTail ? currentRound : undefined}
321
+ isPlanMode={isPlanMode}
322
+ onRegenerate={onRegenerate}
323
+ startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
324
+ durationMs={durationMs}
325
+ turnFiles={turnFiles}
326
+ onShare={message.id === lastAssistantId ? onShare : undefined}
327
+ />
328
+ {!isStreaming &&
329
+ message.id === lastAssistantId &&
330
+ suggestedReplies &&
331
+ suggestedReplies.length > 0 &&
332
+ onSelectSuggestion ? (
333
+ <SuggestedReplies
334
+ suggestions={suggestedReplies}
335
+ onSelect={onSelectSuggestion}
336
+ />
337
+ ) : null}
338
+ </div>
291
339
  );
292
340
  })}
293
341
  {/* W7-1: 中断提示卡在消息列尾部、与最后一条用户消息同列——
@@ -104,7 +104,9 @@ export function ModelPicker({
104
104
 
105
105
  return (
106
106
  <div ref={rootRef} className="flex min-w-0 items-center gap-1">
107
- <div className="relative">
107
+ {/* flex + min-w-0 截断链:窄容器下模型 id 省略号收缩,而不是把
108
+ 旁边的圆形图标按钮压扁。 */}
109
+ <div className="relative flex min-w-0">
108
110
  <button
109
111
  type="button"
110
112
  onClick={() => {
@@ -115,7 +117,7 @@ export function ModelPicker({
115
117
  if (next && status !== 'live' && !loading) void refresh();
116
118
  }}
117
119
  disabled={disabled}
118
- className="inline-flex h-7 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
120
+ className="inline-flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
119
121
  title={
120
122
  status === 'offline'
121
123
  ? `模型目录不可用${catalog?.error ? `:${catalog.error}` : ''}(仍可手动指定模型)`
@@ -124,7 +126,7 @@ export function ModelPicker({
124
126
  aria-haspopup="menu"
125
127
  aria-expanded={modelMenuOpen}
126
128
  >
127
- <span className="truncate">{effectiveModel || '选择模型'}</span>
129
+ <span className="min-w-0 truncate">{effectiveModel || '选择模型'}</span>
128
130
  {model != null && (
129
131
  <span className="inline-flex shrink-0 items-center rounded bg-agent-foreground/10 px-1 py-0.5 text-[9px] font-medium text-agent-foreground">
130
132
  本会话