Compare commits

...
Author SHA1 Message Date
buzzert 3a9c9bb74a ios: add chat fork actions
TestFlight / Build and upload (push) Successful in 1m39s
2026-09-06 14:03:30 -07:00
buzzert 04ac39499f ios: submit quick questions with return 2026-09-06 13:36:18 -07:00
buzzert 26d917e96a Polish streaming thinking traces
TestFlight / Build and upload (push) Successful in 1m41s
2026-08-30 15:26:31 -07:00
15 changed files with 1359 additions and 129 deletions
@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat return response.chat
} }
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/fork",
method: "POST",
body: AnyEncodable(ChatForkBody(messageId: messageID)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary { func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
let response = try await request( let response = try await request(
"/v1/chats/\(chatID)", "/v1/chats/\(chatID)",
@@ -704,6 +714,10 @@ private struct ChatTitleUpdateBody: Encodable {
var title: String var title: String
} }
private struct ChatForkBody: Encodable {
var messageId: String?
}
private struct StarUpdateBody: Encodable { private struct StarUpdateBody: Encodable {
var starred: Bool var starred: Bool
} }
@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
messages: [CompletionRequestMessage]? messages: [CompletionRequestMessage]?
) async throws -> ChatSummary ) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail func getChat(chatID: String) async throws -> ChatDetail
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
func deleteChat(chatID: String) async throws func deleteChat(chatID: String) async throws
@@ -1,13 +1,17 @@
import MarkdownUI import MarkdownUI
import SwiftUI import SwiftUI
import UIKit
struct SybilChatTranscriptView: View { struct SybilChatTranscriptView: View {
var messages: [Message] var messages: [Message]
var isLoading: Bool var isLoading: Bool
var isSending: Bool var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
var topContentInset: CGFloat = 0 var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0 var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0 var bottomPinRequestID: Int = 0
var isForking: Bool = false
var onForkResponse: ((String) -> Void)? = nil
@State private var hasTrackedToolCallMessages = false @State private var hasTrackedToolCallMessages = false
@State private var knownToolCallMessageIDs: Set<String> = [] @State private var knownToolCallMessageIDs: Set<String> = []
@@ -41,7 +45,15 @@ struct SybilChatTranscriptView: View {
ForEach(renderItems) { item in ForEach(renderItems) { item in
switch item { switch item {
case let .message(message): case let .message(message):
MessageBubble(message: message, isSending: isSending) MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: {
onForkResponse?(message.id)
}
)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
case let .toolGroup(id, messages): case let .toolGroup(id, messages):
ToolCallStackView( ToolCallStackView(
@@ -140,6 +152,9 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
private struct MessageBubble: View { private struct MessageBubble: View {
var message: Message var message: Message
var isSending: Bool var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
var isForking: Bool
var onFork: (() -> Void)?
private var toolCallMetadata: ToolCallMetadata? { private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata message.toolCallMetadata
@@ -149,13 +164,46 @@ private struct MessageBubble: View {
message.role == .user message.role == .user
} }
private var isPendingAssistant: Bool { private var isStreamingAssistant: Bool {
message.id.hasPrefix("temp-assistant-") && message.role == .assistant && message.id.hasPrefix("temp-assistant-")
isSending && }
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
return streamingAssistantPresentation
}
private var canFork: Bool {
message.role == .assistant &&
!message.id.hasPrefix("temp-") &&
onFork != nil
} }
var body: some View { var body: some View {
Group {
if canFork {
bubbleContent
.contextMenu {
Button {
UIPasteboard.general.string = message.content
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onFork?()
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(isForking)
}
} else {
bubbleContent
}
}
}
private var bubbleContent: some View {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
leadingSpacer leadingSpacer
@@ -174,16 +222,12 @@ private struct MessageBubble: View {
) )
} }
if isPendingAssistant { if isStreamingAssistant {
HStack(spacing: 8) { StreamingAssistantContentView(
ProgressView() content: message.content,
.controlSize(.small) isSending: isSending,
.tint(SybilTheme.primary) presentation: matchingStreamingAssistantPresentation
Text("Thinking…") )
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content) Markdown(message.content)
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
@@ -232,6 +276,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 ToolCallStackView: View {
private struct CardLayout { private struct CardLayout {
var x: CGFloat var x: CGFloat
@@ -130,7 +130,10 @@ struct SybilPhoneShellView: View {
route: route, route: route,
onRequestBack: { _ in showSidebarOverlay() }, onRequestBack: { _ in showSidebarOverlay() },
onRequestNewChat: sidebarWorkspaceNewChatAction, onRequestNewChat: sidebarWorkspaceNewChatAction,
onShowSidebar: showSidebarOverlay onShowSidebar: showSidebarOverlay,
onOpenForkedChat: { chatID in
showRoute(.chat(chatID))
}
) )
.background(SybilTheme.background) .background(SybilTheme.background)
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress)) .blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
@@ -511,6 +514,9 @@ private struct SybilPhoneSidebarRoot: View {
}, },
onSelect: { item in onSelect: { item in
onSelect(item.selection) onSelect(item.selection)
},
onOpenForkedChat: { chatID in
onSelect(.chat(chatID))
} }
) )
} }
@@ -585,6 +591,7 @@ private struct SybilPhoneDestinationView: View {
let onRequestBack: (_ animateNavigation: Bool) -> Void let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: (() -> Void)? let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void let onShowSidebar: () -> Void
let onOpenForkedChat: (String) -> Void
var body: some View { var body: some View {
SybilWorkspaceView( SybilWorkspaceView(
@@ -593,7 +600,8 @@ private struct SybilPhoneDestinationView: View {
navigationLeadingControl: .showSidebar, navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar, onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack, onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat onRequestNewChat: onRequestNewChat,
onOpenForkedChat: onOpenForkedChat
) )
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) { .task(id: route) {
@@ -62,7 +62,11 @@ struct SybilQuickQuestionView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
if hasAnswerContent { if hasAnswerContent {
ForEach(viewModel.quickQuestionMessages) { message in 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 { if let error = viewModel.quickQuestionError {
@@ -98,13 +102,12 @@ struct SybilQuickQuestionView: View {
set: { viewModel.updateQuickQuestionPrompt($0) } set: { viewModel.updateQuickQuestionPrompt($0) }
), ),
selection: $promptSelection, selection: $promptSelection,
axis: .vertical axis: .horizontal
) )
.focused($promptFocused) .focused($promptFocused)
.font(.body) .font(.body)
.textInputAutocapitalization(.sentences) .textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false) .autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send) .submitLabel(.send)
.onSubmit(submitQuestion) .onSubmit(submitQuestion)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -263,11 +266,11 @@ private struct QuickQuestionPickerPill: View {
private struct QuickQuestionMessageView: View { private struct QuickQuestionMessageView: View {
var message: Message var message: Message
var isSending: Bool var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
private var isPendingAssistant: Bool { private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
message.id.hasPrefix("temp-assistant-quick-") && guard streamingAssistantPresentation?.messageID == message.id else { return nil }
isSending && return streamingAssistantPresentation
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
var body: some View { var body: some View {
@@ -276,15 +279,12 @@ private struct QuickQuestionMessageView: View {
.font(.caption) .font(.caption)
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} else if isPendingAssistant { } else if message.id.hasPrefix("temp-assistant-quick-") {
HStack(spacing: 8) { StreamingAssistantContentView(
ProgressView() content: message.content,
.controlSize(.small) isSending: isSending,
.tint(SybilTheme.primary) presentation: matchingStreamingAssistantPresentation
Text("Thinking...") )
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
}
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content) Markdown(message.content)
.font(.body) .font(.body)
@@ -111,6 +111,7 @@ struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void var onSelect: (SidebarItem) -> Void
var onOpenForkedChat: ((String) -> Void)? = nil
@State private var renameTarget: SidebarItem? @State private var renameTarget: SidebarItem?
@State private var renameTitle = "" @State private var renameTitle = ""
@@ -169,6 +170,13 @@ struct SybilSidebarItemList: View {
} }
if item.kind == .chat { if item.kind == .chat {
Button {
forkChat(item)
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(viewModel.isForkingChat || item.isRunning)
Button { Button {
renameTarget = item renameTarget = item
renameTitle = item.title renameTitle = item.title
@@ -215,6 +223,28 @@ struct SybilSidebarItemList: View {
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
} }
private func forkChat(_ item: SidebarItem) {
guard case let .chat(chatID) = item.selection else {
return
}
let selectionAtStart = viewModel.selectedItem
let draftKindAtStart = viewModel.draftKind
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == draftKindAtStart else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
} }
struct SybilSidebarRow: View { struct SybilSidebarRow: View {
@@ -40,9 +40,72 @@ struct SidebarItem: Identifiable, Hashable {
var isRunning: Bool 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<String> = []
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 { private struct PendingChatState {
var chatID: String? var chatID: String?
var messages: [Message] 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 { private enum ActiveSendContext: Hashable {
@@ -107,6 +170,7 @@ final class SybilViewModel {
var isLoadingCollections = false var isLoadingCollections = false
var isLoadingSelection = false var isLoadingSelection = false
var isCreatingSearchChat = false var isCreatingSearchChat = false
var isForkingChat = false
var chatBottomPinRequestID = 0 var chatBottomPinRequestID = 0
var errorMessage: String? var errorMessage: String?
@@ -125,6 +189,7 @@ final class SybilViewModel {
var quickQuestionSubmittedModel: String? var quickQuestionSubmittedModel: String?
var isQuickQuestionSending = false var isQuickQuestionSending = false
var isConvertingQuickQuestion = false var isConvertingQuickQuestion = false
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
@ObservationIgnored @ObservationIgnored
private var hasBootstrapped = false private var hasBootstrapped = false
@@ -360,6 +425,30 @@ final class SybilViewModel {
return canonical 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? { var displayedSearch: SearchDetail? {
if case let .search(searchID) = selectedItem, if case let .search(searchID) = selectedItem,
let activeSearch = activeSearchDetails[searchID] { let activeSearch = activeSearchDetails[searchID] {
@@ -620,6 +709,7 @@ final class SybilViewModel {
quickQuestionTask = nil quickQuestionTask = nil
quickQuestionRunID = nil quickQuestionRunID = nil
isQuickQuestionSending = false isQuickQuestionSending = false
quickQuestionStreamingAssistantState = StreamingAssistantState()
} }
@discardableResult @discardableResult
@@ -869,6 +959,36 @@ final class SybilViewModel {
} }
} }
@discardableResult
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
guard isAuthenticated, !isForkingChat else {
return nil
}
guard messageID != nil || !isChatRowRunning(chatID) else {
errorMessage = "Wait for the response to finish before forking this chat."
return nil
}
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
errorMessage = nil
isForkingChat = true
defer {
isForkingChat = false
}
do {
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
applyChatSummary(forkedChat, moveToFront: true)
return forkedChat
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
return nil
}
}
func renameChat(chatID: String, title: String) async { func renameChat(chatID: String, title: String) async {
guard isAuthenticated else { guard isAuthenticated else {
return return
@@ -1140,6 +1260,7 @@ final class SybilViewModel {
quickQuestionSubmittedPrompt = prompt quickQuestionSubmittedPrompt = prompt
quickQuestionSubmittedProvider = provider quickQuestionSubmittedProvider = provider
quickQuestionSubmittedModel = model quickQuestionSubmittedModel = model
quickQuestionStreamingAssistantState = StreamingAssistantState()
quickQuestionMessages = [ quickQuestionMessages = [
Message( Message(
id: "temp-assistant-quick-\(UUID().uuidString)", id: "temp-assistant-quick-\(UUID().uuidString)",
@@ -1184,6 +1305,8 @@ final class SybilViewModel {
return return
} }
let update = quickQuestionStreamingAssistantState.receiveError()
applyQuickQuestionAssistantContentUpdate(update)
quickQuestionError = normalizeAPIError(error) quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Quick question failed", error: error) SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
} }
@@ -1198,17 +1321,16 @@ final class SybilViewModel {
upsertQuickQuestionToolCallMessage(payload) upsertQuickQuestionToolCallMessage(payload)
case let .delta(payload): case let .delta(payload):
guard !payload.text.isEmpty else { return } let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
mutateQuickQuestionAssistantMessage { existing in applyQuickQuestionAssistantContentUpdate(update)
existing + payload.text
}
case let .done(payload): case let .done(payload):
mutateQuickQuestionAssistantMessage { _ in let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
payload.text applyQuickQuestionAssistantContentUpdate(update)
}
case let .error(payload): case let .error(payload):
let update = quickQuestionStreamingAssistantState.receiveError()
applyQuickQuestionAssistantContentUpdate(update)
await streamStatus.setError(payload.message) await streamStatus.setError(payload.message)
case .ignored: case .ignored:
@@ -1607,21 +1729,28 @@ final class SybilViewModel {
} }
serverActiveChatIDs.remove(chatID) serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false) await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
if selectedItem == selection, draftKind == nil { if selectedItem == selection, draftKind == nil {
selectedChat = try await client.getChat(chatID: chatID) let refreshedChat = try await client.getChat(chatID: chatID)
if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
selectedSearch = nil selectedSearch = nil
} }
}
pendingChatStates[chatID] = nil
} catch { } catch {
serverActiveChatIDs.remove(chatID) serverActiveChatIDs.remove(chatID)
defer {
pendingChatStates[chatID] = nil pendingChatStates[chatID] = nil
}
if isCancellation(error) { if isCancellation(error) {
return return
} }
applyPendingAssistantError(chatID: chatID)
if isActiveStreamNotFound(error) { if isActiveStreamNotFound(error) {
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists") SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
} else if shouldSuppressInactiveTransportError(error) { } else if shouldSuppressInactiveTransportError(error) {
@@ -1635,8 +1764,11 @@ final class SybilViewModel {
if selectedItem == selection, draftKind == nil { if selectedItem == selection, draftKind == nil {
do { do {
selectedChat = try await client().getChat(chatID: chatID) let refreshedChat = try await client().getChat(chatID: chatID)
if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
selectedSearch = nil selectedSearch = nil
}
} catch { } catch {
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))") SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
} }
@@ -1856,6 +1988,13 @@ final class SybilViewModel {
} else { } else {
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages) pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
} }
defer {
if let resolvedChatID = chatID {
pendingChatStates[resolvedChatID] = nil
} else {
clearPendingChatState(for: sendContext)
}
}
requestChatBottomPin() requestChatBottomPin()
if chatID == nil { if chatID == nil {
@@ -2045,17 +2184,13 @@ final class SybilViewModel {
upsertPendingToolCallMessage(payload, chatID: chatID) upsertPendingToolCallMessage(payload, chatID: chatID)
case let .delta(payload): case let .delta(payload):
guard !payload.text.isEmpty else { return } applyPendingAssistantDelta(payload.text, chatID: chatID)
mutatePendingAssistantMessage(chatID: chatID) { existing in
existing + payload.text
}
case let .done(payload): case let .done(payload):
mutatePendingAssistantMessage(chatID: chatID) { _ in finalizePendingAssistant(payload.text, chatID: chatID)
payload.text
}
case let .error(payload): case let .error(payload):
applyPendingAssistantError(chatID: chatID)
await streamStatus.setError(payload.message) await streamStatus.setError(payload.message)
case .ignored: case .ignored:
@@ -2233,29 +2368,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 { guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
return return
} }
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") } let update = pending.streamingAssistantState.receiveDelta(text)
guard let index else { applyStreamingAssistantContentUpdate(
return update,
} to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
var message = pending.messages[index] )
message.content = transform(message.content)
pending.messages[index] = message
pendingChatStates[chatID] = pending pendingChatStates[chatID] = pending
} }
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) { private func finalizePendingAssistant(_ text: String, chatID: String) {
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") } guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
guard let index else {
return 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) { private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
@@ -2263,6 +2437,16 @@ final class SybilViewModel {
return 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)" }) { 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) pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
pendingChatStates[chatID] = pending pendingChatStates[chatID] = pending
@@ -2281,6 +2465,12 @@ final class SybilViewModel {
} }
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) { 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)" }) { 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) quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
return return
@@ -2430,7 +2620,8 @@ final class SybilViewModel {
private func clearPendingChatState(for context: ActiveSendContext) { private func clearPendingChatState(for context: ActiveSendContext) {
switch context { switch context {
case .draftChat: case let .draftChat(identity):
guard draftKind == .chat, draftIdentity == identity else { return }
pendingDraftChatState = nil pendingDraftChatState = nil
case let .chat(chatID): case let .chat(chatID):
pendingChatStates[chatID] = nil pendingChatStates[chatID] = nil
@@ -19,6 +19,7 @@ struct SybilWorkspaceView: View {
var onShowSidebar: (() -> Void)? = nil var onShowSidebar: (() -> Void)? = nil
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
var onRequestNewChat: (() -> Void)? = nil var onRequestNewChat: (() -> Void)? = nil
var onOpenForkedChat: ((String) -> Void)? = nil
@FocusState private var composerFocused: Bool @FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var isShowingAttachmentOptions = false @State private var isShowingAttachmentOptions = false
@@ -193,9 +194,12 @@ struct SybilWorkspaceView: View {
messages: viewModel.displayedMessages, messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection, isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat, isSending: viewModel.isSendingVisibleChat,
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0, bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
bottomPinRequestID: viewModel.chatBottomPinRequestID bottomPinRequestID: viewModel.chatBottomPinRequestID,
isForking: viewModel.isForkingChat,
onForkResponse: forkResponse
) )
.id(transcriptScrollContextID) .id(transcriptScrollContextID)
} }
@@ -229,6 +233,28 @@ struct SybilWorkspaceView: View {
} }
} }
private func forkResponse(messageID: String) {
guard viewModel.draftKind == nil,
case let .chat(chatID) = viewModel.selectedItem else {
return
}
let selectionAtStart = viewModel.selectedItem
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == nil else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
private var customWorkspaceNavigationBar: some View { private var customWorkspaceNavigationBar: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
workspaceNavigationLeadingControl workspaceNavigationLeadingControl
@@ -13,6 +13,9 @@ private struct MockClientCallSnapshot: Sendable {
var listSearches = 0 var listSearches = 0
var createChat = 0 var createChat = 0
var getChat = 0 var getChat = 0
var forkChat = 0
var lastForkChatID: String?
var lastForkMessageID: String?
var updateChatTitle = 0 var updateChatTitle = 0
var suggestChatTitle = 0 var suggestChatTitle = 0
var updateChatStar = 0 var updateChatStar = 0
@@ -43,6 +46,7 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatDetails: [String: ChatDetail] private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail] private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary? private let createChatResponse: ChatSummary?
private let forkChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary] private let updateChatTitleResponses: [String: ChatSummary]
private let suggestChatTitleResponses: [String: ChatSummary] private let suggestChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary] private let updateChatStarResponses: [String: ChatSummary]
@@ -53,6 +57,7 @@ private actor MockSybilClient: SybilAPIClienting {
private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest? private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]? private var completionStreamEvents: [CompletionStreamEvent]?
private var completionStreamPostEventNetworkErrorMessage: String?
private var listChatsDelayNanoseconds: UInt64 = 0 private var listChatsDelayNanoseconds: UInt64 = 0
private var listSearchesDelayNanoseconds: UInt64 = 0 private var listSearchesDelayNanoseconds: UInt64 = 0
private var getChatDelayNanoseconds: UInt64 = 0 private var getChatDelayNanoseconds: UInt64 = 0
@@ -61,6 +66,7 @@ private actor MockSybilClient: SybilAPIClienting {
private var completionStreamDelayNanoseconds: UInt64 = 0 private var completionStreamDelayNanoseconds: UInt64 = 0
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:] private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
private var completionAttachDelayNanoseconds: UInt64 = 0 private var completionAttachDelayNanoseconds: UInt64 = 0
private var chatDetailResponseSequences: [String: [ChatDetail]] = [:]
private var searchStreamNetworkErrorMessage: String? private var searchStreamNetworkErrorMessage: String?
private var searchStreamDelayNanoseconds: UInt64 = 0 private var searchStreamDelayNanoseconds: UInt64 = 0
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:] private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
@@ -72,6 +78,7 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:], chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:], searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil, createChatResponse: ChatSummary? = nil,
forkChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:], updateChatTitleResponses: [String: ChatSummary] = [:],
suggestChatTitleResponses: [String: ChatSummary] = [:], suggestChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:], updateChatStarResponses: [String: ChatSummary] = [:],
@@ -85,6 +92,7 @@ private actor MockSybilClient: SybilAPIClienting {
self.chatDetails = chatDetails self.chatDetails = chatDetails
self.searchDetails = searchDetails self.searchDetails = searchDetails
self.createChatResponse = createChatResponse self.createChatResponse = createChatResponse
self.forkChatResponse = forkChatResponse
self.updateChatTitleResponses = updateChatTitleResponses self.updateChatTitleResponses = updateChatTitleResponses
self.suggestChatTitleResponses = suggestChatTitleResponses self.suggestChatTitleResponses = suggestChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses self.updateChatStarResponses = updateChatStarResponses
@@ -113,6 +121,10 @@ private actor MockSybilClient: SybilAPIClienting {
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
} }
func setCompletionStreamPostEventNetworkError(_ message: String) {
completionStreamPostEventNetworkErrorMessage = message
}
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
completionStreamNetworkErrorMessage = message completionStreamNetworkErrorMessage = message
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
@@ -127,6 +139,10 @@ private actor MockSybilClient: SybilAPIClienting {
getChatDelayNanoseconds = delayNanoseconds getChatDelayNanoseconds = delayNanoseconds
} }
func setChatDetailResponses(chatID: String, responses: [ChatDetail]) {
chatDetailResponseSequences[chatID] = responses
}
func setGetSearchDelay(_ delayNanoseconds: UInt64) { func setGetSearchDelay(_ delayNanoseconds: UInt64) {
getSearchDelayNanoseconds = delayNanoseconds getSearchDelayNanoseconds = delayNanoseconds
} }
@@ -199,12 +215,27 @@ private actor MockSybilClient: SybilAPIClienting {
if getChatDelayNanoseconds > 0 { if getChatDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: getChatDelayNanoseconds) 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 { guard let detail = chatDetails[chatID] else {
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
return detail return detail
} }
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary {
snapshot.forkChat += 1
snapshot.lastForkChatID = chatID
snapshot.lastForkMessageID = messageID
guard let forkChatResponse else {
throw UnexpectedClientCall()
}
return forkChatResponse
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary { func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
snapshot.updateChatTitle += 1 snapshot.updateChatTitle += 1
guard let summary = updateChatTitleResponses[chatID] else { guard let summary = updateChatTitleResponses[chatID] else {
@@ -319,6 +350,9 @@ private actor MockSybilClient: SybilAPIClienting {
for event in completionStreamEvents { for event in completionStreamEvents {
await onEvent(event) await onEvent(event)
} }
if let completionStreamPostEventNetworkErrorMessage {
throw APIError.networkError(message: completionStreamPostEventNetworkErrorMessage)
}
return return
} }
throw UnexpectedClientCall() throw UnexpectedClientCall()
@@ -457,6 +491,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 { @Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let legacySummary = try decoder.decode( let legacySummary = try decoder.decode(
@@ -543,6 +596,63 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(toolMessage.id == "tool-a") #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 @MainActor
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws { @Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
let defaults = UserDefaults(suiteName: #function)! let defaults = UserDefaults(suiteName: #function)!
@@ -681,6 +791,63 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.errorMessage == nil) #expect(viewModel.errorMessage == nil)
} }
@MainActor
@Test func wholeChatForkAddsReturnedChatToWorkspace() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_160)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "chat-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of Chat chat-source"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
viewModel.selectedItem = .chat(source.id)
let result = await viewModel.forkChat(chatID: source.id)
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == nil)
#expect(result == forked)
#expect(viewModel.chats.first == forked)
#expect(viewModel.workspaceItems.first == WorkspaceItem(chat: forked))
#expect(viewModel.selectedItem == .chat(source.id))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func responseForkPassesAssistantMessageID() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_165)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "response-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of 'A useful response'"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
let result = await viewModel.forkChat(chatID: source.id, messageID: "assistant-message")
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == "assistant-message")
#expect(result == forked)
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat(forked.id) }))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor @MainActor
@Test func starringItemsUpdatesSidebarState() async throws { @Test func starringItemsUpdatesSidebarState() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_175) let date = Date(timeIntervalSince1970: 1_700_000_175)
@@ -848,6 +1015,29 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(!viewModel.isLoadingSelection) #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 @MainActor
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws { @Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_240) let date = Date(timeIntervalSince1970: 1_700_000_240)
@@ -1072,6 +1262,60 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(!viewModel.isQuickQuestionSending) #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 @MainActor
@Test func quickQuestionConvertCreatesSeededChat() async throws { @Test func quickQuestionConvertCreatesSeededChat() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_250) let date = Date(timeIntervalSince1970: 1_700_000_250)
@@ -1184,6 +1428,44 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.displayedMessages.last?.content == "streaming") #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 @MainActor
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws { @Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_270) let date = Date(timeIntervalSince1970: 1_700_000_270)
+6 -2
View File
@@ -17,7 +17,9 @@ Hiding the panel preserves the draft and answer and lets an in-flight answer fin
**Open in Chat** saves the question and answer in the regular workspace. **Open in Chat** saves the question and answer in the regular workspace.
The regular Mac chat/search composer uses the same Return/Shift+Return rules. The regular Mac chat/search composer uses the same Return/Shift+Return rules.
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged. Holding Return does not repeatedly submit. On iOS, Return submits from Quick
Question, while the normal chat/search composer continues to use Return for a
new line.
Closing the last Mac window leaves Sybil running, so Option+Space still works. Closing the last Mac window leaves Sybil running, so Option+Space still works.
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
@@ -66,7 +68,9 @@ action, and does not build or embed the macOS helper.
answer remain available. Reopen the workspace from the panel, then explicitly answer remain available. Reopen the workspace from the panel, then explicitly
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys. quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace - In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline. it. On iOS, verify Return submits Quick Question while Return in the normal
prompt adds a newline. In the Mac workspace, verify Return submits and
Shift+Return adds a newline.
- Check Command+N both with a workspace open and after closing every workspace. - Check Command+N both with a workspace open and after closing every workspace.
Command+Shift+N must open another window even when one already exists, and Command+Shift+N must open another window even when one already exists, and
Command+Option+N must start a search without opening a window. Command+Option+N must start a search without opening a window.
+51 -56
View File
@@ -81,6 +81,16 @@ import {
type SidebarSelection, type SidebarSelection,
} from "@/lib/sidebar-selection"; } from "@/lib/sidebar-selection";
import { buildQuickQuestionRequest } from "@/lib/quick-question"; 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"; import { cn } from "@/lib/utils";
type DraftSelectionKind = "chat" | "search"; type DraftSelectionKind = "chat" | "search";
@@ -2486,7 +2496,7 @@ export default function App() {
role: "assistant", role: "assistant",
content: "", content: "",
name: null, name: null,
metadata: null, metadata: createStreamingAssistantMetadata(),
}; };
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
@@ -2591,7 +2601,7 @@ export default function App() {
while (true) { while (true) {
let streamErrorMessage: string | null = null; let streamErrorMessage: string | null = null;
let replayedAssistantText = ""; let streamingAttempt = createStreamingAttemptState();
const abortController = new AbortController(); const abortController = new AbortController();
chatStreamAbortRefs.current.set(chatId, abortController); chatStreamAbortRefs.current.set(chatId, abortController);
@@ -2609,45 +2619,39 @@ export default function App() {
if (payload.chatId !== chatId) return; if (payload.chatId !== chatId) return;
}, },
onToolCall: (payload) => { onToolCall: (payload) => {
const transition = transitionStreamingToolCall(streamingAttempt, payload);
streamingAttempt = transition.attempt;
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; if (!pendingState) return current;
const messages = transition.startedNewToolCall
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
: pendingState.messages;
return { return {
...current, ...current,
[chatId]: { [chatId]: {
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"), messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
}, },
}; };
}); });
}, },
onDelta: (payload) => { onDelta: (payload) => {
if (!payload.text) return; if (!payload.text) return;
replayedAssistantText += payload.text; streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
const segmentText = streamingAttempt.segmentText;
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; if (!pendingState) return current;
let updated = false; const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
const nextMessages = pendingState.messages.map((message, index, all) => { return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
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;
}); });
}, },
onDone: (payload) => { onDone: (payload) => {
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; if (!pendingState) return current;
let updated = false; const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
const nextMessages = pendingState.messages.map((message, index, all) => { return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
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;
}); });
}, },
onError: (payload) => { onError: (payload) => {
@@ -2896,6 +2900,7 @@ export default function App() {
const abortController = new AbortController(); const abortController = new AbortController();
chatStreamAbortRefs.current.set(chatId, abortController); chatStreamAbortRefs.current.set(chatId, abortController);
let streamErrorMessage: string | null = null; let streamErrorMessage: string | null = null;
let streamingAttempt = createStreamingAttemptState();
try { try {
const baseChat = await getChat(chatId); const baseChat = await getChat(chatId);
@@ -2912,7 +2917,7 @@ export default function App() {
role: "assistant", role: "assistant",
content: "", content: "",
name: null, name: null,
metadata: null, metadata: createStreamingAssistantMetadata(),
}), }),
}, },
}; };
@@ -2922,44 +2927,39 @@ export default function App() {
chatId, chatId,
{ {
onToolCall: (payload) => { onToolCall: (payload) => {
const transition = transitionStreamingToolCall(streamingAttempt, payload);
streamingAttempt = transition.attempt;
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; if (!pendingState) return current;
const messages = transition.startedNewToolCall
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
: pendingState.messages;
return { return {
...current, ...current,
[chatId]: { [chatId]: {
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"), messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
}, },
}; };
}); });
}, },
onDelta: (payload) => { onDelta: (payload) => {
if (!payload.text) return; if (!payload.text) return;
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
const segmentText = streamingAttempt.segmentText;
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; if (!pendingState) return current;
let updated = false; const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
const nextMessages = pendingState.messages.map((message, index, all) => { return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
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;
}); });
}, },
onDone: (payload) => { onDone: (payload) => {
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; if (!pendingState) return current;
let updated = false; const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
const nextMessages = pendingState.messages.map((message, index, all) => { return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
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;
}); });
}, },
onError: (payload) => { onError: (payload) => {
@@ -3211,7 +3211,7 @@ export default function App() {
role: "assistant", role: "assistant",
content: "", content: "",
name: null, name: null,
metadata: null, metadata: createStreamingAssistantMetadata(),
}; };
quickQuestionAbortRef.current?.abort(); quickQuestionAbortRef.current?.abort();
@@ -3225,6 +3225,7 @@ export default function App() {
setIsQuickQuestionSending(true); setIsQuickQuestionSending(true);
let streamErrorMessage: string | null = null; let streamErrorMessage: string | null = null;
let streamingAttempt = createStreamingAttemptState();
try { try {
await runQuickQuestionStream( await runQuickQuestionStream(
@@ -3235,33 +3236,26 @@ export default function App() {
}), }),
{ {
onToolCall: (payload) => { onToolCall: (payload) => {
const transition = transitionStreamingToolCall(streamingAttempt, payload);
streamingAttempt = transition.attempt;
setQuickQuestionMessages((current) => { 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) => { onDelta: (payload) => {
if (!payload.text) return; if (!payload.text) return;
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
const segmentText = streamingAttempt.segmentText;
setQuickQuestionMessages((current) => { setQuickQuestionMessages((current) => {
let updated = false; return setStreamingAssistantSegment(current, "temp-assistant-quick-", segmentText);
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;
}); });
}, },
onDone: (payload) => { onDone: (payload) => {
setQuickQuestionMessages((current) => { setQuickQuestionMessages((current) => {
let updated = false; return finalizeStreamingAssistant(current, "temp-assistant-quick-", payload.text);
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;
}); });
}, },
onError: (payload) => { onError: (payload) => {
@@ -3276,6 +3270,7 @@ export default function App() {
} }
} catch (err) { } catch (err) {
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
setQuickQuestionMessages((current) => clearStreamingAssistantTrace(current, "temp-assistant-quick-"));
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) { if (message.includes("bearer token")) {
handleAuthFailure(message); handleAuthFailure(message);
+143 -3
View File
@@ -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 type { ComponentChildren, JSX } from "preact";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { getMessageAttachments, type Message } from "@/lib/api"; import { getMessageAttachments, type Message } from "@/lib/api";
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
import { MarkdownContent } from "@/components/markdown/markdown-content"; import { MarkdownContent } from "@/components/markdown/markdown-content";
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact"; 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<RenderedThinkingTrace | null>(() =>
traceText ? { text: traceText, revision: traceRevision } : null
);
const [outgoingTrace, setOutgoingTrace] = useState<RenderedThinkingTrace | null>(null);
const [reservedTraceHeight, setReservedTraceHeight] = useState(0);
const activeTraceRef = useRef<HTMLDivElement | null>(null);
const outgoingTraceRef = useRef<HTMLDivElement | null>(null);
const exitTimerRef = useRef<number | null>(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 (
<div
className="streaming-assistant-slot"
data-streaming-assistant="true"
data-streaming-phase={phase}
style={reservedTraceHeight ? { minHeight: `${reservedTraceHeight}px` } : undefined}
>
{outgoingTrace ? (
<div
ref={outgoingTraceRef}
key={`outgoing-trace-${outgoingTrace.revision}`}
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-exit"
data-thinking-trace="true"
data-thinking-trace-state="outgoing"
aria-hidden="true"
>
<p className="whitespace-pre-wrap">{outgoingTrace.text}</p>
</div>
) : null}
{activeTrace ? (
<div
ref={activeTraceRef}
key={`active-trace-${activeTrace.revision}`}
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-enter"
data-thinking-trace="true"
data-thinking-trace-state="active"
aria-hidden="true"
>
<p className="whitespace-pre-wrap">{activeTrace.text}</p>
</div>
) : null}
{activeTrace && !hasAnswer ? (
<span className="sr-only" role="status" aria-live="polite">
Assistant is working
</span>
) : null}
{hasAnswer ? (
<div className="streaming-assistant-layer streaming-assistant-answer" data-streaming-answer="true">
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className="leading-[1.82] text-violet-50 [&_a]:text-inherit [&_a]:underline"
/>
</div>
) : null}
{showTyping ? (
<span
className="streaming-assistant-layer inline-flex items-center gap-1"
data-streaming-pending="true"
aria-label="Assistant is typing"
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>
) : null}
</div>
);
}
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) { 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 renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]); const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null); const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
@@ -457,6 +594,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
} }
const isUser = message.role === "user"; const isUser = message.role === "user";
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0; const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
const attachments = getMessageAttachments(message.metadata); const attachments = getMessageAttachments(message.metadata);
return ( return (
@@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
} }
> >
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null} {attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
{isPendingAssistant ? ( {streamingAssistantMetadata ? (
<StreamingAssistantPresentation message={message} isSending={isSending} />
) : isPendingAssistant ? (
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status"> <span className="inline-flex items-center gap-1" aria-label="Assistant is typing" 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: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:140ms]" />
+91
View File
@@ -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 { .md-content {
word-break: break-word; word-break: break-word;
} }
+139
View File
@@ -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<string, unknown>;
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<ToolCallEvent, "toolCallId" | "status">
): 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,
}));
}
+148
View File
@@ -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);
});