From 5be176a6a01e5c717cb738fa8e0bd1cb79e657c7 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:34:26 +0800 Subject: [PATCH] feat(webui): switch model presets from the composer (#5077) --- webui/src/App.tsx | 2 +- webui/src/components/ChatList.tsx | 4 +- webui/src/components/SessionSearchDialog.tsx | 6 +- .../components/thread/ModelPresetBadge.tsx | 424 ++++++++++++++++++ .../src/components/thread/ThreadComposer.tsx | 108 +---- webui/src/components/thread/ThreadShell.tsx | 92 +++- webui/src/globals.css | 59 +++ webui/src/lib/format.ts | 21 +- webui/src/lib/nanobot-client.ts | 53 ++- webui/src/lib/thread-display-compat.ts | 18 + webui/src/tests/chat-list.test.tsx | 3 + webui/src/tests/nanobot-client.test.ts | 45 ++ .../src/tests/session-search-dialog.test.tsx | 7 +- webui/src/tests/thread-composer.test.tsx | 126 ++++++ webui/src/tests/thread-display-compat.test.ts | 31 +- webui/src/tests/thread-shell.test.tsx | 130 +++++- 16 files changed, 991 insertions(+), 138 deletions(-) create mode 100644 webui/src/components/thread/ModelPresetBadge.tsx diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 13575069..2e9e74e9 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1689,7 +1689,7 @@ function Shell({ } catch { // ignore storage errors } - client.sendMessage(chatId, "/restart"); + void client.sendSystemCommand(chatId, "/restart").catch(() => {}); }, [activeSession?.chatId, client]); useEffect(() => { diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index 37899986..14280805 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -23,7 +23,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { deriveTitle, relativeTime } from "@/lib/format"; +import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { COLLAPSED_CHATS_VISIBLE_COUNT, displayTitle, @@ -237,7 +237,7 @@ export const ChatList = memo(function ChatList({ deriveTitle(s.preview, fallbackTitle); const isPinned = pinned.has(s.key); const isArchived = archived.has(s.key); - const preview = s.preview.trim(); + const preview = visibleSessionPreview(s.preview); const showPreview = showPreviews && preview && preview !== title; const timestamp = showTimestamps ? relativeTime(s.updatedAt ?? s.createdAt) diff --git a/webui/src/components/SessionSearchDialog.tsx b/webui/src/components/SessionSearchDialog.tsx index 5e1e0fa8..4b5b2bcc 100644 --- a/webui/src/components/SessionSearchDialog.tsx +++ b/webui/src/components/SessionSearchDialog.tsx @@ -8,7 +8,7 @@ import { DialogDescription, DialogTitle, } from "@/components/ui/dialog"; -import { deriveTitle } from "@/lib/format"; +import { deriveTitle, visibleSessionPreview } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; @@ -167,7 +167,7 @@ export function SessionSearchDialog({ const title = titleOverrides[session.key]?.trim() || session.title?.trim() || deriveTitle(session.preview, t("chat.newChat")); - const preview = session.preview.trim(); + const preview = visibleSessionPreview(session.preview); const showPreview = preview.length > 0 && preview.toLowerCase() !== title.trim().toLowerCase(); @@ -228,7 +228,7 @@ function sessionMatchesTerms( const haystack = [ titleOverride, session.title, - session.preview, + visibleSessionPreview(session.preview), ] .filter(Boolean) .join(" ") diff --git a/webui/src/components/thread/ModelPresetBadge.tsx b/webui/src/components/thread/ModelPresetBadge.tsx new file mode 100644 index 00000000..1b354e84 --- /dev/null +++ b/webui/src/components/thread/ModelPresetBadge.tsx @@ -0,0 +1,424 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type PointerEvent, +} from "react"; +import { CircleHelp, Sparkles } from "lucide-react"; + +import { useLogoFallback } from "@/hooks/useLogoFallback"; +import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; +import { cn } from "@/lib/utils"; + +export interface ModelPresetOption { + name: string; + label: string; + model?: string | null; + provider?: string | null; +} + +interface ModelPresetBadgeProps { + label: string; + modelDetail?: string | null; + modelPreset?: string | null; + modelPresets?: ModelPresetOption[]; + onPresetChange?: (name: string) => void; + provider?: string | null; + providerLabel?: string | null; + needsSetup?: boolean; + fallbackModelName?: string | null; + isHero: boolean; + onClick?: () => void; +} + +interface PresetGesture { + active: boolean; + baseIndex: number; + latestY: number; + pointerId: number; + startY: number; + step: number; + target: HTMLElement; + timer: ReturnType | null; +} + +interface PresetMotion { + index: number; + remainder: number; + settling: boolean; +} + +const LONG_PRESS_MS = 400; +const PRESS_SLOP_PX = 8; +const PILL_GAP_PX = 4; +const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const; +const HANDOFF_THRESHOLD = 0.56; +const DOCK_MAX_SCALE = 1.08; +const DOCK_RADIUS = 1.5; +const SETTLE_MS = 180; + +function wrapIndex(index: number, length: number): number { + return ((index % length) + length) % length; +} + +function dockScale(distanceFromFocus: number): number { + const distance = Math.abs(distanceFromFocus); + if (distance >= DOCK_RADIUS) return 1; + const influence = (1 + Math.cos(Math.PI * distance / DOCK_RADIUS)) / 2; + return 1 + (DOCK_MAX_SCALE - 1) * influence; +} + +function stepWithHysteresis(raw: number, current: number): number { + let next = current; + while (raw > next + HANDOFF_THRESHOLD) next += 1; + while (raw < next - HANDOFF_THRESHOLD) next -= 1; + return next; +} + +function preventTouchScroll(event: TouchEvent) { + if (event.cancelable) event.preventDefault(); +} + +export function ModelPresetBadge({ + label, + modelDetail, + modelPreset, + modelPresets = [], + onPresetChange, + provider, + providerLabel, + needsSetup = false, + fallbackModelName, + isHero, + onClick, +}: ModelPresetBadgeProps) { + const activeName = modelPreset?.trim() || ""; + const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName); + const activePreset: ModelPresetOption = { + ...(listedIndex >= 0 ? modelPresets[listedIndex] : undefined), + name: activeName, + label: label || modelPresets[listedIndex]?.label || activeName, + model: modelDetail ?? modelPresets[listedIndex]?.model, + provider: provider || modelPresets[listedIndex]?.provider, + }; + const presets = !activeName + ? modelPresets + : listedIndex < 0 + ? [activePreset, ...modelPresets] + : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); + const interactive = Boolean(onClick); + const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; + const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName)); + const pillHeight = isHero ? 32 : 36; + const pillStride = pillHeight + PILL_GAP_PX; + const [motion, setMotion] = useState(null); + const gestureRef = useRef(null); + + function clearGesture() { + const gesture = gestureRef.current; + if (gesture?.timer) clearTimeout(gesture.timer); + if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); + gestureRef.current = null; + } + + useEffect(() => { + if (!canSwitch) { + clearGesture(); + setMotion(null); + } + return clearGesture; + }, [canSwitch]); + + useEffect(() => { + if (!motion?.settling) return; + const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80); + return () => clearTimeout(timer); + }, [motion?.settling]); + + function updateMotion(gesture: PresetGesture, clientY: number) { + const raw = -(clientY - gesture.startY) / pillStride; + gesture.step = stepWithHysteresis(raw, gesture.step); + setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false }); + } + + function handlePointerDown(event: PointerEvent) { + if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + const gesture: PresetGesture = { + active: false, + baseIndex: currentIndex, + latestY: event.clientY, + pointerId: event.pointerId, + startY: event.clientY, + step: 0, + target: event.currentTarget, + timer: null, + }; + gesture.timer = setTimeout(() => { + if (gestureRef.current !== gesture) return; + gesture.active = true; + updateMotion(gesture, gesture.latestY); + gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false }); + try { + gesture.target.setPointerCapture(gesture.pointerId); + } catch { /* The pointer may already have ended. */ } + }, LONG_PRESS_MS); + gestureRef.current = gesture; + } + + function handlePointerMove(event: PointerEvent) { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + gesture.latestY = event.clientY; + if (!gesture.active) { + if (Math.abs(event.clientY - gesture.startY) > PRESS_SLOP_PX) clearGesture(); + return; + } + event.preventDefault(); + updateMotion(gesture, event.clientY); + } + + function finishGesture(event: PointerEvent, commit: boolean) { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + clearGesture(); + if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) { + event.currentTarget.releasePointerCapture?.(gesture.pointerId); + } + if (!commit || !gesture.active) { + setMotion(null); + return; + } + const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)]; + setMotion((current) => current && { ...current, remainder: 0, settling: true }); + if (selected && selected.name !== activeName) onPresetChange?.(selected.name); + } + + function handleKeyDown(event: KeyboardEvent) { + if (!canSwitch) return; + const targetByKey: Record = { + ArrowUp: currentIndex - 1, + ArrowDown: currentIndex + 1, + Home: 0, + End: presets.length - 1, + }; + const target = targetByKey[event.key]; + if (target === undefined) return; + event.preventDefault(); + const next = presets[wrapIndex(target, presets.length)]; + if (next?.name !== activeName) onPresetChange?.(next.name); + } + + const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length); + const previewPreset = presets[previewIndex]; + const Container = interactive || canSwitch ? "button" : "span"; + const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0; + + return ( + { + const gesture = gestureRef.current; + if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture(); + }} + onPointerUp={(event) => finishGesture(event, true)} + onPointerCancel={(event) => finishGesture(event, false)} + onLostPointerCapture={(event) => finishGesture(event, false)} + onContextMenu={(event) => { + if (gestureRef.current?.active) event.preventDefault(); + }} + onDragStart={(event) => event.preventDefault()} + style={{ touchAction: canSwitch ? "manipulation" : undefined }} + className={cn( + "thread-composer-model-badge group/model-badge relative inline-flex w-[5.75rem] min-w-0 justify-end appearance-none border-0 bg-transparent p-0 shadow-none", + interactive && "cursor-pointer", + canSwitch && "cursor-grab select-none focus-visible:outline-none", + motion && "z-10 cursor-grabbing", + isHero ? "h-8 max-w-[44vw]" : "h-9 max-w-[44vw]", + )} + > + {motion ? ( + + { + if (motion.settling && event.currentTarget === event.target) setMotion(null); + }} + style={{ + paddingTop: isHero ? "10px" : "12px", + transform: `translate3d(0, ${trackOffset}px, 0)`, + }} + > + {PILL_OFFSETS.map((offset) => { + const virtualIndex = motion.index + offset; + const preset = presets[wrapIndex(virtualIndex, presets.length)]; + const scale = motion.settling ? 1 : dockScale(offset - motion.remainder); + return ( + + ); + })} + + + ) : ( + + )} + + ); +} + +function PresetPill({ + label, + modelDetail, + provider, + providerLabel, + needsSetup = false, + fallbackModelName, + isHero, + offset, + scale, +}: { + label: string; + modelDetail?: string | null; + provider?: string | null; + providerLabel?: string | null; + needsSetup?: boolean; + fallbackModelName?: string | null; + isHero: boolean; + offset?: number; + scale?: number; +}) { + const labelRef = useRef(null); + const [labelOverflows, setLabelOverflows] = useState(false); + const inferredProvider = needsSetup + ? null + : provider || inferProviderFromModelName(modelDetail || label); + const brand = providerBrand(inferredProvider); + const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); + const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); + const logoTestId = offset !== undefined + ? undefined + : needsSetup + ? "composer-model-setup-icon" + : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`; + + useLayoutEffect(() => { + const node = labelRef.current; + if (!node) return; + const update = () => setLabelOverflows(node.scrollWidth > node.clientWidth + 1); + update(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); + observer?.observe(node); + return () => observer?.disconnect(); + }, [label]); + + return ( + + + {needsSetup ? ( + + ) : logoUrl ? ( + + ) : brand ? ( + + {brand.initials.slice(0, 2)} + + ) : ( + + )} + + + {label} + + + ); +} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index fd66d49d..86f2e0da 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -60,6 +60,10 @@ import { WorkspaceAccessMenu, WorkspaceProjectPicker, } from "@/components/thread/WorkspaceControls"; +import { + ModelPresetBadge, + type ModelPresetOption, +} from "@/components/thread/ModelPresetBadge"; import { ACCEPT_ATTR, MAX_ATTACHMENTS_PER_MESSAGE, @@ -87,9 +91,7 @@ import type { WorkspacesPayload, } from "@/lib/types"; import { - inferProviderFromModelName, logoFallbackUrls, - providerBrand, } from "@/lib/provider-brand"; import { isSideChannelLifecycle, @@ -168,6 +170,10 @@ interface ThreadComposerProps { placeholder?: string; isStreaming?: boolean; modelLabel?: string | null; + modelDetail?: string | null; + modelPreset?: string | null; + modelPresets?: ModelPresetOption[]; + onModelPresetChange?: (name: string) => void; modelProvider?: string | null; modelProviderLabel?: string | null; modelNeedsSetup?: boolean; @@ -814,6 +820,10 @@ export function ThreadComposer({ placeholder, isStreaming = false, modelLabel = null, + modelDetail = null, + modelPreset = null, + modelPresets = [], + onModelPresetChange, modelProvider = null, modelProviderLabel = null, modelNeedsSetup = false, @@ -2084,8 +2094,12 @@ export function ThreadComposer({ )} > {modelLabel && !voiceRecorder.isRecording ? ( - void; -}) { - const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label); - const brand = providerBrand(inferredProvider); - const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); - const showLogo = !!logoUrl; - const title = providerLabel ? `${label} · ${providerLabel}` : label; - const interactive = Boolean(onClick); - const Container = interactive ? "button" : "span"; - - return ( - - - {needsSetup ? ( - - ) : showLogo ? ( - - ) : brand ? ( - - {brand.initials.slice(0, 2)} - - ) : ( - - )} - - {label} - - ); -} - function ComposerCliMentionOverlay({ segments, isHero, diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 2094929d..91cff38b 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -7,6 +7,7 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel"; import { PromptNavigator } from "@/components/thread/PromptNavigator"; import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge"; import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport"; @@ -40,14 +41,9 @@ import type { WorkspaceScopePayload, WorkspacesPayload, } from "@/lib/types"; -import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat"; -import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display"; +import { projectWebuiThreadMessages } from "@/lib/thread-display-compat"; import { useClient } from "@/providers/ClientProvider"; -function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] { - return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages)); -} - type MessageShape = Pick; function sameMessageShape(a: MessageShape, b: MessageShape): boolean { @@ -165,6 +161,7 @@ function toModelBadgeLabel(modelName: string | null): string | null { interface ModelBadgeInfo { label: string | null; + model: string | null; provider: string | null; providerLabel: string | null; needsSetup: boolean; @@ -196,7 +193,7 @@ function toModelBadgeInfo( const model = scopedPreset ? preset?.model || null : settings?.agent.model || modelName || null; - const label = toModelBadgeLabel(model); + const label = preset?.label?.trim() || scopedPreset || toModelBadgeLabel(model); const rawProvider = preset?.provider || (!scopedPreset ? settings?.agent.provider : null) || null; @@ -213,12 +210,37 @@ function toModelBadgeInfo( ); return { label, + model: toModelBadgeLabel(model), provider, providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null, needsSetup, }; } +function modelPresetOptionsFromSettings( + settings: SettingsPayload | null, +): ModelPresetOption[] { + if (!settings) return []; + const order = new Map( + (settings.model_call_order ?? []).map((name, index) => [name.trim(), index]), + ); + return settings.model_presets + .filter((preset) => !preset.is_default && preset.name.trim()) + .sort((a, b) => ( + (order.get(a.name.trim()) ?? Number.POSITIVE_INFINITY) + - (order.get(b.name.trim()) ?? Number.POSITIVE_INFINITY) + )) + .map((preset) => { + const name = preset.name.trim(); + return { + name, + label: preset.label?.trim() || name, + model: preset.model, + provider: preset.resolved_provider || preset.provider, + }; + }); +} + const HERO_GREETING_KEYS = [ "thread.empty.greetings.workOn", "thread.empty.greetings.start", @@ -541,12 +563,32 @@ export function ThreadShell({ token, ]); - const showHeroComposer = messages.length === 0 && !loading; + const showHeroComposer = displayMessages.length === 0 && !loading; const wasShowingHeroComposerRef = useRef(showHeroComposer); const sessionModelPreset = session?.modelPreset?.trim() || null; + const [localModelPreset, setLocalModelPreset] = useState(null); + useEffect(() => { + setLocalModelPreset(null); + }, [session?.key, sessionModelPreset]); + const activeModelPreset = ( + localModelPreset + || sessionModelPreset + || settings?.agent.model_preset + || "default" + ); + const handleModelPresetChange = useCallback((name: string) => { + setLocalModelPreset(name); + if (chatId) { + void client.sendSystemCommand(chatId, `/model ${name}`).catch(() => {}); + } + }, [chatId, client]); + const modelPresetOptions = useMemo( + () => modelPresetOptionsFromSettings(settings), + [settings], + ); const modelBadge = useMemo( - () => toModelBadgeInfo(modelName, settings, sessionModelPreset), - [modelName, sessionModelPreset, settings], + () => toModelBadgeInfo(modelName, settings, activeModelPreset), + [activeModelPreset, modelName, settings], ); const modelBadgeLabel = modelBadge.needsSetup ? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" }) @@ -629,17 +671,16 @@ export function ThreadShell({ return normalizedHistory; } if (cached && cached.length > 0) { - const normalizedCached = projectWebuiThreadMessages(cached); if ( - normalizedHistory.length > normalizedCached.length + normalizedHistory.length > cached.length && !isStaleThreadSnapshot(prev, normalizedHistory) ) { messageCacheRef.current.set(chatId, normalizedHistory); appliedHistoryVersionRef.current.set(chatId, historyVersion); return normalizedHistory; } - if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev); - return normalizedCached; + if (isStaleThreadSnapshot(prev, cached)) return keepLiveMessages(prev); + return cached; } if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev); appliedHistoryVersionRef.current.set(chatId, historyVersion); @@ -679,7 +720,7 @@ export function ThreadShell({ if (chatId) { const prev = prevChatIdForCacheRef.current; if (prev && prev !== chatId) { - messageCacheRef.current.set(prev, projectWebuiThreadMessages(messages)); + messageCacheRef.current.set(prev, displayMessages); skipLayoutCacheRef.current = true; } prevChatIdForCacheRef.current = chatId; @@ -687,13 +728,13 @@ export function ThreadShell({ if (prevChatIdForCacheRef.current) { messageCacheRef.current.set( prevChatIdForCacheRef.current, - projectWebuiThreadMessages(messages), + displayMessages, ); skipLayoutCacheRef.current = true; } prevChatIdForCacheRef.current = null; } - }, [chatId, messages]); + }, [chatId, displayMessages]); // Persist thread to in-memory cache after paint so ``useNanobotStream``'s chat switch // ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still @@ -709,8 +750,8 @@ export function ThreadShell({ if (loading) { return; } - messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages)); - }, [chatId, loading, messages]); + messageCacheRef.current.set(chatId, displayMessages); + }, [chatId, displayMessages, loading]); // The landing composer queues the first message while `new_chat` is in flight. // Only the chat created for that send may consume it; selecting another chat @@ -757,9 +798,12 @@ export function ThreadShell({ setBooting(false); return; } + if (localModelPreset) { + await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {}); + } setPendingFirstTargetChatId(newId); }, - [booting, onCreateChat, withWorkspaceScope, workspaceScope], + [booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope], ); const handleThreadSend = useCallback( @@ -890,6 +934,10 @@ export function ThreadShell({ : t("thread.composer.placeholderThread") } modelLabel={modelBadgeLabel} + modelDetail={modelBadge.model} + modelPreset={activeModelPreset} + modelPresets={modelPresetOptions} + onModelPresetChange={handleModelPresetChange} modelProvider={modelBadge.provider} modelProviderLabel={modelBadge.providerLabel} modelNeedsSetup={modelBadge.needsSetup} @@ -928,6 +976,10 @@ export function ThreadShell({ : t("thread.composer.placeholderHero") } modelLabel={modelBadgeLabel} + modelDetail={modelBadge.model} + modelPreset={activeModelPreset} + modelPresets={modelPresetOptions} + onModelPresetChange={handleModelPresetChange} modelProvider={modelBadge.provider} modelProviderLabel={modelBadge.providerLabel} modelNeedsSetup={modelBadge.needsSetup} diff --git a/webui/src/globals.css b/webui/src/globals.css index 30e5d84a..eec7d801 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -697,6 +697,61 @@ flex-shrink: 1; } +.thread-composer-model-label-fade { + -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); + mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); +} + +.thread-composer-model-badge:not([data-switching="true"]):active + > .composer-model-pill { + transform: scale(0.98); +} + +@keyframes composer-model-pill-viewport-enter { + from { + transform: scale(0.9074); + } + + to { + transform: scale(1); + } +} + +.composer-model-pill-viewport { + transform-origin: right center; + animation: composer-model-pill-viewport-enter 210ms + cubic-bezier(0.2, 0.8, 0.2, 1) both; + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); + mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); +} + +.composer-model-pill-dock { + transform-origin: right center; + transition-property: none; + will-change: transform; +} + +.composer-model-pill-track[data-settling="true"], +.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock { + transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@media (prefers-reduced-motion: reduce) { + .thread-composer-model-badge:active > .composer-model-pill { + transform: none !important; + } + + .composer-model-pill-track[data-settling="true"], + .composer-model-pill-dock { + transition: none; + will-change: auto; + } + + .composer-model-pill-viewport { + animation: none; + } +} + @container thread-composer (max-width: 21rem) { .thread-composer-footer { column-gap: 0.25rem; @@ -735,6 +790,10 @@ .thread-composer-model-badge { width: 2rem; flex: none; + } + + .thread-composer-model-badge .composer-model-pill { + width: 100%; justify-content: center; gap: 0; padding-inline: 0; diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts index b5a3722f..c58ced45 100644 --- a/webui/src/lib/format.ts +++ b/webui/src/lib/format.ts @@ -16,6 +16,25 @@ const LOW_INFORMATION_TITLE_PREVIEWS = new Set([ "在吗", ]); +export function isModelCommandText(text: string | null | undefined): boolean { + return /^\/model(?:@[A-Za-z0-9_]+)?(?:\s|$)/i.test(text?.trim() ?? ""); +} + +export function isModelCommandResponseText(text: string | null | undefined): boolean { + const normalized = text?.trim() ?? ""; + return ( + /^## Model\s+- Current (?:model|selection error):/.test(normalized) + || normalized.startsWith("Switched model preset to ") + || normalized.startsWith("Could not switch model preset:") + || normalized === "Usage: `/model [preset]`" + ); +} + +export function visibleSessionPreview(preview: string | null | undefined): string { + const normalized = preview?.trim() ?? ""; + return isModelCommandText(normalized) || isModelCommandResponseText(normalized) ? "" : normalized; +} + function isLowInformationTitlePreview(text: string): boolean { const normalized = text.toLowerCase().replace(/[.!?。!?~~\s]+$/g, "").trim(); return ( @@ -27,7 +46,7 @@ function isLowInformationTitlePreview(text: string): boolean { /** Truncate the first user message into a chat title. */ export function deriveTitle(preview: string | undefined, fallback: string): string { if (!preview) return fallback; - const oneLine = preview.replace(/\s+/g, " ").trim(); + const oneLine = visibleSessionPreview(preview).replace(/\s+/g, " ").trim(); if (!oneLine) return fallback; if (isLowInformationTitlePreview(oneLine)) return fallback; return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine; diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index 17a7fa15..70680501 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -88,16 +88,16 @@ export type StreamError = type ErrorHandler = (error: StreamError) => void; -interface PendingNewChat { - resolve: (chatId: string) => void; +interface PendingRequest { + resolve: (value: T) => void; reject: (err: Error) => void; timer: ReturnType; } -interface PendingTranscription { - resolve: (text: string) => void; - reject: (err: Error) => void; - timer: ReturnType; +const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:"; + +export function isSystemCommandTurnId(value: string | null | undefined): value is string { + return typeof value === "string" && value.startsWith(SYSTEM_COMMAND_TURN_PREFIX); } export interface NanobotClientOptions { @@ -136,8 +136,9 @@ export class NanobotClient { private runStartedAtByChatId = new Map(); /** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */ private goalStateByChatId = new Map(); - private pendingNewChat: PendingNewChat | null = null; - private pendingTranscriptions = new Map(); + private pendingNewChat: PendingRequest | null = null; + private pendingTranscriptions = new Map>(); + private pendingSystemCommands = new Map>(); // Frames queued while the socket is not yet OPEN private sendQueue: Outbound[] = []; private reconnectAttempts = 0; @@ -407,6 +408,19 @@ export class NanobotClient { this.queueSend(frame); } + sendSystemCommand(chatId: string, command: string, timeoutMs = 5_000): Promise { + const normalized = command.trim(); + const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingSystemCommands.delete(turnId); + reject(new Error("system command timed out")); + }, timeoutMs); + this.pendingSystemCommands.set(turnId, { resolve, reject, timer }); + this.sendMessage(chatId, normalized, undefined, { turnId }); + }); + } + setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void { this.knownChats.add(chatId); this.queueSend({ @@ -462,6 +476,16 @@ export class NanobotClient { console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed)); } + const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string" + ? parsed.turn_id + : null; + if (isSystemCommandTurnId(turnId)) { + if (parsed.event === "message" || parsed.event === "turn_end") { + this.resolveSystemCommand(turnId); + } + return; + } + if (parsed.event === "ready") { this.readyChatId = parsed.chat_id; this.knownChats.add(parsed.chat_id); @@ -578,6 +602,11 @@ export class NanobotClient { this.pendingNewChat = null; } this.rejectAllTranscriptions("socket closed"); + for (const pending of this.pendingSystemCommands.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("socket closed")); + } + this.pendingSystemCommands.clear(); // Surface structured reasons *before* reconnect logic so the UI can // display the error even while the client transparently reconnects. // Browsers populate ``CloseEvent.code`` with the wire-level close code; @@ -634,6 +663,14 @@ export class NanobotClient { } } + private resolveSystemCommand(turnId: string): void { + const pending = this.pendingSystemCommands.get(turnId); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingSystemCommands.delete(turnId); + pending.resolve(); + } + private scheduleReconnect(): void { this.clearRunStatusesForReconnect(); this.setStatus("reconnecting"); diff --git a/webui/src/lib/thread-display-compat.ts b/webui/src/lib/thread-display-compat.ts index 517a45c8..81c70d04 100644 --- a/webui/src/lib/thread-display-compat.ts +++ b/webui/src/lib/thread-display-compat.ts @@ -1,3 +1,6 @@ +import { isModelCommandResponseText, isModelCommandText } from "@/lib/format"; +import { isSystemCommandTurnId } from "@/lib/nanobot-client"; +import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display"; import type { UIMessage } from "@/lib/types"; /** @@ -20,3 +23,18 @@ export function normalizeLegacyLongTaskMessages(messages: UIMessage[]): UIMessag }; }); } + +export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] { + const normalized = scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages)); + const hiddenTurns = new Set(normalized.flatMap((message) => ( + message.role === "user" && isModelCommandText(message.content) && message.turnId + ? [message.turnId] + : [] + ))); + return normalized.filter((message) => ( + !isSystemCommandTurnId(message.turnId) + && (!message.turnId || !hiddenTurns.has(message.turnId)) + && !(message.role === "user" && isModelCommandText(message.content)) + && !(message.role === "assistant" && isModelCommandResponseText(message.content)) + )); +} diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index 50ed9f02..b8f33326 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -23,6 +23,7 @@ describe("ChatList", () => { session({ chatId: "older", title: "Older chat", + preview: "/model fast", updatedAt: "2026-05-21T10:00:00Z", }), session({ @@ -46,6 +47,7 @@ describe("ChatList", () => { onTogglePin={vi.fn()} onRequestRename={vi.fn()} onToggleArchive={vi.fn()} + showPreviews />, ); @@ -54,6 +56,7 @@ describe("ChatList", () => { expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat")); expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat")); + expect(screen.queryByText("/model fast")).not.toBeInTheDocument(); }); it("shows a pin indicator for pinned chats", () => { diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index f60eef97..7284f7c1 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -552,6 +552,51 @@ describe("NanobotClient", () => { }); }); + it("handles the silent system-command lifecycle without hiding concurrent events", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const chatHandler = vi.fn(); + client.onChat("chat-x", chatHandler); + client.connect(); + lastSocket().fakeOpen(); + + const pending = client.sendSystemCommand("chat-x", " /model fast ", 1_000); + const frame = JSON.parse(lastSocket().sent.at(-1) as string); + expect(frame).toMatchObject({ + type: "message", + chat_id: "chat-x", + content: "/model fast", + webui: true, + }); + expect(frame.turn_id).toMatch(/^webui-system:/); + + lastSocket().fakeMessage({ + event: "message", + chat_id: "chat-x", + text: "normal reply", + turn_id: "normal-turn", + }); + lastSocket().fakeMessage({ + event: "message", + chat_id: "chat-x", + text: "Switched model preset to fast.", + turn_id: frame.turn_id, + }); + + await expect(pending).resolves.toBeUndefined(); + expect(chatHandler).toHaveBeenCalledTimes(1); + expect(chatHandler).toHaveBeenCalledWith(expect.objectContaining({ + text: "normal reply", + turn_id: "normal-turn", + })); + const interrupted = client.sendSystemCommand("chat-x", "/model fast", 1_000); + lastSocket().close(); + await expect(interrupted).rejects.toThrow("socket closed"); + }); + it("sends selected assistant text as separate quoted context", () => { const client = new NanobotClient({ url: "ws://test", diff --git a/webui/src/tests/session-search-dialog.test.tsx b/webui/src/tests/session-search-dialog.test.tsx index 3d252bf7..bad0c810 100644 --- a/webui/src/tests/session-search-dialog.test.tsx +++ b/webui/src/tests/session-search-dialog.test.tsx @@ -25,7 +25,7 @@ describe("SessionSearchDialog", () => { render( {}} @@ -38,6 +38,11 @@ describe("SessionSearchDialog", () => { expect(dialog.className).not.toContain("bg-popover/"); expect(dialog.className).not.toContain("backdrop-blur"); expect(screen.getByTestId("session-search-scroll")).toHaveClass("overflow-y-auto"); + expect(screen.queryByText("/model fast")).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole("textbox", { name: "Search" }), { + target: { value: "model fast" }, + }); + expect(screen.queryByText("Model chat")).not.toBeInTheDocument(); }); it("keeps keyboard navigation scrollable through long result lists", () => { diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 814318ff..5ca9cf5c 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -164,6 +164,7 @@ function stubVisualViewport({ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); Reflect.deleteProperty(window, "nanobotHost"); if (ORIGINAL_MEDIA_DEVICES) { Object.defineProperty(navigator, "mediaDevices", { @@ -291,6 +292,49 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string { return String.fromCharCode(...bytes.slice(offset, offset + length)); } +const MODEL_PRESETS = [ + { name: "kimi", label: "Kimi", provider: "moonshot" }, + { name: "dflash", label: "DFlash", provider: "deepseek" }, + { name: "dspro", label: "DS Pro", provider: "deepseek" }, +]; + +function renderPresetComposer(variant: "thread" | "hero" = "thread") { + const onPresetChange = vi.fn(); + render( + , + ); + return { + badge: screen.getByRole("spinbutton", { name: "Kimi" }), + onPresetChange, + }; +} + +function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) { + fireEvent.pointerDown(badge, { + button, + clientY, + isPrimary: true, + pointerId, + pointerType: "mouse", + }); +} + +function longPress(badge: HTMLElement, pointerId = 7) { + pointerDown(badge, pointerId); + act(() => { + vi.advanceTimersByTime(400); + }); +} + describe("ThreadComposer", () => { it("focuses and sends a removable quoted answer excerpt", async () => { const onSend = vi.fn(); @@ -386,6 +430,88 @@ describe("ThreadComposer", () => { expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); }); + it("scrolls complete preset pills after a left-button long press and wraps", () => { + vi.useFakeTimers(); + const { badge, onPresetChange } = renderPresetComposer(); + expect(badge).toHaveClass("h-9"); + expect(badge).toHaveStyle({ touchAction: "manipulation" }); + const idleTouchMove = new Event("touchmove", { + bubbles: true, + cancelable: true, + }); + badge.dispatchEvent(idleTouchMove); + expect(idleTouchMove.defaultPrevented).toBe(false); + fireEvent.click(badge); + pointerDown(badge); + fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); + act(() => vi.advanceTimersByTime(500)); + fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); + expect(onPresetChange).not.toHaveBeenCalled(); + + longPress(badge); + expect(badge).toHaveAttribute("data-switching", "true"); + const viewport = screen.getByTestId("composer-model-pill-viewport"); + expect(viewport).toHaveClass("overflow-hidden", "-left-2", "-top-3", "-bottom-3"); + const track = screen.getByTestId("composer-model-pill-track"); + expect(track).toHaveClass("items-end", "gap-1"); + const activeTouchMove = new Event("touchmove", { + bubbles: true, + cancelable: true, + }); + badge.dispatchEvent(activeTouchMove); + expect(activeTouchMove.defaultPrevented).toBe(true); + const pills = track.querySelectorAll(".composer-model-pill"); + expect(pills).toHaveLength(5); + expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true); + expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true); + expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true); + const centeredPill = track.querySelector("[data-preset-offset='0']"); + expect(centeredPill).toHaveTextContent("Kimi"); + expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" }); + expect( + track.querySelector("[data-preset-offset='1']"), + ).toHaveStyle({ transform: "scale(1.0200)" }); + + fireEvent.pointerMove(badge, { + clientY: 122, + pointerId: 7, + pointerType: "mouse", + }); + expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("Kimi"); + fireEvent.pointerMove(badge, { + clientY: 123, + pointerId: 7, + pointerType: "mouse", + }); + expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("DS Pro"); + fireEvent.pointerUp(badge, { + clientY: 123, + pointerId: 7, + pointerType: "mouse", + }); + + expect(onPresetChange).toHaveBeenCalledWith("dspro"); + expect(badge).toHaveAttribute("data-settling", "true"); + expect(track).toHaveAttribute("data-settling", "true"); + act(() => { + vi.advanceTimersByTime(260); + }); + expect(badge).not.toHaveAttribute("data-switching"); + expect(badge).not.toHaveAttribute("data-settling"); + }); + + it("supports the same long-press switcher in hero mode and cancels pointercancel", () => { + vi.useFakeTimers(); + const { badge, onPresetChange } = renderPresetComposer("hero"); + expect(badge).toHaveClass("h-8"); + longPress(badge, 9); + expect(badge).toHaveAttribute("data-switching", "true"); + fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); + fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); + expect(badge).not.toHaveAttribute("data-switching"); + expect(onPresetChange).not.toHaveBeenCalled(); + }); + it("transcribes voice input into the composer without sending", async () => { mockVoiceRecorder(); const onSend = vi.fn(); diff --git a/webui/src/tests/thread-display-compat.test.ts b/webui/src/tests/thread-display-compat.test.ts index a6177881..f0fe9bb0 100644 --- a/webui/src/tests/thread-display-compat.test.ts +++ b/webui/src/tests/thread-display-compat.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat"; +import { deriveTitle, isModelCommandText, visibleSessionPreview } from "@/lib/format"; +import { + normalizeLegacyLongTaskMessages, + projectWebuiThreadMessages, +} from "@/lib/thread-display-compat"; import type { UIMessage } from "@/lib/types"; describe("normalizeLegacyLongTaskMessages", () => { @@ -17,4 +21,29 @@ describe("normalizeLegacyLongTaskMessages", () => { expect(out[0]!.role).toBe("tool"); expect(out[0]!.traces).toEqual(["long_task · done"]); }); + + it("removes model and silent-command turns without hiding concurrent replies", () => { + const message = ( + id: string, + role: UIMessage["role"], + content: string, + turnId?: string, + ): UIMessage => ({ id, role, content, createdAt: 1, turnId }); + const visible = projectWebuiThreadMessages([ + message("model", "user", "/model fast", "model-turn"), + message("model-reply", "assistant", "Switched model preset to fast.", "model-turn"), + message("silent", "user", "/restart", "webui-system:restart"), + message("reply", "assistant", "This unrelated reply stays visible.", "other-turn"), + ]); + + expect(visible.map(({ content }) => content)).toEqual([ + "This unrelated reply stays visible.", + ]); + expect([ + isModelCommandText("/MODEL@nanobot fast"), + isModelCommandText("/modelish"), + ]).toEqual([true, false]); + expect(visibleSessionPreview("Switched model preset to `fast`.")).toBe(""); + expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat"); + }); }); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 55d22f79..acc9189e 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -72,6 +72,7 @@ function makeClient() { for (const h of sessionUpdateHandlers) h(chatId, scope); }, sendMessage: vi.fn(), + sendSystemCommand: vi.fn().mockResolvedValue(undefined), newChat: vi.fn(), forkChat: vi.fn(), attach: vi.fn(), @@ -387,7 +388,7 @@ describe("ThreadShell", () => { ); expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument(); - expect(screen.getByText("gpt-5.5")).toBeInTheDocument(); + expect(screen.getByText("Default")).toBeInTheDocument(); expect(screen.queryByText("ling-3.0-flash")).not.toBeInTheDocument(); }); @@ -406,8 +407,55 @@ describe("ThreadShell", () => { ), ); - expect(await screen.findByTitle("gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); - expect(screen.queryByTitle("deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument(); + expect(await screen.findByTitle("Fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); + expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument(); + }); + + it("switches through every named preset while preserving call-order priority", async () => { + const client = makeClient(); + const settings = settingsWithFastPreset(); + settings.model_presets.push({ + ...settings.model_presets.at(-1)!, + name: "extra", + label: "Extra", + model: "deepseek/extra", + provider: "deepseek", + active: false, + is_default: false, + }); + settings.model_call_order = ["fast"]; + + const view = (preset: string) => wrap(client, ( + {}} + settingsSnapshot={settings} + /> + )); + const { rerender } = render(view("default")); + + const badge = await screen.findByRole("spinbutton", { name: "Default" }); + expect(badge).toHaveTextContent("Default"); + fireEvent.keyDown(badge, { key: "ArrowDown" }); + + expect(client.sendSystemCommand).toHaveBeenCalledWith( + "preset-order", + "/model fast", + ); + expect(await screen.findByText("Fast")).toBeInTheDocument(); + fireEvent.keyDown( + screen.getByRole("spinbutton", { name: "Fast" }), + { key: "End" }, + ); + expect(client.sendSystemCommand).toHaveBeenLastCalledWith( + "preset-order", + "/model extra", + ); + expect(await screen.findByText("Extra")).toBeInTheDocument(); + + rerender(view("fast")); + expect(await screen.findByText("Fast")).toBeInTheDocument(); }); it("uses the backend-resolved provider for an auto session preset", async () => { @@ -442,7 +490,7 @@ describe("ThreadShell", () => { ), ); - expect(await screen.findByTitle("gpt-4 · Company Proxy")).toBeInTheDocument(); + expect(await screen.findByTitle("Fast · gpt-4 · Company Proxy")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument(); }); @@ -459,7 +507,7 @@ describe("ThreadShell", () => { "openai-codex/gpt-5.5", )); - expect(await screen.findByText("gpt-5.5")).toBeInTheDocument(); + expect(await screen.findByText("Default")).toBeInTheDocument(); const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement; expect(configuredBadge).not.toBeNull(); expect(configuredBadge).toHaveClass("composer-model-badge"); @@ -477,7 +525,7 @@ describe("ThreadShell", () => { const badge = logo.parentElement; expect(badge).not.toBeNull(); expect(badge).toBe(configuredBadge); - expect(screen.getByText("gpt-5.5")).toBeInTheDocument(); + expect(screen.getByText("Default")).toBeInTheDocument(); expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument(); expect(badge).toHaveAttribute("data-fallback", "true"); expect(badge).toHaveAttribute( @@ -500,7 +548,7 @@ describe("ThreadShell", () => { }); expect( screen.getByTestId("composer-model-logo-openai_codex").parentElement, - ).toHaveAttribute("title", "gpt-5.5 · OpenAI Codex"); + ).toHaveAttribute("title", "Default · gpt-5.5 · OpenAI Codex"); expect( screen.getByTestId("composer-model-logo-openai_codex").parentElement, ).toBe(badge); @@ -750,6 +798,57 @@ describe("ThreadShell", () => { expect(onNewChat).not.toHaveBeenCalled(); }); + it("applies the selected landing preset before sending the first prompt", async () => { + const client = makeClient(); + const settings = settingsWithFastPreset(); + settings.model_call_order = ["fast"]; + let resolveModelCommand!: () => void; + client.sendSystemCommand.mockImplementation( + () => new Promise((resolve) => { + resolveModelCommand = resolve; + }), + ); + const onCreateChat = vi.fn().mockResolvedValue("chat-new"); + + const view = (currentSession: ReturnType | null) => wrap(client, ( + {}} + onCreateChat={onCreateChat} + settingsSnapshot={settings} + /> + )); + const { rerender } = render(view(null)); + + fireEvent.keyDown( + await screen.findByRole("spinbutton", { name: "Default" }), + { key: "ArrowDown" }, + ); + expect(await screen.findByText("Fast")).toBeInTheDocument(); + expect(client.sendSystemCommand).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "use the selected model" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => expect(client.sendSystemCommand).toHaveBeenCalledWith( + "chat-new", + "/model fast", + )); + + rerender(view(session("chat-new"))); + expect(client.sendMessage).not.toHaveBeenCalled(); + + await act(async () => { + resolveModelCommand(); + }); + await waitFor(() => { + expectSendMessageWithTurn(client, "chat-new", "use the selected model"); + }); + }); + it("binds a pending landing message to the chat created for it", async () => { const client = makeClient(); let resolveCreate: ((chatId: string) => void) | null = null; @@ -869,7 +968,7 @@ describe("ThreadShell", () => { expect(screen.queryByText(HERO_GREETING_PATTERN)).not.toBeInTheDocument(); }); - it("keeps a live first command reply when the initial history snapshot is stale", async () => { + it("hides a live first /model turn when the initial history snapshot is stale", async () => { const client = makeClient(); const onCreateChat = vi.fn().mockResolvedValue("chat-new"); let resolveThread: @@ -935,8 +1034,15 @@ describe("ThreadShell", () => { chat_id: "chat-new", text: "## Model\n- Current model: `Ring-2.6-1T`", }); + client._emitChat("chat-new", { + event: "message", + chat_id: "chat-new", + text: "This unrelated reply stays visible.", + }); }); - expect(screen.getByText(/Current model/)).toBeInTheDocument(); + expect(screen.queryByText("/model")).not.toBeInTheDocument(); + expect(screen.queryByText(/Current model/)).not.toBeInTheDocument(); + expect(screen.getByText("This unrelated reply stays visible.")).toBeInTheDocument(); await act(async () => { resolveThread?.( @@ -944,7 +1050,11 @@ describe("ThreadShell", () => { ); }); - await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument()); + await waitFor(() => { + expect(screen.queryByText("/model")).not.toBeInTheDocument(); + expect(screen.queryByText(/Current model/)).not.toBeInTheDocument(); + expect(screen.getByText("This unrelated reply stays visible.")).toBeInTheDocument(); + }); }); it("keeps the empty thread landing focused on the composer", async () => {