From 26d917e96a9995f37edc7e7cb1af6381015b6f07 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 30 Aug 2026 15:25:22 -0700 Subject: [PATCH] Polish streaming thinking traces --- .../Sybil/SybilChatTranscriptView.swift | 152 ++++++++++-- .../Sybil/SybilQuickQuestionView.swift | 29 +-- .../Sybil/Sources/Sybil/SybilViewModel.swift | 228 +++++++++++++++--- .../Sources/Sybil/SybilWorkspaceView.swift | 1 + .../Sybil/Tests/SybilTests/SybilTests.swift | 209 ++++++++++++++++ web/src/App.tsx | 107 ++++---- .../components/chat/chat-messages-panel.tsx | 146 ++++++++++- web/src/index.css | 91 +++++++ web/src/lib/chat-stream-presentation.ts | 139 +++++++++++ web/tests/chat-stream-presentation.test.mjs | 148 ++++++++++++ 10 files changed, 1128 insertions(+), 122 deletions(-) create mode 100644 web/src/lib/chat-stream-presentation.ts create mode 100644 web/tests/chat-stream-presentation.test.mjs diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index da1e979..2541b06 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View { var messages: [Message] var isLoading: Bool var isSending: Bool + var streamingAssistantPresentation: StreamingAssistantPresentation? var topContentInset: CGFloat = 0 var bottomContentInset: CGFloat = 0 var bottomPinRequestID: Int = 0 @@ -41,7 +42,11 @@ struct SybilChatTranscriptView: View { ForEach(renderItems) { item in switch item { case let .message(message): - MessageBubble(message: message, isSending: isSending) + MessageBubble( + message: message, + isSending: isSending, + streamingAssistantPresentation: streamingAssistantPresentation + ) .frame(maxWidth: .infinity) case let .toolGroup(id, messages): ToolCallStackView( @@ -140,6 +145,7 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt private struct MessageBubble: View { var message: Message var isSending: Bool + var streamingAssistantPresentation: StreamingAssistantPresentation? private var toolCallMetadata: ToolCallMetadata? { message.toolCallMetadata @@ -149,10 +155,13 @@ private struct MessageBubble: View { message.role == .user } - private var isPendingAssistant: Bool { - message.id.hasPrefix("temp-assistant-") && - isSending && - message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + private var isStreamingAssistant: Bool { + message.role == .assistant && message.id.hasPrefix("temp-assistant-") + } + + private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? { + guard streamingAssistantPresentation?.messageID == message.id else { return nil } + return streamingAssistantPresentation } var body: some View { @@ -174,16 +183,12 @@ private struct MessageBubble: View { ) } - if isPendingAssistant { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - .tint(SybilTheme.primary) - Text("Thinking…") - .font(.sybil(.footnote)) - .foregroundStyle(SybilTheme.textMuted) - } - .padding(.vertical, 2) + if isStreamingAssistant { + StreamingAssistantContentView( + content: message.content, + isSending: isSending, + presentation: matchingStreamingAssistantPresentation + ) } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Markdown(message.content) .tint(SybilTheme.primary) @@ -232,6 +237,123 @@ private struct MessageBubble: View { } } +private struct StreamingTraceHeightPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +struct StreamingAssistantContentView: View { + private enum Phase: Hashable { + case pending + case trace(Int) + case answer + } + + var content: String + var isSending: Bool + var presentation: StreamingAssistantPresentation? + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var retainedTraceHeight: CGFloat = 0 + + private var traceText: String? { + guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines), + !trace.isEmpty + else { + return nil + } + return trace + } + + private var hasAnswer: Bool { + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var phase: Phase { + if hasAnswer { + return .answer + } + if traceText != nil { + return .trace(presentation?.traceRevision ?? 0) + } + return .pending + } + + private var traceTransition: AnyTransition { + guard !reduceMotion else { return .identity } + return .asymmetric( + insertion: .opacity.combined(with: .offset(y: 5)), + removal: .opacity.combined(with: .offset(y: -5)) + ) + } + + private var answerTransition: AnyTransition { + guard !reduceMotion else { return .identity } + return .opacity.combined(with: .offset(y: 3)) + } + + var body: some View { + ZStack(alignment: .topLeading) { + if let traceText { + Text(traceText) + .font(.sybil(size: 15)) + .italic() + .foregroundStyle(SybilTheme.textMuted.opacity(0.88)) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + .opacity(0.78) + .textSelection(.disabled) + .allowsHitTesting(false) + .accessibilityLabel("Assistant is working") + .background { + GeometryReader { geometry in + Color.clear.preference( + key: StreamingTraceHeightPreferenceKey.self, + value: geometry.size.height + ) + } + } + .id("thinking-trace-\(presentation?.traceRevision ?? 0)") + .transition(traceTransition) + .zIndex(1) + } + + if hasAnswer { + Markdown(content) + .tint(SybilTheme.primary) + .foregroundStyle(SybilTheme.text.opacity(0.95)) + .markdownTheme(.sybilReadable) + .textSelection(.enabled) + .id("streaming-assistant-answer") + .transition(answerTransition) + .zIndex(2) + } + + if isSending && !hasAnswer && traceText == nil { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + .tint(SybilTheme.primary) + Text("Thinking…") + .font(.sybil(.footnote)) + .foregroundStyle(SybilTheme.textMuted) + } + .padding(.vertical, 2) + .transition(reduceMotion ? .identity : .opacity) + } + } + .frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading) + .onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in + guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return } + retainedTraceHeight = measuredHeight + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase) + } +} + private struct ToolCallStackView: View { private struct CardLayout { var x: CGFloat diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift index 7faec6f..72195bd 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift @@ -62,7 +62,11 @@ struct SybilQuickQuestionView: View { VStack(alignment: .leading, spacing: 12) { if hasAnswerContent { ForEach(viewModel.quickQuestionMessages) { message in - QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending) + QuickQuestionMessageView( + message: message, + isSending: viewModel.isQuickQuestionSending, + streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation + ) } if let error = viewModel.quickQuestionError { @@ -263,11 +267,11 @@ private struct QuickQuestionPickerPill: View { private struct QuickQuestionMessageView: View { var message: Message var isSending: Bool + var streamingAssistantPresentation: StreamingAssistantPresentation? - private var isPendingAssistant: Bool { - message.id.hasPrefix("temp-assistant-quick-") && - isSending && - message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? { + guard streamingAssistantPresentation?.messageID == message.id else { return nil } + return streamingAssistantPresentation } var body: some View { @@ -276,15 +280,12 @@ private struct QuickQuestionMessageView: View { .font(.caption) .foregroundStyle(SybilTheme.textMuted) .fixedSize(horizontal: false, vertical: true) - } else if isPendingAssistant { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - .tint(SybilTheme.primary) - Text("Thinking...") - .font(.caption) - .foregroundStyle(SybilTheme.textMuted) - } + } else if message.id.hasPrefix("temp-assistant-quick-") { + StreamingAssistantContentView( + content: message.content, + isSending: isSending, + presentation: matchingStreamingAssistantPresentation + ) } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Markdown(message.content) .font(.body) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 672d905..b71ea2f 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -40,9 +40,72 @@ struct SidebarItem: Identifiable, Hashable { var isRunning: Bool } +enum StreamingAssistantContentUpdate: Equatable, Sendable { + case unchanged + case replace(String) +} + +struct StreamingAssistantState: Equatable, Sendable { + private(set) var segmentText = "" + private(set) var initiatedToolCallIDs: Set = [] + private(set) var ephemeralTrace: String? + private(set) var traceRevision = 0 + + mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate { + guard !text.isEmpty else { return .unchanged } + segmentText += text + ephemeralTrace = nil + return .replace(segmentText) + } + + mutating func receiveToolCall(id: String, status: String) -> StreamingAssistantContentUpdate { + guard status.lowercased() == "initiated", initiatedToolCallIDs.insert(id).inserted else { + return .unchanged + } + + let trace = segmentText.trimmingCharacters(in: .whitespacesAndNewlines) + segmentText = "" + if !trace.isEmpty { + ephemeralTrace = trace + traceRevision += 1 + } + return .replace("") + } + + mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate { + segmentText = "" + ephemeralTrace = nil + return .replace(text) + } + + mutating func receiveError() -> StreamingAssistantContentUpdate { + segmentText = "" + ephemeralTrace = nil + return .unchanged + } +} + +struct StreamingAssistantPresentation: Equatable, Sendable { + var messageID: String + var ephemeralTrace: String? + var traceRevision: Int +} + private struct PendingChatState { var chatID: String? var messages: [Message] + var streamingAssistantState = StreamingAssistantState() + + var streamingAssistantPresentation: StreamingAssistantPresentation? { + guard let messageID = messages.last(where: { $0.id.hasPrefix("temp-assistant-") })?.id else { + return nil + } + return StreamingAssistantPresentation( + messageID: messageID, + ephemeralTrace: streamingAssistantState.ephemeralTrace, + traceRevision: streamingAssistantState.traceRevision + ) + } } private enum ActiveSendContext: Hashable { @@ -125,6 +188,7 @@ final class SybilViewModel { var quickQuestionSubmittedModel: String? var isQuickQuestionSending = false var isConvertingQuickQuestion = false + private var quickQuestionStreamingAssistantState = StreamingAssistantState() @ObservationIgnored private var hasBootstrapped = false @@ -360,6 +424,30 @@ final class SybilViewModel { return canonical } + var displayedStreamingAssistantPresentation: StreamingAssistantPresentation? { + if case let .chat(chatID) = selectedItem, + let pending = pendingChatStates[chatID] { + return pending.streamingAssistantPresentation + } + + if draftKind == .chat, let pending = pendingDraftChatState { + return pending.streamingAssistantPresentation + } + + return nil + } + + var quickQuestionStreamingAssistantPresentation: StreamingAssistantPresentation? { + guard let messageID = quickQuestionMessages.last(where: { $0.id.hasPrefix("temp-assistant-quick-") })?.id else { + return nil + } + return StreamingAssistantPresentation( + messageID: messageID, + ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace, + traceRevision: quickQuestionStreamingAssistantState.traceRevision + ) + } + var displayedSearch: SearchDetail? { if case let .search(searchID) = selectedItem, let activeSearch = activeSearchDetails[searchID] { @@ -620,6 +708,7 @@ final class SybilViewModel { quickQuestionTask = nil quickQuestionRunID = nil isQuickQuestionSending = false + quickQuestionStreamingAssistantState = StreamingAssistantState() } @discardableResult @@ -1140,6 +1229,7 @@ final class SybilViewModel { quickQuestionSubmittedPrompt = prompt quickQuestionSubmittedProvider = provider quickQuestionSubmittedModel = model + quickQuestionStreamingAssistantState = StreamingAssistantState() quickQuestionMessages = [ Message( id: "temp-assistant-quick-\(UUID().uuidString)", @@ -1184,6 +1274,8 @@ final class SybilViewModel { return } + let update = quickQuestionStreamingAssistantState.receiveError() + applyQuickQuestionAssistantContentUpdate(update) quickQuestionError = normalizeAPIError(error) SybilLog.error(SybilLog.ui, "Quick question failed", error: error) } @@ -1198,17 +1290,16 @@ final class SybilViewModel { upsertQuickQuestionToolCallMessage(payload) case let .delta(payload): - guard !payload.text.isEmpty else { return } - mutateQuickQuestionAssistantMessage { existing in - existing + payload.text - } + let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text) + applyQuickQuestionAssistantContentUpdate(update) case let .done(payload): - mutateQuickQuestionAssistantMessage { _ in - payload.text - } + let update = quickQuestionStreamingAssistantState.receiveDone(payload.text) + applyQuickQuestionAssistantContentUpdate(update) case let .error(payload): + let update = quickQuestionStreamingAssistantState.receiveError() + applyQuickQuestionAssistantContentUpdate(update) await streamStatus.setError(payload.message) case .ignored: @@ -1607,21 +1698,28 @@ final class SybilViewModel { } serverActiveChatIDs.remove(chatID) - pendingChatStates[chatID] = nil await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false) if selectedItem == selection, draftKind == nil { - selectedChat = try await client.getChat(chatID: chatID) - selectedSearch = nil + let refreshedChat = try await client.getChat(chatID: chatID) + if selectedItem == selection, draftKind == nil { + selectedChat = refreshedChat + selectedSearch = nil + } } + pendingChatStates[chatID] = nil } catch { serverActiveChatIDs.remove(chatID) - pendingChatStates[chatID] = nil + defer { + pendingChatStates[chatID] = nil + } if isCancellation(error) { return } + applyPendingAssistantError(chatID: chatID) + if isActiveStreamNotFound(error) { SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists") } else if shouldSuppressInactiveTransportError(error) { @@ -1635,8 +1733,11 @@ final class SybilViewModel { if selectedItem == selection, draftKind == nil { do { - selectedChat = try await client().getChat(chatID: chatID) - selectedSearch = nil + let refreshedChat = try await client().getChat(chatID: chatID) + if selectedItem == selection, draftKind == nil { + selectedChat = refreshedChat + selectedSearch = nil + } } catch { SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))") } @@ -1856,6 +1957,13 @@ final class SybilViewModel { } else { pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages) } + defer { + if let resolvedChatID = chatID { + pendingChatStates[resolvedChatID] = nil + } else { + clearPendingChatState(for: sendContext) + } + } requestChatBottomPin() if chatID == nil { @@ -2045,17 +2153,13 @@ final class SybilViewModel { upsertPendingToolCallMessage(payload, chatID: chatID) case let .delta(payload): - guard !payload.text.isEmpty else { return } - mutatePendingAssistantMessage(chatID: chatID) { existing in - existing + payload.text - } + applyPendingAssistantDelta(payload.text, chatID: chatID) case let .done(payload): - mutatePendingAssistantMessage(chatID: chatID) { _ in - payload.text - } + finalizePendingAssistant(payload.text, chatID: chatID) case let .error(payload): + applyPendingAssistantError(chatID: chatID) await streamStatus.setError(payload.message) case .ignored: @@ -2233,29 +2337,68 @@ final class SybilViewModel { } } - private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) { + private func applyPendingAssistantDelta(_ text: String, chatID: String) { guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else { return } - let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") } - guard let index else { - return - } - - var message = pending.messages[index] - message.content = transform(message.content) - pending.messages[index] = message + let update = pending.streamingAssistantState.receiveDelta(text) + applyStreamingAssistantContentUpdate( + update, + to: &pending.messages, + assistantMessagePrefix: "temp-assistant-" + ) pendingChatStates[chatID] = pending } - private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) { - let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") } - guard let index else { + private func finalizePendingAssistant(_ text: String, chatID: String) { + guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else { return } - quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content) + let update = pending.streamingAssistantState.receiveDone(text) + applyStreamingAssistantContentUpdate( + update, + to: &pending.messages, + assistantMessagePrefix: "temp-assistant-" + ) + pendingChatStates[chatID] = pending + } + + private func applyPendingAssistantError(chatID: String) { + guard var pending = pendingChatStates[chatID] else { + return + } + + let update = pending.streamingAssistantState.receiveError() + applyStreamingAssistantContentUpdate( + update, + to: &pending.messages, + assistantMessagePrefix: "temp-assistant-" + ) + pendingChatStates[chatID] = pending + } + + private func applyQuickQuestionAssistantContentUpdate(_ update: StreamingAssistantContentUpdate) { + applyStreamingAssistantContentUpdate( + update, + to: &quickQuestionMessages, + assistantMessagePrefix: "temp-assistant-quick-" + ) + } + + private func applyStreamingAssistantContentUpdate( + _ update: StreamingAssistantContentUpdate, + to messages: inout [Message], + assistantMessagePrefix: String + ) { + guard case let .replace(content) = update, + let index = messages.indices.last(where: { messages[$0].id.hasPrefix(assistantMessagePrefix) }) + else { + return + } + + messages[index].content = content } private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { @@ -2263,6 +2406,16 @@ final class SybilViewModel { return } + let update = pending.streamingAssistantState.receiveToolCall( + id: payload.toolCallId, + status: payload.status + ) + applyStreamingAssistantContentUpdate( + update, + to: &pending.messages, + assistantMessagePrefix: "temp-assistant-" + ) + if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) { pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id) pendingChatStates[chatID] = pending @@ -2281,6 +2434,12 @@ final class SybilViewModel { } private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) { + let update = quickQuestionStreamingAssistantState.receiveToolCall( + id: payload.toolCallId, + status: payload.status + ) + applyQuickQuestionAssistantContentUpdate(update) + if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) { quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id) return @@ -2430,7 +2589,8 @@ final class SybilViewModel { private func clearPendingChatState(for context: ActiveSendContext) { switch context { - case .draftChat: + case let .draftChat(identity): + guard draftKind == .chat, draftIdentity == identity else { return } pendingDraftChatState = nil case let .chat(chatID): pendingChatStates[chatID] = nil diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 9aa179c..2c2194a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -193,6 +193,7 @@ struct SybilWorkspaceView: View { messages: viewModel.displayedMessages, isLoading: viewModel.isLoadingSelection, isSending: viewModel.isSendingVisibleChat, + streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0, bottomPinRequestID: viewModel.chatBottomPinRequestID diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index c56bb37..2ae2f76 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -53,6 +53,7 @@ private actor MockSybilClient: SybilAPIClienting { private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest? private var completionStreamEvents: [CompletionStreamEvent]? + private var completionStreamPostEventNetworkErrorMessage: String? private var listChatsDelayNanoseconds: UInt64 = 0 private var listSearchesDelayNanoseconds: UInt64 = 0 private var getChatDelayNanoseconds: UInt64 = 0 @@ -61,6 +62,7 @@ private actor MockSybilClient: SybilAPIClienting { private var completionStreamDelayNanoseconds: UInt64 = 0 private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:] private var completionAttachDelayNanoseconds: UInt64 = 0 + private var chatDetailResponseSequences: [String: [ChatDetail]] = [:] private var searchStreamNetworkErrorMessage: String? private var searchStreamDelayNanoseconds: UInt64 = 0 private var searchAttachEvents: [String: [SearchStreamEvent]] = [:] @@ -113,6 +115,10 @@ private actor MockSybilClient: SybilAPIClienting { completionStreamDelayNanoseconds = delayNanoseconds } + func setCompletionStreamPostEventNetworkError(_ message: String) { + completionStreamPostEventNetworkErrorMessage = message + } + func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { completionStreamNetworkErrorMessage = message completionStreamDelayNanoseconds = delayNanoseconds @@ -127,6 +133,10 @@ private actor MockSybilClient: SybilAPIClienting { getChatDelayNanoseconds = delayNanoseconds } + func setChatDetailResponses(chatID: String, responses: [ChatDetail]) { + chatDetailResponseSequences[chatID] = responses + } + func setGetSearchDelay(_ delayNanoseconds: UInt64) { getSearchDelayNanoseconds = delayNanoseconds } @@ -199,6 +209,11 @@ private actor MockSybilClient: SybilAPIClienting { if getChatDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: getChatDelayNanoseconds) } + if var responses = chatDetailResponseSequences[chatID], !responses.isEmpty { + let response = responses.removeFirst() + chatDetailResponseSequences[chatID] = responses + return response + } guard let detail = chatDetails[chatID] else { throw UnexpectedClientCall() } @@ -319,6 +334,9 @@ private actor MockSybilClient: SybilAPIClienting { for event in completionStreamEvents { await onEvent(event) } + if let completionStreamPostEventNetworkErrorMessage { + throw APIError.networkError(message: completionStreamPostEventNetworkErrorMessage) + } return } throw UnexpectedClientCall() @@ -457,6 +475,25 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran ) } +private func makeCompletionToolCall( + id: String, + status: String, + summary: String = "Searched the web" +) -> CompletionStreamToolCall { + CompletionStreamToolCall( + toolCallId: id, + name: "web_search", + status: status, + summary: summary, + args: ["query": .string("Sybil streaming traces")], + startedAt: "2026-08-30T12:00:00.000Z", + completedAt: status == "initiated" ? nil : "2026-08-30T12:00:00.120Z", + durationMs: status == "initiated" ? nil : 120, + error: nil, + resultPreview: status == "initiated" ? nil : "{\"ok\":true}" + ) +} + @Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws { let decoder = JSONDecoder() let legacySummary = try decoder.decode( @@ -543,6 +580,63 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran #expect(toolMessage.id == "tool-a") } +@Test func streamingAssistantStatePromotesAndReplacesEphemeralTraces() { + var state = StreamingAssistantState() + + #expect(state.receiveDelta(" I'll search ") == .replace(" I'll search ")) + #expect(state.receiveDelta("for that. ") == .replace(" I'll search for that. ")) + #expect(state.receiveToolCall(id: "call-1", status: "INITIATED") == .replace("")) + #expect(state.segmentText == "") + #expect(state.ephemeralTrace == "I'll search for that.") + #expect(state.traceRevision == 1) + + #expect(state.receiveToolCall(id: "call-1", status: "completed") == .unchanged) + #expect(state.receiveToolCall(id: "call-1", status: "initiated") == .unchanged) + #expect(state.ephemeralTrace == "I'll search for that.") + #expect(state.traceRevision == 1) + + #expect(state.receiveToolCall(id: "parallel-call", status: "initiated") == .replace("")) + #expect(state.ephemeralTrace == "I'll search for that.") + #expect(state.traceRevision == 1) + + #expect(state.receiveDelta("I need one more source.") == .replace("I need one more source.")) + #expect(state.ephemeralTrace == nil) + #expect(state.receiveToolCall(id: "call-2", status: "initiated") == .replace("")) + #expect(state.ephemeralTrace == "I need one more source.") + #expect(state.traceRevision == 2) + + #expect(state.receiveDelta("Final answer") == .replace("Final answer")) + #expect(state.ephemeralTrace == nil) + #expect(state.receiveDone("Canonical final answer.") == .replace("Canonical final answer.")) + #expect(state.segmentText == "") + #expect(state.ephemeralTrace == nil) +} + +@Test func streamingAssistantErrorClearsTraceWithoutReplacingPartialContent() { + var traceState = StreamingAssistantState() + #expect(traceState.receiveDelta("I'll inspect that.") == .replace("I'll inspect that.")) + #expect(traceState.receiveToolCall(id: "inspect", status: "initiated") == .replace("")) + #expect(traceState.ephemeralTrace == "I'll inspect that.") + #expect(traceState.receiveError() == .unchanged) + #expect(traceState.segmentText.isEmpty) + #expect(traceState.ephemeralTrace == nil) + + var partialState = StreamingAssistantState() + var renderedContent = "" + if case let .replace(content) = partialState.receiveDelta("Partial answer") { + renderedContent = content + } + let errorUpdate = partialState.receiveError() + if case let .replace(content) = errorUpdate { + renderedContent = content + } + + #expect(errorUpdate == .unchanged) + #expect(renderedContent == "Partial answer") + #expect(partialState.segmentText.isEmpty) + #expect(partialState.ephemeralTrace == nil) +} + @MainActor @Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws { let defaults = UserDefaults(suiteName: #function)! @@ -848,6 +942,29 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran #expect(!viewModel.isLoadingSelection) } +@MainActor +@Test func failedFirstSendClearsPendingStateMigratedToCreatedChat() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_235) + let createdChat = makeChatSummary(id: "chat-created-before-failure", date: date) + let client = MockSybilClient(createChatResponse: createdChat) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.startNewChat() + viewModel.model = "" + viewModel.composer = "This send should fail after chat creation" + + await viewModel.sendComposer() + + let snapshot = await client.currentSnapshot() + #expect(snapshot.createChat == 1) + #expect(snapshot.runCompletionStream == 0) + #expect(viewModel.selectedItem == .chat(createdChat.id)) + #expect(viewModel.selectedChat?.id == createdChat.id) + #expect(viewModel.displayedMessages.isEmpty) + #expect(viewModel.displayedStreamingAssistantPresentation == nil) +} + @MainActor @Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws { let date = Date(timeIntervalSince1970: 1_700_000_240) @@ -1072,6 +1189,60 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran #expect(!viewModel.isQuickQuestionSending) } +@MainActor +@Test func quickQuestionKeepsToolNarrationEphemeralAndFinalizesCanonicalAnswer() async throws { + let client = MockSybilClient() + await client.setCompletionStreamEvents([ + .delta(CompletionStreamDelta(text: "I'll search for that.")), + .toolCall(makeCompletionToolCall(id: "quick-search", status: "initiated")), + .toolCall(makeCompletionToolCall(id: "quick-search", status: "completed")), + .delta(CompletionStreamDelta(text: "The streamed answer")), + .done(CompletionStreamDone(text: "The canonical answer.")) + ]) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.quickQuestionPrompt = "What changed?" + + let task = viewModel.sendQuickQuestion() + await task?.value + + let toolMessages = viewModel.quickQuestionMessages.filter { $0.toolCallMetadata != nil } + let assistantMessage = try #require(viewModel.quickQuestionMessages.last(where: { $0.role == .assistant })) + #expect(toolMessages.count == 1) + #expect(toolMessages.first?.toolCallMetadata?.status == "completed") + #expect(assistantMessage.content == "The canonical answer.") + #expect(!assistantMessage.content.contains("I'll search")) + #expect(viewModel.quickQuestionAnswerText == "The canonical answer.") + #expect(viewModel.quickQuestionStreamingAssistantPresentation?.ephemeralTrace == nil) + #expect(!viewModel.isQuickQuestionSending) +} + +@MainActor +@Test func quickQuestionTransportErrorClearsTraceAndPreservesPartialAnswer() async throws { + let client = MockSybilClient() + await client.setCompletionStreamEvents([ + .delta(CompletionStreamDelta(text: "I'll inspect that.")), + .toolCall(makeCompletionToolCall(id: "quick-inspect", status: "initiated")), + .delta(CompletionStreamDelta(text: "Partial answer")) + ]) + await client.setCompletionStreamPostEventNetworkError("connection lost") + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.quickQuestionPrompt = "What changed?" + + let task = viewModel.sendQuickQuestion() + await task?.value + + let assistantMessage = try #require(viewModel.quickQuestionMessages.last(where: { $0.role == .assistant })) + #expect(assistantMessage.content == "Partial answer") + #expect(viewModel.quickQuestionAnswerText == "Partial answer") + #expect(viewModel.quickQuestionStreamingAssistantPresentation?.ephemeralTrace == nil) + #expect(viewModel.quickQuestionError != nil) + #expect(!viewModel.isQuickQuestionSending) +} + @MainActor @Test func quickQuestionConvertCreatesSeededChat() async throws { let date = Date(timeIntervalSince1970: 1_700_000_250) @@ -1184,6 +1355,44 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran #expect(viewModel.displayedMessages.last?.content == "streaming") } +@MainActor +@Test func completedAttachKeepsFinalContentVisibleUntilCanonicalChatLoads() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_265) + let chat = makeChatSummary(id: "chat-active-complete", date: date) + let baseDetail = makeChatDetail(id: chat.id, date: date, body: "base transcript") + let canonicalDetail = makeChatDetail(id: chat.id, date: date, body: "canonical attached response") + let client = MockSybilClient( + chatsResponse: [chat], + chatDetails: [chat.id: canonicalDetail], + activeRunsResponse: ActiveRunsResponse(chats: [chat.id]) + ) + await client.setChatDetailResponses(chatID: chat.id, responses: [baseDetail, canonicalDetail]) + await client.setGetChatDelay(80_000_000) + await client.setCompletionAttachEvents( + chatID: chat.id, + events: [.done(CompletionStreamDone(text: "attached response"))] + ) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + + await viewModel.reconnect() + + var snapshot = await client.currentSnapshot() + for _ in 0..<80 { + guard snapshot.getChat < 2 else { break } + try await Task.sleep(nanoseconds: 5_000_000) + snapshot = await client.currentSnapshot() + } + + #expect(snapshot.getChat >= 2) + #expect(viewModel.displayedMessages.last?.content == "attached response") + + for _ in 0..<80 { + guard viewModel.displayedMessages.last?.content != "canonical attached response" else { break } + try await Task.sleep(nanoseconds: 5_000_000) + } + #expect(viewModel.displayedMessages.last?.content == "canonical attached response") +} + @MainActor @Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws { let date = Date(timeIntervalSince1970: 1_700_000_270) diff --git a/web/src/App.tsx b/web/src/App.tsx index 141cc79..843e624 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -81,6 +81,16 @@ import { type SidebarSelection, } from "@/lib/sidebar-selection"; import { buildQuickQuestionRequest } from "@/lib/quick-question"; +import { + appendStreamingDelta, + clearStreamingAssistantTrace, + createStreamingAssistantMetadata, + createStreamingAttemptState, + finalizeStreamingAssistant, + promoteStreamingAssistantTrace, + setStreamingAssistantSegment, + transitionStreamingToolCall, +} from "@/lib/chat-stream-presentation"; import { cn } from "@/lib/utils"; type DraftSelectionKind = "chat" | "search"; @@ -2486,7 +2496,7 @@ export default function App() { role: "assistant", content: "", name: null, - metadata: null, + metadata: createStreamingAssistantMetadata(), }; let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; @@ -2591,7 +2601,7 @@ export default function App() { while (true) { let streamErrorMessage: string | null = null; - let replayedAssistantText = ""; + let streamingAttempt = createStreamingAttemptState(); const abortController = new AbortController(); chatStreamAbortRefs.current.set(chatId, abortController); @@ -2609,45 +2619,39 @@ export default function App() { if (payload.chatId !== chatId) return; }, onToolCall: (payload) => { + const transition = transitionStreamingToolCall(streamingAttempt, payload); + streamingAttempt = transition.attempt; setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; + const messages = transition.startedNewToolCall + ? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "") + : pendingState.messages; return { ...current, [chatId]: { - messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"), + messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"), }, }; }); }, onDelta: (payload) => { if (!payload.text) return; - replayedAssistantText += payload.text; + streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text); + const segmentText = streamingAttempt.segmentText; setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; - let updated = false; - const nextMessages = pendingState.messages.map((message, index, all) => { - const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-"); - if (!isTarget) return message; - updated = true; - return { ...message, content: replayedAssistantText }; - }); - return updated ? { ...current, [chatId]: { messages: nextMessages } } : current; + const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText); + return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } }; }); }, onDone: (payload) => { setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; - let updated = false; - const nextMessages = pendingState.messages.map((message, index, all) => { - const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-"); - if (!isTarget) return message; - updated = true; - return { ...message, content: payload.text }; - }); - return updated ? { ...current, [chatId]: { messages: nextMessages } } : current; + const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text); + return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } }; }); }, onError: (payload) => { @@ -2896,6 +2900,7 @@ export default function App() { const abortController = new AbortController(); chatStreamAbortRefs.current.set(chatId, abortController); let streamErrorMessage: string | null = null; + let streamingAttempt = createStreamingAttemptState(); try { const baseChat = await getChat(chatId); @@ -2912,7 +2917,7 @@ export default function App() { role: "assistant", content: "", name: null, - metadata: null, + metadata: createStreamingAssistantMetadata(), }), }, }; @@ -2922,44 +2927,39 @@ export default function App() { chatId, { onToolCall: (payload) => { + const transition = transitionStreamingToolCall(streamingAttempt, payload); + streamingAttempt = transition.attempt; setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; + const messages = transition.startedNewToolCall + ? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "") + : pendingState.messages; return { ...current, [chatId]: { - messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"), + messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"), }, }; }); }, onDelta: (payload) => { if (!payload.text) return; + streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text); + const segmentText = streamingAttempt.segmentText; setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; - let updated = false; - const nextMessages = pendingState.messages.map((message, index, all) => { - const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-"); - if (!isTarget) return message; - updated = true; - return { ...message, content: message.content + payload.text }; - }); - return updated ? { ...current, [chatId]: { messages: nextMessages } } : current; + const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText); + return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } }; }); }, onDone: (payload) => { setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; - let updated = false; - const nextMessages = pendingState.messages.map((message, index, all) => { - const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-"); - if (!isTarget) return message; - updated = true; - return { ...message, content: payload.text }; - }); - return updated ? { ...current, [chatId]: { messages: nextMessages } } : current; + const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text); + return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } }; }); }, onError: (payload) => { @@ -3211,7 +3211,7 @@ export default function App() { role: "assistant", content: "", name: null, - metadata: null, + metadata: createStreamingAssistantMetadata(), }; quickQuestionAbortRef.current?.abort(); @@ -3225,6 +3225,7 @@ export default function App() { setIsQuickQuestionSending(true); let streamErrorMessage: string | null = null; + let streamingAttempt = createStreamingAttemptState(); try { await runQuickQuestionStream( @@ -3235,33 +3236,26 @@ export default function App() { }), { onToolCall: (payload) => { + const transition = transitionStreamingToolCall(streamingAttempt, payload); + streamingAttempt = transition.attempt; setQuickQuestionMessages((current) => { - return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-"); + const messages = transition.startedNewToolCall + ? promoteStreamingAssistantTrace(current, "temp-assistant-quick-", transition.traceText ?? "") + : current; + return upsertOptimisticToolMessage(messages, payload, "temp-assistant-quick-"); }); }, onDelta: (payload) => { if (!payload.text) return; + streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text); + const segmentText = streamingAttempt.segmentText; setQuickQuestionMessages((current) => { - let updated = false; - const nextMessages = current.map((message, index, all) => { - const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-"); - if (!isTarget) return message; - updated = true; - return { ...message, content: message.content + payload.text }; - }); - return updated ? nextMessages : current; + return setStreamingAssistantSegment(current, "temp-assistant-quick-", segmentText); }); }, onDone: (payload) => { setQuickQuestionMessages((current) => { - let updated = false; - const nextMessages = current.map((message, index, all) => { - const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-"); - if (!isTarget) return message; - updated = true; - return { ...message, content: payload.text }; - }); - return updated ? nextMessages : current; + return finalizeStreamingAssistant(current, "temp-assistant-quick-", payload.text); }); }, onError: (payload) => { @@ -3276,6 +3270,7 @@ export default function App() { } } catch (err) { if (abortController.signal.aborted) return; + setQuickQuestionMessages((current) => clearStreamingAssistantTrace(current, "temp-assistant-quick-")); const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx index 0886470..519ca0c 100644 --- a/web/src/components/chat/chat-messages-panel.tsx +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; 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 { MarkdownContent } from "@/components/markdown/markdown-content"; import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact"; @@ -396,8 +397,144 @@ function ToolCallStack({ ); } +type RenderedThinkingTrace = { + text: string; + revision: number; +}; + +const THINKING_TRACE_EXIT_MS = 220; + +export function StreamingAssistantPresentation({ message, isSending }: { message: Message; isSending: boolean }) { + const metadata = asStreamingAssistantMetadata(message.metadata); + const traceText = metadata?.ephemeralTrace?.trim() || null; + const traceRevision = metadata?.traceRevision ?? 0; + const [activeTrace, setActiveTrace] = useState(() => + traceText ? { text: traceText, revision: traceRevision } : null + ); + const [outgoingTrace, setOutgoingTrace] = useState(null); + const [reservedTraceHeight, setReservedTraceHeight] = useState(0); + const activeTraceRef = useRef(null); + const outgoingTraceRef = useRef(null); + const exitTimerRef = useRef(null); + + useLayoutEffect(() => { + const isSameTrace = + (!activeTrace && !traceText) || + (activeTrace?.text === traceText && activeTrace?.revision === traceRevision && Boolean(activeTrace) === Boolean(traceText)); + if (isSameTrace) return; + + if (exitTimerRef.current !== null) { + window.clearTimeout(exitTimerRef.current); + exitTimerRef.current = null; + } + + if (activeTrace) { + setOutgoingTrace(activeTrace); + exitTimerRef.current = window.setTimeout(() => { + setOutgoingTrace(null); + exitTimerRef.current = null; + }, THINKING_TRACE_EXIT_MS); + } else { + setOutgoingTrace(null); + } + + setActiveTrace(traceText ? { text: traceText, revision: traceRevision } : null); + }, [traceRevision, traceText]); + + useEffect(() => { + return () => { + if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current); + }; + }, []); + + useLayoutEffect(() => { + const traceElements = [activeTraceRef.current, outgoingTraceRef.current].filter( + (element): element is HTMLDivElement => element !== null + ); + if (!traceElements.length) return; + + const reserveMeasuredHeight = () => { + const measuredHeight = Math.max(...traceElements.map((element) => element.getBoundingClientRect().height)); + if (measuredHeight > 0) { + setReservedTraceHeight((current) => Math.max(current, Math.ceil(measuredHeight))); + } + }; + + reserveMeasuredHeight(); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(reserveMeasuredHeight); + for (const element of traceElements) observer.observe(element); + return () => observer.disconnect(); + }, [activeTrace, outgoingTrace]); + + const hasAnswer = message.content.trim().length > 0; + const showTyping = isSending && !hasAnswer && !traceText && !activeTrace && !outgoingTrace; + const phase = hasAnswer ? "answer" : traceText || activeTrace || outgoingTrace ? "trace" : "pending"; + + return ( +
+ {outgoingTrace ? ( + + ) : null} + {activeTrace ? ( + + ) : null} + {activeTrace && !hasAnswer ? ( + + Assistant is working + + ) : null} + {hasAnswer ? ( +
+ +
+ ) : null} + {showTyping ? ( + + + + + + ) : null} +
+ ); +} + export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) { - const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0); + const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-")); const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]); const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]); const seenToolCallMessageIDsRef = useRef | null>(null); @@ -457,6 +594,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon } const isUser = message.role === "user"; + const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata); const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0; const attachments = getMessageAttachments(message.metadata); return ( @@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon } > {attachments.length ? : null} - {isPendingAssistant ? ( + {streamingAssistantMetadata ? ( + + ) : isPendingAssistant ? ( diff --git a/web/src/index.css b/web/src/index.css index d9a7839..005e3e8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -321,6 +321,97 @@ textarea { } } +.streaming-assistant-slot { + display: grid; + min-width: 0; + min-height: 1.82em; + align-items: start; +} + +.streaming-assistant-layer { + grid-area: 1 / 1; + min-width: 0; + transform-origin: top left; +} + +.ephemeral-thinking-trace { + position: relative; + z-index: 1; + color: hsl(252 35% 88% / 0.8); + font-style: italic; + opacity: 0.78; + filter: saturate(0.72); + will-change: opacity, transform, filter; +} + +.ephemeral-thinking-trace-enter { + animation: ephemeral-thinking-trace-enter 200ms cubic-bezier(0.2, 0.78, 0.24, 1) both; +} + +.ephemeral-thinking-trace-exit { + pointer-events: none; + animation: ephemeral-thinking-trace-exit 220ms cubic-bezier(0.4, 0, 0.8, 0.28) both; +} + +.streaming-assistant-answer { + position: relative; + z-index: 2; + animation: streaming-assistant-answer-enter 180ms cubic-bezier(0.2, 0.78, 0.24, 1) both; +} + +@keyframes ephemeral-thinking-trace-enter { + from { + opacity: 0; + filter: saturate(0.72) blur(1px); + transform: translate3d(0, 0.32rem, 0); + } + + to { + opacity: 0.78; + filter: saturate(0.72) blur(0); + transform: translate3d(0, 0, 0); + } +} + +@keyframes ephemeral-thinking-trace-exit { + from { + opacity: 0.78; + filter: saturate(0.72) blur(0); + transform: translate3d(0, 0, 0); + } + + to { + opacity: 0; + filter: saturate(0.72) blur(1px); + transform: translate3d(0, -0.32rem, 0); + } +} + +@keyframes streaming-assistant-answer-enter { + from { + opacity: 0; + transform: translate3d(0, 0.18rem, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .ephemeral-thinking-trace-enter, + .ephemeral-thinking-trace-exit, + .streaming-assistant-answer { + animation: none; + transform: none; + } + + .ephemeral-thinking-trace-exit { + opacity: 0; + } +} + .md-content { word-break: break-word; } diff --git a/web/src/lib/chat-stream-presentation.ts b/web/src/lib/chat-stream-presentation.ts new file mode 100644 index 0000000..a33b25b --- /dev/null +++ b/web/src/lib/chat-stream-presentation.ts @@ -0,0 +1,139 @@ +import type { Message, ToolCallEvent } from "@/lib/api"; + +export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant"; + +export type StreamingAssistantMetadata = { + kind: typeof STREAMING_ASSISTANT_METADATA_KIND; + ephemeralTrace: string | null; + traceRevision: number; +}; + +export type StreamingAttemptState = { + segmentText: string; + initiatedToolCallIds: string[]; +}; + +export type StreamingToolCallTransition = { + attempt: StreamingAttemptState; + startedNewToolCall: boolean; + traceText: string | null; +}; + +export function createStreamingAssistantMetadata(): StreamingAssistantMetadata { + return { + kind: STREAMING_ASSISTANT_METADATA_KIND, + ephemeralTrace: null, + traceRevision: 0, + }; +} + +export function asStreamingAssistantMetadata(value: unknown): StreamingAssistantMetadata | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.kind !== STREAMING_ASSISTANT_METADATA_KIND) return null; + + return { + kind: STREAMING_ASSISTANT_METADATA_KIND, + ephemeralTrace: typeof record.ephemeralTrace === "string" ? record.ephemeralTrace : null, + traceRevision: + typeof record.traceRevision === "number" && Number.isFinite(record.traceRevision) + ? Math.max(0, Math.floor(record.traceRevision)) + : 0, + }; +} + +export function createStreamingAttemptState(): StreamingAttemptState { + return { + segmentText: "", + initiatedToolCallIds: [], + }; +} + +export function appendStreamingDelta(attempt: StreamingAttemptState, delta: string): StreamingAttemptState { + if (!delta) return attempt; + return { + ...attempt, + segmentText: attempt.segmentText + delta, + }; +} + +export function transitionStreamingToolCall( + attempt: StreamingAttemptState, + event: Pick +): StreamingToolCallTransition { + if (event.status !== "initiated" || attempt.initiatedToolCallIds.includes(event.toolCallId)) { + return { attempt, startedNewToolCall: false, traceText: null }; + } + + const traceText = attempt.segmentText.trim(); + return { + attempt: { + segmentText: "", + initiatedToolCallIds: attempt.initiatedToolCallIds.concat(event.toolCallId), + }, + startedNewToolCall: true, + traceText: traceText || null, + }; +} + +function updateStreamingAssistant( + messages: Message[], + assistantMessagePrefix: string, + update: (message: Message, metadata: StreamingAssistantMetadata) => Message +) { + let didUpdate = false; + const nextMessages = messages.map((message, index, all) => { + const isTarget = index === all.length - 1 && message.id.startsWith(assistantMessagePrefix); + if (!isTarget) return message; + didUpdate = true; + return update(message, asStreamingAssistantMetadata(message.metadata) ?? createStreamingAssistantMetadata()); + }); + return didUpdate ? nextMessages : messages; +} + +export function setStreamingAssistantSegment(messages: Message[], assistantMessagePrefix: string, segmentText: string) { + return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({ + ...message, + content: segmentText, + metadata: { + ...metadata, + ephemeralTrace: null, + } satisfies StreamingAssistantMetadata, + })); +} + +export function promoteStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string, traceText: string) { + const normalizedTrace = traceText.trim(); + return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({ + ...message, + content: "", + metadata: normalizedTrace + ? ({ + ...metadata, + ephemeralTrace: normalizedTrace, + traceRevision: metadata.traceRevision + 1, + } satisfies StreamingAssistantMetadata) + : metadata, + })); +} + +export function finalizeStreamingAssistant(messages: Message[], assistantMessagePrefix: string, finalText: string) { + return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({ + ...message, + content: finalText, + metadata: { + ...metadata, + ephemeralTrace: null, + } satisfies StreamingAssistantMetadata, + })); +} + +export function clearStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string) { + return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({ + ...message, + metadata: { + ...metadata, + ephemeralTrace: null, + } satisfies StreamingAssistantMetadata, + })); +} diff --git a/web/tests/chat-stream-presentation.test.mjs b/web/tests/chat-stream-presentation.test.mjs new file mode 100644 index 0000000..c50e102 --- /dev/null +++ b/web/tests/chat-stream-presentation.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + appendStreamingDelta, + asStreamingAssistantMetadata, + clearStreamingAssistantTrace, + createStreamingAssistantMetadata, + createStreamingAttemptState, + finalizeStreamingAssistant, + promoteStreamingAssistantTrace, + setStreamingAssistantSegment, + transitionStreamingToolCall, +} from "../src/lib/chat-stream-presentation.ts"; + +function assistantMessage(content = "") { + return { + id: "temp-assistant-test", + createdAt: "2026-08-30T00:00:00.000Z", + role: "assistant", + content, + name: null, + metadata: createStreamingAssistantMetadata(), + }; +} + +function metadata(messages) { + return asStreamingAssistantMetadata(messages.at(-1)?.metadata); +} + +test("a tool boundary promotes the current segment and the next delta starts the answer in its place", () => { + let messages = [assistantMessage()]; + let attempt = appendStreamingDelta(createStreamingAttemptState(), "I'll search for that."); + messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText); + + const toolTransition = transitionStreamingToolCall(attempt, { + toolCallId: "search-1", + status: "initiated", + }); + attempt = toolTransition.attempt; + messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", toolTransition.traceText ?? ""); + + assert.equal(messages.at(-1).content, ""); + assert.deepEqual(metadata(messages), { + kind: "streaming_assistant", + ephemeralTrace: "I'll search for that.", + traceRevision: 1, + }); + + attempt = appendStreamingDelta(attempt, "Here is the answer"); + messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText); + + assert.equal(messages.at(-1).content, "Here is the answer"); + assert.equal(metadata(messages)?.ephemeralTrace, null); + + messages = finalizeStreamingAssistant(messages, "temp-assistant-", "Here is the answer."); + assert.equal(messages.at(-1).content, "Here is the answer."); + assert.equal(metadata(messages)?.ephemeralTrace, null); +}); + +test("a later tool round replaces the previous trace and parallel initiated calls do not erase it", () => { + let messages = [assistantMessage("First trace")]; + let attempt = appendStreamingDelta(createStreamingAttemptState(), "First trace"); + let transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-1", status: "initiated" }); + attempt = transition.attempt; + messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? ""); + + attempt = appendStreamingDelta(attempt, "I need one more lookup."); + messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText); + transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-2", status: "initiated" }); + attempt = transition.attempt; + messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? ""); + + assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup."); + assert.equal(metadata(messages)?.traceRevision, 2); + + transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-3", status: "initiated" }); + assert.equal(transition.startedNewToolCall, true); + assert.equal(transition.traceText, null); + messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? ""); + + assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup."); + assert.equal(metadata(messages)?.traceRevision, 2); +}); + +test("terminal and duplicate tool events never promote or reset the in-progress segment", () => { + const initial = appendStreamingDelta(createStreamingAttemptState(), "candidate"); + const completed = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "completed" }); + + assert.equal(completed.startedNewToolCall, false); + assert.strictEqual(completed.attempt, initial); + + const initiated = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "initiated" }); + const withNextSegment = appendStreamingDelta(initiated.attempt, "next segment"); + const duplicate = transitionStreamingToolCall(withNextSegment, { toolCallId: "tool-1", status: "initiated" }); + + assert.equal(duplicate.startedNewToolCall, false); + assert.strictEqual(duplicate.attempt, withNextSegment); + assert.equal(duplicate.attempt.segmentText, "next segment"); +}); + +test("a fresh per-attempt accumulator can replay and reclassify an already-seen tool round", () => { + let messages = [assistantMessage("stale partial answer")]; + let replayAttempt = appendStreamingDelta(createStreamingAttemptState(), "Replayed trace"); + messages = setStreamingAssistantSegment(messages, "temp-assistant-", replayAttempt.segmentText); + + const replayedTool = transitionStreamingToolCall(replayAttempt, { toolCallId: "same-tool-id", status: "initiated" }); + messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", replayedTool.traceText ?? ""); + + assert.equal(replayedTool.startedNewToolCall, true); + assert.equal(messages.at(-1).content, ""); + assert.equal(metadata(messages)?.ephemeralTrace, "Replayed trace"); +}); + +test("message helpers preserve identity when no matching temporary assistant exists", () => { + const messages = [ + { + id: "persisted-assistant", + createdAt: "2026-08-30T00:00:00.000Z", + role: "assistant", + content: "Persisted", + name: null, + metadata: null, + }, + ]; + + assert.strictEqual(setStreamingAssistantSegment(messages, "temp-assistant-", "new"), messages); + assert.strictEqual(promoteStreamingAssistantTrace(messages, "temp-assistant-", "trace"), messages); + assert.strictEqual(finalizeStreamingAssistant(messages, "temp-assistant-", "done"), messages); + assert.strictEqual(clearStreamingAssistantTrace(messages, "temp-assistant-"), messages); +}); + +test("a terminal error clears the ephemeral trace without discarding a partial answer", () => { + let messages = [ + { + ...assistantMessage("Partial answer"), + metadata: { + ...createStreamingAssistantMetadata(), + ephemeralTrace: "Still checking one source.", + traceRevision: 1, + }, + }, + ]; + + messages = clearStreamingAssistantTrace(messages, "temp-assistant-"); + + assert.equal(messages.at(-1).content, "Partial answer"); + assert.equal(metadata(messages)?.ephemeralTrace, null); +});