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