web: tool stacking ui

This commit is contained in:
2026-06-12 00:09:44 -07:00
parent d7214c88ad
commit 95796646b1
2 changed files with 217 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "preact/hooks";
import { useMemo, useRef, useState } from "preact/hooks";
import type { JSX } from "preact";
import { cn } from "@/lib/utils";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
@@ -82,9 +82,21 @@ type ToolStackStyle = JSX.CSSProperties & {
"--tool-stack-scale"?: string;
"--tool-stack-opacity"?: string;
"--tool-stack-delay"?: string;
"--tool-stack-from-transform"?: string;
"--tool-stack-to-transform"?: string;
"--tool-stack-from-opacity"?: string;
"--tool-stack-to-opacity"?: string;
};
type ToolStackContainerStyle = JSX.CSSProperties & {
"--tool-stack-from-height"?: string;
"--tool-stack-to-height"?: string;
};
type ToolStackMotionDirection = "expand" | "collapse" | null;
const COLLAPSED_TOOL_STACK_LIMIT = 4;
const TOOL_STACK_CARD_HEIGHT = 62;
const TOOL_STACK_CARD_GAP = 10;
const TOOL_STACK_LAYOUT_ANIMATION_MS = 340;
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
if (metadata.status === "failed") return "failed";
@@ -130,21 +142,81 @@ function buildMessageRenderItems(messages: Message[]) {
return items;
}
function getToolStackStyle(depth: number, totalVisible: number): ToolStackStyle {
function getToolStackHeight(messageCount: number, expanded: boolean) {
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
return expanded
? `${TOOL_STACK_CARD_HEIGHT + Math.max(0, messageCount - 1) * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`
: `${TOOL_STACK_CARD_HEIGHT + Math.max(0, visibleCount - 1) * TOOL_STACK_CARD_GAP}px`;
}
function getToolStackContainerStyle(messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackContainerStyle {
const collapsedHeight = getToolStackHeight(messageCount, false);
const expandedHeight = getToolStackHeight(messageCount, true);
const targetHeight = expanded ? expandedHeight : collapsedHeight;
const fromHeight = motionDirection === "expand" ? collapsedHeight : motionDirection === "collapse" ? expandedHeight : targetHeight;
return {
"--tool-stack-x": `${depth * 9}px`,
"--tool-stack-y": `${depth * 8}px`,
"--tool-stack-z": `${depth * -36}px`,
"--tool-stack-scale": `${Math.max(0.88, 1 - depth * 0.035)}`,
"--tool-stack-opacity": `${Math.max(0.48, 1 - depth * 0.15)}`,
"--tool-stack-delay": `${depth * 44}ms`,
zIndex: totalVisible - depth,
"--tool-stack-from-height": fromHeight,
"--tool-stack-to-height": targetHeight,
height: targetHeight,
};
}
function getExpandedToolStyle(index: number): ToolStackStyle {
function getExpandedToolLayout(index: number, messageCount: number) {
const y = `${index * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`;
return {
"--tool-stack-delay": `${Math.min(index, 6) * 34}ms`,
opacity: "1",
transform: `translate3d(0px, ${y}, 0px) scale(1)`,
x: "0px",
y,
z: "0px",
scale: "1",
zIndex: messageCount - index,
};
}
function getCollapsedToolLayout(index: number, messageCount: number) {
const depth = messageCount - index - 1;
const visibleDepth = Math.min(depth, COLLAPSED_TOOL_STACK_LIMIT - 1);
const isHidden = depth >= COLLAPSED_TOOL_STACK_LIMIT;
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
const x = `${visibleDepth * 11}px`;
const y = `${visibleDepth * TOOL_STACK_CARD_GAP}px`;
const z = `${visibleDepth * -36}px`;
const scale = `${Math.max(0.88, 1 - visibleDepth * 0.035)}`;
const opacity = isHidden ? "0" : `${Math.max(0.34, 1 - visibleDepth * 0.22)}`;
return {
opacity,
transform: `translate3d(${x}, ${y}, ${z}) scale(${scale})`,
x,
y,
z,
scale,
zIndex: isHidden ? 0 : visibleCount - visibleDepth,
};
}
function getToolStackStyle(index: number, messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackStyle {
const expandedLayout = getExpandedToolLayout(index, messageCount);
const collapsedLayout = getCollapsedToolLayout(index, messageCount);
const targetLayout = expanded ? expandedLayout : collapsedLayout;
const fromLayout = motionDirection === "expand" ? collapsedLayout : motionDirection === "collapse" ? expandedLayout : targetLayout;
return {
"--tool-stack-x": targetLayout.x,
"--tool-stack-y": targetLayout.y,
"--tool-stack-z": targetLayout.z,
"--tool-stack-scale": targetLayout.scale,
"--tool-stack-opacity": targetLayout.opacity,
"--tool-stack-delay": `${Math.min(messageCount - index - 1, COLLAPSED_TOOL_STACK_LIMIT - 1) * 34}ms`,
"--tool-stack-from-transform": fromLayout.transform,
"--tool-stack-to-transform": targetLayout.transform,
"--tool-stack-from-opacity": fromLayout.opacity,
"--tool-stack-to-opacity": targetLayout.opacity,
opacity: targetLayout.opacity,
transform: targetLayout.transform,
zIndex: targetLayout.zIndex,
};
}
@@ -219,62 +291,73 @@ function ToolCallStack({
expanded: boolean;
onToggle: (groupKey: string) => void;
}) {
const visibleStackMessages = messages.slice(-COLLAPSED_TOOL_STACK_LIMIT).reverse();
const hiddenCount = Math.max(0, messages.length - visibleStackMessages.length);
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
const [motionDirection, setMotionDirection] = useState<ToolStackMotionDirection>(null);
const [motionRevision, setMotionRevision] = useState(0);
const motionResetTimerRef = useRef<number | null>(null);
if (expanded) {
return (
<div className="flex justify-start">
<div className="relative flex w-full max-w-[85%] flex-col gap-2.5 pr-5">
<button
type="button"
className="tool-call-stack-toggle absolute -right-3 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
aria-expanded="true"
aria-label={`Collapse ${countLabel}`}
title={`Collapse ${countLabel}`}
onClick={() => onToggle(groupKey)}
>
<ChevronUp className="h-4 w-4" />
</button>
{messages.map((message, index) => (
<ToolCallCard
key={message.id}
message={message}
className="tool-call-stack-expanded-card w-full max-w-full"
style={getExpandedToolStyle(index)}
/>
))}
</div>
</div>
);
}
const handleToggle = () => {
setMotionDirection(expanded ? "collapse" : "expand");
setMotionRevision((current) => current + 1);
if (typeof window !== "undefined") {
if (motionResetTimerRef.current !== null) window.clearTimeout(motionResetTimerRef.current);
motionResetTimerRef.current = window.setTimeout(() => {
setMotionDirection(null);
motionResetTimerRef.current = null;
}, TOOL_STACK_LAYOUT_ANIMATION_MS + 60);
}
onToggle(groupKey);
};
return (
<div className="flex justify-start">
<div className="tool-call-stack-shell relative inline-grid w-full max-w-[85%] min-w-0 pb-6 pr-9">
{visibleStackMessages.map((message, index) => (
<ToolCallCard
key={message.id}
message={message}
className={cn("tool-call-stack-card col-start-1 row-start-1 w-full max-w-full", index > 0 && "pointer-events-none")}
style={getToolStackStyle(index, visibleStackMessages.length)}
/>
))}
{hiddenCount ? (
<div
className={cn(
"tool-call-stack-shell relative w-full max-w-[85%] min-w-0 pr-10",
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-shell-layout-a" : "tool-call-stack-shell-layout-b")
)}
data-tool-stack-group={groupKey}
data-expanded={expanded ? "true" : "false"}
style={getToolStackContainerStyle(messages.length, expanded, motionDirection)}
>
{messages.map((message, index) => {
const depth = messages.length - index - 1;
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
return (
<div
key={message.id}
className={cn(
"tool-call-stack-card absolute left-0 right-10 top-0 w-auto max-w-none",
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-card-layout-a" : "tool-call-stack-card-layout-b"),
isHidden && "pointer-events-none"
)}
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
aria-hidden={isHidden ? "true" : undefined}
>
<div
className={cn("tool-call-stack-card-surface", !isHidden && "tool-call-stack-card-enter")}
data-tool-stack-card-id={message.id}
>
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
</div>
</div>
);
})}
{!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount}
</span>
) : null}
<button
type="button"
className="tool-call-stack-toggle absolute -right-3 top-1/2 z-20 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
aria-expanded="false"
aria-label={`Expand ${countLabel}`}
title={`Expand ${countLabel}`}
onClick={() => onToggle(groupKey)}
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
onClick={handleToggle}
>
<ChevronDown className="h-4 w-4" />
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
</div>
</div>