ios: add tool call stacking
This commit is contained in:
@@ -10,6 +10,9 @@ struct SybilChatTranscriptView: View {
|
|||||||
var bottomPinRequestID: Int = 0
|
var bottomPinRequestID: Int = 0
|
||||||
|
|
||||||
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||||
|
private var renderItems: [TranscriptRenderItem] {
|
||||||
|
buildTranscriptRenderItems(from: messages)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
@@ -22,9 +25,16 @@ struct SybilChatTranscriptView: View {
|
|||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(messages) { message in
|
ForEach(renderItems) { item in
|
||||||
|
switch item {
|
||||||
|
case let .message(message):
|
||||||
MessageBubble(message: message, isSending: isSending)
|
MessageBubble(message: message, isSending: isSending)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
case let .toolGroup(id, messages):
|
||||||
|
ToolCallStackView(groupID: id, messages: messages)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.id(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Color.clear
|
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 {
|
private struct MessageBubble: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
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 {
|
private struct ToolCallActivityChip: View {
|
||||||
enum VisualState {
|
enum VisualState {
|
||||||
case initiated
|
case initiated
|
||||||
@@ -164,6 +405,7 @@ private struct ToolCallActivityChip: View {
|
|||||||
var metadata: ToolCallMetadata
|
var metadata: ToolCallMetadata
|
||||||
var fallbackContent: String
|
var fallbackContent: String
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
|
var compactLayout: Bool = false
|
||||||
|
|
||||||
private var summary: String {
|
private var summary: String {
|
||||||
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||||
@@ -250,7 +492,9 @@ private struct ToolCallActivityChip: View {
|
|||||||
.font(.sybil(.subheadline))
|
.font(.sybil(.subheadline))
|
||||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
||||||
.lineSpacing(3)
|
.lineSpacing(3)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.lineLimit(compactLayout ? 1 : nil)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.fixedSize(horizontal: false, vertical: !compactLayout)
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(toolLabel)
|
Text(toolLabel)
|
||||||
|
|||||||
@@ -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
|
@MainActor
|
||||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||||
let defaults = UserDefaults(suiteName: #function)!
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
|||||||
Reference in New Issue
Block a user