feat(webui): unify turn observability
This commit is contained in:
@@ -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({
|
||||
<ThinkingReasoningShell
|
||||
active={isTurnStreaming}
|
||||
expanded={outerExpanded}
|
||||
label={thoughtLabel}
|
||||
label={activityLabel}
|
||||
viewportRef={activityScrollRef}
|
||||
contentRef={activityContentRef}
|
||||
fadeTop={activityScrollFade.top}
|
||||
@@ -352,6 +348,7 @@ export function AgentActivityCluster({
|
||||
active={isTurnStreaming}
|
||||
cliAppsByName={cliAppsByName}
|
||||
mcpPresetsByName={mcpPresetsByName}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
@@ -417,15 +414,28 @@ function ActivityMessageTimeline({
|
||||
active,
|
||||
cliAppsByName,
|
||||
mcpPresetsByName,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
active: boolean;
|
||||
cliAppsByName: Map<string, CliAppInfo>;
|
||||
mcpPresetsByName: Map<string, McpPresetInfo>;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (message.activityKind === "model") {
|
||||
items.push(
|
||||
<ActivityModelMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
active={active}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isReasoningOnlyAssistant(message)) {
|
||||
items.push(
|
||||
<ReasoningRow
|
||||
@@ -451,6 +461,40 @@ function ActivityMessageTimeline({
|
||||
return <>{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 (
|
||||
<div
|
||||
data-testid="activity-model-message"
|
||||
data-assistant-selectable={active && message.isStreaming ? undefined : "true"}
|
||||
className="w-full min-w-0 py-1 text-[15px]"
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={active && !!message.isStreaming}
|
||||
preserveStreamingLayout
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTraceList({
|
||||
lines,
|
||||
active,
|
||||
|
||||
@@ -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<PresetMotion | null>(null);
|
||||
const [motionWidth, setMotionWidth] = useState<number | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(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<PresetMotion | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(null);
|
||||
const clickAnimationFrameRef = useRef<number | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const suppressClickTimerRef = useRef<number | null>(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<HTMLElement>) {
|
||||
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
|
||||
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
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<HTMLElement>) {
|
||||
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
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<HTMLElement>, commit: boolean) {
|
||||
const finishGesture = (event: PointerEvent<HTMLButtonElement>, 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<HTMLElement>) {
|
||||
if (!canSwitch) return;
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const targetByKey: Record<string, number> = {
|
||||
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 = (
|
||||
<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?.name || label : undefined}
|
||||
role={canSwitch ? "spinbutton" : undefined}
|
||||
tabIndex={!interactive && !canSwitch ? 0 : undefined}
|
||||
type={interactive || canSwitch ? "button" : undefined}
|
||||
onClick={interactive || canSwitch ? handleClick : 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-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",
|
||||
)}
|
||||
>
|
||||
<PresetPill
|
||||
className={motion && "invisible"}
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={provider}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
/>
|
||||
{motion ? (
|
||||
<span
|
||||
data-testid="composer-model-pill-viewport"
|
||||
className={cn(
|
||||
"composer-model-pill-viewport pointer-events-none absolute right-0 w-max max-w-[calc(44vw+0.5rem)] overflow-hidden bg-transparent pl-2 sm:max-w-[18.5rem]",
|
||||
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-max max-w-full 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.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Container>
|
||||
const pill = (
|
||||
<PresetPill
|
||||
label={displayLabel}
|
||||
modelDetail={displayModelDetail}
|
||||
provider={displayProvider}
|
||||
providerLabel={fallbackModelName ? null : providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
fallbackFromLabel={fallbackModelName ? label : null}
|
||||
isHero={isHero}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltipLabel) return badge;
|
||||
if (!canSwitch) {
|
||||
const Container = opensSetup ? "button" : "span";
|
||||
return (
|
||||
<Container
|
||||
aria-label={fallbackModelName ? `${displayLabel} (fallback from ${label})` : label}
|
||||
type={opensSetup ? "button" : undefined}
|
||||
onClick={opensSetup ? onClick : undefined}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] appearance-none border-0 bg-transparent p-0 shadow-none",
|
||||
opensSetup && "cursor-pointer focus-visible:outline-none",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
{pill}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="max-w-[min(24rem,calc(100vw-2rem))] break-all"
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-switching={motion ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
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()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClickCapture={(event) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: "manipulation",
|
||||
width: motionWidth ? `${motionWidth}px` : undefined,
|
||||
}}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] cursor-pointer appearance-none border-0 bg-transparent p-0 shadow-none focus-visible:outline-none",
|
||||
motion && "z-10 cursor-grabbing",
|
||||
!motion && "cursor-grab",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{motion ? (
|
||||
<>
|
||||
<span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
|
||||
{pill}
|
||||
</span>
|
||||
<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) 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 (
|
||||
<PresetPill
|
||||
key={motion.index + offset}
|
||||
label={preset.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={motion.settling ? 1 : dockScale(offset - motion.remainder)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : pill}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
role="dialog"
|
||||
aria-label={switchModelLabel}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
const content = event.currentTarget;
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const selected = content.querySelector<HTMLElement>(
|
||||
'[role="option"][aria-selected="true"]',
|
||||
);
|
||||
selected?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
pickerWidthClassName,
|
||||
"origin-[var(--radix-popover-content-transform-origin)] p-1.5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1 data-[state=open]:slide-in-from-bottom-1 duration-200 ease-out motion-reduce:animate-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={switchModelLabel}
|
||||
className="max-h-[min(16rem,var(--radix-popover-content-available-height))] overflow-y-auto py-1 scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{presets.map((preset) => (
|
||||
<PresetOption
|
||||
key={preset.name}
|
||||
preset={preset}
|
||||
selected={preset.name === activeName}
|
||||
onSelect={selectPreset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetOption({
|
||||
preset,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
preset: ModelPresetOption;
|
||||
selected: boolean;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const detail = compactModelName(preset.model);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={preset.name}
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(preset.name)}
|
||||
className={cn(
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
"flex min-h-9 w-full cursor-pointer gap-2.5 px-2.5 py-1.5 text-left hover:bg-muted/55",
|
||||
selected && "bg-muted/55 text-foreground",
|
||||
)}
|
||||
>
|
||||
<PresetProviderIcon
|
||||
label={preset.name}
|
||||
modelDetail={detail}
|
||||
provider={preset.provider}
|
||||
isHero={false}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 text-[13px] font-medium text-foreground">{preset.name}</span>
|
||||
{detail && detail !== preset.name ? (
|
||||
<span className="truncate text-[12px] text-muted-foreground">{detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{selected ? <Check className="h-4 w-4 shrink-0 text-foreground/80" aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetPill({
|
||||
className,
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup = false,
|
||||
fallbackModelName,
|
||||
fallbackFromLabel,
|
||||
isHero,
|
||||
offset,
|
||||
scale,
|
||||
}: {
|
||||
className?: string | false | null;
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
fallbackFromLabel?: string | null;
|
||||
isHero: boolean;
|
||||
offset?: number;
|
||||
scale?: number;
|
||||
@@ -406,13 +492,10 @@ function PresetPill({
|
||||
const inferredProvider = needsSetup
|
||||
? null
|
||||
: provider || inferProviderFromModelName(modelDetail || label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
const logoTestId = offset !== undefined
|
||||
? undefined
|
||||
: needsSetup
|
||||
? "composer-model-setup-icon"
|
||||
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
|
||||
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
||||
const fallbackTitle = fallbackModelName
|
||||
? `${fallbackFromLabel || label} · using ${fallbackModelName}`
|
||||
: title;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = labelRef.current;
|
||||
@@ -428,14 +511,14 @@ function PresetPill({
|
||||
<span
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
data-preset-offset={offset}
|
||||
title={fallbackTitle || 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-medium text-foreground/70",
|
||||
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",
|
||||
"composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||
"w-fit",
|
||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible: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",
|
||||
className,
|
||||
)}
|
||||
style={scale === undefined ? undefined : {
|
||||
height: `${isHero ? 32 : 36}px`,
|
||||
@@ -443,46 +526,14 @@ function PresetPill({
|
||||
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>
|
||||
<PresetProviderIcon
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={inferredProvider}
|
||||
needsSetup={needsSetup}
|
||||
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
|
||||
isHero={isHero}
|
||||
/>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn(
|
||||
@@ -495,3 +546,63 @@ function PresetPill({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetProviderIcon({
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
needsSetup = false,
|
||||
testId,
|
||||
isHero,
|
||||
}: {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
needsSetup?: boolean;
|
||||
testId?: string;
|
||||
isHero: boolean;
|
||||
}) {
|
||||
const inferredProvider = needsSetup
|
||||
? null
|
||||
: provider || inferProviderFromModelName(modelDetail || label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center",
|
||||
needsSetup && "text-amber-800 dark:text-amber-200",
|
||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||
)}
|
||||
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.5 w-3.5" : "h-[18px] w-[18px]")}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
readDraggedSession,
|
||||
} from "@/lib/session-drag";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { formatCompactTokenCount } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||
@@ -178,6 +179,107 @@ function getVoiceShortcutLabel(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComposerContextUsage {
|
||||
contextTokens: number;
|
||||
contextWindowTokens?: number;
|
||||
}
|
||||
|
||||
function ComposerContextBadge({ usage }: { usage: ComposerContextUsage | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!usage || !Number.isFinite(usage.contextTokens) || usage.contextTokens < 0) return null;
|
||||
|
||||
const context = formatCompactTokenCount(usage.contextTokens);
|
||||
const contextWindow = typeof usage.contextWindowTokens === "number"
|
||||
&& Number.isFinite(usage.contextWindowTokens)
|
||||
&& usage.contextWindowTokens > 0
|
||||
? usage.contextWindowTokens
|
||||
: null;
|
||||
const capacity = contextWindow !== null
|
||||
? ` / ${formatCompactTokenCount(contextWindow)}`
|
||||
: "";
|
||||
// The meter is meaningful only with a known capacity. Do not turn an
|
||||
// otherwise unknown total into a second, text-heavy control beside the
|
||||
// model picker.
|
||||
if (contextWindow === null) return null;
|
||||
const percentage = Math.min(100, Math.round(usage.contextTokens / contextWindow * 100));
|
||||
const status = percentage >= 90
|
||||
? "critical"
|
||||
: percentage >= 75
|
||||
? "caution"
|
||||
: "normal";
|
||||
const contextDescription = t("thread.composer.context.tooltip", {
|
||||
defaultValue: "Context · {{tokens}}{{capacity}}",
|
||||
tokens: context,
|
||||
capacity,
|
||||
});
|
||||
const meterDescription = t("thread.composer.context.meterDescription", {
|
||||
defaultValue: "{{context}}. {{percent}}% used.",
|
||||
context: contextDescription,
|
||||
percent: percentage,
|
||||
});
|
||||
const ringCircumference = 2 * Math.PI * 6;
|
||||
const ringLength = ringCircumference * percentage / 100;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="composer-context-usage"
|
||||
aria-label={meterDescription}
|
||||
className={cn(
|
||||
"inline-flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground/75 transition-colors hover:text-foreground/85",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-[15px] shrink-0 -rotate-90",
|
||||
status === "critical" && "text-destructive",
|
||||
status === "caution" && "text-amber-600 dark:text-amber-400",
|
||||
status === "normal" && "text-muted-foreground/75",
|
||||
)}
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${ringLength} ${ringCircumference}`}
|
||||
data-testid="composer-context-meter"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
className="w-fit max-w-[calc(100vw-2rem)] rounded-full border-border/70 px-2.5 py-1 text-xs font-medium shadow-[0_8px_24px_rgba(15,23,42,0.13)]"
|
||||
>
|
||||
<span className="whitespace-nowrap tabular-nums">{contextDescription}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadComposerProps {
|
||||
onSend: (
|
||||
content: string,
|
||||
@@ -198,6 +300,7 @@ interface ThreadComposerProps {
|
||||
modelNeedsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
onModelBadgeClick?: () => void;
|
||||
contextUsage?: ComposerContextUsage | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
@@ -895,6 +998,7 @@ export function ThreadComposer({
|
||||
modelNeedsSetup = false,
|
||||
fallbackModelName = null,
|
||||
onModelBadgeClick,
|
||||
contextUsage = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
@@ -2435,6 +2539,7 @@ export function ThreadComposer({
|
||||
modelPreset={modelPreset}
|
||||
modelPresets={modelPresets}
|
||||
onPresetChange={onModelPresetChange}
|
||||
onRequestComposerFocus={() => textareaRef.current?.focus()}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
@@ -2443,6 +2548,7 @@ export function ThreadComposer({
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
|
||||
{showVoiceButton ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
@@ -29,10 +29,9 @@ export type DisplayUnit = TurnUnit;
|
||||
export function buildDisplayUnits(
|
||||
messages: UIMessage[],
|
||||
isStreaming = false,
|
||||
activeTurnId: string | null = null,
|
||||
): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages, {
|
||||
preserveTrailingActivity: isStreaming,
|
||||
});
|
||||
return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
|
||||
}
|
||||
|
||||
export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
||||
@@ -69,7 +68,10 @@ export function ThreadMessages({
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const messageListRef = useRef<HTMLDivElement>(null);
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const units = useMemo(
|
||||
() => buildDisplayUnits(messages, isStreaming, activeTurnId),
|
||||
[activeTurnId, isStreaming, messages],
|
||||
);
|
||||
const forkBoundaryAfterUnitIndex = useMemo(
|
||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||
[forkBoundaryMessageCount, units],
|
||||
|
||||
@@ -8,7 +8,10 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import {
|
||||
ThreadComposer,
|
||||
type ComposerContextUsage,
|
||||
} from "@/components/thread/ThreadComposer";
|
||||
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
@@ -83,6 +86,30 @@ function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function latestComposerContextUsage(messages: UIMessage[]): ComposerContextUsage | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const contextTokens = message.usage?.context_tokens;
|
||||
if (
|
||||
message.role !== "assistant"
|
||||
|| message.kind === "trace"
|
||||
|| message.isStreaming
|
||||
|| typeof contextTokens !== "number"
|
||||
|| !Number.isFinite(contextTokens)
|
||||
|| contextTokens < 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
contextTokens,
|
||||
...(typeof message.contextWindowTokens === "number"
|
||||
? { contextWindowTokens: message.contextWindowTokens }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function snapshotPreservesMessage(
|
||||
current: MessageShape,
|
||||
candidate: MessageShape,
|
||||
@@ -400,7 +427,7 @@ function toModelBadgeInfo(
|
||||
);
|
||||
return {
|
||||
label,
|
||||
model: model?.trim() || null,
|
||||
model: toModelBadgeLabel(model),
|
||||
provider,
|
||||
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
||||
needsSetup,
|
||||
@@ -803,8 +830,12 @@ export function ThreadShell({
|
||||
}, []);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const composerContextUsage = useMemo(
|
||||
() => latestComposerContextUsage(displayMessages),
|
||||
[displayMessages],
|
||||
);
|
||||
const currentGoalState = messagesReady ? goalState : undefined;
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||
const restoredViewportTurnId = useMemo(
|
||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||
@@ -882,9 +913,17 @@ export function ThreadShell({
|
||||
useEffect(() => {
|
||||
setLocalModelPreset(null);
|
||||
}, [session?.key, sessionModelPreset]);
|
||||
const configuredPresetNames = useMemo(
|
||||
() => new Set(settings?.model_presets.map((preset) => preset.name) ?? []),
|
||||
[settings],
|
||||
);
|
||||
const activeModelPreset = (
|
||||
localModelPreset
|
||||
|| sessionModelPreset
|
||||
(localModelPreset && (!settings || configuredPresetNames.has(localModelPreset))
|
||||
? localModelPreset
|
||||
: null)
|
||||
|| (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset))
|
||||
? sessionModelPreset
|
||||
: null)
|
||||
|| settings?.agent.model_preset
|
||||
|| "default"
|
||||
);
|
||||
@@ -958,13 +997,10 @@ export function ThreadShell({
|
||||
}
|
||||
setFallbackModelName(null);
|
||||
return client.onChat(chatId, (event) => {
|
||||
if (event.event !== "turn_model_updated") return;
|
||||
const activeModel = event.model_name.trim();
|
||||
setFallbackModelName(
|
||||
modelBadge.model && activeModel !== modelBadge.model ? activeModel : null,
|
||||
);
|
||||
if (event.event !== "turn_model_updated" || event.fallback !== true) return;
|
||||
setFallbackModelName(event.model_name);
|
||||
});
|
||||
}, [chatId, client, modelBadge.model]);
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyKey || !chatId || loading) return;
|
||||
@@ -1454,7 +1490,7 @@ export function ThreadShell({
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
||||
modelDetail={modelBadge.model}
|
||||
modelPreset={activeModelPreset}
|
||||
modelPresets={modelPresetOptions}
|
||||
onModelPresetChange={handleModelPresetChange}
|
||||
@@ -1463,6 +1499,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
contextUsage={composerContextUsage}
|
||||
variant={composerVariant}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
@@ -1501,7 +1538,7 @@ export function ThreadShell({
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
||||
modelDetail={modelBadge.model}
|
||||
modelPreset={activeModelPreset}
|
||||
modelPresets={modelPresetOptions}
|
||||
onModelPresetChange={handleModelPresetChange}
|
||||
@@ -1510,6 +1547,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
contextUsage={composerContextUsage}
|
||||
variant="hero"
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
|
||||
Reference in New Issue
Block a user