@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.
- package/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- 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
|
+
});
|