diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx
index 00141d62d..be35dcd1f 100644
--- a/webui/src/components/thread/AgentActivityCluster.tsx
+++ b/webui/src/components/thread/AgentActivityCluster.tsx
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
+import { MarkdownText } from "@/components/MarkdownText";
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
@@ -55,6 +56,7 @@ export { isAgentActivityMember, isReasoningOnlyAssistant };
interface ActivityCounts {
reasoningSteps: number;
toolCalls: number;
+ modelSegments: number;
cliCount: number;
mcpCount: number;
fileCount: number;
@@ -91,9 +93,14 @@ function countActivity(
): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
+ let modelSegments = 0;
const cliCount = cliRuns.length;
const mcpCount = mcpRuns.length;
for (const m of messages) {
+ if (m.activityKind === "model") {
+ modelSegments += 1;
+ continue;
+ }
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
@@ -110,6 +117,7 @@ function countActivity(
return {
reasoningSteps,
toolCalls,
+ modelSegments,
cliCount,
mcpCount,
fileCount: fileEdits.length,
@@ -131,8 +139,8 @@ interface AgentActivityClusterProps {
}
/**
- * Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows.
- * Fixed max height with inner scroll and a single flat list of activity rows.
+ * One fold wrapping the complete middle of a turn: reasoning, model segments,
+ * tool traces, and file edits. The final assistant answer stays outside it.
*/
export function AgentActivityCluster({
messages,
@@ -165,6 +173,7 @@ export function AgentActivityCluster({
const {
reasoningSteps,
toolCalls,
+ modelSegments,
cliCount,
mcpCount,
fileCount,
@@ -186,9 +195,8 @@ export function AgentActivityCluster({
? outerOpenLocal
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming);
- const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
+ const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || modelSegments > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
- const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const durationMs = activityDurationMs(
activityMessages,
isTurnStreaming,
@@ -197,28 +205,16 @@ export function AgentActivityCluster({
startedAtMs,
);
const activityDuration = formatActivityDuration(durationMs);
- const thoughtLabel = hasNonReasoningActivity
- ? isTurnStreaming
- ? t("message.activityWorkingFor", {
- duration: activityDuration,
- defaultValue: "Working for {{duration}}",
- })
- : durationMs <= 0
- ? t("message.activityWorked", { defaultValue: "Worked" })
+ const activityLabel = isTurnStreaming
+ ? t("message.activityWorkingFor", {
+ duration: activityDuration,
+ defaultValue: "Working for {{duration}}",
+ })
+ : durationMs <= 0
+ ? t("message.activityWorked", { defaultValue: "Worked" })
: t("message.activityWorkedFor", {
duration: activityDuration,
defaultValue: "Worked for {{duration}}",
- })
- : isTurnStreaming
- ? t("message.activityThinkingFor", {
- duration: activityDuration,
- defaultValue: "Thinking for {{duration}}",
- })
- : durationMs <= 0
- ? t("message.activityThought", { defaultValue: "Thought" })
- : t("message.activityThoughtFor", {
- duration: activityDuration,
- defaultValue: "Thought for {{duration}}",
});
const cancelActivityScrollFrame = useCallback(() => {
@@ -338,7 +334,7 @@ export function AgentActivityCluster({
{fileEdits.length ? (
;
mcpPresetsByName: Map;
+ onOpenFilePreview?: (path: string) => void;
}) {
const items: ReactNode[] = [];
messages.forEach((message, index) => {
+ if (message.activityKind === "model") {
+ items.push(
+ ,
+ );
+ return;
+ }
if (isReasoningOnlyAssistant(message)) {
items.push(
{items}>;
}
+/**
+ * Keep an intermediate assistant segment as normal Markdown. The activity
+ * surface owns ordering and lifecycle, not a reduced rendering mode: users
+ * should see the same prose, links, and code treatment before and after the
+ * surrounding turn is folded.
+ */
+function ActivityModelMessage({
+ message,
+ active,
+ onOpenFilePreview,
+}: {
+ message: UIMessage;
+ active: boolean;
+ onOpenFilePreview?: (path: string) => void;
+}) {
+ if (!message.content.trim()) return null;
+ return (
+
+
+ {message.content}
+
+
+ );
+}
+
function ActivityTraceList({
lines,
active,
diff --git a/webui/src/components/thread/ModelPresetBadge.tsx b/webui/src/components/thread/ModelPresetBadge.tsx
index 93011bde9..5476736c0 100644
--- a/webui/src/components/thread/ModelPresetBadge.tsx
+++ b/webui/src/components/thread/ModelPresetBadge.tsx
@@ -6,37 +6,27 @@ import {
type KeyboardEvent,
type PointerEvent,
} from "react";
-import { CircleHelp, Sparkles } from "lucide-react";
+import { Check, CircleHelp, Sparkles } from "lucide-react";
+import { useTranslation } from "react-i18next";
import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+ floatingItemClassName,
+ floatingItemFocusClassName,
+} from "@/components/ui/floating-surface";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
-export interface ModelPresetOption {
- name: 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;
-}
+const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]";
+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 = 200;
interface PresetGesture {
active: boolean;
@@ -55,15 +45,6 @@ interface PresetMotion {
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;
}
@@ -86,12 +67,40 @@ function preventTouchScroll(event: TouchEvent) {
if (event.cancelable) event.preventDefault();
}
+function compactModelName(model?: string | null): string | null {
+ const value = model?.trim();
+ if (!value) return null;
+ return value.split("/").at(-1) || value;
+}
+
+export interface ModelPresetOption {
+ name: string;
+ model?: string | null;
+ provider?: string | null;
+}
+
+interface ModelPresetBadgeProps {
+ label: string;
+ modelDetail?: string | null;
+ modelPreset?: string | null;
+ modelPresets?: ModelPresetOption[];
+ onPresetChange?: (name: string) => void;
+ onRequestComposerFocus?: () => void;
+ provider?: string | null;
+ providerLabel?: string | null;
+ needsSetup?: boolean;
+ fallbackModelName?: string | null;
+ isHero: boolean;
+ onClick?: () => void;
+}
+
export function ModelPresetBadge({
label,
modelDetail,
modelPreset,
modelPresets = [],
onPresetChange,
+ onRequestComposerFocus,
provider,
providerLabel,
needsSetup = false,
@@ -99,6 +108,12 @@ export function ModelPresetBadge({
isHero,
onClick,
}: ModelPresetBadgeProps) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const [motion, setMotion] = useState(null);
+ const [motionWidth, setMotionWidth] = useState(null);
+ const gestureRef = useRef(null);
+ const suppressClickRef = useRef(false);
const activeName = modelPreset?.trim() || "";
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
const activePreset: ModelPresetOption = {
@@ -107,97 +122,78 @@ export function ModelPresetBadge({
model: modelDetail ?? modelPresets[listedIndex]?.model,
provider: provider || modelPresets[listedIndex]?.provider,
};
+ const fallbackPreset = fallbackModelName
+ ? modelPresets.find((preset) => preset.model?.trim() === fallbackModelName.trim())
+ : undefined;
+ const fallbackDisplayLabel = fallbackPreset?.name
+ || fallbackModelName?.trim().split(/[/:]/).pop()
+ || null;
+ const displayLabel = fallbackDisplayLabel || label;
+ const displayModelDetail = fallbackPreset
+ ? fallbackPreset.model
+ : fallbackModelName
+ ? null
+ : modelDetail;
+ const displayProvider = fallbackPreset?.provider
+ || (fallbackModelName ? inferProviderFromModelName(fallbackModelName) : 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 opensSetup = Boolean(onClick);
+ const canSwitch = !opensSetup && 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);
- const clickAnimationFrameRef = useRef(null);
- const suppressClickRef = useRef(false);
- const suppressClickTimerRef = useRef(null);
+ const switchModelLabel = t("thread.composer.switchModel", {
+ defaultValue: "Switch model for this chat",
+ });
- function clearGesture() {
+ const selectPreset = (name: string) => {
+ setOpen(false);
+ if (name !== activeName) onPresetChange?.(name);
+ requestAnimationFrame(() => onRequestComposerFocus?.());
+ };
+
+ const clearGesture = () => {
const gesture = gestureRef.current;
if (gesture?.timer) clearTimeout(gesture.timer);
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
gestureRef.current = null;
- }
+ };
+
+ const clearMotion = () => {
+ setMotion(null);
+ setMotionWidth(null);
+ };
useEffect(() => {
if (!canSwitch) {
clearGesture();
- setMotion(null);
+ clearMotion();
}
- return () => {
- clearGesture();
- if (clickAnimationFrameRef.current !== null) {
- window.cancelAnimationFrame(clickAnimationFrameRef.current);
- clickAnimationFrameRef.current = null;
- }
- if (suppressClickTimerRef.current !== null) {
- window.clearTimeout(suppressClickTimerRef.current);
- suppressClickTimerRef.current = null;
- }
- };
+ return clearGesture;
}, [canSwitch]);
useEffect(() => {
if (!motion?.settling) return;
- const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80);
+ const timer = setTimeout(clearMotion, SETTLE_MS + 80);
return () => clearTimeout(timer);
}, [motion?.settling]);
- function updateMotion(gesture: PresetGesture, clientY: number) {
+ const 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 suppressFollowingClick() {
- suppressClickRef.current = true;
- if (suppressClickTimerRef.current !== null) {
- window.clearTimeout(suppressClickTimerRef.current);
- }
- suppressClickTimerRef.current = window.setTimeout(() => {
- suppressClickRef.current = false;
- suppressClickTimerRef.current = null;
- }, 0);
- }
-
- function cycleToNextPreset() {
- if (!canSwitch || motion) return;
- const nextVirtualIndex = currentIndex + 1;
- const next = presets[wrapIndex(nextVirtualIndex, presets.length)];
- if (!next || next.name === activeName) return;
-
- // Mount the same five-pill track one step before its destination, then
- // settle it into place so clicks share the drag interaction's motion.
- setMotion({ index: nextVirtualIndex, remainder: -1, settling: false });
- clickAnimationFrameRef.current = window.requestAnimationFrame(() => {
- clickAnimationFrameRef.current = null;
- setMotion({ index: nextVirtualIndex, remainder: 0, settling: true });
- onPresetChange?.(next.name);
+ setMotion({
+ index: gesture.baseIndex + gesture.step,
+ remainder: raw - gesture.step,
+ settling: false,
});
- }
+ };
- function handleClick() {
- if (interactive) {
- onClick?.();
- return;
- }
- if (suppressClickRef.current) return;
- cycleToNextPreset();
- }
-
- function handlePointerDown(event: PointerEvent) {
- if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
+ const handlePointerDown = (event: PointerEvent) => {
+ if (!canSwitch || gestureRef.current || motion) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
const gesture: PresetGesture = {
active: false,
@@ -212,16 +208,19 @@ export function ModelPresetBadge({
gesture.timer = setTimeout(() => {
if (gestureRef.current !== gesture) return;
gesture.active = true;
+ setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null);
updateMotion(gesture, gesture.latestY);
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
try {
gesture.target.setPointerCapture(gesture.pointerId);
- } catch { /* The pointer may already have ended. */ }
+ } catch {
+ // The pointer may already have ended.
+ }
}, LONG_PRESS_MS);
gestureRef.current = gesture;
- }
+ };
- function handlePointerMove(event: PointerEvent) {
+ const handlePointerMove = (event: PointerEvent) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
gesture.latestY = event.clientY;
@@ -231,27 +230,26 @@ export function ModelPresetBadge({
}
event.preventDefault();
updateMotion(gesture, event.clientY);
- }
+ };
- function finishGesture(event: PointerEvent, commit: boolean) {
+ const 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 (gesture.active) suppressFollowingClick();
if (!commit || !gesture.active) {
- setMotion(null);
+ clearMotion();
return;
}
+ suppressClickRef.current = true;
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);
- }
+ if (selected && selected.name !== activeName) selectPreset(selected.name);
+ };
- function handleKeyDown(event: KeyboardEvent) {
- if (!canSwitch) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
const targetByKey: Record = {
ArrowUp: currentIndex - 1,
ArrowDown: currentIndex + 1,
@@ -262,141 +260,229 @@ export function ModelPresetBadge({
if (target === undefined) return;
event.preventDefault();
const next = presets[wrapIndex(target, presets.length)];
- if (next?.name !== activeName) onPresetChange?.(next.name);
- }
+ if (next?.name !== activeName) selectPreset(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;
- const tooltipLabel = fallbackModelName
- || [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
-
- const badge = (
- {
- 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-fit min-w-0 max-w-[min(18rem,44vw)] 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" : "h-9",
- )}
- >
-
- {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 (
-
- );
- })}
-
-
- ) : null}
-
+ const pill = (
+
);
- if (!tooltipLabel) return badge;
+ if (!canSwitch) {
+ const Container = opensSetup ? "button" : "span";
+ return (
+
+ {pill}
+
+ );
+ }
+
return (
-
-
- {badge}
- {
+ setOpen(nextOpen);
+ if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
+ }}
+ >
+
+
-
-
+ {motion ? (
+ <>
+
+ {pill}
+
+
+ {
+ if (motion.settling && event.currentTarget === event.target) clearMotion();
+ }}
+ style={{
+ paddingTop: isHero ? "10px" : "12px",
+ transform: `translate3d(0, ${-pillStride * (2 + motion.remainder)}px, 0)`,
+ }}
+ >
+ {PILL_OFFSETS.map((offset) => {
+ const preset = presets[wrapIndex(motion.index + offset, presets.length)];
+ return (
+
+ );
+ })}
+
+
+ >
+ ) : pill}
+