@steerable/agent-shell-web 0.6.20 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.20",
3
+ "version": "0.6.21",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -45,8 +45,8 @@
45
45
  "react-router-dom": "^6.30.0",
46
46
  "rehype-raw": "^7.0.0",
47
47
  "remark-gfm": "^4.0.1",
48
- "@steerable/agent-ui": "0.6.20",
49
- "@steerable/agent-protocol": "0.6.20"
48
+ "@steerable/agent-ui": "0.6.21",
49
+ "@steerable/agent-protocol": "0.6.21"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.0",
@@ -59,7 +59,7 @@
59
59
  "typescript": "^5.8.3",
60
60
  "vite": "^5.4.21",
61
61
  "vitest": "^3.2.4",
62
- "@steerable/pack-sdk": "0.6.20"
62
+ "@steerable/pack-sdk": "0.6.21"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -1,25 +1,21 @@
1
- import { useEffect, useState } from 'react';
2
1
  import { LuShieldAlert, LuTriangleAlert } from 'react-icons/lu';
3
2
  import {
4
- getElectronBridge,
5
3
  type ApprovalDecisionKind,
6
4
  type ApprovalPromptRequest,
7
5
  } from '@/lib/electron-bridge';
8
6
 
9
7
  /**
10
- * ApprovalModalHost — W4-1 审批代数的 Electron UI 半侧。
8
+ * ApprovalPromptMenu — W4-1 审批代数的输入区 UI。
11
9
  *
12
- * 挂载一次(AgentPage),订阅主进程广播的 `approval:request`,弹出模态
13
- * 让用户对工具调用做 7 变体决策(允许/拒绝 × 一次性/会话/持久 + 中止)。
14
- * 决定经 `approval:decide` IPC 回到主进程,再应答 sidecar 的反向调用。
10
+ * 由 ApprovalPromptProvider 提供当前请求,在普通输入框的位置让用户对
11
+ * 工具调用做 7 变体决策(允许/拒绝 × 一次性/会话/持久 + 中止)。
15
12
  *
16
13
  * 持久化语义(与框架 ApprovalExecutor 对齐):
17
14
  * - 一次性:只作用于本次调用,不缓存。
18
15
  * - 会话:按 category(默认=工具名)缓存在本 chat 的会话级 cache。
19
16
  * - 持久:写入 ~/.steerable/approvals.json,跨会话生效。
20
17
  *
21
- * 队列为 FIFO:模型一批并发多个工具调用时逐个请示(sidecar 串行等待
22
- * 每个应答)。组件卸载/无窗口时主进程 fail-closed 为 deny_once。
18
+ * 请求队列及刷新恢复由 Provider 管理。
23
19
  */
24
20
 
