supposedly better tool call animation
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user