Show inline Agent pending dots
TestFlight / Build and upload (push) Successful in 1m50s

This commit is contained in:
Sybil Agent
2026-10-29 21:31:11 -07:00
parent 5d738f897b
commit a390c7287a
8 changed files with 261 additions and 8 deletions
@@ -235,6 +235,53 @@ struct AgentAvatar: View {
}
}
struct AgentTypingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
TimelineView(.animation(minimumInterval: 1 / 30, paused: reduceMotion)) { timeline in
HStack(spacing: 3) {
ForEach(0..<3, id: \.self) { index in
let pulse = pulse(at: timeline.date, index: index)
Circle()
.fill(Color(red: 0.80, green: 0.70, blue: 0.91))
.frame(width: 3, height: 3)
.opacity(reduceMotion ? 0.7 : pulse.opacity)
.offset(y: reduceMotion ? 0 : pulse.rise)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
Color(red: 0.12, green: 0.075, blue: 0.21).opacity(0.42),
in: RoundedRectangle(cornerRadius: 9)
)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Sybil is thinking")
}
private func pulse(at date: Date, index: Int) -> (opacity: Double, rise: CGFloat) {
let cycle = 1.4
let delay = Double(index) * 0.16 / cycle
let elapsed = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycle) / cycle
let phase = (elapsed - delay + 1).truncatingRemainder(dividingBy: 1)
let intensity: Double
if phase <= 0.35 {
let progress = phase / 0.35
let eased = progress * progress * (3 - 2 * progress)
intensity = 0.35 + 0.65 * eased
} else if phase <= 0.70 {
let progress = (phase - 0.35) / 0.35
let eased = progress * progress * (3 - 2 * progress)
intensity = 1 - 0.65 * eased
} else {
intensity = 0.35
}
return (intensity, -2 * CGFloat((intensity - 0.35) / 0.65))
}
}
// Activity/read publications still update the transcript. Completed rows depend
// only on message values and heading; scrolling needs no per-row geometry.
private struct AgentTranscriptRow: View, Equatable {
@@ -234,7 +234,13 @@ struct MessageBubble: View {
)
}
if isStreamingAssistant && !agentStyle {
if agentStyle && shouldShowInlineAgentPendingDots(
message: message,
isSending: isSending,
presentation: matchingStreamingAssistantPresentation
) {
AgentTypingDots()
} else if isStreamingAssistant && !agentStyle {
StreamingAssistantContentView(
content: message.content,
isSending: isSending,
@@ -289,6 +295,24 @@ struct MessageBubble: View {
}
}
func shouldShowInlineAgentPendingDots(
message: Message,
isSending: Bool,
presentation: StreamingAssistantPresentation?
) -> Bool {
guard message.isAgentAside,
message.role == .assistant,
message.id.hasPrefix("temp-assistant-"),
isSending,
let presentation,
presentation.messageID == message.id,
presentation.isAwaitingVisibleText
else {
return false
}
return message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
@@ -54,10 +54,14 @@ struct StreamingAssistantState: Equatable, Sendable {
private(set) var initiatedToolCallIDs: Set<String> = []
private(set) var ephemeralTrace: String?
private(set) var traceRevision = 0
private(set) var isAwaitingVisibleText = true
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
guard !text.isEmpty else { return .unchanged }
segmentText += text
if !segmentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
isAwaitingVisibleText = false
}
ephemeralTrace = nil
return .replace(segmentText)
}
@@ -79,20 +83,27 @@ struct StreamingAssistantState: Equatable, Sendable {
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
segmentText = ""
ephemeralTrace = nil
isAwaitingVisibleText = false
return .replace(text)
}
mutating func receiveError() -> StreamingAssistantContentUpdate {
segmentText = ""
ephemeralTrace = nil
isAwaitingVisibleText = false
return .unchanged
}
mutating func receiveCancel() {
isAwaitingVisibleText = false
}
}
struct StreamingAssistantPresentation: Equatable, Sendable {
var messageID: String
var ephemeralTrace: String?
var traceRevision: Int
var isAwaitingVisibleText: Bool
}
private struct PendingChatState {
@@ -107,7 +118,8 @@ private struct PendingChatState {
return StreamingAssistantPresentation(
messageID: messageID,
ephemeralTrace: streamingAssistantState.ephemeralTrace,
traceRevision: streamingAssistantState.traceRevision
traceRevision: streamingAssistantState.traceRevision,
isAwaitingVisibleText: streamingAssistantState.isAwaitingVisibleText
)
}
}
@@ -467,7 +479,8 @@ final class SybilViewModel {
return StreamingAssistantPresentation(
messageID: messageID,
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
traceRevision: quickQuestionStreamingAssistantState.traceRevision
traceRevision: quickQuestionStreamingAssistantState.traceRevision,
isAwaitingVisibleText: quickQuestionStreamingAssistantState.isAwaitingVisibleText
)
}
@@ -2151,6 +2164,13 @@ final class SybilViewModel {
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
}
defer {
if Task.isCancelled {
if let resolvedChatID = chatID {
pendingChatStates[resolvedChatID]?.streamingAssistantState.receiveCancel()
} else {
pendingDraftChatState?.streamingAssistantState.receiveCancel()
}
}
if let resolvedChatID = chatID {
pendingChatStates[resolvedChatID] = nil
} else {
@@ -0,0 +1,141 @@
import SwiftUI
import UIKit
import XCTest
@testable import Sybil
@MainActor
final class AgentInlineThinkingDotsTests: XCTestCase {
func testPendingDotsReplaceWithTextAndHideOnDoneFailureOrCancel() async throws {
let messageID = "temp-assistant-inline-dots-fixture"
let date = Date(timeIntervalSince1970: 1_750_000_000)
let user = Message(id: "temp-user-inline-dots-fixture", createdAt: date, role: .user, content: "@Sybil, summarize this.", name: nil)
var reply = Message(
id: messageID,
createdAt: date,
role: .assistant,
content: "",
name: nil,
metadata: .object(["agentAside": .bool(true)])
)
var state = StreamingAssistantState()
var presentation = Self.presentation(messageID: messageID, state: state)
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
await capture(messages: [user, reply], presentation: presentation, sending: true, name: "inline-agent-pending-dancing-dots")
XCTAssertEqual(state.receiveDelta(" \n"), .replace(" \n"))
reply.content = state.segmentText
presentation = Self.presentation(messageID: messageID, state: state)
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
XCTAssertEqual(state.receiveDelta("Here is the Agent reply."), .replace(" \nHere is the Agent reply."))
reply.content = state.segmentText
presentation = Self.presentation(messageID: messageID, state: state)
XCTAssertFalse(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
await capture(messages: [user, reply], presentation: presentation, sending: true, name: "inline-agent-first-visible-text-same-bubble")
XCTAssertEqual(state.receiveDone("Completed Agent reply."), .replace("Completed Agent reply."))
reply.content = "Completed Agent reply."
presentation = Self.presentation(messageID: messageID, state: state)
XCTAssertFalse(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
XCTAssertEqual(reply.id, messageID)
var failed = StreamingAssistantState()
let failurePresentation = Self.presentation(messageID: messageID, state: failed)
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply.withContent(""), isSending: true, presentation: failurePresentation))
XCTAssertEqual(failed.receiveError(), .unchanged)
XCTAssertFalse(shouldShowInlineAgentPendingDots(
message: reply.withContent(""), isSending: true, presentation: Self.presentation(messageID: messageID, state: failed)
))
var cancelled = StreamingAssistantState()
let cancelPresentation = Self.presentation(messageID: messageID, state: cancelled)
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply.withContent(""), isSending: true, presentation: cancelPresentation))
cancelled.receiveCancel()
XCTAssertFalse(shouldShowInlineAgentPendingDots(
message: reply.withContent(""), isSending: true, presentation: Self.presentation(messageID: messageID, state: cancelled)
))
}
private static func presentation(messageID: String, state: StreamingAssistantState) -> StreamingAssistantPresentation {
StreamingAssistantPresentation(
messageID: messageID,
ephemeralTrace: state.ephemeralTrace,
traceRevision: state.traceRevision,
isAwaitingVisibleText: state.isAwaitingVisibleText
)
}
private func capture(
messages: [Message],
presentation: StreamingAssistantPresentation,
sending: Bool,
name: String
) async {
let host = UIHostingController(rootView: InlineAgentReplySnapshot(
messages: messages,
presentation: presentation,
sending: sending
))
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 170))
window.rootViewController = host
window.makeKeyAndVisible()
host.view.frame = window.bounds
host.view.setNeedsLayout()
host.view.layoutIfNeeded()
try? await Task.sleep(for: .milliseconds(80))
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true))
}
if let data = image.pngData() {
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
} else {
XCTFail("Could not encode (name) screenshot")
}
window.isHidden = true
window.rootViewController = nil
}
}
private struct InlineAgentReplySnapshot: View {
var messages: [Message]
var presentation: StreamingAssistantPresentation
var sending: Bool
var body: some View {
ZStack(alignment: .topLeading) {
SybilTheme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 8) {
Text("Inline @Sybil reply")
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
ForEach(messages) { message in
if message.isAgentAside {
AgentMessageRow(
message: message,
isSending: sending,
streamingAssistantPresentation: presentation
)
} else {
Text(message.content).foregroundStyle(SybilTheme.text)
}
}
Spacer(minLength: 0)
}
.padding(16)
}
.frame(width: 390, height: 170)
.environment(\.colorScheme, .dark)
}
}
private extension Message {
func withContent(_ content: String) -> Message {
var copy = self
copy.content = content
return copy
}
}