25
21
  const MODE_LABEL: Record<string, string> = {
@@ -88,26 +84,15 @@ function DecisionRow({
88
84
  );
89
85
  }
90
86
 
91
- export function ApprovalModalHost() {
92
- const [queue, setQueue] = useState<ApprovalPromptRequest[]>([]);
93
-
94
- useEffect(() => {
95
- const bridge = getElectronBridge();
96
- if (!bridge?.approval) return;
97
- return bridge.approval.onRequest((request) => {
98
- setQueue((prev) => [...prev, request]);
99
- });
100
- }, []);
101
-
102
- const current = queue[0] ?? null;
103
- if (!current) return null;
104
-
105
- const decide = (kind: ApprovalDecisionKind) => {
106
- const bridge = getElectronBridge();
107
- setQueue((prev) => prev.slice(1));
108
- void bridge?.approval?.decide({ requestId: current.requestId, kind });
109
- };
110
-
87
+ export function ApprovalPromptMenu({
88
+ request: current,
89
+ pendingCount,
90
+ onDecide,
91
+ }: {
92
+ request: ApprovalPromptRequest;
93
+ pendingCount: number;
94
+ onDecide: (kind: ApprovalDecisionKind) => void;
95
+ }) {
111
96
  const destructive = current.mode === 'destructive';
112
97
  // 网络出口拓宽(W-egress-ask):sidecar 的 web 工具被 egress 代理 403
113
98
  // 后发起。白名单是代理进程级的(会话寿命),所以「始终」变体对网络
@@ -121,13 +106,9 @@ export function ApprovalModalHost() {
121
106
 
122
107
  return (
123
108
  <div
124
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
125
- role="dialog"
126
- aria-modal="true"
127
- aria-label="工具调用审批"
128
- data-testid="approval-dialog"
109
+ className="overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas text-sm text-agent-foreground shadow-xl"
110
+ data-testid="approval-composer"
129
111
  >
130
- <div className="w-full max-w-lg rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
131
112
  <div className="flex items-center gap-2 border-b border-agent-border px-4 py-3">
132
113
  {destructive ? (
133
114
  <LuTriangleAlert className="h-4 w-4 shrink-0 text-agent-destructive" />
@@ -154,9 +135,9 @@ export function ApprovalModalHost() {
154
135
  </>
155
136
  )}
156
137
  </div>
157
- {queue.length > 1 && (
138
+ {pendingCount > 0 && (
158
139
  <div className="mt-0.5 text-[11px] text-agent-muted-foreground">
159
- 还有 {queue.length - 1} 个待审批
140
+ 还有 {pendingCount} 个待审批
160
141
  </div>
161
142
  )}
162
143
  </div>
@@ -195,7 +176,7 @@ export function ApprovalModalHost() {
195
176
  ? ALLOW_BUTTONS.filter((b) => b.kind !== 'allow_always')
196
177
  : ALLOW_BUTTONS
197
178
  }
198
- onPick={decide}
179
+ onPick={onDecide}
199
180
  />
200
181
  <div className="flex items-center justify-between gap-2">
201
182
  <DecisionRow
@@ -204,11 +185,11 @@ export function ApprovalModalHost() {
204
185
  ? DENY_BUTTONS.filter((b) => b.kind !== 'deny_always')
205
186
  : DENY_BUTTONS
206
187
  }
207
- onPick={decide}
188
+ onPick={onDecide}
208
189
  />
209
190
  <button
210
191
  type="button"
211
- onClick={() => decide('abort')}
192
+ onClick={() => onDecide('abort')}
212
193
  className="shrink-0 rounded-agent-md bg-agent-destructive px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
213
194
  data-testid="approval-abort"
214
195
  >
@@ -221,9 +202,8 @@ export function ApprovalModalHost() {
221
202
  : '「会话」决定在本对话内记住;「始终」决定写入本机 ~/.steerable/approvals.json,跨对话生效。不操作约 2 分钟后按拒绝处理。'}
222
203
  </p>
223
204
  </div>
224
- </div>
225
205
  </div>
226
206
  );
227
207
  }
228
208
 
229
- export default ApprovalModalHost;
209
+ export default ApprovalPromptMenu;
@@ -0,0 +1,98 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import {
12
+ getElectronBridge,
13
+ type ApprovalDecisionKind,
14
+ type ApprovalPromptRequest,
15
+ } from '@/lib/electron-bridge';
16
+
17
+ interface ApprovalPromptContextValue {
18
+ current: ApprovalPromptRequest | null;
19
+ pendingCount: number;
20
+ decide: (kind: ApprovalDecisionKind) => void;
21
+ }
22
+
23
+ const ApprovalPromptContext = createContext<ApprovalPromptContextValue | null>(null);
24
+
25
+ /** Keeps approval prompts alive while the active composer presents them. */
26
+ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
27
+ const [queue, setQueue] = useState<ApprovalPromptRequest[]>([]);
28
+ const decidedRequestIds = useRef(new Set<string>());
29
+
30
+ useEffect(() => {
31
+ const bridge = getElectronBridge();
32
+ if (!bridge?.approval) return;
33
+ let active = true;
34
+ const addRequests = (
35
+ requests: ApprovalPromptRequest[],
36
+ placement: 'append' | 'prepend' = 'append',
37
+ ) => {
38
+ setQueue((previous) => {
39
+ const known = new Set(previous.map(({ requestId }) => requestId));
40
+ const additions = requests.filter(
41
+ ({ requestId }) =>
42
+ !known.has(requestId) && !decidedRequestIds.current.has(requestId),
43
+ );
44
+ if (additions.length === 0) return previous;
45
+ return placement === 'prepend'
46
+ ? [...additions, ...previous]
47
+ : [...previous, ...additions];
48
+ });
49
+ };
50
+ const unsubscribe = bridge.approval.onRequest((request) => {
51
+ addRequests([request]);
52
+ });
53
+ void bridge.approval
54
+ .pending()
55
+ .then((requests) => {
56
+ if (active) addRequests(requests, 'prepend');
57
+ })
58
+ .catch((error) => {
59
+ console.error('恢复待审批请求失败:', error);
60
+ });
61
+ return () => {
62
+ active = false;
63
+ unsubscribe();
64
+ };
65
+ }, []);
66
+
67
+ const current = queue[0] ?? null;
68
+ const decide = useCallback(
69
+ (kind: ApprovalDecisionKind) => {
70
+ if (!current) return;
71
+ const bridge = getElectronBridge();
72
+ decidedRequestIds.current.add(current.requestId);
73
+ setQueue((previous) => previous.slice(1));
74
+ void bridge?.approval?.decide({ requestId: current.requestId, kind });
75
+ },
76
+ [current],
77
+ );
78
+
79
+ const value = useMemo(
80
+ () => ({
81
+ current,
82
+ pendingCount: Math.max(0, queue.length - 1),
83
+ decide,
84
+ }),
85
+ [current, decide, queue.length],
86
+ );
87
+
88
+ return (
89
+ <ApprovalPromptContext.Provider value={value}>
90
+ {children}
91
+ </ApprovalPromptContext.Provider>
92
+ );
93
+ }
94
+
95
+ /** Returns the approval prompt currently assigned to the chat composer. */
96
+ export function useApprovalPrompt(): ApprovalPromptContextValue | null {
97
+ return useContext(ApprovalPromptContext);
98
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { getElectronBridge, type AskUserPromptRequest } from '@/lib/electron-bridge';
12
+
13
+ type AnswerValue = string | string[];
14
+
15
+ interface AskUserPromptContextValue {
16
+ current: AskUserPromptRequest | null;
17
+ pendingCount: number;
18
+ answer: (answers: Record<string, AnswerValue>) => void;
19
+ }
20
+
21
+ const AskUserPromptContext = createContext<AskUserPromptContextValue | null>(null);
22
+
23
+ /**
24
+ * Keeps structured user prompts alive across chat navigation while allowing
25
+ * the active composer to present the current prompt.
26
+ */
27
+ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
28
+ const [queue, setQueue] = useState<AskUserPromptRequest[]>([]);
29
+ const answeredRequestIds = useRef(new Set<string>());
30
+
31
+ useEffect(() => {
32
+ const bridge = getElectronBridge();
33
+ if (!bridge?.askUser) return;
34
+ let active = true;
35
+ const addRequests = (
36
+ requests: AskUserPromptRequest[],
37
+ placement: 'append' | 'prepend' = 'append',
38
+ ) => {
39
+ setQueue((previous) => {
40
+ const known = new Set(previous.map(({ requestId }) => requestId));
41
+ const additions = requests.filter(
42
+ ({ requestId }) =>
43
+ !known.has(requestId) && !answeredRequestIds.current.has(requestId),
44
+ );
45
+ if (additions.length === 0) return previous;
46
+ return placement === 'prepend'
47
+ ? [...additions, ...previous]
48
+ : [...previous, ...additions];
49
+ });
50
+ };
51
+ const unsubscribe = bridge.askUser.onRequest((request) => {
52
+ addRequests([request]);
53
+ });
54
+ void bridge.askUser
55
+ .pending()
56
+ .then((requests) => {
57
+ if (active) addRequests(requests, 'prepend');
58
+ })
59
+ .catch((error) => {
60
+ console.error('恢复待回答问题失败:', error);
61
+ });
62
+ return () => {
63
+ active = false;
64
+ unsubscribe();
65
+ };
66
+ }, []);
67
+
68
+ const current = queue[0] ?? null;
69
+ const answer = useCallback(
70
+ (answers: Record<string, AnswerValue>) => {
71
+ if (!current) return;
72
+ const bridge = getElectronBridge();
73
+ answeredRequestIds.current.add(current.requestId);
74
+ setQueue((previous) => previous.slice(1));
75
+ void bridge?.askUser?.answer({ requestId: current.requestId, answers });
76
+ },
77
+ [current],
78
+ );
79
+
80
+ const value = useMemo(
81
+ () => ({
82
+ current,
83
+ pendingCount: Math.max(0, queue.length - 1),
84
+ answer,
85
+ }),
86
+ [answer, current, queue.length],
87
+ );
88
+
89
+ return (
90
+ <AskUserPromptContext.Provider value={value}>
91
+ {children}
92
+ </AskUserPromptContext.Provider>
93
+ );
94
+ }
95
+
96
+ /** Returns the structured prompt currently assigned to the chat composer. */
97
+ export function useAskUserPrompt(): AskUserPromptContextValue | null {
98
+ return useContext(AskUserPromptContext);
99
+ }
@@ -11,6 +11,8 @@ import { getFriendlyDate } from './timestamp';
11
11
  import { useCopy } from './useCopy';
12
12
  import type { TurnBlock } from './turn-timeline';
13
13
  import { TurnProcessGroup } from './TurnProcessGroup';
14
+ import { TurnFilesCard } from './TurnFilesCard';
15
+ import type { TurnFile } from './turn-files';
14
16
 
15
17
  /**
16
18
  * AssistantMessage — Tier-1 port of `deeppath`'s assistant-bubble.
@@ -55,6 +57,11 @@ interface AssistantMessageProps {
55
57
  */
56
58
  agents: LocalChatAgent[];
57
59
  chats?: LocalChat[];
60
+ /**
61
+ * 当前对话。正文行内代码里的路径按它绑定的项目根解析相对路径后,
62
+ * 确认存在的会变成可点击(见 FilePathCode)。
63
+ */
64
+ chatId?: string | null;
58
65
  /** Active chat-level agent — used as a fallback during streaming. */
59
66
  currentAgent: LocalChatAgent | null;
60
67
  /**
@@ -91,6 +98,12 @@ interface AssistantMessageProps {
91
98
  startedAtMs?: number;
92
99
  /** Frozen wall-clock of a finished turn. */
93
100
  durationMs?: number;
101
+ /**
102
+ * 本回合产生/修改的文件列表(local-backend 回合收尾时收集,经
103
+ * `turn_files` SSE + messageMetadata.turnFiles 持久化)。渲染在回答
104
+ * 气泡之下,点击用系统默认应用打开。空/undefined = 不渲染。
105
+ */
106
+ turnFiles?: TurnFile[];
94
107
  /**
95
108
  * 分享当前对话(截图复制到剪贴板)。只传给最近一条助手消息,画在时间戳
96
109
  * 行上,跟复制 / 重新生成同一排。
@@ -198,6 +211,7 @@ export function AssistantMessage({
198
211
  isStreaming,
199
212
  agents,
200
213
  chats = [],
214
+ chatId = null,
201
215
  currentAgent,
202
216
  executedActions,
203
217
  timeline,
@@ -207,6 +221,7 @@ export function AssistantMessage({
207
221
  onRegenerate,
208
222
  startedAtMs,
209
223
  durationMs,
224
+ turnFiles,
210
225
  onShare,
211
226
  }: AssistantMessageProps) {
212
227
  const content = message.content || '';
@@ -263,6 +278,7 @@ export function AssistantMessage({
263
278
  durationMs={durationMs}
264
279
  agents={agents}
265
280
  chats={chats}
281
+ chatId={chatId}
266
282
  emptyFallback={
267
283
  failure ? (
268
284
  <TurnErrorBubble reason={failure.reason} />
@@ -290,7 +306,7 @@ export function AssistantMessage({
290
306
  renderAnswer={(block, isLast) => (
291
307
  <div className={bubbleClass(isPlanMode)}>
292
308
  <div className="markdown-content text-sm leading-relaxed text-agent-foreground">
293
- <Markdown agents={agents} chats={chats}>{block.content}</Markdown>
309
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
294
310
  </div>
295
311
  {isStreaming && isLast && <StreamingCursor />}
296
312
  </div>
@@ -312,7 +328,7 @@ export function AssistantMessage({
312
328
  {displayContent ? (
313
329
  <div className={bubbleClass(isPlanMode)}>
314
330
  <div className="markdown-content text-sm leading-relaxed text-agent-foreground">
315
- <Markdown agents={agents} chats={chats}>{displayContent}</Markdown>
331
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{displayContent}</Markdown>
316
332
  </div>
317
333
  {isStreaming && <StreamingCursor />}
318
334
  </div>
@@ -338,6 +354,13 @@ export function AssistantMessage({
338
354
  ) : null}
339
355
  </>
340
356
  )}
357
+ {/* 回合产物文件列表:钉在回答之下、时间戳行之上(Codex 式收尾)。
358
+ 回合收尾才有数据,流式期间天然为空。 */}
359
+ {!isStreaming && turnFiles && turnFiles.length > 0 && (
360
+ <div className="mt-1.5">
361
+ <TurnFilesCard files={turnFiles} />
362
+ </div>
363
+ )}
341
364
  <div className="mt-1 flex items-center gap-2 text-[11px] text-agent-muted-foreground">
342
365
  <span>
343
366
  {message.createdAt
@@ -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
  );