From 7436544a6911085a5bffb11211d097fab0531da1 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 12 Jun 2026 00:26:21 -0700 Subject: [PATCH] ios: add tool call stacking --- .../Sybil/SybilChatTranscriptView.swift | 252 +++++++++++++++++- .../Sybil/Tests/SybilTests/SybilTests.swift | 64 +++++ 2 files changed, 312 insertions(+), 4 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 2e80187..4ffb697 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -10,6 +10,9 @@ struct SybilChatTranscriptView: View { var bottomPinRequestID: Int = 0 private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor" + private var renderItems: [TranscriptRenderItem] { + buildTranscriptRenderItems(from: messages) + } var body: some View { ScrollViewReader { proxy in @@ -22,9 +25,16 @@ struct SybilChatTranscriptView: View { .padding(.top, 24) } - ForEach(messages) { message in - MessageBubble(message: message, isSending: isSending) - .frame(maxWidth: .infinity) + ForEach(renderItems) { item in + switch item { + case let .message(message): + MessageBubble(message: message, isSending: isSending) + .frame(maxWidth: .infinity) + case let .toolGroup(id, messages): + ToolCallStackView(groupID: id, messages: messages) + .frame(maxWidth: .infinity) + .id(id) + } } Color.clear @@ -59,6 +69,47 @@ struct SybilChatTranscriptView: View { } } +enum TranscriptRenderItem: Identifiable { + case message(Message) + case toolGroup(id: String, messages: [Message]) + + var id: String { + switch self { + case let .message(message): + return message.id + case let .toolGroup(id, _): + return "tool-group-\(id)" + } + } +} + +func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] { + var items: [TranscriptRenderItem] = [] + var toolRun: [Message] = [] + + func flushToolRun() { + guard !toolRun.isEmpty else { return } + if toolRun.count == 1, let message = toolRun.first { + items.append(.message(message)) + } else if let first = toolRun.first { + items.append(.toolGroup(id: first.id, messages: toolRun)) + } + toolRun.removeAll(keepingCapacity: true) + } + + for message in messages { + if message.toolCallMetadata != nil { + toolRun.append(message) + } else { + flushToolRun() + items.append(.message(message)) + } + } + + flushToolRun() + return items +} + private struct MessageBubble: View { var message: Message var isSending: Bool @@ -154,6 +205,196 @@ private struct MessageBubble: View { } } +private struct ToolCallStackView: View { + private struct CardLayout { + var x: CGFloat + var y: CGFloat + var scale: CGFloat + var opacity: Double + var zIndex: Double + } + + var groupID: String + var messages: [Message] + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isExpanded = false + + private let visibleCollapsedLimit = 4 + private let cardHeight: CGFloat = 62 + private let expandedGap: CGFloat = 10 + private let collapsedStepX: CGFloat = 11 + private let collapsedStepY: CGFloat = 10 + private let toggleSize: CGFloat = 32 + private let toggleGap: CGFloat = 12 + + private var animation: Animation? { + reduceMotion ? nil : .easeInOut(duration: 0.34) + } + + private var visibleCollapsedCount: Int { + min(messages.count, visibleCollapsedLimit) + } + + private var hiddenCount: Int { + max(0, messages.count - visibleCollapsedLimit) + } + + private var containerHeight: CGFloat { + if isExpanded { + return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap) + } + return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY + } + + private var accessibilityLabel: String { + "\(messages.count) tool \(messages.count == 1 ? "call" : "calls")" + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + GeometryReader { geometry in + let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap)) + let toggleX = cardWidth + toggleGap + + ZStack(alignment: .topLeading) { + ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in + let layout = layout(for: index) + let depth = messages.count - index - 1 + let isHidden = !isExpanded && depth >= visibleCollapsedLimit + + ToolCallStackCard(message: message, cardHeight: cardHeight, compactLayout: true) + .frame(width: cardWidth, height: cardHeight, alignment: .topLeading) + .scaleEffect(layout.scale, anchor: .topLeading) + .opacity(layout.opacity) + .offset(x: layout.x, y: layout.y) + .zIndex(layout.zIndex) + .allowsHitTesting(!isHidden) + .accessibilityHidden(isHidden) + } + + if !isExpanded && hiddenCount > 0 { + Text("+\(hiddenCount)") + .font(.sybil(.caption2, weight: .semibold)) + .foregroundStyle(SybilTheme.accent.opacity(0.95)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule() + .fill(Color.black.opacity(0.58)) + .overlay( + Capsule() + .stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1) + ) + ) + .offset(x: max(0, cardWidth - 56), y: containerHeight - 13) + .transition(.opacity) + } + + Button { + withAnimation(animation) { + isExpanded.toggle() + } + } label: { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(SybilTheme.accent.opacity(0.95)) + .frame(width: toggleSize, height: toggleSize) + .background( + Circle() + .fill( + LinearGradient( + colors: [ + Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96), + Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + Circle() + .stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)") + .offset(x: toggleX, y: 8) + .zIndex(Double(messages.count + 2)) + } + .frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading) + .animation(animation, value: isExpanded) + } + .frame(height: containerHeight) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func layout(for index: Int) -> CardLayout { + if isExpanded { + return CardLayout( + x: 0, + y: CGFloat(index) * (cardHeight + expandedGap), + scale: 1, + opacity: 1, + zIndex: Double(messages.count - index) + ) + } + + let depth = messages.count - index - 1 + let visibleDepth = min(depth, visibleCollapsedLimit - 1) + let isHidden = depth >= visibleCollapsedLimit + return CardLayout( + x: CGFloat(visibleDepth) * collapsedStepX, + y: CGFloat(visibleDepth) * collapsedStepY, + scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035), + opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22), + zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth) + ) + } +} + +private struct ToolCallStackCard: View { + var message: Message + var cardHeight: CGFloat + var compactLayout: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var didEnter = false + + var body: some View { + Group { + if let metadata = message.toolCallMetadata { + ToolCallActivityChip( + metadata: metadata, + fallbackContent: message.content, + createdAt: message.createdAt, + compactLayout: compactLayout + ) + } + } + .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) + .onAppear { + guard !didEnter else { return } + if reduceMotion { + didEnter = true + } else { + withAnimation(.easeOut(duration: 0.32).delay(0.03)) { + didEnter = true + } + } + } + } +} + private struct ToolCallActivityChip: View { enum VisualState { case initiated @@ -164,6 +405,7 @@ private struct ToolCallActivityChip: View { var metadata: ToolCallMetadata var fallbackContent: String var createdAt: Date + var compactLayout: Bool = false private var summary: String { if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { @@ -250,7 +492,9 @@ private struct ToolCallActivityChip: View { .font(.sybil(.subheadline)) .foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94)) .lineSpacing(3) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(compactLayout ? 1 : nil) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: !compactLayout) HStack(spacing: 6) { Text(toolLabel) diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 38ac0b9..ea98bc7 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -402,6 +402,70 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD ) } +private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran a tool") -> Message { + Message( + id: id, + createdAt: date, + role: .tool, + content: summary, + name: "web_search", + metadata: .object([ + "kind": .string("tool_call"), + "toolCallId": .string("call-\(id)"), + "toolName": .string("web_search"), + "status": .string("completed"), + "summary": .string(summary), + "durationMs": .number(120) + ]) + ) +} + +@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil) + let toolA = makeToolCallMessage(id: "tool-a", date: date, summary: "Search A") + let toolB = makeToolCallMessage(id: "tool-b", date: date, summary: "Search B") + let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil) + + let items = buildTranscriptRenderItems(from: [user, toolA, toolB, assistant]) + + #expect(items.count == 3) + guard case let .message(firstMessage) = items[0] else { + Issue.record("Expected the first item to remain a normal message") + return + } + #expect(firstMessage.id == "user-1") + + guard case let .toolGroup(groupID, groupedMessages) = items[1] else { + Issue.record("Expected adjacent tool calls to be grouped") + return + } + #expect(groupID == "tool-a") + #expect(groupedMessages.map(\.id) == ["tool-a", "tool-b"]) + + guard case let .message(lastMessage) = items[2] else { + Issue.record("Expected the assistant response to remain a normal message") + return + } + #expect(lastMessage.id == "assistant-1") +} + +@Test func transcriptRenderItemsKeepSingleToolCallsInline() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil) + let tool = makeToolCallMessage(id: "tool-a", date: date) + let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil) + + let items = buildTranscriptRenderItems(from: [user, tool, assistant]) + + #expect(items.count == 3) + guard case let .message(toolMessage) = items[1] else { + Issue.record("Expected a single tool call to use the existing inline chip") + return + } + #expect(toolMessage.id == "tool-a") +} + @MainActor @Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws { let defaults = UserDefaults(suiteName: #function)!