diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 04f921e..049ebe2 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -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 { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index bd4f7c8..44b8a19 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 0222557..4173aa6 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -54,10 +54,14 @@ struct StreamingAssistantState: Equatable, Sendable { private(set) var initiatedToolCallIDs: Set = [] 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 { diff --git a/ios/Tests/SybilPresentationTests/AgentInlineThinkingDotsTests.swift b/ios/Tests/SybilPresentationTests/AgentInlineThinkingDotsTests.swift new file mode 100644 index 0000000..97f9204 --- /dev/null +++ b/ios/Tests/SybilPresentationTests/AgentInlineThinkingDotsTests.swift @@ -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 + } +} diff --git a/web/src/components/agent/agent-conversation.tsx b/web/src/components/agent/agent-conversation.tsx index 02af08a..92b1ef9 100644 --- a/web/src/components/agent/agent-conversation.tsx +++ b/web/src/components/agent/agent-conversation.tsx @@ -50,19 +50,23 @@ function toolName(value: unknown) { return typeof value === "string" ? value.replace(/_/g, " ") : "Tool"; } -export const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download, showThinkingIndicator = false }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"]; showThinkingIndicator?: boolean }) { +export function AgentThinkingDots({ label = "Sybil is thinking" }: { label?: string }) { + return ; +} + +export const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download, showThinkingIndicator = false, isPending = false }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"]; showThinkingIndicator?: boolean; isPending?: boolean }) { const own = message.role === "user"; const info = metadata(message); const status = typeof info.status === "string" ? info.status : "complete"; const failed = status === "failed" || status === "unconfirmed"; - const thinking = showThinkingIndicator && !own && status === "sending" && !message.content.trim(); + const thinking = showThinkingIndicator && !own && (isPending ?? status === "sending") && !message.content.trim(); const date = dateOf(message.createdAt); return (
{!own ? : null}
- {own ?

{message.content}

: thinking ? : } + {own ?

{message.content}

: thinking ? : } {agentAttachments(message.metadata, message.role).map(file => )}
{own && failed && typeof info.error === "string" ?
{info.error}
: null} diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx index a231c15..adae4f4 100644 --- a/web/src/components/chat/chat-messages-panel.tsx +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -3,7 +3,7 @@ import type { ComponentChildren, JSX } from "preact"; import { cn } from "@/lib/utils"; import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { getMessageAttachments, type Message } from "@/lib/api"; -import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation"; +import { asStreamingAssistantMetadata, isInlineAgentReplyPending } from "@/lib/chat-stream-presentation"; import { AgentBubble } from "@/components/agent/agent-conversation"; import { MarkdownContent } from "@/components/markdown/markdown-content"; import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact"; @@ -604,7 +604,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon if (message.role === "assistant" && metadata.agentAside === true) { return (
- {}} download={noAgentAttachmentDownload} showThinkingIndicator /> + {}} download={noAgentAttachmentDownload} showThinkingIndicator isPending={isInlineAgentReplyPending(message, isSending)} />
); } diff --git a/web/src/lib/chat-stream-presentation.ts b/web/src/lib/chat-stream-presentation.ts index 71b4deb..31ee4b4 100644 --- a/web/src/lib/chat-stream-presentation.ts +++ b/web/src/lib/chat-stream-presentation.ts @@ -2,6 +2,13 @@ import type { Message, ToolCallEvent } from "@/lib/api"; export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant"; +export function isInlineAgentReplyPending(message: Message, isSending: boolean): boolean { + const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata) + ? message.metadata as Record : {}; + return isSending && message.role === "assistant" && message.id.startsWith("temp-assistant-") + && metadata.agentAside === true && !message.content.trim(); +} + export type StreamingAssistantMetadata = { kind: typeof STREAMING_ASSISTANT_METADATA_KIND; ephemeralTrace: string | null; diff --git a/web/tests/chat-stream-presentation.test.mjs b/web/tests/chat-stream-presentation.test.mjs index 61f3551..ea71561 100644 --- a/web/tests/chat-stream-presentation.test.mjs +++ b/web/tests/chat-stream-presentation.test.mjs @@ -7,6 +7,7 @@ import { createStreamingAssistantMetadata, createStreamingAttemptState, finalizeStreamingAssistant, + isInlineAgentReplyPending, markStreamingAssistantAsAgentAside, promoteStreamingAssistantTrace, setStreamingAssistantSegment, @@ -126,6 +127,15 @@ test("Agent aside stream keeps identity through loading, markdown deltas, and co assert.equal(messages.at(-1).content, "**Inline** reply"); }); +test("inline Agent pending dots stop on first text, completion, failure, and cancellation", () => { + let message = markStreamingAssistantAsAgentAside([assistantMessage()], "temp-assistant-").at(-1); + assert.equal(isInlineAgentReplyPending(message, true), true); + message = setStreamingAssistantSegment([message], "temp-assistant-", "First visible text").at(-1); + assert.equal(isInlineAgentReplyPending(message, true), false); + assert.equal(isInlineAgentReplyPending(assistantMessage(), false), false, "failed or cancelled pending state is hidden"); + assert.equal(isInlineAgentReplyPending({ ...message, id: "saved-agent-reply", content: "" }, true), false); +}); + test("message helpers preserve identity when no matching temporary assistant exists", () => { const messages = [ {