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 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user