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 type { JSX } from "preact";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
@@ -82,9 +82,21 @@ type ToolStackStyle = JSX.CSSProperties & {
"--tool-stack-scale"?: string; "--tool-stack-scale"?: string;
"--tool-stack-opacity"?: string; "--tool-stack-opacity"?: string;
"--tool-stack-delay"?: 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 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 { function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
if (metadata.status === "failed") return "failed"; if (metadata.status === "failed") return "failed";
@@ -130,21 +142,81 @@ function buildMessageRenderItems(messages: Message[]) {
return items; 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 { return {
"--tool-stack-x": `${depth * 9}px`, "--tool-stack-from-height": fromHeight,
"--tool-stack-y": `${depth * 8}px`, "--tool-stack-to-height": targetHeight,
"--tool-stack-z": `${depth * -36}px`, height: targetHeight,
"--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,
}; };
} }
function getExpandedToolStyle(index: number): ToolStackStyle { function getExpandedToolLayout(index: number, messageCount: number) {
const y = `${index * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`;
return { 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; expanded: boolean;
onToggle: (groupKey: string) => void; onToggle: (groupKey: string) => void;
}) { }) {
const visibleStackMessages = messages.slice(-COLLAPSED_TOOL_STACK_LIMIT).reverse(); const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
const hiddenCount = Math.max(0, messages.length - visibleStackMessages.length);
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`; 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) { const handleToggle = () => {
return ( setMotionDirection(expanded ? "collapse" : "expand");
<div className="flex justify-start"> setMotionRevision((current) => current + 1);
<div className="relative flex w-full max-w-[85%] flex-col gap-2.5 pr-5"> if (typeof window !== "undefined") {
<button if (motionResetTimerRef.current !== null) window.clearTimeout(motionResetTimerRef.current);
type="button" motionResetTimerRef.current = window.setTimeout(() => {
className="tool-call-stack-toggle absolute -right-3 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full" setMotionDirection(null);
aria-expanded="true" motionResetTimerRef.current = null;
aria-label={`Collapse ${countLabel}`} }, TOOL_STACK_LAYOUT_ANIMATION_MS + 60);
title={`Collapse ${countLabel}`} }
onClick={() => onToggle(groupKey)} 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>
);
}
return ( return (
<div className="flex justify-start"> <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"> <div
{visibleStackMessages.map((message, index) => ( className={cn(
<ToolCallCard "tool-call-stack-shell relative w-full max-w-[85%] min-w-0 pr-10",
key={message.id} motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-shell-layout-a" : "tool-call-stack-shell-layout-b")
message={message} )}
className={cn("tool-call-stack-card col-start-1 row-start-1 w-full max-w-full", index > 0 && "pointer-events-none")} data-tool-stack-group={groupKey}
style={getToolStackStyle(index, visibleStackMessages.length)} data-expanded={expanded ? "true" : "false"}
/> style={getToolStackContainerStyle(messages.length, expanded, motionDirection)}
))} >
{hiddenCount ? ( {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"> <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} +{hiddenCount}
</span> </span>
) : null} ) : null}
<button <button
type="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" 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="false" aria-expanded={expanded ? "true" : "false"}
aria-label={`Expand ${countLabel}`} aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`Expand ${countLabel}`} title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
onClick={() => onToggle(groupKey)} onClick={handleToggle}
> >
<ChevronDown className="h-4 w-4" /> {expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -150,13 +150,38 @@ textarea {
transform: translate3d(var(--tool-stack-x, 0), var(--tool-stack-y, 0), var(--tool-stack-z, 0)) scale(var(--tool-stack-scale, 1)); transform: translate3d(var(--tool-stack-x, 0), var(--tool-stack-y, 0), var(--tool-stack-z, 0)) scale(var(--tool-stack-scale, 1));
transform-origin: top left; transform-origin: top left;
opacity: var(--tool-stack-opacity, 1); opacity: var(--tool-stack-opacity, 1);
animation: tool-call-stack-in 360ms cubic-bezier(0.18, 0.95, 0.28, 1) both; transition:
animation-delay: var(--tool-stack-delay, 0ms); opacity 180ms ease,
transform 300ms cubic-bezier(0.2, 0.8, 0.22, 1);
will-change: transform, opacity; will-change: transform, opacity;
} }
.tool-call-stack-expanded-card { .tool-call-stack-shell-layout-a {
animation: tool-call-inline-in 220ms ease-out both; animation: tool-call-stack-height-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-shell-layout-b {
animation: tool-call-stack-height-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-card-layout-a {
animation: tool-call-stack-layout-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-card-layout-b {
animation: tool-call-stack-layout-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-card-surface {
transform-origin: top left;
}
.tool-call-stack-card-glass {
backdrop-filter: blur(10px);
}
.tool-call-stack-card-enter {
animation: tool-call-stack-drop-in 320ms cubic-bezier(0.18, 0.95, 0.28, 1) backwards;
animation-delay: var(--tool-stack-delay, 0ms); animation-delay: var(--tool-stack-delay, 0ms);
} }
@@ -187,33 +212,72 @@ textarea {
outline-offset: 2px; outline-offset: 2px;
} }
@keyframes tool-call-stack-in { @keyframes tool-call-stack-height-a {
from { from {
opacity: 0; height: var(--tool-stack-from-height);
transform: translate3d(0, 0.85rem, -72px) scale(0.96) rotateX(-8deg);
} }
to { to {
opacity: var(--tool-stack-opacity, 1); height: var(--tool-stack-to-height);
transform: translate3d(var(--tool-stack-x, 0), var(--tool-stack-y, 0), var(--tool-stack-z, 0)) scale(var(--tool-stack-scale, 1)) rotateX(0);
} }
} }
@keyframes tool-call-inline-in { @keyframes tool-call-stack-height-b {
from { from {
opacity: 0; height: var(--tool-stack-from-height);
transform: translateY(-0.35rem); }
to {
height: var(--tool-stack-to-height);
}
}
@keyframes tool-call-stack-layout-a {
from {
opacity: var(--tool-stack-from-opacity, 1);
transform: var(--tool-stack-from-transform);
}
to {
opacity: var(--tool-stack-to-opacity, 1);
transform: var(--tool-stack-to-transform);
}
}
@keyframes tool-call-stack-layout-b {
from {
opacity: var(--tool-stack-from-opacity, 1);
transform: var(--tool-stack-from-transform);
}
to {
opacity: var(--tool-stack-to-opacity, 1);
transform: var(--tool-stack-to-transform);
}
}
@keyframes tool-call-stack-drop-in {
from {
opacity: 0.72;
transform: translate3d(0, -0.65rem, 120px) scale(1.025) rotateX(3deg);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translate3d(0, 0, 0) scale(1) rotateX(0);
} }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tool-call-stack-card, .tool-call-stack-card {
.tool-call-stack-expanded-card { transition: none;
}
.tool-call-stack-shell-layout-a,
.tool-call-stack-shell-layout-b,
.tool-call-stack-card-layout-a,
.tool-call-stack-card-layout-b,
.tool-call-stack-card-enter {
animation: none; animation: none;
} }
} }