@steerable/agent-shell-web 0.6.15

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 (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,621 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { LuCheck, LuChevronLeft, LuPencil, LuWand } from 'react-icons/lu';
3
+
4
+ /**
5
+ * AskUserQuestionMenu — W8 结构化提问的分步菜单卡片。
6
+ *
7
+ * 替代 `@steerable/agent-ui` 的 `AskUserQuestionsCard`:
8
+ * - 一次只展示一个问题,顶部显示进度(问题 1/3);
9
+ * - select 题渲染为纵向菜单,模型给出的 options 是菜单项;
10
+ * - 菜单底部追加「其他 / 自定义」:选中后展开输入框,用户输入直接作为
11
+ * 该题答案(不会用补充信息再创建新问题);
12
+ * - 支持 ↑↓ 移动焦点,Enter 选中/切换;单选 Enter/点击 即提交并进入
13
+ * 下一题(或提交全部),多选勾选后用「下一题 / 提交」按钮;
14
+ * - 提交时仍返回 `{ [questionId]: string | string[] }`,与主进程/
15
+ * sidecar 的 answers 映射契约保持一致。
16
+ */
17
+
18
+ type AnswerValue = string | string[];
19
+ type RawQuestion = Record<string, unknown>;
20
+
21
+ interface AskOption {
22
+ label: string;
23
+ description?: string;
24
+ }
25
+
26
+ interface AskQuestion {
27
+ id: string;
28
+ text: string;
29
+ type: 'select' | 'text' | 'password';
30
+ options: AskOption[];
31
+ placeholder?: string;
32
+ multiSelect: boolean;
33
+ detail?: string;
34
+ header?: string;
35
+ }
36
+
37
+ function normalizeOption(raw: unknown): AskOption | null {
38
+ if (typeof raw === 'string') return { label: raw };
39
+ if (raw && typeof raw === 'object') {
40
+ const obj = raw as Record<string, unknown>;
41
+ if (typeof obj.label === 'string') {
42
+ return {
43
+ label: obj.label,
44
+ description: typeof obj.description === 'string' ? obj.description : undefined,
45
+ };
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function normalizeQuestion(raw: RawQuestion): AskQuestion | null {
52
+ if (typeof raw.id !== 'string') return null;
53
+ const text =
54
+ typeof raw.text === 'string'
55
+ ? raw.text
56
+ : typeof raw.question === 'string'
57
+ ? raw.question
58
+ : '';
59
+ if (!text) return null;
60
+
61
+ const rawType = raw.type;
62
+ const inputType: AskQuestion['type'] =
63
+ rawType === 'text' || rawType === 'password' ? rawType : 'select';
64
+
65
+ const options = Array.isArray(raw.options)
66
+ ? (raw.options.map(normalizeOption).filter(Boolean) as AskOption[])
67
+ : [];
68
+
69
+ return {
70
+ id: raw.id,
71
+ text,
72
+ type: options.length > 0 ? 'select' : inputType === 'select' ? 'text' : inputType,
73
+ options,
74
+ placeholder: typeof raw.placeholder === 'string' ? raw.placeholder : undefined,
75
+ multiSelect: raw.multiSelect === true,
76
+ detail: typeof raw.detail === 'string' ? raw.detail : undefined,
77
+ header: typeof raw.header === 'string' ? raw.header : undefined,
78
+ };
79
+ }
80
+
81
+ function normalizeQuestions(raw: Array<Record<string, unknown>>): AskQuestion[] {
82
+ return raw.map(normalizeQuestion).filter(Boolean) as AskQuestion[];
83
+ }
84
+
85
+ function initialDraft(question: AskQuestion, answer: AnswerValue | undefined): {
86
+ selected: string[];
87
+ customMode: boolean;
88
+ customText: string;
89
+ } {
90
+ const optionLabels = question.options.map((o) => o.label);
91
+ if (Array.isArray(answer)) {
92
+ const selected = answer.filter((v) => optionLabels.includes(v));
93
+ const customBits = answer.filter((v) => !optionLabels.includes(v));
94
+ return {
95
+ selected,
96
+ customMode: customBits.length > 0,
97
+ customText: customBits.join('、'),
98
+ };
99
+ }
100
+ if (typeof answer === 'string' && answer.length > 0) {
101
+ if (optionLabels.includes(answer)) {
102
+ return { selected: [answer], customMode: false, customText: '' };
103
+ }
104
+ return { selected: [], customMode: true, customText: answer };
105
+ }
106
+ return { selected: [], customMode: false, customText: '' };
107
+ }
108
+
109
+ function Footer({
110
+ canGoBack,
111
+ onBack,
112
+ onAutoContinue,
113
+ hint,
114
+ children,
115
+ }: {
116
+ canGoBack: boolean;
117
+ onBack?: () => void;
118
+ onAutoContinue?: () => void;
119
+ hint?: string;
120
+ children?: React.ReactNode;
121
+ }) {
122
+ return (
123
+ <div className="mt-4 flex items-center justify-between gap-3 border-t border-agent-border px-4 py-3">
124
+ <div className="flex min-w-0 items-center gap-2">
125
+ {canGoBack && onBack && (
126
+ <button
127
+ type="button"
128
+ onClick={onBack}
129
+ className="inline-flex items-center gap-1 rounded-agent-md border border-agent-border px-2.5 py-1.5 text-xs font-medium text-agent-foreground transition hover:bg-agent-muted"
130
+ >
131
+ <LuChevronLeft size={12} />
132
+ 上一题
133
+ </button>
134
+ )}
135
+ {onAutoContinue && (
136
+ <button
137
+ type="button"
138
+ onClick={onAutoContinue}
139
+ className="inline-flex items-center gap-1 rounded-agent-md px-2.5 py-1.5 text-xs font-medium text-agent-muted-foreground transition hover:bg-agent-muted hover:text-agent-foreground"
140
+ >
141
+ <LuWand size={12} />
142
+ 交给我决定
143
+ </button>
144
+ )}
145
+ </div>
146
+ <div className="flex min-w-0 items-center gap-3">
147
+ <span className="hidden truncate text-[11px] text-agent-muted-foreground sm:inline">
148
+ {hint}
149
+ </span>
150
+ {children}
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ function PrimaryButton({
157
+ disabled,
158
+ isLast,
159
+ onClick,
160
+ }: {
161
+ disabled?: boolean;
162
+ isLast: boolean;
163
+ onClick: () => void;
164
+ }) {
165
+ return (
166
+ <button
167
+ type="button"
168
+ onClick={onClick}
169
+ disabled={disabled}
170
+ className={[
171
+ 'inline-flex items-center gap-1.5 rounded-agent-md px-4 py-1.5 text-xs font-medium transition',
172
+ disabled
173
+ ? 'cursor-not-allowed bg-agent-muted text-agent-muted-foreground'
174
+ : 'bg-agent-foreground text-agent-canvas shadow-sm hover:opacity-90',
175
+ ].join(' ')}
176
+ >
177
+ <LuCheck size={12} />
178
+ {isLast ? '提交' : '下一题'}
179
+ </button>
180
+ );
181
+ }
182
+
183
+ function SelectQuestionStep({
184
+ question,
185
+ initialAnswer,
186
+ isLast,
187
+ onCommit,
188
+ onBack,
189
+ onAutoContinue,
190
+ }: {
191
+ question: AskQuestion;
192
+ initialAnswer: AnswerValue | undefined;
193
+ isLast: boolean;
194
+ onCommit: (value: AnswerValue) => void;
195
+ onBack?: () => void;
196
+ onAutoContinue?: () => void;
197
+ }) {
198
+ const initial = useMemo(
199
+ () => initialDraft(question, initialAnswer),
200
+ [question, initialAnswer],
201
+ );
202
+ const [selected, setSelected] = useState<string[]>(initial.selected);
203
+ const [customMode, setCustomMode] = useState(initial.customMode);
204
+ const [customText, setCustomText] = useState(initial.customText);
205
+
206
+ const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
207
+ const customInputRef = useRef<HTMLInputElement | null>(null);
208
+
209
+ useEffect(() => {
210
+ if (customMode) {
211
+ customInputRef.current?.focus();
212
+ }
213
+ }, [customMode]);
214
+
215
+ const commitOption = (label: string) => {
216
+ if (question.multiSelect) {
217
+ setSelected((prev) =>
218
+ prev.includes(label) ? prev.filter((v) => v !== label) : [...prev, label],
219
+ );
220
+ return;
221
+ }
222
+ onCommit(label);
223
+ };
224
+
225
+ const commitCustom = () => {
226
+ const text = customText.trim();
227
+ if (!text) return;
228
+ if (question.multiSelect) {
229
+ const merged = [...selected];
230
+ if (!merged.includes(text)) merged.push(text);
231
+ onCommit(merged);
232
+ } else {
233
+ onCommit(text);
234
+ }
235
+ };
236
+
237
+ const commitMulti = () => {
238
+ const text = customText.trim();
239
+ const merged = [...selected];
240
+ if (customMode && text && !merged.includes(text)) merged.push(text);
241
+ if (merged.length === 0) return;
242
+ onCommit(merged);
243
+ };
244
+
245
+ const focusItem = (index: number) => {
246
+ const item = optionRefs.current[index];
247
+ if (item) {
248
+ item.focus();
249
+ return true;
250
+ }
251
+ return false;
252
+ };
253
+
254
+ const handleListKeyDown = (e: React.KeyboardEvent) => {
255
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
256
+ e.preventDefault();
257
+ const count = question.options.length + 1; // options + 「其他 / 自定义」
258
+ const current = optionRefs.current.findIndex((el) => el === document.activeElement);
259
+ const next = e.key === 'ArrowDown' ? (current + 1) % count : (current - 1 + count) % count;
260
+ focusItem(next);
261
+ };
262
+
263
+ const renderOption = (opt: AskOption, index: number) => {
264
+ const active = selected.includes(opt.label);
265
+ return (
266
+ <button
267
+ key={opt.label}
268
+ type="button"
269
+ ref={(el) => {
270
+ optionRefs.current[index] = el;
271
+ }}
272
+ onClick={() => commitOption(opt.label)}
273
+ onKeyDown={(e) => {
274
+ if (e.key === 'Enter' || e.key === ' ') {
275
+ e.preventDefault();
276
+ commitOption(opt.label);
277
+ }
278
+ }}
279
+ role={question.multiSelect ? 'checkbox' : 'radio'}
280
+ aria-checked={active}
281
+ className={[
282
+ 'flex w-full items-center gap-2 rounded-agent-md border px-3 py-2 text-left text-sm transition',
283
+ active
284
+ ? 'border-agent-foreground bg-agent-muted text-agent-foreground'
285
+ : 'border-agent-border bg-agent-canvas text-agent-foreground hover:bg-agent-muted',
286
+ ].join(' ')}
287
+ >
288
+ <span
289
+ className={[
290
+ 'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px]',
291
+ active
292
+ ? 'border-agent-foreground bg-agent-foreground text-agent-canvas'
293
+ : 'border-agent-border text-transparent',
294
+ ].join(' ')}
295
+ >
296
+ <LuCheck size={10} />
297
+ </span>
298
+ <span className="min-w-0 flex-1">
299
+ <span className="block truncate">{opt.label}</span>
300
+ {opt.description && (
301
+ <span className="mt-0.5 block text-xs text-agent-muted-foreground">
302
+ {opt.description}
303
+ </span>
304
+ )}
305
+ </span>
306
+ {!question.multiSelect && (
307
+ <kbd className="hidden rounded border border-agent-border px-1 text-[10px] text-agent-muted-foreground sm:inline">
308
+ ↵
309
+ </kbd>
310
+ )}
311
+ </button>
312
+ );
313
+ };
314
+
315
+ const renderCustomOption = (index: number) => {
316
+ const active = customMode;
317
+ return (
318
+ <button
319
+ key="__custom__"
320
+ type="button"
321
+ ref={(el) => {
322
+ optionRefs.current[index] = el;
323
+ }}
324
+ onClick={() => {
325
+ setCustomMode((prev) => !prev);
326
+ setCustomText('');
327
+ }}
328
+ onKeyDown={(e) => {
329
+ if (e.key === 'Enter' || e.key === ' ') {
330
+ e.preventDefault();
331
+ setCustomMode((prev) => !prev);
332
+ setCustomText('');
333
+ }
334
+ }}
335
+ role={question.multiSelect ? 'checkbox' : 'radio'}
336
+ aria-checked={active}
337
+ className={[
338
+ 'flex w-full items-center gap-2 rounded-agent-md border border-dashed px-3 py-2 text-left text-sm transition',
339
+ active
340
+ ? 'border-agent-foreground bg-agent-muted text-agent-foreground'
341
+ : 'border-agent-border bg-agent-canvas text-agent-muted-foreground hover:bg-agent-muted hover:text-agent-foreground',
342
+ ].join(' ')}
343
+ >
344
+ <span
345
+ className={[
346
+ 'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px]',
347
+ active
348
+ ? 'border-agent-foreground bg-agent-foreground text-agent-canvas'
349
+ : 'border-agent-border text-transparent',
350
+ ].join(' ')}
351
+ >
352
+ <LuCheck size={10} />
353
+ </span>
354
+ <LuPencil size={12} className="shrink-0" />
355
+ <span className="min-w-0 flex-1">其他 / 自定义</span>
356
+ </button>
357
+ );
358
+ };
359
+
360
+ return (
361
+ <div>
362
+ <div className="space-y-2 px-4 py-3">
363
+ {question.header && (
364
+ <p className="text-[11px] font-medium uppercase tracking-wide text-agent-muted-foreground">
365
+ {question.header}
366
+ </p>
367
+ )}
368
+ <p className="text-sm font-medium text-agent-foreground">{question.text}</p>
369
+ {question.detail && (
370
+ <p className="whitespace-pre-line text-xs text-agent-muted-foreground">
371
+ {question.detail}
372
+ </p>
373
+ )}
374
+
375
+ {!customMode || question.multiSelect ? (
376
+ <div
377
+ className="space-y-1.5"
378
+ role={question.multiSelect ? 'group' : 'radiogroup'}
379
+ onKeyDown={handleListKeyDown}
380
+ >
381
+ {question.options.map((opt, index) => renderOption(opt, index))}
382
+ {renderCustomOption(question.options.length)}
383
+ </div>
384
+ ) : null}
385
+
386
+ {customMode && question.multiSelect && (
387
+ <input
388
+ ref={customInputRef}
389
+ type="text"
390
+ value={customText}
391
+ onChange={(e) => setCustomText(e.target.value)}
392
+ onKeyDown={(e) => {
393
+ if (e.key === 'Enter') {
394
+ e.preventDefault();
395
+ commitMulti();
396
+ }
397
+ }}
398
+ placeholder={question.placeholder || '输入你的补充...'}
399
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground focus:border-agent-foreground focus:ring-1 focus:ring-agent-foreground"
400
+ />
401
+ )}
402
+
403
+ {customMode && !question.multiSelect && (
404
+ <div className="space-y-2 rounded-agent-md border border-agent-border bg-agent-muted/40 p-3">
405
+ <input
406
+ ref={customInputRef}
407
+ type="text"
408
+ value={customText}
409
+ onChange={(e) => setCustomText(e.target.value)}
410
+ onKeyDown={(e) => {
411
+ if (e.key === 'Enter') {
412
+ e.preventDefault();
413
+ commitCustom();
414
+ }
415
+ }}
416
+ placeholder={question.placeholder || '输入你的回答...'}
417
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground focus:border-agent-foreground focus:ring-1 focus:ring-agent-foreground"
418
+ />
419
+ <button
420
+ type="button"
421
+ onClick={() => {
422
+ setCustomMode(false);
423
+ setCustomText('');
424
+ }}
425
+ className="text-xs text-agent-muted-foreground transition hover:text-agent-foreground"
426
+ >
427
+ ← 返回选项
428
+ </button>
429
+ </div>
430
+ )}
431
+ </div>
432
+
433
+ <Footer
434
+ canGoBack={Boolean(onBack)}
435
+ onBack={onBack}
436
+ onAutoContinue={onAutoContinue}
437
+ hint={question.multiSelect ? '↑↓ 移动,Enter 勾选' : '↑↓ 选择,Enter 确认'}
438
+ >
439
+ {question.multiSelect ? (
440
+ <PrimaryButton
441
+ disabled={selected.length === 0 && !(customMode && customText.trim())}
442
+ isLast={isLast}
443
+ onClick={commitMulti}
444
+ />
445
+ ) : (
446
+ customMode && (
447
+ <PrimaryButton
448
+ disabled={!customText.trim()}
449
+ isLast={isLast}
450
+ onClick={commitCustom}
451
+ />
452
+ )
453
+ )}
454
+ </Footer>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ function TextQuestionStep({
460
+ question,
461
+ initialAnswer,
462
+ isLast,
463
+ onCommit,
464
+ onBack,
465
+ onAutoContinue,
466
+ }: {
467
+ question: AskQuestion;
468
+ initialAnswer: AnswerValue | undefined;
469
+ isLast: boolean;
470
+ onCommit: (value: AnswerValue) => void;
471
+ onBack?: () => void;
472
+ onAutoContinue?: () => void;
473
+ }) {
474
+ const [value, setValue] = useState(
475
+ typeof initialAnswer === 'string' ? initialAnswer : '',
476
+ );
477
+ const inputRef = useRef<HTMLInputElement | null>(null);
478
+
479
+ useEffect(() => {
480
+ inputRef.current?.focus();
481
+ }, []);
482
+
483
+ const commit = () => {
484
+ const text = value.trim();
485
+ if (!text) return;
486
+ onCommit(text);
487
+ };
488
+
489
+ return (
490
+ <div>
491
+ <div className="space-y-2 px-4 py-3">
492
+ {question.header && (
493
+ <p className="text-[11px] font-medium uppercase tracking-wide text-agent-muted-foreground">
494
+ {question.header}
495
+ </p>
496
+ )}
497
+ <p className="text-sm font-medium text-agent-foreground">{question.text}</p>
498
+ {question.detail && (
499
+ <p className="whitespace-pre-line text-xs text-agent-muted-foreground">
500
+ {question.detail}
501
+ </p>
502
+ )}
503
+ <input
504
+ ref={inputRef}
505
+ type={question.type === 'password' ? 'password' : 'text'}
506
+ value={value}
507
+ onChange={(e) => setValue(e.target.value)}
508
+ onKeyDown={(e) => {
509
+ if (e.key === 'Enter') {
510
+ e.preventDefault();
511
+ commit();
512
+ }
513
+ }}
514
+ placeholder={question.placeholder || '输入你的回答...'}
515
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground focus:border-agent-foreground focus:ring-1 focus:ring-agent-foreground"
516
+ />
517
+ </div>
518
+
519
+ <Footer
520
+ canGoBack={Boolean(onBack)}
521
+ onBack={onBack}
522
+ onAutoContinue={onAutoContinue}
523
+ hint="Enter 确认"
524
+ >
525
+ <PrimaryButton disabled={!value.trim()} isLast={isLast} onClick={commit} />
526
+ </Footer>
527
+ </div>
528
+ );
529
+ }
530
+
531
+ export interface AskUserQuestionMenuProps {
532
+ intro: string;
533
+ questions: Array<Record<string, unknown>>;
534
+ onSubmit: (answers: Record<string, AnswerValue>) => void;
535
+ onAutoContinue?: () => void;
536
+ bottomHint?: string;
537
+ }
538
+
539
+ export function AskUserQuestionMenu({
540
+ intro,
541
+ questions,
542
+ onSubmit,
543
+ onAutoContinue,
544
+ bottomHint,
545
+ }: AskUserQuestionMenuProps) {
546
+ const normalized = useMemo(() => normalizeQuestions(questions), [questions]);
547
+ const [step, setStep] = useState(0);
548
+ const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
549
+
550
+ useEffect(() => {
551
+ if (normalized.length === 0) {
552
+ onSubmit({});
553
+ }
554
+ }, [normalized.length, onSubmit]);
555
+
556
+ const current = normalized[Math.min(step, normalized.length - 1)] ?? null;
557
+ const isLast = normalized.length > 0 && step >= normalized.length - 1;
558
+
559
+ const commit = useCallback(
560
+ (value: AnswerValue) => {
561
+ if (!current) return;
562
+ const next = { ...answers, [current.id]: value };
563
+ setAnswers(next);
564
+ if (isLast) {
565
+ onSubmit(next);
566
+ } else {
567
+ setStep((s) => s + 1);
568
+ }
569
+ },
570
+ [answers, current, isLast, onSubmit],
571
+ );
572
+
573
+ const goBack = useCallback(() => {
574
+ setStep((s) => Math.max(0, s - 1));
575
+ }, []);
576
+
577
+ if (!current) return null;
578
+
579
+ return (
580
+ <div className="overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas text-sm text-agent-foreground shadow-xl">
581
+ <div className="flex items-start justify-between gap-3 border-b border-agent-border px-4 py-3">
582
+ <div className="min-w-0 flex-1">
583
+ <p className="text-sm font-medium text-agent-foreground">
584
+ {intro || '需要你的输入'}
585
+ </p>
586
+ <p className="mt-0.5 text-[11px] text-agent-muted-foreground">
587
+ 问题 {step + 1} / {normalized.length}
588
+ {bottomHint ? ` · ${bottomHint}` : ''}
589
+ </p>
590
+ </div>
591
+ <span className="rounded-full border border-agent-border px-2 py-0.5 text-[10px] font-medium text-agent-muted-foreground">
592
+ {current.type === 'password' ? '密码输入' : current.multiSelect ? '可多选' : '菜单选择'}
593
+ </span>
594
+ </div>
595
+
596
+ {current.type === 'select' ? (
597
+ <SelectQuestionStep
598
+ key={`${step}-${current.id}`}
599
+ question={current}
600
+ initialAnswer={answers[current.id]}
601
+ isLast={isLast}
602
+ onCommit={commit}
603
+ onBack={step > 0 ? goBack : undefined}
604
+ onAutoContinue={onAutoContinue}
605
+ />
606
+ ) : (
607
+ <TextQuestionStep
608
+ key={`${step}-${current.id}`}
609
+ question={current}
610
+ initialAnswer={answers[current.id]}
611
+ isLast={isLast}
612
+ onCommit={commit}
613
+ onBack={step > 0 ? goBack : undefined}
614
+ onAutoContinue={onAutoContinue}
615
+ />
616
+ )}
617
+ </div>
618
+ );
619
+ }
620
+
621
+ export default AskUserQuestionMenu;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * AssistantMessage 时间戳行:分享对话的入口从标题栏挪到最近一条助手回复
3
+ * 时间戳行上的分享、复制、重新生成共用悬停显隐。
4
+ */
5
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+ import type { ChatMessage } from '@steerable/agent-protocol';
8
+ import { AssistantMessage } from './AssistantMessage';
9
+
10
+ afterEach(cleanup);
11
+
12
+ const MESSAGE: ChatMessage = {
13
+ id: 'm1',
14
+ role: 'assistant',
15
+ content: '问好完成。',
16
+ createdAt: '2026-09-13T09:03:00.000Z',
17
+ };
18
+
19
+ describe('AssistantMessage 分享', () => {
20
+ it('没有 onShare 时不画分享按钮', () => {
21
+ render(
22
+ <AssistantMessage
23
+ message={MESSAGE}
24
+ isStreaming={false}
25
+ agents={[]}
26
+ currentAgent={null}
27
+ />,
28
+ );
29
+ expect(screen.queryByRole('button', { name: '分享对话截图' })).toBeNull();
30
+ });
31
+
32
+ it('流式中不画分享按钮', () => {
33
+ render(
34
+ <AssistantMessage
35
+ message={MESSAGE}
36
+ isStreaming
37
+ agents={[]}
38
+ currentAgent={null}
39
+ onShare={vi.fn()}
40
+ />,
41
+ );
42
+ expect(screen.queryByRole('button', { name: '分享对话截图' })).toBeNull();
43
+ });
44
+
45
+ it('点分享会调用 onShare,成功后提示已复制', async () => {
46
+ const onShare = vi.fn().mockResolvedValue(true);
47
+ render(
48
+ <AssistantMessage
49
+ message={MESSAGE}
50
+ isStreaming={false}
51
+ agents={[]}
52
+ currentAgent={null}
53
+ onShare={onShare}
54
+ />,
55
+ );
56
+
57
+ fireEvent.click(screen.getByRole('button', { name: '分享对话截图' }));
58
+ expect(screen.getByRole('button', { name: '分享对话截图' }).className).toContain(
59
+ 'group-hover/message:opacity-100',
60
+ );
61
+ await waitFor(() => expect(onShare).toHaveBeenCalledTimes(1));
62
+ await waitFor(() =>
63
+ expect(screen.getByRole('button', { name: '分享对话截图' }).getAttribute('title')).toBe(
64
+ '截图已复制到剪贴板',
65
+ ),
66
+ );
67
+ });
68
+ });