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 {
|
||||
|
||||
Reference in New Issue
Block a user