feat(webui): switch model presets from the composer (#5077)

This commit is contained in:
chengyongru
2026-07-24 17:34:26 +08:00
committed by GitHub
parent 9aab94c766
commit 5be176a6a0
16 changed files with 991 additions and 138 deletions
+2 -2
View File
@@ -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)
+3 -3
View File
@@ -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>
);
}
+17 -91
View File
@@ -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,
+72 -20
View File
@@ -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}