@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,332 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { LuArrowDown } from 'react-icons/lu';
10
+ import type { ChatMessage } from '@steerable/agent-protocol';
11
+ import type { LocalChat, LocalChatAgent, LocalTask } from '@/lib/local-api';
12
+ import { UserMessage } from './UserMessage';
13
+ import { AssistantMessage } from './AssistantMessage';
14
+ import { InterruptedTurnCard } from './InterruptedTurnCard';
15
+ import { TaskOutcomeCards } from './TaskOutcomeCards';
16
+ import type { ExecutedAction } from './ExecutedActionsCard';
17
+ import type { ChildInfo } from './OrchestrationChildrenCard';
18
+ import type { ChatMode } from './ChatInput';
19
+ import type { TurnBlock } from './turn-timeline';
20
+ import { inferDurationMs, readPersistedDurationMs } from './elapsed';
21
+
22
+ /**
23
+ * MessageList — scrolling viewport that renders user/assistant message
24
+ * bubbles.
25
+ *
26
+ * Tier-1 port of `deeppath`'s MessageList. We kept the visible interaction
27
+ * primitives that matter for parity:
28
+ * - vertical-scroll container, padding mirrors the cloud product
29
+ * - sticky-ish "back to bottom" floater that appears when the user scrolls
30
+ * up during streaming
31
+ * - empty-state slot
32
+ * - auto-scroll on new message **only if** the user is already near the
33
+ * bottom (don't yank focus when they're reading older messages)
34
+ *
35
+ * Intentionally simpler than the original:
36
+ * - **No per-chat scroll memory across tabs.** Switching chats remounts
37
+ * this list (parent passes a new `key`), so we don't need the
38
+ * `chatScrollMemoryRef` machinery.
39
+ * - **No sticky user-message group / `--user-msg-h` CSS var.** The cloud
40
+ * product uses these to keep the user prompt pinned at the top of the
41
+ * viewport while reading long assistant replies; agent UX defers that
42
+ * to a later phase.
43
+ * - **No load-more on scroll-up.** Local-backend currently hydrates the
44
+ * last 200 messages on mount (see `AgentPage.tsx`). Pagination is a
45
+ * phase-2c follow-up.
46
+ * - **No turn-process toggle memory.** Expand/collapse of the think+tool
47
+ * group is per-mount; it resets when the list remounts.
48
+ */
49
+
50
+ const NEAR_BOTTOM_THRESHOLD_PX = 100;
51
+
52
+ interface MessageListProps {
53
+ messages: ChatMessage[];
54
+ isStreaming: boolean;
55
+ emptyState?: ReactNode;
56
+ agents: LocalChatAgent[];
57
+ chats?: LocalChat[];
58
+ currentAgent: LocalChatAgent | null;
59
+ /**
60
+ * Tool calls keyed by the persisted message id (post-stream). Used for
61
+ * historical assistant turns where the `executed_actions` event already
62
+ * fired and was reconciled with the DB-assigned message id.
63
+ */
64
+ executedActionsByMessageId?: Record<string, ExecutedAction[]>;
65
+ /**
66
+ * Call-order blocks keyed by persisted message id. Preferred over stacking
67
+ * all tools above the reply when present.
68
+ */
69
+ timelineByMessageId?: Record<string, TurnBlock[]>;
70
+ /** In-flight timeline for the latest assistant turn. */
71
+ currentTurnTimeline?: TurnBlock[];
72
+ /** Epoch ms when the in-flight turn started (Codex elapsed ticker). */
73
+ currentTurnStartedAtMs?: number;
74
+ /** Frozen duration keyed by assistant message id (live freeze + history). */
75
+ durationByMessageId?: Record<string, number>;
76
+ /**
77
+ * Tool calls accumulated for the in-flight assistant message that the
78
+ * backend hasn't assigned a DB id to yet. Rendered under the latest
79
+ * assistant message while `isStreaming === true`.
80
+ */
81
+ currentTurnActions?: ExecutedAction[];
82
+ /**
83
+ * P3.1 orchestration: live child agents of the in-flight turn (rendered
84
+ * as an OrchestrationPlanCard under the latest assistant message), and
85
+ * the per-message reconciliation map mirroring executedActionsByMessageId.
86
+ */
87
+ currentTurnChildren?: ChildInfo[];
88
+ orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
89
+ /**
90
+ * Round counter for the in-flight turn. Forwarded to the tail
91
+ * AssistantMessage so its `StreamingStatus` can show "Round 2 · 继续推理...".
92
+ */
93
+ currentRound?: number;
94
+ /** Current chat mode (Agent / Plan). */
95
+ mode?: ChatMode;
96
+ /** W1.2.1: regenerate an assistant turn (fork-preserving). */
97
+ onRegenerate?: (messageId: string) => Promise<void>;
98
+ /**
99
+ * W7-1: the chat's last turn was interrupted (crash/kill — no completion
100
+ * record). Rendered as a tail card offering to continue the turn; hidden
101
+ * while streaming (a live stream is never interrupted).
102
+ */
103
+ interrupted?: boolean;
104
+ /** W7-1: continue the interrupted turn via the backend resume channel. */
105
+ onContinueInterrupted?: () => void;
106
+ /** W7-1: hide the card for this mount (not persisted). */
107
+ onDismissInterrupted?: () => void;
108
+ /**
109
+ * 本次挂载期间跑完的后台任务(新→旧)——尾部终态通知卡的数据源。
110
+ * 后台任务跑在主对话之外,终态不落消息行,卡片是它回到对话里的唯一位置。
111
+ */
112
+ finishedTasks?: LocalTask[];
113
+ /** 点「查看过程」:在右侧栏打开该任务的推理过程。 */
114
+ onInspectTask?: (task: LocalTask) => void;
115
+ /** 点「忽略」:隐藏这条终态通知(本次挂载内,不落库)。 */
116
+ onDismissFinishedTask?: (taskId: string) => void;
117
+ /** 分享当前对话(截图)。只画在最近一条助手消息的时间戳行上。 */
118
+ onShare?: () => Promise<boolean>;
119
+ }
120
+
121
+ export function MessageList({
122
+ messages,
123
+ isStreaming,
124
+ emptyState,
125
+ agents,
126
+ chats = [],
127
+ currentAgent,
128
+ executedActionsByMessageId,
129
+ currentTurnActions,
130
+ timelineByMessageId,
131
+ currentTurnTimeline,
132
+ currentTurnStartedAtMs,
133
+ durationByMessageId,
134
+ currentTurnChildren,
135
+ orchestrationChildrenByMessageId,
136
+ currentRound,
137
+ mode,
138
+ onRegenerate,
139
+ interrupted = false,
140
+ onContinueInterrupted,
141
+ onDismissInterrupted,
142
+ finishedTasks,
143
+ onInspectTask,
144
+ onDismissFinishedTask,
145
+ onShare,
146
+ }: MessageListProps) {
147
+ const containerRef = useRef<HTMLDivElement>(null);
148
+ const endRef = useRef<HTMLDivElement>(null);
149
+ const [isAtBottom, setIsAtBottom] = useState(true);
150
+
151
+ const visibleMessages = messages.filter(
152
+ (m) => m.role === 'user' || m.role === 'assistant',
153
+ );
154
+ let lastAssistantId: string | undefined;
155
+ for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
156
+ if (visibleMessages[i].role === 'assistant') {
157
+ lastAssistantId = visibleMessages[i].id;
158
+ break;
159
+ }
160
+ }
161
+
162
+ const checkAtBottom = useCallback(() => {
163
+ const el = containerRef.current;
164
+ if (!el) return;
165
+ const atBottom =
166
+ el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
167
+ setIsAtBottom(atBottom);
168
+ }, []);
169
+
170
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
171
+ const el = endRef.current;
172
+ if (!el) return;
173
+ el.scrollIntoView({ behavior, block: 'end' });
174
+ }, []);
175
+
176
+ useEffect(() => {
177
+ const el = containerRef.current;
178
+ if (!el) return;
179
+ el.addEventListener('scroll', checkAtBottom, { passive: true });
180
+ return () => {
181
+ el.removeEventListener('scroll', checkAtBottom);
182
+ };
183
+ }, [checkAtBottom]);
184
+
185
+ // Snap to bottom on first paint, regardless of whether the user "was" at
186
+ // the bottom — there's no "before" on mount.
187
+ useLayoutEffect(() => {
188
+ scrollToBottom('instant' as ScrollBehavior);
189
+ setIsAtBottom(true);
190
+ }, [scrollToBottom]);
191
+
192
+ // Subsequent auto-scroll only when the user is already near the bottom,
193
+ // so reading old messages doesn't get yanked.
194
+ const lastMessageId = visibleMessages[visibleMessages.length - 1]?.id;
195
+ const lastContentLen = visibleMessages[visibleMessages.length - 1]?.content?.length ?? 0;
196
+ const lastTimelineSig = currentTurnTimeline
197
+ ?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
198
+ .join('|') ?? '';
199
+ useEffect(() => {
200
+ if (!isAtBottom) return;
201
+ scrollToBottom('smooth');
202
+ }, [lastMessageId, lastContentLen, lastTimelineSig, isAtBottom, scrollToBottom]);
203
+
204
+ return (
205
+ <div className="relative flex-1 overflow-hidden">
206
+ <div
207
+ ref={containerRef}
208
+ className="h-full overflow-y-auto px-3 py-3"
209
+ >
210
+ {visibleMessages.length === 0 ? (
211
+ emptyState ?? null
212
+ ) : (
213
+ <div className="mx-auto w-full space-y-1.5">
214
+ {visibleMessages.map((message, index) => {
215
+ const isLast = index === visibleMessages.length - 1;
216
+ if (message.role === 'user') {
217
+ return <UserMessage key={message.id} message={message} agents={agents} chats={chats} />;
218
+ }
219
+ // Action attribution: prefer the per-message map (set when the
220
+ // backend's `message_id` event reconciles the stream-time queue
221
+ // with the DB id). The framework keeps the in-flight assistant's
222
+ // placeholder id even after the backend emits `message_id`, so
223
+ // the current page cannot always key by DB id until reload.
224
+ // Keep showing `currentTurnActions` on the latest assistant turn
225
+ // after streaming ends; it is cleared when the next turn starts.
226
+ const persistedActions =
227
+ executedActionsByMessageId?.[message.id];
228
+ const isStreamingTail = isStreaming && isLast;
229
+ const isCurrentTurnTail =
230
+ isLast && currentTurnActions !== undefined && currentTurnActions.length > 0;
231
+ const actions = persistedActions
232
+ ?? (isStreamingTail || isCurrentTurnTail ? currentTurnActions : undefined);
233
+ const persistedChildren = orchestrationChildrenByMessageId?.[message.id];
234
+ const isChildrenTail =
235
+ isLast && currentTurnChildren !== undefined && currentTurnChildren.length > 0;
236
+ const childList = persistedChildren
237
+ ?? (isStreamingTail || isChildrenTail ? currentTurnChildren : undefined);
238
+
239
+ let isPlanMode = false;
240
+ if (message.messageMetadata) {
241
+ try {
242
+ const meta = JSON.parse(message.messageMetadata);
243
+ if (meta?.mode === 'plan') {
244
+ isPlanMode = true;
245
+ }
246
+ } catch {}
247
+ } else if (isStreamingTail && mode === 'plan') {
248
+ isPlanMode = true;
249
+ }
250
+
251
+ const persistedTimeline = timelineByMessageId?.[message.id];
252
+ const isCurrentTimelineTail = isLast && currentTurnTimeline !== undefined;
253
+ const turnTimeline = persistedTimeline
254
+ ?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
255
+
256
+ const metadataJson =
257
+ typeof message.messageMetadata === 'string'
258
+ ? message.messageMetadata
259
+ : undefined;
260
+ let previousUserCreatedAt: string | undefined;
261
+ for (let i = index - 1; i >= 0; i -= 1) {
262
+ if (visibleMessages[i].role === 'user') {
263
+ previousUserCreatedAt = visibleMessages[i].createdAt;
264
+ break;
265
+ }
266
+ }
267
+ const durationMs = isStreamingTail
268
+ ? undefined
269
+ : durationByMessageId?.[message.id]
270
+ ?? readPersistedDurationMs(metadataJson)
271
+ ?? inferDurationMs(previousUserCreatedAt, message.createdAt);
272
+
273
+ 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
+ />
291
+ );
292
+ })}
293
+ {/* W7-1: 中断提示卡在消息列尾部、与最后一条用户消息同列——
294
+ 视觉上隶属于被截断的那一轮,而非全局横幅。流式期间不显示
295
+ (进行中的流不是中断)。 */}
296
+ {interrupted && !isStreaming && onContinueInterrupted && onDismissInterrupted ? (
297
+ <InterruptedTurnCard
298
+ onContinue={onContinueInterrupted}
299
+ onDismiss={onDismissInterrupted}
300
+ />
301
+ ) : null}
302
+ {/* 后台任务终态通知:与中断卡同列在消息列尾部。任务跨 turn 跑,
303
+ 结束时间与当前这一轮无关,所以钉在末尾而不是挂到某条消息下。
304
+ 流式期间照常显示——任务的结束和主对话在不在说话没有关系。 */}
305
+ {finishedTasks && onInspectTask && onDismissFinishedTask ? (
306
+ <TaskOutcomeCards
307
+ tasks={finishedTasks}
308
+ onInspect={onInspectTask}
309
+ onDismiss={onDismissFinishedTask}
310
+ />
311
+ ) : null}
312
+ </div>
313
+ )}
314
+ <div ref={endRef} />
315
+ </div>
316
+
317
+ {!isAtBottom && visibleMessages.length > 0 && (
318
+ <button
319
+ type="button"
320
+ onClick={() => scrollToBottom('smooth')}
321
+ 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"
322
+ title="回到底部"
323
+ aria-label="回到底部"
324
+ >
325
+ <LuArrowDown className="h-4 w-4" />
326
+ </button>
327
+ )}
328
+ </div>
329
+ );
330
+ }
331
+
332
+ export default MessageList;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * ModelPicker 交互测试:
3
+ * - 目录行渲染 + 选模型(换模型清掉旧档位);
4
+ * - 有 reasoningLevels 的模型才出档位选择器;
5
+ * - 「跟随设置」回到 null(全局设置);
6
+ * - offline/stale 状态如实披露(目录不可用 / 缓存),offline 时仍可选择。
7
+ */
8
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import type { GatewayModelCatalog } from '@/lib/local-api';
11
+
12
+ const getLlmModels = vi.fn();
13
+ const getLlmSettings = vi.fn();
14
+
15
+ vi.mock('@/lib/local-api', () => ({
16
+ getLlmModels: () => getLlmModels(),
17
+ getLlmSettings: () => getLlmSettings(),
18
+ }));
19
+
20
+ const { ModelPicker } = await import('./ModelPicker');
21
+
22
+ const CATALOG: GatewayModelCatalog = {
23
+ models: [
24
+ {
25
+ id: 'z-ai/glm-5.3-flash',
26
+ name: 'GLM 5.3 Flash',
27
+ window: 131072,
28
+ modalities: ['text'],
29
+ reasoningLevels: ['low', 'high', 'max'],
30
+ pricing: { promptPerMtok: 0.2, completionPerMtok: 0.8 },
31
+ joinedFrom: 'z-ai/glm-5.3-flash',
32
+ capabilities: 'known',
33
+ },
34
+ {
35
+ id: 'openai/qwen/qwen3.8-27b',
36
+ name: null,
37
+ window: null,
38
+ modalities: ['text'],
39
+ reasoningLevels: [],
40
+ pricing: null,
41
+ joinedFrom: null,
42
+ capabilities: 'unknown',
43
+ },
44
+ ],
45
+ catalogStatus: 'live',
46
+ fetchedAt: 1_757_000_000,
47
+ current: { model: null, reasoningEffort: null },
48
+ };
49
+
50
+ function renderPicker(overrides: Partial<Parameters<typeof ModelPicker>[0]> = {}) {
51
+ const props = {
52
+ model: null as string | null,
53
+ reasoningEffort: null as string | null,
54
+ onSelectModel: vi.fn(),
55
+ onSelectEffort: vi.fn(),
56
+ ...overrides,
57
+ };
58
+ const utils = render(<ModelPicker {...props} />);
59
+ return { ...utils, props };
60
+ }
61
+
62
+ describe('ModelPicker', () => {
63
+ beforeEach(() => {
64
+ getLlmModels.mockResolvedValue(CATALOG);
65
+ getLlmSettings.mockResolvedValue({ provider: 'openai-compat', model: 'deepseek-chat' });
66
+ });
67
+ afterEach(() => cleanup());
68
+
69
+ it('shows the settings model by default and lists catalog rows', async () => {
70
+ const { props } = renderPicker();
71
+ // 默认展示设置里的模型
72
+ const trigger = await screen.findByText('deepseek-chat');
73
+ fireEvent.click(trigger);
74
+
75
+ // 目录行 + 跟随设置行
76
+ expect(await screen.findByText('z-ai/glm-5.3-flash')).toBeTruthy();
77
+ expect(screen.getByText('openai/qwen/qwen3.8-27b')).toBeTruthy();
78
+ expect(screen.getByText(/跟随设置/)).toBeTruthy();
79
+ expect(screen.getByText('思考')).toBeTruthy();
80
+ expect(screen.getByText('131K')).toBeTruthy();
81
+ expect(screen.getByText('未识别')).toBeTruthy();
82
+
83
+ fireEvent.click(screen.getByText('z-ai/glm-5.3-flash'));
84
+ expect(props.onSelectModel).toHaveBeenCalledWith('z-ai/glm-5.3-flash');
85
+ // 换模型清档位,让 sidecar 按新模型校验
86
+ expect(props.onSelectEffort).toHaveBeenCalledWith(null);
87
+ });
88
+
89
+ it('offers the effort picker only for models with reasoning levels', async () => {
90
+ const { props, rerender } = renderPicker({ model: 'z-ai/glm-5.3-flash' });
91
+ // 等目录到达后档位按钮出现
92
+ const effortTrigger = await screen.findByText('自动');
93
+ fireEvent.click(effortTrigger);
94
+ for (const level of ['low', 'high', 'max']) {
95
+ expect(screen.getByText(level)).toBeTruthy();
96
+ }
97
+ fireEvent.click(screen.getByText('max'));
98
+ expect(props.onSelectEffort).toHaveBeenCalledWith('max');
99
+
100
+ // 切到未识别模型:档位选择器消失
101
+ rerender(
102
+ <ModelPicker
103
+ model="openai/qwen/qwen3.8-27b"
104
+ reasoningEffort={null}
105
+ onSelectModel={props.onSelectModel}
106
+ onSelectEffort={props.onSelectEffort}
107
+ />,
108
+ );
109
+ expect(screen.queryByText('自动')).toBeNull();
110
+ });
111
+
112
+ it('discloses the offline state and still allows manual selection', async () => {
113
+ getLlmModels.mockResolvedValue({
114
+ models: [],
115
+ catalogStatus: 'offline',
116
+ error: 'connect refused',
117
+ });
118
+ renderPicker();
119
+ const trigger = await screen.findByText('deepseek-chat');
120
+ fireEvent.click(trigger);
121
+ expect(await screen.findByText('目录不可用')).toBeTruthy();
122
+ expect(screen.getByText(/网关不可达/)).toBeTruthy();
123
+ });
124
+
125
+ it('badges a stale catalog as cache', async () => {
126
+ getLlmModels.mockResolvedValue({ ...CATALOG, catalogStatus: 'stale' });
127
+ renderPicker();
128
+ fireEvent.click(await screen.findByText('deepseek-chat'));
129
+ expect(await screen.findByText('缓存')).toBeTruthy();
130
+ // 缓存目录的模型行照常可选
131
+ expect(screen.getByText('z-ai/glm-5.3-flash')).toBeTruthy();
132
+ });
133
+
134
+ it('omits a leading icon on the trigger', async () => {
135
+ renderPicker({ model: 'z-ai/glm-5.3-flash' });
136
+ const trigger = (await screen.findByText('z-ai/glm-5.3-flash')).closest('button');
137
+ expect(trigger).toBeTruthy();
138
+ expect(screen.getByText('本会话')).toBeTruthy();
139
+ // Chevron only — no CPU / vendor logo in the chip.
140
+ expect(trigger!.querySelectorAll('svg')).toHaveLength(1);
141
+ });
142
+
143
+ it('resets to the global settings model via 跟随设置', async () => {
144
+ const { props } = renderPicker({ model: 'z-ai/glm-5.3-flash' });
145
+ fireEvent.click(await screen.findByText('z-ai/glm-5.3-flash'));
146
+ fireEvent.click(await screen.findByText(/跟随设置/));
147
+ expect(props.onSelectModel).toHaveBeenCalledWith(null);
148
+ });
149
+ });