From 27c425f6646a01135e137e97d6cc0ee79b3261cf Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 14 Jun 2026 19:10:56 -0700 Subject: [PATCH] supposedly better tool call animation --- .../Sybil/SybilChatTranscriptView.swift | 59 ++++++++++++++-- .../Sybil/Sources/Sybil/SybilTheme.swift | 12 ++-- .../components/chat/chat-messages-panel.tsx | 70 ++++++++++++++++--- web/src/index.css | 2 +- 4 files changed, 119 insertions(+), 24 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 4ffb697..4325236 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -9,10 +9,23 @@ struct SybilChatTranscriptView: View { var bottomContentInset: CGFloat = 0 var bottomPinRequestID: Int = 0 + @State private var hasTrackedToolCallMessages = false + @State private var knownToolCallMessageIDs: Set = [] + private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor" private var renderItems: [TranscriptRenderItem] { buildTranscriptRenderItems(from: messages) } + private var toolCallMessageIDs: Set { + Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id }) + } + private var enteringToolCallMessageIDs: Set { + guard hasTrackedToolCallMessages else { return [] } + return toolCallMessageIDs.subtracting(knownToolCallMessageIDs) + } + private var toolCallMessageIDSignature: String { + toolCallMessageIDs.sorted().joined(separator: "|") + } var body: some View { ScrollViewReader { proxy in @@ -31,7 +44,11 @@ struct SybilChatTranscriptView: View { MessageBubble(message: message, isSending: isSending) .frame(maxWidth: .infinity) case let .toolGroup(id, messages): - ToolCallStackView(groupID: id, messages: messages) + ToolCallStackView( + groupID: id, + messages: messages, + entryAnimationIDs: enteringToolCallMessageIDs + ) .frame(maxWidth: .infinity) .id(id) } @@ -48,8 +65,12 @@ struct SybilChatTranscriptView: View { .frame(maxWidth: .infinity, alignment: .leading) .scrollDismissesKeyboard(.interactively) .onAppear { + syncKnownToolCallMessageIDs() scrollToBottom(with: proxy, animated: false) } + .onChange(of: toolCallMessageIDSignature) { _, _ in + syncKnownToolCallMessageIDs() + } .onChange(of: bottomPinRequestID) { _, _ in scrollToBottom(with: proxy, animated: true) } @@ -67,6 +88,12 @@ struct SybilChatTranscriptView: View { action() } } + + private func syncKnownToolCallMessageIDs() { + guard !toolCallMessageIDs.isEmpty else { return } + knownToolCallMessageIDs.formUnion(toolCallMessageIDs) + hasTrackedToolCallMessages = true + } } enum TranscriptRenderItem: Identifiable { @@ -216,6 +243,7 @@ private struct ToolCallStackView: View { var groupID: String var messages: [Message] + var entryAnimationIDs: Set @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isExpanded = false @@ -262,8 +290,14 @@ private struct ToolCallStackView: View { let layout = layout(for: index) let depth = messages.count - index - 1 let isHidden = !isExpanded && depth >= visibleCollapsedLimit + let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden - ToolCallStackCard(message: message, cardHeight: cardHeight, compactLayout: true) + ToolCallStackCard( + message: message, + cardHeight: cardHeight, + compactLayout: true, + animateEntry: shouldAnimateEntry + ) .frame(width: cardWidth, height: cardHeight, alignment: .topLeading) .scaleEffect(layout.scale, anchor: .topLeading) .opacity(layout.opacity) @@ -362,10 +396,16 @@ private struct ToolCallStackCard: View { var message: Message var cardHeight: CGFloat var compactLayout: Bool + var animateEntry: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var entryAnimationArmed = false @State private var didEnter = false + private var isPreparingEntry: Bool { + (animateEntry || entryAnimationArmed) && !didEnter + } + var body: some View { Group { if let metadata = message.toolCallMetadata { @@ -378,12 +418,17 @@ private struct ToolCallStackCard: View { } } .frame(height: cardHeight, alignment: .top) - .scaleEffect(didEnter ? 1 : 1.025, anchor: .topLeading) - .offset(y: didEnter ? 0 : -8) - .rotation3DEffect(.degrees(didEnter ? 0 : 3), axis: (x: 1, y: 0, z: 0), anchor: .top) - .opacity(didEnter ? 1 : 0.72) + .scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading) + .offset(y: isPreparingEntry ? -8 : 0) + .rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top) + .opacity(isPreparingEntry ? 0.72 : 1) .onAppear { - guard !didEnter else { return } + guard !didEnter, !entryAnimationArmed else { return } + guard animateEntry else { + didEnter = true + return + } + entryAnimationArmed = true if reduceMotion { didEnter = true } else { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift index 85a06d4..256bea9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift @@ -179,8 +179,8 @@ enum SybilTheme { static var toolCallGradient: LinearGradient { LinearGradient( colors: [ - Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70), - Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78) + Color(red: 0.01, green: 0.15, blue: 0.17), + Color(red: 0.03, green: 0.09, blue: 0.15) ], startPoint: .leading, endPoint: .trailing @@ -190,8 +190,8 @@ enum SybilTheme { static var runningToolCallGradient: LinearGradient { LinearGradient( colors: [ - Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72), - Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78) + Color(red: 0.30, green: 0.19, blue: 0.04), + Color(red: 0.09, green: 0.05, blue: 0.17) ], startPoint: .leading, endPoint: .trailing @@ -201,8 +201,8 @@ enum SybilTheme { static var failedToolCallGradient: LinearGradient { LinearGradient( colors: [ - danger.opacity(0.18), - Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72) + Color(red: 0.27, green: 0.04, blue: 0.10), + Color(red: 0.15, green: 0.03, blue: 0.07) ], startPoint: .leading, endPoint: .trailing diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx index 2c88ecc..0ba25f8 100644 --- a/web/src/components/chat/chat-messages-panel.tsx +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -1,5 +1,5 @@ -import { useMemo, useRef, useState } from "preact/hooks"; -import type { JSX } from "preact"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import type { ComponentChildren, JSX } from "preact"; import { cn } from "@/lib/utils"; import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { getMessageAttachments, type Message } from "@/lib/api"; @@ -142,6 +142,14 @@ function buildMessageRenderItems(messages: Message[]) { return items; } +function getToolCallMessageIDs(messages: Message[]) { + const ids = new Set(); + for (const message of messages) { + if (message.role === "tool" && asToolLogMetadata(message.metadata)) ids.add(message.id); + } + return ids; +} + function getToolStackHeight(messageCount: number, expanded: boolean) { const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT); return expanded @@ -246,10 +254,10 @@ function ToolCallCard({ className={cn( "inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]", isFailed - ? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]" + ? "border-rose-400/44 bg-[linear-gradient(90deg,hsl(350_64%_20%),hsl(342_58%_9%))]" : isInitiated - ? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]" - : "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]", + ? "border-amber-300/44 bg-[linear-gradient(90deg,hsl(43_72%_20%),hsl(260_48%_13%))]" + : "border-cyan-400/44 bg-[linear-gradient(90deg,hsl(184_82%_14%),hsl(208_66%_10%))]", className )} style={style} @@ -280,15 +288,40 @@ function ToolCallCard({ ); } +function ToolCallStackCardSurface({ + messageID, + animateEntry, + isHidden, + children, +}: { + messageID: string; + animateEntry: boolean; + isHidden: boolean; + children: ComponentChildren; +}) { + const [shouldAnimateEntry] = useState(() => animateEntry); + + return ( +
+ {children} +
+ ); +} + function ToolCallStack({ groupKey, messages, expanded, + entryMessageIDs, onToggle, }: { groupKey: string; messages: Message[]; expanded: boolean; + entryMessageIDs: Set; onToggle: (groupKey: string) => void; }) { const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT); @@ -324,6 +357,7 @@ function ToolCallStack({ {messages.map((message, index) => { const depth = messages.length - index - 1; const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT; + const shouldAnimateEntry = entryMessageIDs.has(message.id) && !isHidden; return (
-
+ -
+
); })} @@ -367,8 +398,26 @@ function ToolCallStack({ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0); const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]); + const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]); + const seenToolCallMessageIDsRef = useRef | null>(null); + const entryToolCallMessageIDs = useMemo(() => { + const seenIDs = seenToolCallMessageIDsRef.current; + if (!seenIDs) return new Set(); + const entryIDs = new Set(); + for (const id of toolCallMessageIDs) { + if (!seenIDs.has(id)) entryIDs.add(id); + } + return entryIDs; + }, [toolCallMessageIDs]); const [expandedToolGroups, setExpandedToolGroups] = useState>(() => new Set()); + useEffect(() => { + if (!toolCallMessageIDs.size) return; + const seenIDs = seenToolCallMessageIDsRef.current ?? new Set(); + for (const id of toolCallMessageIDs) seenIDs.add(id); + seenToolCallMessageIDsRef.current = seenIDs; + }, [toolCallMessageIDs]); + const toggleToolGroup = (groupKey: string) => { setExpandedToolGroups((current) => { const next = new Set(current); @@ -390,6 +439,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { groupKey={item.key} messages={item.messages} expanded={expandedToolGroups.has(item.key)} + entryMessageIDs={entryToolCallMessageIDs} onToggle={toggleToolGroup} /> ); diff --git a/web/src/index.css b/web/src/index.css index 87543b6..1fa41b0 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -177,7 +177,7 @@ textarea { } .tool-call-stack-card-glass { - backdrop-filter: blur(10px); + backdrop-filter: none; } .tool-call-stack-card-enter {