supposedly better tool call animation

This commit is contained in:
2026-06-14 19:10:56 -07:00
parent 297b053a91
commit 27c425f664
4 changed files with 119 additions and 24 deletions

View File

@@ -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<String> = []
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
private var renderItems: [TranscriptRenderItem] {
buildTranscriptRenderItems(from: messages)
}
private var toolCallMessageIDs: Set<String> {
Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id })
}
private var enteringToolCallMessageIDs: Set<String> {
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<String>
@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 {

View File

@@ -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

View File

@@ -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<string>();
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 (
<div
className={cn("tool-call-stack-card-surface", shouldAnimateEntry && !isHidden && "tool-call-stack-card-enter")}
data-tool-stack-card-id={messageID}
>
{children}
</div>
);
}
function ToolCallStack({
groupKey,
messages,
expanded,
entryMessageIDs,
onToggle,
}: {
groupKey: string;
messages: Message[];
expanded: boolean;
entryMessageIDs: Set<string>;
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 (
<div
key={message.id}
@@ -335,12 +369,9 @@ function ToolCallStack({
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}
>
<ToolCallStackCardSurface messageID={message.id} animateEntry={shouldAnimateEntry} isHidden={isHidden}>
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
</div>
</ToolCallStackCardSurface>
</div>
);
})}
@@ -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<Set<string> | null>(null);
const entryToolCallMessageIDs = useMemo(() => {
const seenIDs = seenToolCallMessageIDsRef.current;
if (!seenIDs) return new Set<string>();
const entryIDs = new Set<string>();
for (const id of toolCallMessageIDs) {
if (!seenIDs.has(id)) entryIDs.add(id);
}
return entryIDs;
}, [toolCallMessageIDs]);
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (!toolCallMessageIDs.size) return;
const seenIDs = seenToolCallMessageIDsRef.current ?? new Set<string>();
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}
/>
);

View File

@@ -177,7 +177,7 @@ textarea {
}
.tool-call-stack-card-glass {
backdrop-filter: blur(10px);
backdrop-filter: none;
}
.tool-call-stack-card-enter {