From 95796646b128c291fade536bcccf86a4045cdd9a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 12 Jun 2026 00:09:44 -0700 Subject: [PATCH] web: tool stacking ui --- .../components/chat/chat-messages-panel.tsx | 193 +++++++++++++----- web/src/index.css | 94 +++++++-- 2 files changed, 217 insertions(+), 70 deletions(-) diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx index 3cf0c05..2c88ecc 100644 --- a/web/src/components/chat/chat-messages-panel.tsx +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -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(null); + const [motionRevision, setMotionRevision] = useState(0); + const motionResetTimerRef = useRef(null); - if (expanded) { - return ( -
-
- - {messages.map((message, index) => ( - - ))} -
-
- ); - } + 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 (
-
- {visibleStackMessages.map((message, index) => ( - 0 && "pointer-events-none")} - style={getToolStackStyle(index, visibleStackMessages.length)} - /> - ))} - {hiddenCount ? ( +
+ {messages.map((message, index) => { + const depth = messages.length - index - 1; + const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT; + return ( +
+
+ +
+
+ ); + })} + {!expanded && hiddenCount ? ( +{hiddenCount} ) : null}
diff --git a/web/src/index.css b/web/src/index.css index 1a76db8..87543b6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; } }