web: tool stacking ui
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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-origin: top left;
|
||||
opacity: var(--tool-stack-opacity, 1);
|
||||
animation: tool-call-stack-in 360ms cubic-bezier(0.18, 0.95, 0.28, 1) both;
|
||||
animation-delay: var(--tool-stack-delay, 0ms);
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 300ms cubic-bezier(0.2, 0.8, 0.22, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.tool-call-stack-expanded-card {
|
||||
animation: tool-call-inline-in 220ms ease-out both;
|
||||
.tool-call-stack-shell-layout-a {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -187,33 +212,72 @@ textarea {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-in {
|
||||
@keyframes tool-call-stack-height-a {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0.85rem, -72px) scale(0.96) rotateX(-8deg);
|
||||
height: var(--tool-stack-from-height);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: var(--tool-stack-opacity, 1);
|
||||
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);
|
||||
height: var(--tool-stack-to-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-inline-in {
|
||||
@keyframes tool-call-stack-height-b {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.35rem);
|
||||
height: var(--tool-stack-from-height);
|
||||
}
|
||||
|
||||
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 {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translate3d(0, 0, 0) scale(1) rotateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-call-stack-card,
|
||||
.tool-call-stack-expanded-card {
|
||||
.tool-call-stack-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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user