feat(webui): switch model presets from the composer (#5077)
This commit is contained in:
+1
-1
@@ -1689,7 +1689,7 @@ function Shell({
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
client.sendMessage(chatId, "/restart");
|
||||
void client.sendSystemCommand(chatId, "/restart").catch(() => {});
|
||||
}, [activeSession?.chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(" ")
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<PresetMotion | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(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<HTMLElement>) {
|
||||
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<HTMLElement>) {
|
||||
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<HTMLElement>, 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<HTMLElement>) {
|
||||
if (!canSwitch) return;
|
||||
const targetByKey: Record<string, number> = {
|
||||
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 (
|
||||
<Container
|
||||
data-switching={motion ? "true" : undefined}
|
||||
data-settling={motion?.settling ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-orientation={canSwitch ? "vertical" : undefined}
|
||||
aria-valuemax={canSwitch ? presets.length - 1 : undefined}
|
||||
aria-valuemin={canSwitch ? 0 : undefined}
|
||||
aria-valuenow={canSwitch ? previewIndex : undefined}
|
||||
aria-valuetext={canSwitch ? previewPreset?.label || label : undefined}
|
||||
role={canSwitch ? "spinbutton" : undefined}
|
||||
type={interactive || canSwitch ? "button" : undefined}
|
||||
onClick={interactive ? onClick : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={(event) => {
|
||||
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 ? (
|
||||
<span
|
||||
data-testid="composer-model-pill-viewport"
|
||||
className={cn(
|
||||
"composer-model-pill-viewport pointer-events-none absolute -left-2 right-0 overflow-hidden bg-transparent",
|
||||
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
data-testid="composer-model-pill-track"
|
||||
data-settling={motion.settling ? "true" : undefined}
|
||||
className="composer-model-pill-track ml-auto flex w-[calc(100%-0.5rem)] flex-col items-end gap-1 will-change-transform"
|
||||
onTransitionEnd={(event) => {
|
||||
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 (
|
||||
<PresetPill
|
||||
key={virtualIndex}
|
||||
label={preset.label || preset.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<PresetPill
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={provider}
|
||||
providerLabel={providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLSpanElement | null>(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 (
|
||||
<span
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
data-preset-offset={offset}
|
||||
title={fallbackModelName || title || undefined}
|
||||
className={cn(
|
||||
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-semibold text-foreground/58",
|
||||
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
|
||||
offset !== undefined && "composer-model-pill-dock",
|
||||
)}
|
||||
style={scale === undefined ? undefined : {
|
||||
height: `${isHero ? 32 : 36}px`,
|
||||
transform: `scale(${scale.toFixed(4)})`,
|
||||
zIndex: Math.round(scale * 100),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-testid={logoTestId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden",
|
||||
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background",
|
||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||
)}
|
||||
style={{
|
||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-full w-full place-items-center rounded-full text-white",
|
||||
isHero ? "text-[7.5px]" : "text-[8px]",
|
||||
)}
|
||||
style={{ backgroundColor: brand.color }}
|
||||
>
|
||||
{brand.initials.slice(0, 2)}
|
||||
</span>
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn(
|
||||
"thread-composer-model-label min-w-0 overflow-hidden whitespace-nowrap text-center",
|
||||
labelOverflows && "thread-composer-model-label-fade",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<ComposerModelBadge
|
||||
<ModelPresetBadge
|
||||
label={modelLabel}
|
||||
modelDetail={modelDetail}
|
||||
modelPreset={modelPreset}
|
||||
modelPresets={modelPresets}
|
||||
onPresetChange={onModelPresetChange}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
@@ -2373,94 +2387,6 @@ function QueuedPromptRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerModelBadge({
|
||||
label,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup,
|
||||
fallbackModelName,
|
||||
isHero,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
onClick?: () => 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 (
|
||||
<Container
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
title={fallbackModelName || title}
|
||||
aria-label={label}
|
||||
type={interactive ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"composer-model-badge thread-composer-model-badge inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
||||
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero
|
||||
? "h-8 max-w-[min(12.5rem,44vw)] gap-1.5 px-2 text-[11.5px]"
|
||||
: "h-9 max-w-[min(12rem,44vw)] gap-2 px-2.5 text-[12px]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-testid={needsSetup ? "composer-model-setup-icon" : inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden",
|
||||
needsSetup
|
||||
? "text-amber-800 dark:text-amber-200"
|
||||
: "rounded-full border bg-background",
|
||||
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
|
||||
)}
|
||||
style={{
|
||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : showLogo ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-full w-full place-items-center rounded-full text-white",
|
||||
isHero ? "text-[7.5px]" : "text-[8px]",
|
||||
)}
|
||||
style={{ backgroundColor: brand.color }}
|
||||
>
|
||||
{brand.initials.slice(0, 2)}
|
||||
</span>
|
||||
) : (
|
||||
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3 w-3" : "h-3 w-3")} />
|
||||
)}
|
||||
</span>
|
||||
<span className="thread-composer-model-label truncate">{label}</span>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerCliMentionOverlay({
|
||||
segments,
|
||||
isHero,
|
||||
|
||||
@@ -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<UIMessage, "role" | "kind" | "content">;
|
||||
|
||||
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<string | null>(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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+20
-1
@@ -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;
|
||||
|
||||
@@ -88,16 +88,16 @@ export type StreamError =
|
||||
|
||||
type ErrorHandler = (error: StreamError) => void;
|
||||
|
||||
interface PendingNewChat {
|
||||
resolve: (chatId: string) => void;
|
||||
interface PendingRequest<T> {
|
||||
resolve: (value: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface PendingTranscription {
|
||||
resolve: (text: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
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<string, number>();
|
||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
private pendingNewChat: PendingNewChat | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingTranscription>();
|
||||
private pendingNewChat: PendingRequest<string> | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||
// 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<void> {
|
||||
const normalized = command.trim();
|
||||
const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`;
|
||||
return new Promise<void>((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");
|
||||
|
||||
@@ -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))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("SessionSearchDialog", () => {
|
||||
render(
|
||||
<SessionSearchDialog
|
||||
open
|
||||
sessions={[session(1)]}
|
||||
sessions={[{ ...session(1), title: "Model chat", preview: "/model fast" }]}
|
||||
activeKey={null}
|
||||
loading={false}
|
||||
onOpenChange={() => {}}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="Kimi"
|
||||
modelPreset="kimi"
|
||||
modelProvider="moonshot"
|
||||
modelPresets={MODEL_PRESETS}
|
||||
onModelPresetChange={onPresetChange}
|
||||
placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."}
|
||||
variant={variant}
|
||||
/>,
|
||||
);
|
||||
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<HTMLElement>(".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<HTMLElement>("[data-preset-offset='0']");
|
||||
expect(centeredPill).toHaveTextContent("Kimi");
|
||||
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
|
||||
expect(
|
||||
track.querySelector<HTMLElement>("[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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, (
|
||||
<ThreadShell
|
||||
session={session("preset-order", preset)}
|
||||
title="Preset order"
|
||||
onToggleSidebar={() => {}}
|
||||
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<void>((resolve) => {
|
||||
resolveModelCommand = resolve;
|
||||
}),
|
||||
);
|
||||
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
|
||||
|
||||
const view = (currentSession: ReturnType<typeof session> | null) => wrap(client, (
|
||||
<ThreadShell
|
||||
session={currentSession}
|
||||
title={currentSession ? "New chat" : "nanobot"}
|
||||
onToggleSidebar={() => {}}
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user