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 bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 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 let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
private var renderItems: [TranscriptRenderItem] { private var renderItems: [TranscriptRenderItem] {
buildTranscriptRenderItems(from: messages) 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 { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
@@ -31,7 +44,11 @@ struct SybilChatTranscriptView: View {
MessageBubble(message: message, isSending: isSending) MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
case let .toolGroup(id, messages): case let .toolGroup(id, messages):
ToolCallStackView(groupID: id, messages: messages) ToolCallStackView(
groupID: id,
messages: messages,
entryAnimationIDs: enteringToolCallMessageIDs
)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.id(id) .id(id)
} }
@@ -48,8 +65,12 @@ struct SybilChatTranscriptView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onAppear { .onAppear {
syncKnownToolCallMessageIDs()
scrollToBottom(with: proxy, animated: false) scrollToBottom(with: proxy, animated: false)
} }
.onChange(of: toolCallMessageIDSignature) { _, _ in
syncKnownToolCallMessageIDs()
}
.onChange(of: bottomPinRequestID) { _, _ in .onChange(of: bottomPinRequestID) { _, _ in
scrollToBottom(with: proxy, animated: true) scrollToBottom(with: proxy, animated: true)
} }
@@ -67,6 +88,12 @@ struct SybilChatTranscriptView: View {
action() action()
} }
} }
private func syncKnownToolCallMessageIDs() {
guard !toolCallMessageIDs.isEmpty else { return }
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
hasTrackedToolCallMessages = true
}
} }
enum TranscriptRenderItem: Identifiable { enum TranscriptRenderItem: Identifiable {
@@ -216,6 +243,7 @@ private struct ToolCallStackView: View {
var groupID: String var groupID: String
var messages: [Message] var messages: [Message]
var entryAnimationIDs: Set<String>
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false @State private var isExpanded = false
@@ -262,8 +290,14 @@ private struct ToolCallStackView: View {
let layout = layout(for: index) let layout = layout(for: index)
let depth = messages.count - index - 1 let depth = messages.count - index - 1
let isHidden = !isExpanded && depth >= visibleCollapsedLimit 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) .frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
.scaleEffect(layout.scale, anchor: .topLeading) .scaleEffect(layout.scale, anchor: .topLeading)
.opacity(layout.opacity) .opacity(layout.opacity)
@@ -362,10 +396,16 @@ private struct ToolCallStackCard: View {
var message: Message var message: Message
var cardHeight: CGFloat var cardHeight: CGFloat
var compactLayout: Bool var compactLayout: Bool
var animateEntry: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var entryAnimationArmed = false
@State private var didEnter = false @State private var didEnter = false
private var isPreparingEntry: Bool {
(animateEntry || entryAnimationArmed) && !didEnter
}
var body: some View { var body: some View {
Group { Group {
if let metadata = message.toolCallMetadata { if let metadata = message.toolCallMetadata {
@@ -378,12 +418,17 @@ private struct ToolCallStackCard: View {
} }
} }
.frame(height: cardHeight, alignment: .top) .frame(height: cardHeight, alignment: .top)
.scaleEffect(didEnter ? 1 : 1.025, anchor: .topLeading) .scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
.offset(y: didEnter ? 0 : -8) .offset(y: isPreparingEntry ? -8 : 0)
.rotation3DEffect(.degrees(didEnter ? 0 : 3), axis: (x: 1, y: 0, z: 0), anchor: .top) .rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
.opacity(didEnter ? 1 : 0.72) .opacity(isPreparingEntry ? 0.72 : 1)
.onAppear { .onAppear {
guard !didEnter else { return } guard !didEnter, !entryAnimationArmed else { return }
guard animateEntry else {
didEnter = true
return
}
entryAnimationArmed = true
if reduceMotion { if reduceMotion {
didEnter = true didEnter = true
} else { } else {

View File

@@ -179,8 +179,8 @@ enum SybilTheme {
static var toolCallGradient: LinearGradient { static var toolCallGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70), Color(red: 0.01, green: 0.15, blue: 0.17),
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78) Color(red: 0.03, green: 0.09, blue: 0.15)
], ],
startPoint: .leading, startPoint: .leading,
endPoint: .trailing endPoint: .trailing
@@ -190,8 +190,8 @@ enum SybilTheme {
static var runningToolCallGradient: LinearGradient { static var runningToolCallGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [
Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72), Color(red: 0.30, green: 0.19, blue: 0.04),
Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78) Color(red: 0.09, green: 0.05, blue: 0.17)
], ],
startPoint: .leading, startPoint: .leading,
endPoint: .trailing endPoint: .trailing
@@ -201,8 +201,8 @@ enum SybilTheme {
static var failedToolCallGradient: LinearGradient { static var failedToolCallGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [
danger.opacity(0.18), Color(red: 0.27, green: 0.04, blue: 0.10),
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72) Color(red: 0.15, green: 0.03, blue: 0.07)
], ],
startPoint: .leading, startPoint: .leading,
endPoint: .trailing endPoint: .trailing

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { JSX } from "preact"; import type { ComponentChildren, 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";
import { getMessageAttachments, type Message } from "@/lib/api"; import { getMessageAttachments, type Message } from "@/lib/api";
@@ -142,6 +142,14 @@ function buildMessageRenderItems(messages: Message[]) {
return items; 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) { function getToolStackHeight(messageCount: number, expanded: boolean) {
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT); const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
return expanded return expanded
@@ -246,10 +254,10 @@ function ToolCallCard({
className={cn( 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)]", "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 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 : isInitiated
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]" ? "border-amber-300/44 bg-[linear-gradient(90deg,hsl(43_72%_20%),hsl(260_48%_13%))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]", : "border-cyan-400/44 bg-[linear-gradient(90deg,hsl(184_82%_14%),hsl(208_66%_10%))]",
className className
)} )}
style={style} 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({ function ToolCallStack({
groupKey, groupKey,
messages, messages,
expanded, expanded,
entryMessageIDs,
onToggle, onToggle,
}: { }: {
groupKey: string; groupKey: string;
messages: Message[]; messages: Message[];
expanded: boolean; expanded: boolean;
entryMessageIDs: Set<string>;
onToggle: (groupKey: string) => void; onToggle: (groupKey: string) => void;
}) { }) {
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT); const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
@@ -324,6 +357,7 @@ function ToolCallStack({
{messages.map((message, index) => { {messages.map((message, index) => {
const depth = messages.length - index - 1; const depth = messages.length - index - 1;
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT; const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
const shouldAnimateEntry = entryMessageIDs.has(message.id) && !isHidden;
return ( return (
<div <div
key={message.id} key={message.id}
@@ -335,12 +369,9 @@ function ToolCallStack({
style={getToolStackStyle(index, messages.length, expanded, motionDirection)} style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
aria-hidden={isHidden ? "true" : undefined} aria-hidden={isHidden ? "true" : undefined}
> >
<div <ToolCallStackCardSurface messageID={message.id} animateEntry={shouldAnimateEntry} isHidden={isHidden}>
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" /> <ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
</div> </ToolCallStackCardSurface>
</div> </div>
); );
})} })}
@@ -367,8 +398,26 @@ function ToolCallStack({
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0); const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]); 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()); 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) => { const toggleToolGroup = (groupKey: string) => {
setExpandedToolGroups((current) => { setExpandedToolGroups((current) => {
const next = new Set(current); const next = new Set(current);
@@ -390,6 +439,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
groupKey={item.key} groupKey={item.key}
messages={item.messages} messages={item.messages}
expanded={expandedToolGroups.has(item.key)} expanded={expandedToolGroups.has(item.key)}
entryMessageIDs={entryToolCallMessageIDs}
onToggle={toggleToolGroup} onToggle={toggleToolGroup}
/> />
); );

View File

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