This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 <span className="agent-room__typing-dots" aria-label={label} role="status"><i /><i /><i /></span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
|
||||
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
||||
<div className="agent-room__message-content">
|
||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : thinking ? <span className="inline-flex items-center gap-1" aria-label="Sybil is thinking" role="status"><span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" /><span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" /><span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" /></span> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : thinking ? <AgentThinkingDots /> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
||||
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
|
||||
</div>
|
||||
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
||||
|
||||
@@ -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 (
|
||||
<div key={message.id} className="flex justify-start" data-agent-aside="true">
|
||||
<AgentBubble message={message} animate={message.id.startsWith("temp-assistant-")} onRetry={() => {}} download={noAgentAttachmentDownload} showThinkingIndicator />
|
||||
<AgentBubble message={message} animate={message.id.startsWith("temp-assistant-")} onRetry={() => {}} download={noAgentAttachmentDownload} showThinkingIndicator isPending={isInlineAgentReplyPending(message, isSending)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> : {};
|
||||
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;
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user