Compare commits

...
Author SHA1 Message Date
buzzert dd6cd1ea7b Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s
2026-10-07 22:05:49 -07:00
buzzert 3f9c1409fe ios: remove animated workspace character
TestFlight / Build and upload (push) Successful in 1m40s
2026-10-04 21:48:45 -07:00
buzzert 411a8cebc7 Expose chat-latest and default Quick Question to it
TestFlight / Build and upload (push) Successful in 1m54s
2026-10-02 13:56:44 -07:00
buzzert 14191de8f4 No rounded corners 2026-09-30 21:30:54 -07:00
buzzert 4ef48fad82 remove "open mode" extra text 2026-09-27 22:56:07 -07:00
buzzert 21beab26f1 Replace quick question backdrop blur with dimming 2026-09-27 00:51:38 -07:00
buzzert 050a46d7a8 Better mobile-optimized settings UI 2026-09-19 18:55:17 -07:00
buzzert 3217c3f66e ios: add copy session ID to chat menu
TestFlight / Build and upload (push) Successful in 1m45s
2026-09-06 14:17:42 -07:00
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
39 changed files with 2807 additions and 450 deletions
+13 -2
View File
@@ -31,7 +31,7 @@ Chat upload limits:
```json ```json
{ {
"providers": { "providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null }, "openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null }, "anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }, "xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null }, "gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
@@ -39,7 +39,7 @@ Chat upload limits:
} }
} }
``` ```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation. - OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids. - Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id. - `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message. - The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
@@ -81,6 +81,8 @@ Behavior notes:
### `GET /v1/workspace-items` ### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }` - Response: `{ "items": WorkspaceItem[] }`
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending: - `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json ```json
{ {
@@ -123,6 +125,15 @@ Behavior notes:
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain. - Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model. - `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
History search (`q`) notes:
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
## Chats ## Chats
### `GET /v1/chats` ### `GET /v1/chats`
+1
View File
@@ -109,6 +109,7 @@ Request body:
``` ```
Behavior notes: Behavior notes:
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
- `question` is required, trimmed by the server, and must not be empty. - `question` is required, trimmed by the server, and must not be empty.
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt. - The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata. - Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -49,6 +49,15 @@ actor SybilAPIClient: SybilAPIClienting {
return response.items return response.items
} }
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
try await request(
"/v1/workspace-items",
method: "GET",
queryItems: [URLQueryItem(name: "q", value: query)],
responseType: WorkspaceSearchResponse.self
)
}
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self) let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats return response.chats
@@ -74,6 +83,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)",
@@ -280,9 +299,10 @@ actor SybilAPIClient: SybilAPIClienting {
_ path: String, _ path: String,
method: String, method: String,
body: AnyEncodable? = nil, body: AnyEncodable? = nil,
queryItems: [URLQueryItem] = [],
responseType: Response.Type responseType: Response.Type
) async throws -> Response { ) async throws -> Response {
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false) let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
SybilLog.debug( SybilLog.debug(
SybilLog.network, SybilLog.network,
@@ -334,9 +354,10 @@ actor SybilAPIClient: SybilAPIClienting {
path: String, path: String,
method: String, method: String,
body: AnyEncodable?, body: AnyEncodable?,
acceptsSSE: Bool acceptsSSE: Bool,
queryItems: [URLQueryItem] = []
) throws -> URLRequest { ) throws -> URLRequest {
let url = try buildURL(path: path) let url = try buildURL(path: path, queryItems: queryItems)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = method request.httpMethod = method
request.timeoutInterval = 120 request.timeoutInterval = 120
@@ -359,7 +380,7 @@ actor SybilAPIClient: SybilAPIClienting {
return request return request
} }
private func buildURL(path: String) throws -> URL { private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else { guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
throw APIError.invalidBaseURL throw APIError.invalidBaseURL
} }
@@ -370,6 +391,9 @@ actor SybilAPIClient: SybilAPIClienting {
basePath.removeLast() basePath.removeLast()
} }
components.path = "\(basePath)/\(trimmedPath)" components.path = "\(basePath)/\(trimmedPath)"
if !queryItems.isEmpty {
components.queryItems = (components.queryItems ?? []) + queryItems
}
guard let url = components.url else { guard let url = components.url else {
throw APIError.invalidBaseURL throw APIError.invalidBaseURL
@@ -704,6 +728,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
} }
@@ -3,6 +3,7 @@ import Foundation
protocol SybilAPIClienting: Sendable { protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession func verifySession() async throws -> AuthSession
func listWorkspaceItems() async throws -> [WorkspaceItem] func listWorkspaceItems() async throws -> [WorkspaceItem]
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
func listChats() async throws -> [ChatSummary] func listChats() async throws -> [ChatSummary]
func createChat( func createChat(
title: String?, title: String?,
@@ -11,6 +12,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
@@ -219,6 +219,7 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var title: String? public var title: String?
public var query: String? public var query: String?
public var searchSnippet: String? = nil
public var parentChatId: String? = nil public var parentChatId: String? = nil
public var titleGenerationPending = false public var titleGenerationPending = false
public var createdAt: Date public var createdAt: Date
@@ -302,6 +303,7 @@ extension WorkspaceItem {
case id case id
case title case title
case query case query
case searchSnippet
case parentChatId case parentChatId
case titleGenerationPending case titleGenerationPending
case createdAt case createdAt
@@ -320,6 +322,7 @@ extension WorkspaceItem {
id = try container.decode(String.self, forKey: .id) id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title) title = try container.decodeIfPresent(String.self, forKey: .title)
query = try container.decodeIfPresent(String.self, forKey: .query) query = try container.decodeIfPresent(String.self, forKey: .query)
searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId) parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt) createdAt = try container.decode(Date.self, forKey: .createdAt)
@@ -730,6 +733,11 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary] var searches: [SearchSummary]
} }
struct WorkspaceSearchResponse: Codable, Sendable {
var items: [WorkspaceItem]
var hasMore: Bool
}
struct WorkspaceListResponse: Codable { struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem] var items: [WorkspaceItem]
} }
@@ -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))
@@ -239,6 +242,7 @@ struct SybilPhoneShellView: View {
} }
private func showRoute(_ nextRoute: PhoneRoute) { private func showRoute(_ nextRoute: PhoneRoute) {
dismissSidebarKeyboard()
let update = { let update = {
route = nextRoute route = nextRoute
} }
@@ -268,12 +272,19 @@ struct SybilPhoneShellView: View {
} }
private func hideSidebarOverlay() { private func hideSidebarOverlay() {
dismissSidebarKeyboard()
withAnimation(.easeOut(duration: 0.18)) { withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = false isSidebarOverlayPresented = false
} }
resetSidebarSwipe(animated: false) resetSidebarSwipe(animated: false)
} }
private func dismissSidebarKeyboard() {
if isSidebarOverlayPresented {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
private func openSidebarSelection(_ selection: SidebarSelection) { private func openSidebarSelection(_ selection: SidebarSelection) {
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection { if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
return return
@@ -511,6 +522,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 +599,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 +608,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)
@@ -52,9 +52,9 @@ final class SybilSettingsStore {
self.preferredModelByProvider = preferredModels self.preferredModelByProvider = preferredModels
self.quickQuestionPreferredProvider = self.quickQuestionPreferredProvider =
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.quickQuestionPreferredModelByProvider = [ self.quickQuestionPreferredModelByProvider = [
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini", .openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest", .anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini", .xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash", .gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
@@ -1,5 +1,6 @@
import Observation import Observation
import SwiftUI import SwiftUI
import UIKit
struct SybilSidebarView: View { struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
@@ -111,8 +112,10 @@ 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 = ""
@FocusState private var isSearchFocused: Bool
private var isRenameAlertPresented: Binding<Bool> { private var isRenameAlertPresented: Binding<Bool> {
Binding { Binding {
@@ -126,37 +129,98 @@ struct SybilSidebarItemList: View {
} }
var body: some View { var body: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(SybilTheme.textMuted)
TextField("Search chats and web searches", text: Binding(
get: { viewModel.sidebarSearchQuery },
set: { viewModel.setSidebarSearchQuery($0) }
))
.font(.sybil(.subheadline))
.foregroundStyle(SybilTheme.text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($isSearchFocused)
.accessibilityLabel("Search conversation history and saved web searches")
.onSubmit {
isSearchFocused = false
Task { await viewModel.refreshSidebarSearch() }
}
if !viewModel.sidebarSearchQuery.isEmpty {
Button {
viewModel.setSidebarSearchQuery("")
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(SybilTheme.textMuted)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear history search")
}
}
.padding(12)
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
.padding(10)
itemList
}
}
private var itemList: some View {
Group { Group {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { if viewModel.isSearchingSidebar ||
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ProgressView() ProgressView()
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
Text("Loading conversations…") Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
.font(.sybil(.footnote)) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16) .padding(16)
} else if viewModel.sidebarItems.isEmpty { } else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
VStack(spacing: 12) {
Text(error)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger)
.multilineTextAlignment(.center)
Button("Try again") {
Task { await viewModel.refreshSidebarSearch() }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else if viewModel.visibleSidebarItems.isEmpty {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "message.badge") Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.") Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
.font(.sybil(.footnote)) .font(.sybil(.footnote))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
if viewModel.isSidebarSearchActive {
Text("Try a title, a query, or words from a conversation.")
.font(.sybil(.caption))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16) .padding(16)
} else { } else {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in ForEach(viewModel.visibleSidebarItems) { item in
Button { Button {
isSearchFocused = false
onSelect(item) onSelect(item)
} label: { } label: {
SybilSidebarRow(item: item, isSelected: isSelected(item)) SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.contextMenu { .contextMenu {
@@ -168,13 +232,26 @@ struct SybilSidebarItemList: View {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star") Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
} }
if item.kind == .chat { if case let .chat(chatID) = item.selection {
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
} label: { } label: {
Label("Rename", systemImage: "pencil") Label("Rename", systemImage: "pencil")
} }
Button {
UIPasteboard.general.string = chatID
} label: {
Label("Copy Session ID", systemImage: "doc.on.doc")
}
} }
Button(role: .destructive) { Button(role: .destructive) {
@@ -186,9 +263,16 @@ struct SybilSidebarItemList: View {
} }
} }
} }
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
Text("Showing the 100 most recent matches. Refine your search to find more.")
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
.padding(12)
}
} }
.padding(10) .padding(10)
} }
.scrollDismissesKeyboard(.interactively)
.refreshable { .refreshable {
await viewModel.refreshSidebarCollectionsFromPullToRefresh() await viewModel.refreshSidebarCollectionsFromPullToRefresh()
} }
@@ -215,11 +299,52 @@ 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 {
var item: SidebarItem var item: SidebarItem
var isSelected: Bool var isSelected: Bool
var searchQuery = ""
private func highlightedSnippet(_ snippet: String) -> AttributedString {
var highlighted = AttributedString(snippet)
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
guard let range = Range(match.range, in: searchQuery) else { return nil }
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
}.filter { !$0.isEmpty }
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
guard let range = Range(word.range, in: snippet) else { continue }
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
guard terms.contains(where: { normalized.hasPrefix($0) }),
let start = AttributedString.Index(range.lowerBound, within: highlighted),
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
}
return highlighted
}
private var isHighlighted: Bool { private var isHighlighted: Bool {
isSelected isSelected
@@ -281,6 +406,15 @@ struct SybilSidebarRow: View {
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
} }
if let snippet = item.searchSnippet, !snippet.isEmpty {
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.truncationMode(.middle)
.multilineTextAlignment(.leading)
}
} }
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -38,11 +38,75 @@ struct SidebarItem: Identifiable, Hashable {
var starredAt: Date? var starredAt: Date?
var initiatedLabel: String? var initiatedLabel: String?
var isRunning: Bool var isRunning: Bool
var searchSnippet: String? = nil
}
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 {
@@ -98,6 +162,15 @@ final class SybilViewModel {
var chats: [ChatSummary] = [] var chats: [ChatSummary] = []
var searches: [SearchSummary] = [] var searches: [SearchSummary] = []
var workspaceItems: [WorkspaceItem] = [] var workspaceItems: [WorkspaceItem] = []
private(set) var sidebarSearchQuery = ""
private(set) var sidebarSearchResults: [WorkspaceItem] = []
private(set) var isSearchingSidebar = false
private(set) var sidebarSearchError: String?
private(set) var sidebarSearchHasMore = false
@ObservationIgnored
private var sidebarSearchTask: Task<Void, Never>?
@ObservationIgnored
private var sidebarSearchRequestID = UUID()
var selectedItem: SidebarSelection? var selectedItem: SidebarSelection?
var selectedChat: ChatDetail? var selectedChat: ChatDetail?
@@ -107,6 +180,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 +199,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
@@ -157,7 +232,7 @@ final class SybilViewModel {
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
private let fallbackModels: [Provider: [String]] = [ private let fallbackModels: [Provider: [String]] = [
.openai: ["gpt-4.1-mini"], .openai: ["gpt-4.1-mini", "chat-latest"],
.anthropic: ["claude-3-5-sonnet-latest"], .anthropic: ["claude-3-5-sonnet-latest"],
.xai: ["grok-3-mini"], .xai: ["grok-3-mini"],
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"], .gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
@@ -360,6 +435,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] {
@@ -393,7 +492,19 @@ final class SybilViewModel {
} }
var sidebarItems: [SidebarItem] { var sidebarItems: [SidebarItem] {
workspaceItems.map { item in makeSidebarItems(workspaceItems)
}
var isSidebarSearchActive: Bool {
!sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var visibleSidebarItems: [SidebarItem] {
isSidebarSearchActive ? makeSidebarItems(sidebarSearchResults) : sidebarItems
}
private func makeSidebarItems(_ items: [WorkspaceItem]) -> [SidebarItem] {
items.map { item in
switch item.type { switch item.type {
case .chat: case .chat:
let initiatedLabel: String? let initiatedLabel: String?
@@ -407,7 +518,8 @@ final class SybilViewModel {
initiatedLabel = nil initiatedLabel = nil
} }
let starOwner = item.parentChatId.flatMap { parentChatID in let starOwner = item.parentChatId.flatMap { parentChatID in
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID }) workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID }) ??
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == parentChatID })
} ?? item } ?? item
return SidebarItem( return SidebarItem(
@@ -418,7 +530,8 @@ final class SybilViewModel {
starred: starOwner.starred, starred: starOwner.starred,
starredAt: starOwner.starredAt, starredAt: starOwner.starredAt,
initiatedLabel: initiatedLabel, initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id) isRunning: isChatRowRunning(item.id),
searchSnippet: item.searchSnippet
) )
case .search: case .search:
@@ -430,20 +543,70 @@ final class SybilViewModel {
starred: item.starred, starred: item.starred,
starredAt: item.starredAt, starredAt: item.starredAt,
initiatedLabel: "exa", initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id) isRunning: isSearchRowRunning(item.id),
searchSnippet: item.searchSnippet
) )
} }
} }
} }
func setSidebarSearchQuery(_ query: String) {
let previous = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
sidebarSearchQuery = query
guard query.trimmingCharacters(in: .whitespacesAndNewlines) != previous else { return }
scheduleSidebarSearch()
}
func refreshSidebarSearch() async {
guard isSidebarSearchActive else { return }
scheduleSidebarSearch(debounce: false)
await sidebarSearchTask?.value
}
private func scheduleSidebarSearch(debounce: Bool = true) {
sidebarSearchTask?.cancel()
let requestID = UUID()
sidebarSearchRequestID = requestID
sidebarSearchResults = []
sidebarSearchError = nil
sidebarSearchHasMore = false
isSearchingSidebar = false
let query = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty, isAuthenticated else { return }
guard query.utf16.count <= 200 else {
sidebarSearchError = "Use 200 characters or fewer."
return
}
isSearchingSidebar = true
sidebarSearchTask = Task { [weak self] in
do {
if debounce { try await Task.sleep(for: .milliseconds(300)) }
guard let self, !Task.isCancelled else { return }
let response = try await self.client().searchWorkspaceItems(query: query)
guard !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
self.sidebarSearchResults = response.items
self.sidebarSearchHasMore = response.hasMore
self.isSearchingSidebar = false
} catch {
guard let self, !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
self.isSearchingSidebar = false
if !self.isCancellation(error) {
self.sidebarSearchError = self.normalizeAPIError(error)
}
}
}
}
var selectedChatSummary: ChatSummary? { var selectedChatSummary: ChatSummary? {
guard case let .chat(chatID) = selectedItem else { return nil } guard case let .chat(chatID) = selectedItem else { return nil }
return chats.first(where: { $0.id == chatID }) return chats.first(where: { $0.id == chatID }) ??
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.chatSummary
} }
var selectedSearchSummary: SearchSummary? { var selectedSearchSummary: SearchSummary? {
guard case let .search(searchID) = selectedItem else { return nil } guard case let .search(searchID) = selectedItem else { return nil }
return searches.first(where: { $0.id == searchID }) return searches.first(where: { $0.id == searchID }) ??
sidebarSearchResults.first(where: { $0.type == .search && $0.id == searchID })?.searchSummary
} }
var hasRefreshableSelection: Bool { var hasRefreshableSelection: Bool {
@@ -470,6 +633,7 @@ final class SybilViewModel {
} }
func reconnect() async { func reconnect() async {
setSidebarSearchQuery("")
isCheckingSession = true isCheckingSession = true
authError = nil authError = nil
errorMessage = nil errorMessage = nil
@@ -620,6 +784,7 @@ final class SybilViewModel {
quickQuestionTask = nil quickQuestionTask = nil
quickQuestionRunID = nil quickQuestionRunID = nil
isQuickQuestionSending = false isQuickQuestionSending = false
quickQuestionStreamingAssistantState = StreamingAssistantState()
} }
@discardableResult @discardableResult
@@ -775,6 +940,15 @@ final class SybilViewModel {
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? { private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)") SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
// Search can open history outside the recent collection. Retain its
// summary for provider settings, starring, and subsequent refreshes.
if let item = sidebarSearchResults.first(where: {
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == selection
}) {
if let chat = item.chatSummary { applyChatSummary(chat, moveToFront: false) }
if let search = item.searchSummary { applySearchSummary(search, moveToFront: false) }
}
if draftKind == nil, selectedItem == selection { if draftKind == nil, selectedItem == selection {
errorMessage = nil errorMessage = nil
if case .search = selection { if case .search = selection {
@@ -825,7 +999,7 @@ final class SybilViewModel {
} }
private func selectAdjacentSidebarItem(offset: Int) { private func selectAdjacentSidebarItem(offset: Int) {
let items = sidebarItems let items = visibleSidebarItems
guard !items.isEmpty else { guard !items.isEmpty else {
return return
} }
@@ -861,6 +1035,7 @@ final class SybilViewModel {
break break
} }
await refreshCollections(preferredSelection: nil) await refreshCollections(preferredSelection: nil)
await refreshSidebarSearch()
} catch { } catch {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Delete failed", error: error) SybilLog.error(SybilLog.ui, "Delete failed", error: error)
@@ -869,6 +1044,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
@@ -886,6 +1091,7 @@ final class SybilViewModel {
do { do {
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle) let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
applyChatSummary(updated, moveToFront: true) applyChatSummary(updated, moveToFront: true)
await refreshSidebarSearch()
} catch { } catch {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error) SybilLog.error(SybilLog.ui, "Rename failed", error: error)
@@ -985,6 +1191,10 @@ final class SybilViewModel {
} }
func refreshSidebarCollectionsFromPullToRefresh() async { func refreshSidebarCollectionsFromPullToRefresh() async {
if isSidebarSearchActive {
await refreshSidebarSearch()
return
}
guard isAuthenticated, !isCheckingSession else { guard isAuthenticated, !isCheckingSession else {
return return
} }
@@ -1140,6 +1350,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 +1395,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 +1411,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:
@@ -1323,7 +1535,15 @@ final class SybilViewModel {
async let activeRunsValue = client.getActiveRuns() async let activeRunsValue = client.getActiveRuns()
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue) let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
applyWorkspaceItems(nextWorkspaceItems) var items = nextWorkspaceItems
// A selected historical search result may be outside the latest 100.
if let preferredSelection,
let selectedSummary = workspaceItems.first(where: {
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == preferredSelection
}), !items.contains(where: { $0.type == selectedSummary.type && $0.id == selectedSummary.id }) {
items.append(selectedSummary)
}
applyWorkspaceItems(items)
applyActiveRuns(nextActiveRuns) applyActiveRuns(nextActiveRuns)
SybilLog.info( SybilLog.info(
@@ -1501,6 +1721,11 @@ final class SybilViewModel {
} }
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) { private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
if let index = sidebarSearchResults.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
var updated = item
updated.searchSnippet = sidebarSearchResults[index].searchSnippet
sidebarSearchResults[index] = updated
}
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) { if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
workspaceItems.remove(at: existingIndex) workspaceItems.remove(at: existingIndex)
if moveToFront { if moveToFront {
@@ -1524,6 +1749,9 @@ final class SybilViewModel {
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId { if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID return parentChatID
} }
if let parentChatID = sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID
}
return chatID return chatID
} }
@@ -1607,21 +1835,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)
selectedSearch = nil if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
selectedSearch = nil
}
} }
pendingChatStates[chatID] = nil
} catch { } catch {
serverActiveChatIDs.remove(chatID) serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil defer {
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 +1870,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)
selectedSearch = nil if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
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 +2094,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 +2290,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 +2474,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 +2543,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 +2571,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 +2726,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
@@ -1,4 +1,3 @@
import ImageIO
import Observation import Observation
import PhotosUI import PhotosUI
import SwiftUI import SwiftUI
@@ -19,6 +18,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
@@ -155,8 +155,6 @@ struct SybilWorkspaceView: View {
workspaceContentStack workspaceContentStack
if showsCustomWorkspaceNavigation { if showsCustomWorkspaceNavigation {
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
.allowsHitTesting(false)
customWorkspaceNavigationBar customWorkspaceNavigationBar
} }
} }
@@ -193,9 +191,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 +230,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
@@ -1349,173 +1372,6 @@ private struct SybilNavigationFadeBackground: View {
} }
} }
private struct SybilWorkspaceCharacterBackdrop: View {
var isBusy: Bool
var body: some View {
ZStack(alignment: .topTrailing) {
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.36),
SybilTheme.primary.opacity(0.13),
Color.clear
],
center: .center,
startRadius: 10,
endRadius: 150
)
.frame(width: 136, height: 118)
.blur(radius: 7)
.offset(x: 28, y: -24)
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
.frame(width: 172, height: 172)
.opacity(0.92)
.mask {
LinearGradient(
colors: [
Color.black,
Color.black,
Color.black.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
}
.mask {
LinearGradient(
colors: [
Color.clear,
Color.black.opacity(0.98),
Color.black
],
startPoint: .leading,
endPoint: .trailing
)
}
.offset(x: 8, y: 4)
}
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
.clipped()
.ignoresSafeArea(edges: .top)
.accessibilityHidden(true)
}
}
private struct SybilAnimatedGIFView: UIViewRepresentable {
var resourceName: String
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIImageView {
let imageView = SybilAnimatedUIImageView()
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = false
imageView.isOpaque = false
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return imageView
}
func updateUIView(_ imageView: UIImageView, context: Context) {
guard context.coordinator.resourceName != resourceName else {
if !imageView.isAnimating {
imageView.startAnimating()
}
return
}
context.coordinator.resourceName = resourceName
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
imageView.startAnimating()
}
final class Coordinator {
var resourceName: String?
}
}
private final class SybilAnimatedUIImageView: UIImageView {
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
}
@MainActor
private enum SybilAnimatedGIFCache {
private static var images: [String: UIImage] = [:]
static func image(named name: String) -> UIImage? {
if let cached = images[name] {
return cached
}
guard let image = loadImage(named: name) else {
return nil
}
images[name] = image
return image
}
private static func loadImage(named name: String) -> UIImage? {
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
Bundle.main.url(forResource: name, withExtension: "gif")
guard let url,
let data = try? Data(contentsOf: url),
let source = CGImageSourceCreateWithData(data as CFData, nil)
else {
return nil
}
let frameCount = CGImageSourceGetCount(source)
guard frameCount > 0 else {
return nil
}
var frames: [UIImage] = []
frames.reserveCapacity(frameCount)
var duration: TimeInterval = 0
for index in 0 ..< frameCount {
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
continue
}
frames.append(UIImage(cgImage: cgImage))
duration += frameDuration(at: index, source: source)
}
guard !frames.isEmpty else {
return nil
}
if frames.count == 1 {
return frames[0]
}
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
}
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
else {
return 0.08
}
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
return max(delay, 0.03)
}
}
private struct NewChatSwipeBackdrop: View { private struct NewChatSwipeBackdrop: View {
var progress: CGFloat var progress: CGFloat
var hasLatched: Bool var hasLatched: Bool
@@ -9,10 +9,14 @@ import UIKit
private struct MockClientCallSnapshot: Sendable { private struct MockClientCallSnapshot: Sendable {
var listWorkspaceItems = 0 var listWorkspaceItems = 0
var workspaceSearchQueries: [String] = []
var listChats = 0 var listChats = 0
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
@@ -40,19 +44,23 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary] private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary] private let searchesResponse: [SearchSummary]
private let workspaceItemsResponse: [WorkspaceItem] private let workspaceItemsResponse: [WorkspaceItem]
private let workspaceSearchHandler: @Sendable (String) async throws -> WorkspaceSearchResponse
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]
private let updateSearchStarResponses: [String: SearchSummary] private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse private let activeRunsResponse: ActiveRunsResponse
private let modelCatalogResponse: ModelCatalogResponse
private var snapshot = MockClientCallSnapshot() private var snapshot = MockClientCallSnapshot()
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 +69,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,24 +81,32 @@ 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] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:], updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(), activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil workspaceItemsResponse: [WorkspaceItem]? = nil,
workspaceSearchHandler: @escaping @Sendable (String) async throws -> WorkspaceSearchResponse = { _ in
WorkspaceSearchResponse(items: [], hasMore: false)
},
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
) { ) {
self.chatsResponse = chatsResponse self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse self.searchesResponse = searchesResponse
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse) self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
self.workspaceSearchHandler = workspaceSearchHandler
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
self.updateSearchStarResponses = updateSearchStarResponses self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse self.activeRunsResponse = activeRunsResponse
self.modelCatalogResponse = modelCatalogResponse
} }
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] { private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
@@ -113,6 +130,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 +148,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
} }
@@ -167,6 +192,11 @@ private actor MockSybilClient: SybilAPIClienting {
return workspaceItemsResponse return workspaceItemsResponse
} }
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
snapshot.workspaceSearchQueries.append(query)
return try await workspaceSearchHandler(query)
}
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1 snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 { if listChatsDelayNanoseconds > 0 {
@@ -199,12 +229,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 {
@@ -275,7 +320,7 @@ private actor MockSybilClient: SybilAPIClienting {
} }
func listModels() async throws -> ModelCatalogResponse { func listModels() async throws -> ModelCatalogResponse {
ModelCatalogResponse(providers: [:]) modelCatalogResponse
} }
func getActiveRuns() async throws -> ActiveRunsResponse { func getActiveRuns() async throws -> ActiveRunsResponse {
@@ -319,6 +364,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 +505,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 +610,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 +805,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 +1029,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)
@@ -1067,11 +1271,66 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(snapshot.runQuickQuestionStream == 1) #expect(snapshot.runQuickQuestionStream == 1)
#expect(snapshot.runCompletionStream == 0) #expect(snapshot.runCompletionStream == 0)
#expect(body?.provider == .openai) #expect(body?.provider == .openai)
#expect(body?.model == "chat-latest")
#expect(body?.question == "How do I reset my password?") #expect(body?.question == "How do I reset my password?")
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.") #expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
#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)
@@ -1126,6 +1385,42 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.quickQuestionPrompt.isEmpty) #expect(viewModel.quickQuestionPrompt.isEmpty)
} }
@MainActor
@Test func quickQuestionDefaultsToChatLatestIndependentlyOfRegularChatPreferences() async {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
defaults.set("anthropic", forKey: "sybil.ios.preferredProvider")
defaults.set("gpt-4o", forKey: "sybil.ios.preferredOpenAIModel")
let settings = SybilSettingsStore(defaults: defaults)
let client = MockSybilClient(modelCatalogResponse: ModelCatalogResponse(providers: [
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "chat-latest"], loadedAt: nil, error: nil),
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest"], loadedAt: nil, error: nil)
]))
let viewModel = SybilViewModel(settings: settings) { _ in client }
#expect(viewModel.provider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
#expect(viewModel.quickQuestionProvider == .openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions.contains("chat-latest"))
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
await viewModel.bootstrap()
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions == ["gpt-4.1-mini", "chat-latest"])
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(SybilSettingsStore(defaults: defaults).quickQuestionPreferredModelByProvider[.openai] == "chat-latest")
#expect(settings.preferredProvider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
}
@MainActor @MainActor
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws { @Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
let defaults = UserDefaults(suiteName: #function)! let defaults = UserDefaults(suiteName: #function)!
@@ -1184,6 +1479,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)
@@ -1676,3 +2009,173 @@ private final class MockQuickQuestionLifecycle {
controller.stop() controller.stop()
} }
#endif #endif
// Deliberately ignores cancellation so stale-response protection is exercised.
private actor WorkspaceSearchGate {
private var pending: [String: CheckedContinuation<WorkspaceSearchResponse, any Error>] = [:]
private var observers: [String: CheckedContinuation<Void, Never>] = [:]
func response(for query: String) async throws -> WorkspaceSearchResponse {
try await withCheckedThrowingContinuation { continuation in
pending[query] = continuation
observers.removeValue(forKey: query)?.resume()
}
}
func waitUntilRequested(_ query: String) async {
if pending[query] != nil { return }
await withCheckedContinuation { observers[query] = $0 }
}
func complete(_ query: String, with response: WorkspaceSearchResponse) {
pending.removeValue(forKey: query)?.resume(returning: response)
}
func fail(_ query: String) {
pending.removeValue(forKey: query)?.resume(throwing: APIError.networkError(message: "Offline"))
}
}
@MainActor
@Test func historySearchKeepsRecentItemsAndOpensOlderChat() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let recent = makeChatSummary(id: "recent", date: date)
let old = makeChatSummary(id: "old", date: date.addingTimeInterval(-100))
var match = WorkspaceItem(chat: old)
match.searchSnippet = "The nebula we discussed"
let response = WorkspaceSearchResponse(items: [match, WorkspaceItem(search: makeSearchSummary(id: "web", date: date))], hasMore: true)
let client = MockSybilClient(
chatsResponse: [recent],
chatDetails: ["old": makeChatDetail(id: "old", date: date, body: "The nebula we discussed")],
workspaceSearchHandler: { _ in response }
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.workspaceItems = [WorkspaceItem(chat: recent)]
viewModel.chats = [recent]
viewModel.selectedItem = .chat("recent")
viewModel.setSidebarSearchQuery(" nebula ")
await viewModel.refreshSidebarSearch()
#expect(viewModel.selectedItem == .chat("recent"))
#expect(viewModel.workspaceItems == [WorkspaceItem(chat: recent)])
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("old"), .search("web")])
#expect(viewModel.visibleSidebarItems.first?.searchSnippet == "The nebula we discussed")
#expect(viewModel.sidebarSearchHasMore)
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
await viewModel.selectForNavigation(.chat("old"))
#expect(viewModel.selectedChat?.id == "old")
viewModel.setSidebarSearchQuery("")
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
#expect(viewModel.selectedItem == .chat("old"))
#expect(viewModel.selectedChat?.id == "old")
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat("old") }))
}
@MainActor
@Test func historySearchDebouncesAndClearingRestoresRecentItems() async throws {
let client = MockSybilClient()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
let recent = WorkspaceItem(chat: makeChatSummary(id: "recent", date: Date()))
viewModel.workspaceItems = [recent]
viewModel.setSidebarSearchQuery("n")
viewModel.setSidebarSearchQuery("ne")
viewModel.setSidebarSearchQuery("nebula")
#expect(viewModel.isSearchingSidebar)
#expect(await client.currentSnapshot().workspaceSearchQueries.isEmpty)
try await Task.sleep(for: .milliseconds(600))
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
#expect(viewModel.visibleSidebarItems.isEmpty)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery(" ")
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("recent")])
#expect(!viewModel.isSidebarSearchActive)
#expect(viewModel.sidebarSearchError == nil)
}
@MainActor
@Test func historySearchDiscardsOutOfOrderAndClearedResponses() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.setSidebarSearchQuery("first")
let first = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("first")
viewModel.setSidebarSearchQuery("second")
let second = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("second")
let match = WorkspaceItem(chat: makeChatSummary(id: "second", date: Date()))
await gate.complete("second", with: WorkspaceSearchResponse(items: [match], hasMore: false))
await second.value
await gate.fail("first")
await first.value
#expect(viewModel.sidebarSearchResults == [match])
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery("third")
let third = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("third")
viewModel.setSidebarSearchQuery("")
await gate.complete("third", with: WorkspaceSearchResponse(items: [match], hasMore: true))
await third.value
#expect(viewModel.sidebarSearchResults.isEmpty)
#expect(!viewModel.sidebarSearchHasMore)
#expect(!viewModel.isSearchingSidebar)
}
@MainActor
@Test func historySearchFailureCanRetryWithoutChangingSelection() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.selectedItem = .chat("current")
viewModel.setSidebarSearchQuery("nebula")
let failed = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.fail("nebula")
await failed.value
#expect(viewModel.sidebarSearchError != nil)
#expect(viewModel.selectedItem == .chat("current"))
let retry = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.complete("nebula", with: WorkspaceSearchResponse(items: [], hasMore: false))
await retry.value
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
#expect(viewModel.selectedItem == .chat("current"))
viewModel.setSidebarSearchQuery(String(repeating: "a", count: 201))
#expect(viewModel.sidebarSearchError == "Use 200 characters or fewer.")
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula", "nebula"])
}
private final class HistorySearchURLProtocol: URLProtocol, @unchecked Sendable {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let url = request.url!
#expect(url.path == "/api/v1/workspace-items")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "q", value: "café + AI & #ideas?")])
let json = #"{"items":[{"type":"chat","id":"old","title":"Notes","createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z","searchSnippet":"Some café ideas"}],"hasMore":false}"#
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data(json.utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@Test func historySearchEncodesQueryAndDecodesSnippet() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [HistorySearchURLProtocol.self]
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: APIConfiguration(baseURL: URL(string: "https://example.com/api")!, authToken: "test-token"), session: session)
let response = try await client.searchWorkspaceItems(query: "café + AI & #ideas?")
#expect(response.items.first?.searchSnippet == "Some café ideas")
#expect(!response.hasMore)
}
+17 -2
View File
@@ -5,6 +5,14 @@ Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
project from `project.yml`. The Mac recipes use ad-hoc signing for local development; project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
release/archive signing settings are unchanged. release/archive signing settings are unchanged.
The conversation sidebar includes **Search chats and web searches** on iPhone,
iPad, and Mac. It searches all saved history by title, conversation text, and saved
web-search query/answer, with single-line excerpts that bold matching words and
truncate in the middle. Search waits briefly while typing,
cancels superseded requests, and leaves the current conversation open. Clear the
field to return to recent items. Searches use word prefixes (for example, `plan`
finds `planning`); if more than 100 items match, refine the query.
## Quick Question on macOS ## Quick Question on macOS
While the Catalyst app is running, **Option+Space** toggles a floating Quick While the Catalyst app is running, **Option+Space** toggles a floating Quick
@@ -16,8 +24,13 @@ second **Option+Space**, the close button, or clicking outside dismisses it.
Hiding the panel preserves the draft and answer and lets an in-flight answer finish. Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
**Open in Chat** saves the question and answer in the regular workspace. **Open in Chat** saves the question and answer in the regular workspace.
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
provider/model selection takes precedence and is separate from regular chat preferences.
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 +79,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.
@@ -0,0 +1,70 @@
-- External-content indexes avoid storing a second copy of conversation text.
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
title, content='Chat', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
-- Hidden system prompts and tool payloads are not searchable conversation text.
CREATE VIEW "MessageSearchContent" AS
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
content, content='MessageSearchContent', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
title, query, answerText, content='Search', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
END;
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
WHEN old.title IS NOT new.title BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
WHEN new.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
WHEN old.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
INSERT INTO "MessageSearch" (rowid, content)
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
END;
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
-- Index existing history during migration, then maintain it transactionally.
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
+1
View File
@@ -76,6 +76,7 @@ model Chat {
@@index([userId]) @@index([userId])
@@index([parentChatId]) @@index([parentChatId])
@@index([updatedAt])
} }
model Message { model Message {
+1 -1
View File
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false; if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false; if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
if (id.includes("search") || id.includes("computer-use")) return false; if (id.includes("search") || id.includes("computer-use")) return false;
return /^(gpt-|o\d|chatgpt-)/.test(id); return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
} }
function isLikelyGeminiChatModel(model: string) { function isLikelyGeminiChatModel(model: string) {
+32
View File
@@ -16,6 +16,7 @@ import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js"; import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js"; import { exaClient } from "./search/exa.js";
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js"; import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
import { searchWorkspace } from "./workspace-search.js";
import type { ChatAttachment } from "./llm/types.js"; import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]); const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
@@ -927,6 +928,37 @@ export async function registerRoutes(app: FastifyInstance) {
app.get("/v1/workspace-items", async (req) => { app.get("/v1/workspace-items", async (req) => {
requireAdmin(req); requireAdmin(req);
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
if (parsed.data.q) {
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
select: {
...chatSummarySelect,
parentChat: { select: { projectItems: starredProjectItemsSelect } },
},
}),
prisma.search.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
select: searchSummarySelect,
}),
]);
const summaries = new Map<string, Record<string, unknown>>([
...chats.map(({ parentChat, ...chat }) => [
`chat:${chat.id}`,
// Search omits unmatched roots, but sidebar fork stars belong to the root.
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
] as const),
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
]);
const items = hits.flatMap((hit) => {
const summary = summaries.get(`${hit.type}:${hit.id}`);
return summary ? [{ ...summary, ...hit }] : [];
});
return { items, hasMore };
}
return { items: await listWorkspaceItems() }; return { items: await listWorkspaceItems() };
}); });
+74
View File
@@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
import { prisma } from "./db.js";
export const WORKSPACE_SEARCH_LIMIT = 100;
// Treat input as literal Unicode words, never FTS operators/column expressions.
// Prefix matching supports type-ahead, including short terms such as "AI".
export function workspaceSearchExpression(query: string): string | null {
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
}
export type WorkspaceSearchHit = {
type: "chat" | "search";
id: string;
searchSnippet: string | null;
};
export function workspaceSearchSQL(expression: string) {
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
return Prisma.sql`
WITH chat_hits AS (
SELECT c.id, NULL AS message_rowid
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
WHERE "ChatSearch" MATCH ${expression}
UNION ALL
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
WHERE "MessageSearch" MATCH ${expression}
GROUP BY m.chatId
), matches AS (
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
NULL AS search_rowid
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
GROUP BY c.id
UNION ALL
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
WHERE "SavedSearchSearch" MATCH ${expression}
), limited AS MATERIALIZED (
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
), message_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
FROM "MessageSearch"
WHERE "MessageSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
AND +rowid IN (SELECT message_rowid FROM limited)
), search_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
FROM "SavedSearchSearch"
WHERE "SavedSearchSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
AND +rowid IN (SELECT search_rowid FROM limited)
)
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
FROM limited
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
ORDER BY updatedAt DESC, type, id
`;
}
export async function searchWorkspace(query: string) {
const expression = workspaceSearchExpression(query);
if (!expression) return { hits: [], hasMore: false };
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
// only for the bounded result set, never fetch full transcripts into JS.
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
}
+2 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import test from "node:test"; import test from "node:test";
@@ -11,6 +11,7 @@ import sensible from "@fastify/sensible";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-")); const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`; process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
writeFileSync(join(databaseDir, "test.db"), "");
process.env.OPENAI_API_KEY = ""; process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN; delete process.env.ADMIN_TOKEN;
+45 -1
View File
@@ -1,6 +1,50 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js"; import { env } from "../src/env.js";
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
for (const { name, listedModels, expectedModels } of [
{
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
listedModels: [
"gpt-4.1-mini",
"chat-latest",
"o3",
"chatgpt-4o-latest",
"chat-latest",
"text-embedding-3-small",
"omni-moderation-latest",
"gpt-4o-audio-preview",
"gpt-realtime",
"gpt-4o-transcribe",
"gpt-4o-mini-tts",
"gpt-image-1",
"chatgpt-image-latest",
"gpt-4o-search-preview",
"computer-use-preview",
"sora-2",
],
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
},
{
name: "OpenAI catalog does not invent models absent from the provider response",
listedModels: ["gpt-4.1-mini"],
expectedModels: ["gpt-4.1-mini"],
},
]) {
test(name, async (t) => {
const originalKey = env.OPENAI_API_KEY;
env.OPENAI_API_KEY = "test-openai-key";
t.after(() => { env.OPENAI_API_KEY = originalKey; });
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
assert.equal(url, "https://api.openai.com/v1/models");
return Response.json({ data: listedModels.map((id) => ({ id })) });
});
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
});
}
test("provider backend registry selects chat protocol and managed-tool mode", () => { test("provider backend registry selects chat protocol and managed-tool mode", () => {
assert.deepEqual(describeProviderChatBackend("openai", []), { assert.deepEqual(describeProviderChatBackend("openai", []), {
+188
View File
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import sensible from "@fastify/sensible";
import { Prisma } from "@prisma/client";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
const migration = "20261007000000_add_workspace_full_text_search";
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
writeFileSync(join(databaseDir, "test.db"), "");
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
}
function migrate() {
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
cwd: serverRoot, env: process.env, stdio: "pipe",
});
}
migrate();
const { prisma } = await import("../src/db.js");
const oldChat = await prisma.chat.create({ data: {
title: "Old constellation notes",
messages: { create: [
{ role: "user", content: "We photographed a nebula before the migration." },
{ role: "system", content: "secretmigrationprompt" },
] },
} });
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
migrate();
const { registerRoutes } = await import("../src/routes.js");
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
const app = Fastify({ logger: false });
await app.register(sensible);
await registerRoutes(app);
await app.ready();
async function search(query: string) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
assert.equal(response.statusCode, 200, response.body);
return response.json();
}
test.afterEach(async () => {
await prisma.chat.deleteMany();
await prisma.search.deleteMany();
});
test.after(async () => {
await app.close();
await prisma.$disconnect();
rmSync(databaseDir, { recursive: true, force: true });
});
test("migration indexes existing titles, messages, queries, and answers", async () => {
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
assert.equal((await search("constellation")).items.length, 2);
assert.equal((await search("secretmigrationprompt")).items.length, 0);
});
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
const chat = await prisma.chat.create({ data: {
title: "Travel notes", updatedAt: new Date("2000-01-01"),
messages: { create: [
{ role: "user", content: "Let's discuss café planning in Zürich" },
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
{ role: "system", content: "hiddensystemvalue" },
{ role: "tool", content: "hiddentoolvalue" },
] },
} });
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
const result = await search("CAFE plan");
assert.equal(result.hasMore, false);
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
assert.match(result.items[0].searchSnippet, /café planning/i);
assert.equal(result.items[0].messages, undefined);
assert.equal((await search("zuri")).items.length, 1);
assert.equal((await search("hiddensystemvalue")).items.length, 0);
assert.equal((await search("hiddentoolvalue")).items.length, 0);
});
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
const chat = await prisma.chat.create({ data: {
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
}, include: { messages: true } });
const messageID = chat.messages[0].id;
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
assert.equal((await search("original")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
assert.equal((await search("replacementbody")).items.length, 0);
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
assert.equal((await search("replacementbody")).items.length, 1);
await assert.rejects(prisma.$transaction(async (tx) => {
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
throw new Error("rollback");
}));
assert.equal((await search("rolledbackvalue")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
assert.equal(fork.statusCode, 200, fork.body);
assert.equal((await search("replacementbody")).items.length, 2);
await prisma.message.delete({ where: { id: messageID } });
const onlyFork = (await search("replacementbody")).items;
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
assert.equal(starResponse.statusCode, 200, starResponse.body);
const starredFork = (await search("replacementbody")).items[0];
assert.equal(starredFork.starred, true);
assert.ok(starredFork.starredAt);
assert.equal(starredFork.parentChat, undefined);
await prisma.chat.delete({ where: { id: chat.id } });
assert.equal((await search("replacement")).items.length, 0);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
}
});
test("saved searches match query and answer, and update/delete their index", async () => {
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
assert.equal((await search("ai des")).items[0].type, "search");
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
assert.equal((await search("nebula")).items.length, 0);
assert.equal((await search("design")).items.length, 0);
assert.equal((await search("Bookmarks")).items.length, 0);
assert.equal((await search("newanswer")).items.length, 1);
await prisma.search.delete({ where: { id: saved.id } });
assert.equal((await search("new")).items.length, 0);
});
test("literal queries, validation, blank queries, and bounded snippets", async () => {
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
assert.equal((await search('"AI" OR safety*')).items.length, 1);
assert.equal((await search("AI OR other")).items.length, 0);
assert.equal((await search("AI")).items.length, 1);
assert.equal((await search("*** : () --")).items.length, 0);
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.deepEqual(await search(" "), recent);
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
assert.equal(response.statusCode, 400, response.body);
}
});
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
const date = new Date("2026-01-01");
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
const result = await search("common");
assert.equal(result.items.length, 100);
assert.equal(result.hasMore, true);
assert.equal(result.items[0].id, "saved");
assert.equal(result.items[1].id, "chat-000");
assert.equal(result.items.at(-1).id, "chat-098");
});
test("query plan uses all three FTS indexes", async () => {
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
}
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
});
test("vacuum preserves external-content index row identities", async () => {
const removed = await prisma.chat.create({ data: { title: "removed" } });
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
await prisma.chat.delete({ where: { id: removed.id } });
await prisma.$executeRaw`VACUUM`;
assert.equal((await search("kepttitle")).items[0].id, kept.id);
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
});
+7
View File
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
## UI ## UI
- Left panel: mixed list of chat conversations and Exa searches. - Left panel: mixed list of chat conversations and Exa searches.
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
conversation messages, and web-search queries/answers. It waits 300 ms while
typing and cancels superseded requests. Results include a single-line matching excerpt
with matching words in bold and middle truncation
(or “Title match” for title-only chat matches), with up to 100 newest matches.
Clear the field or press Escape to return to recent items. Matching uses word
prefixes, so `plan` also finds `planning`.
- Right panel: - Right panel:
- Chat mode: transcript + provider/model controls. - Chat mode: transcript + provider/model controls.
- Search mode: top AI answer block + Google-style Exa results view. - Search mode: top AI answer block + Google-style Exa results view.
+232 -144
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { TargetedTouchEvent } from "preact"; import type { TargetedTouchEvent } from "preact";
import { import {
Check, Check,
@@ -26,6 +26,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatComposer } from "@/components/chat/chat-composer"; import { ChatComposer } from "@/components/chat/chat-composer";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel"; import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SearchMatchLabel } from "@/components/search/search-match-label";
import { SybilCharacter } from "@/components/sybil-character"; import { SybilCharacter } from "@/components/sybil-character";
import { import {
createChat, createChat,
@@ -66,26 +67,38 @@ import {
type WorkspaceItem, type WorkspaceItem,
} from "@/lib/api"; } from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth"; import { useSessionAuth } from "@/hooks/use-session-auth";
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
import { import {
getChatModelSelection, getChatModelSelection,
getChatModelSelectionSyncKey, getChatModelSelectionSyncKey,
} from "@/lib/chat-model-selection"; } from "@/lib/chat-model-selection";
import { import {
filterSidebarItemsWithForkGroups,
getForkGroupPresentation, getForkGroupPresentation,
groupForkedChatItems, groupForkedChatItems,
shouldRequestChatTitle, shouldRequestChatTitle,
} from "@/lib/chat-forking"; } from "@/lib/chat-forking";
import { import {
retainSelectedHistoryItem,
resolveSidebarSelectionAfterRefresh, resolveSidebarSelectionAfterRefresh,
type SidebarSelection, type SidebarSelection,
} from "@/lib/sidebar-selection"; } from "@/lib/sidebar-selection";
import { buildQuickQuestionRequest } from "@/lib/quick-question"; import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } 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";
type SidebarItem = SidebarSelection & { type SidebarItem = SidebarSelection & {
title: string; title: string;
searchSnippet: string | null;
parentChatId: string | null; parentChatId: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -151,7 +164,7 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
} }
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = { const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"], openai: ["gpt-4.1-mini", QUICK_QUESTION_DEFAULT_MODEL],
anthropic: ["claude-3-5-sonnet-latest"], anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"], xai: ["grok-3-mini"],
gemini: ["gemini-3.5-flash", "gemini-flash-latest"], gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
@@ -644,9 +657,10 @@ type ModelComboboxProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
className?: string;
}; };
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) { function ModelCombobox({ options, value, onChange, disabled = false, className }: ModelComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [draftValue, setDraftValue] = useState(value); const [draftValue, setDraftValue] = useState(value);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@@ -700,7 +714,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
return ( return (
<div className="relative" ref={rootRef}> <div className="relative" ref={rootRef}>
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]"> <div
className={cn(
"flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]",
className
)}
>
<input <input
ref={inputRef} ref={inputRef}
value={draftValue} value={draftValue}
@@ -827,6 +846,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
kind: "chat" as const, kind: "chat" as const,
id: chat.id, id: chat.id,
title: getChatTitle(chat), title: getChatTitle(chat),
searchSnippet: chat.searchSnippet ?? null,
parentChatId: chat.parentChatId, parentChatId: chat.parentChatId,
updatedAt: chat.updatedAt, updatedAt: chat.updatedAt,
createdAt: chat.createdAt, createdAt: chat.createdAt,
@@ -844,6 +864,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
kind: "search" as const, kind: "search" as const,
id: search.id, id: search.id,
title: getSearchTitle(search), title: getSearchTitle(search),
searchSnippet: search.searchSnippet ?? null,
parentChatId: null, parentChatId: null,
updatedAt: search.updatedAt, updatedAt: search.updatedAt,
createdAt: search.createdAt, createdAt: search.createdAt,
@@ -938,7 +959,6 @@ export default function App() {
isCheckingSession, isCheckingSession,
isSigningIn, isSigningIn,
isAuthenticated, isAuthenticated,
authMode,
authError, authError,
handleAuthFailure: baseHandleAuthFailure, handleAuthFailure: baseHandleAuthFailure,
handleSignIn, handleSignIn,
@@ -976,7 +996,7 @@ export default function App() {
); );
const [quickModel, setQuickModel] = useState(() => { const [quickModel, setQuickModel] = useState(() => {
const stored = loadStoredQuickQuestionModelSelection(); const stored = loadStoredQuickQuestionModelSelection();
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0]; return pickQuickQuestionModel(stored.provider, PROVIDER_FALLBACK_MODELS[stored.provider], stored.modelPreferences[stored.provider]);
}); });
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false); const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
const [quickPrompt, setQuickPrompt] = useState(""); const [quickPrompt, setQuickPrompt] = useState("");
@@ -1008,6 +1028,7 @@ export default function App() {
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
const renameChatInputRef = useRef<HTMLInputElement>(null); const renameChatInputRef = useRef<HTMLInputElement>(null);
const chatSettingsDialogRef = useRef<HTMLDialogElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]); const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
@@ -1036,6 +1057,10 @@ export default function App() {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [sidebarQuery, setSidebarQuery] = useState(""); const [sidebarQuery, setSidebarQuery] = useState("");
const [sidebarSearchRevision, setSidebarSearchRevision] = useState(0);
const sidebarSearch = useWorkspaceSearch(sidebarQuery, isAuthenticated, sidebarSearchRevision);
const isSidebarSearchActive = Boolean(sidebarQuery.trim());
const selectedHistoryItemRef = useRef<WorkspaceItem | null>(null);
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl()); const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
const hasSyncedSelectionHistoryRef = useRef(false); const hasSyncedSelectionHistoryRef = useRef(false);
@@ -1101,19 +1126,16 @@ export default function App() {
}, [pendingAttachments]); }, [pendingAttachments]);
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]); const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
const filteredSidebarItems = useMemo(() => { const searchSidebarItems = useMemo(() => buildSidebarItems(sidebarSearch.items), [sidebarSearch.items]);
const query = sidebarQuery.trim().toLowerCase(); const knownSidebarItems = useMemo(() => {
if (!query) return groupForkedChatItems(sidebarItems).flat(); const matches = new Set(searchSidebarItems.map((item) => `${item.kind}:${item.id}`));
return filterSidebarItemsWithForkGroups(sidebarItems, (item) => { return [...searchSidebarItems, ...sidebarItems.filter((item) => !matches.has(`${item.kind}:${item.id}`))];
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase(); }, [sidebarItems, searchSidebarItems]);
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
});
}, [sidebarItems, sidebarQuery]);
const sidebarSections = useMemo( const sidebarSections = useMemo(
() => buildSidebarSections(filteredSidebarItems, sidebarItems), () => isSidebarSearchActive
[filteredSidebarItems, sidebarItems] ? (searchSidebarItems.length ? [{ label: "SEARCH RESULTS", items: searchSidebarItems }] : [])
: buildSidebarSections(sidebarItems),
[isSidebarSearchActive, searchSidebarItems, sidebarItems]
); );
const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]); const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]);
@@ -1132,6 +1154,8 @@ export default function App() {
setChats([]); setChats([]);
setSearches([]); setSearches([]);
setWorkspaceItems([]); setWorkspaceItems([]);
setSidebarQuery("");
selectedHistoryItemRef.current = null;
setSelectedItem(null); setSelectedItem(null);
setSelectedChat(null); setSelectedChat(null);
setSelectedSearch(null); setSelectedSearch(null);
@@ -1183,6 +1207,10 @@ export default function App() {
resetWorkspaceState(); resetWorkspaceState();
}; };
useEffect(() => {
if (sidebarSearch.error?.includes("bearer token")) handleAuthFailure(sidebarSearch.error);
}, [sidebarSearch.error]);
const refreshCollections = async ({ const refreshCollections = async ({
initialSelection, initialSelection,
reportTransientError = true, reportTransientError = true,
@@ -1195,12 +1223,14 @@ export default function App() {
}; };
setIsLoadingCollections(true); setIsLoadingCollections(true);
try { try {
const nextWorkspaceItems = await listWorkspaceItems(); let nextWorkspaceItems = await listWorkspaceItems();
if (requestId !== collectionLoadRequestRef.current) return; if (requestId !== collectionLoadRequestRef.current) return;
nextWorkspaceItems = retainSelectedHistoryItem(nextWorkspaceItems, selectedItemRef.current, selectedHistoryItemRef.current);
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems); const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
setWorkspaceItems(nextWorkspaceItems); setWorkspaceItems(nextWorkspaceItems);
setChats(nextChats); setChats(nextChats);
setSearches(nextSearches); setSearches(nextSearches);
setSidebarSearchRevision((current) => current + 1);
setSelectedItem((current) => { setSelectedItem((current) => {
const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, { const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
@@ -1433,7 +1463,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (quickModel.trim()) return; if (quickModel.trim()) return;
setQuickModel((current) => { setQuickModel((current) => {
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]); return current.trim() || pickQuickQuestionModel(quickProvider, quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
}); });
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]); }, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
@@ -1496,16 +1526,16 @@ export default function App() {
const isItemRunning = (item: SidebarSelection) => const isItemRunning = (item: SidebarSelection) =>
item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id]; item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id];
const getChatFamilyItems = (chatId: string) => { const getChatFamilyItems = (chatId: string) => {
const chat = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId); const chat = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
if (!chat) return []; if (!chat) return [];
const rootId = chat.parentChatId ?? chat.id; const rootId = chat.parentChatId ?? chat.id;
return sidebarItems.filter( return knownSidebarItems.filter(
(item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId) (item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)
); );
}; };
const isRootChat = (item: SidebarSelection) => { const isRootChat = (item: SidebarSelection) => {
if (item.kind !== "chat") return false; if (item.kind !== "chat") return false;
const chat = sidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id); const chat = knownSidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
return chat?.parentChatId === null; return chat?.parentChatId === null;
}; };
const isDeleteBlocked = (item: SidebarSelection) => { const isDeleteBlocked = (item: SidebarSelection) => {
@@ -1514,9 +1544,10 @@ export default function App() {
}; };
const isSidebarTargetStarred = (item: SidebarSelection) => { const isSidebarTargetStarred = (item: SidebarSelection) => {
if (item.kind === "chat") { if (item.kind === "chat") {
return getChatFamilyItems(item.id).find((familyItem) => familyItem.parentChatId === null)?.starred ?? false; const family = getChatFamilyItems(item.id);
return (family.find((familyItem) => familyItem.parentChatId === null) ?? family[0])?.starred ?? false;
} }
return sidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false; return knownSidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
}; };
const isCurrentSelection = (item: SidebarSelection) => { const isCurrentSelection = (item: SidebarSelection) => {
const current = selectedItemRef.current; const current = selectedItemRef.current;
@@ -1912,14 +1943,26 @@ export default function App() {
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction)); : Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
const nextItem = orderedSidebarItems[nextIndex]; const nextItem = orderedSidebarItems[nextIndex];
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id }; const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
rememberHistoryResult(target);
selectedItemRef.current = target; selectedItemRef.current = target;
setSelectedItem(target); setSelectedItem(target);
}; };
const rememberHistoryResult = (target: SidebarSelection) => {
const item = sidebarSearch.items.find((item) => item.type === target.kind && item.id === target.id);
if (!item) return;
selectedHistoryItemRef.current = item;
const { chats: matchedChats, searches: matchedSearches } = splitWorkspaceItems([item]);
if (matchedChats.length) setChats((current) => [...matchedChats, ...current.filter((chat) => chat.id !== item.id)]);
if (matchedSearches.length) setSearches((current) => [...matchedSearches, ...current.filter((search) => search.id !== item.id)]);
setWorkspaceItems((current) => upsertWorkspaceItem(current, item, false));
};
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (chatSettingsDialogRef.current?.open) return;
const hasPrimaryModifier = event.metaKey || event.ctrlKey; const hasPrimaryModifier = event.metaKey || event.ctrlKey;
if (!hasPrimaryModifier || event.altKey) return; if (!hasPrimaryModifier || event.altKey) return;
@@ -1958,11 +2001,15 @@ export default function App() {
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages); if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
const summary = chats.find((chat) => chat.id === chatId); const summary = chats.find((chat) => chat.id === chatId);
if (summary) return getChatTitle(summary); if (summary) return getChatTitle(summary);
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId); const sidebarItem = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
return sidebarItem?.title ?? "New chat"; return sidebarItem?.title ?? "New chat";
}; };
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => { const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
setSidebarSearchRevision((current) => current + 1);
if (selectedHistoryItemRef.current?.type === "chat" && selectedHistoryItemRef.current.id === updatedChat.id) {
selectedHistoryItemRef.current = chatWorkspaceItem(updatedChat);
}
supersedeCollectionLoad(); supersedeCollectionLoad();
setChats((current) => { setChats((current) => {
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id); const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
@@ -1995,6 +2042,10 @@ export default function App() {
}; };
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => { const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
setSidebarSearchRevision((current) => current + 1);
if (selectedHistoryItemRef.current?.type === "search" && selectedHistoryItemRef.current.id === updatedSearch.id) {
selectedHistoryItemRef.current = searchWorkspaceItem(updatedSearch);
}
supersedeCollectionLoad(); supersedeCollectionLoad();
setSearches((current) => { setSearches((current) => {
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id); const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
@@ -2181,7 +2232,7 @@ export default function App() {
const family = getChatFamilyItems(chatId); const family = getChatFamilyItems(chatId);
const root = family.find((item) => item.parentChatId === null) ?? family[0]; const root = family.find((item) => item.parentChatId === null) ?? family[0];
if (!root) return; if (!root) return;
applyChatSummary(await updateChatStar(root.id, !root.starred), false); applyChatSummary(await updateChatStar(root.parentChatId ?? root.id, !root.starred), false);
}; };
const handleToggleStar = async (target: SidebarSelection) => { const handleToggleStar = async (target: SidebarSelection) => {
@@ -2192,7 +2243,7 @@ export default function App() {
if (target.kind === "chat") { if (target.kind === "chat") {
await toggleChatFamilyStar(target.id); await toggleChatFamilyStar(target.id);
} else { } else {
const current = sidebarItems.find((item) => item.kind === "search" && item.id === target.id); const current = knownSidebarItems.find((item) => item.kind === "search" && item.id === target.id);
const updatedSearch = await updateSearchStar(target.id, !current?.starred); const updatedSearch = await updateSearchStar(target.id, !current?.starred);
applySearchSummary(updatedSearch, false); applySearchSummary(updatedSearch, false);
} }
@@ -2284,6 +2335,11 @@ export default function App() {
} else { } else {
await deleteSearch(target.id); await deleteSearch(target.id);
} }
const retained = selectedHistoryItemRef.current;
if (retained && ((retained.type === target.kind && retained.id === target.id) ||
(retained.type === "chat" && target.kind === "chat" && retained.parentChatId === target.id))) {
selectedHistoryItemRef.current = null;
}
await refreshCollections({ selectFallback: true }); await refreshCollections({ selectFallback: true });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -2321,16 +2377,16 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [renameChatDialog]); }, [renameChatDialog]);
useEffect(() => { useLayoutEffect(() => {
if (!isChatSettingsOpen) return; if (!isChatSettingsOpen) return;
const handleKeyDown = (event: KeyboardEvent) => { const dialog = chatSettingsDialogRef.current;
if (event.key !== "Escape" || isSavingChatSettings) return; const previouslyFocused = document.activeElement;
event.preventDefault(); dialog?.showModal();
setIsChatSettingsOpen(false); return () => {
dialog?.close();
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus({ preventScroll: true });
}; };
window.addEventListener("keydown", handleKeyDown); }, [isChatSettingsOpen]);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isChatSettingsOpen, isSavingChatSettings]);
useEffect(() => { useEffect(() => {
if (!isQuickQuestionOpen) return; if (!isQuickQuestionOpen) return;
@@ -2486,7 +2542,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 +2647,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 +2665,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 +2946,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 +2963,7 @@ export default function App() {
role: "assistant", role: "assistant",
content: "", content: "",
name: null, name: null,
metadata: null, metadata: createStreamingAssistantMetadata(),
}), }),
}, },
}; };
@@ -2922,44 +2973,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 +3257,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 +3271,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 +3282,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 +3316,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);
@@ -3438,7 +3479,7 @@ export default function App() {
return ( return (
<div className="app-grid-surface app-safe-frame h-full"> <div className="app-grid-surface app-safe-frame h-full">
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent md:gap-2"> <div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent">
{isMobileSidebarOpen ? ( {isMobileSidebarOpen ? (
<button <button
type="button" type="button"
@@ -3450,19 +3491,13 @@ export default function App() {
<aside <aside
className={cn( className={cn(
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border", "workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
<div className="relative min-h-24 px-4 pb-4 pt-5"> <div className="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
<div className="pr-24"> <div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent"> SYBIL
SYBIL
</div>
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
</p>
</div> </div>
<SybilCharacter <SybilCharacter
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]" className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
@@ -3507,33 +3542,55 @@ export default function App() {
<input <input
value={sidebarQuery} value={sidebarQuery}
onInput={(event) => setSidebarQuery(event.currentTarget.value)} onInput={(event) => setSidebarQuery(event.currentTarget.value)}
placeholder="Search chats" placeholder="Search chats and web searches"
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70" aria-label="Search conversation history and saved web searches"
autoComplete="off"
spellcheck={false}
onKeyDown={(event) => {
if (event.key === "Escape") setSidebarQuery("");
}}
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-9 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
/> />
{sidebarQuery ? (
<button type="button" onClick={() => setSidebarQuery("")} aria-label="Clear history search"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-violet-200/70 hover:text-violet-50">
<X className="h-4 w-4" />
</button>
) : null}
</div> </div>
</div> </div>
<Separator className="bg-violet-300/10" /> <Separator className="bg-violet-300/10" />
<div className="flex-1 overflow-y-auto px-2 py-3"> <div className="flex-1 overflow-y-auto px-2 py-3">
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null} {!isSidebarSearchActive && isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
{!isLoadingCollections && sidebarItems.length === 0 ? ( {!isSidebarSearchActive && !isLoadingCollections && sidebarItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
<MessageSquare className="h-5 w-5" /> <MessageSquare className="h-5 w-5" />
Start a chat or run your first search. Start a chat or run your first search.
</div> </div>
) : null} ) : null}
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? ( {isSidebarSearchActive ? (
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p> <div aria-live="polite" role="status">
{sidebarSearch.isLoading ? <p className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground"><LoaderCircle className="h-4 w-4 animate-spin" />Searching history…</p> : null}
{sidebarSearch.error ? (
<div className="space-y-2 px-3 py-3 text-sm">
<p className="text-red-300">{sidebarSearch.error}</p>
<Button variant="secondary" onClick={() => setSidebarSearchRevision((current) => current + 1)}>Try again</Button>
</div>
) : !sidebarSearch.isLoading && searchSidebarItems.length === 0 ? (
<p className="px-3 py-3 text-sm text-muted-foreground">No matching chats or web searches. Try a title, query, or words from a conversation.</p>
) : null}
</div>
) : null} ) : null}
{sidebarSections.map((section) => ( {sidebarSections.map((section) => (
<div key={section.label} className="mb-4"> <div key={section.label} className="mb-4">
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p> <p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
{section.items.map((item) => { {section.items.map((item) => {
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id; const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
const isForkChild = item.kind === "chat" && item.parentChatId !== null; const isForkChild = !isSidebarSearchActive && item.kind === "chat" && item.parentChatId !== null;
const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item]; const familyItems = !isSidebarSearchActive && item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem)); const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem));
const itemIsStarred = const itemIsStarred =
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false; familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? item.starred;
const itemUpdatedAt = familyItems.reduce( const itemUpdatedAt = familyItems.reduce(
(newest, familyItem) => (newest, familyItem) =>
new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime() new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime()
@@ -3559,6 +3616,7 @@ export default function App() {
draftKindRef.current = null; draftKindRef.current = null;
setDraftKind(null); setDraftKind(null);
const target = { kind: item.kind, id: item.id } as SidebarSelection; const target = { kind: item.kind, id: item.id } as SidebarSelection;
rememberHistoryResult(target);
selectedItemRef.current = target; selectedItemRef.current = target;
setSelectedItem(target); setSelectedItem(target);
setIsMobileSidebarOpen(false); setIsMobileSidebarOpen(false);
@@ -3577,7 +3635,7 @@ export default function App() {
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70" active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
)} )}
> >
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />} {item.kind === "chat" ? (item.parentChatId ? <GitFork className="h-3.5 w-3.5" aria-label="Fork" /> : <MessageSquare className="h-3.5 w-3.5" />) : <Search className="h-3.5 w-3.5" />}
</span> </span>
<span className="flex min-w-0 flex-1 items-center gap-1.5"> <span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate text-sm font-semibold">{item.title}</span> <span className="truncate text-sm font-semibold">{item.title}</span>
@@ -3602,6 +3660,9 @@ export default function App() {
</span> </span>
) : null} ) : null}
</span> </span>
{isSidebarSearchActive ? (
<SearchMatchLabel text={item.searchSnippet || "Title match"} query={item.searchSnippet ? sidebarQuery : ""} />
) : null}
</div> </div>
)} )}
</button> </button>
@@ -3609,11 +3670,14 @@ export default function App() {
})} })}
</div> </div>
))} ))}
{isSidebarSearchActive && sidebarSearch.hasMore ? (
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
) : null}
</div> </div>
</aside> </aside>
<main <main
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden border-violet-300/18 md:touch-auto md:rounded-2xl md:border" className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
onTouchStart={handleMobileWorkspaceTouchStart} onTouchStart={handleMobileWorkspaceTouchStart}
onTouchMove={handleMobileWorkspaceTouchMove} onTouchMove={handleMobileWorkspaceTouchMove}
onTouchEnd={handleMobileWorkspaceTouchEnd} onTouchEnd={handleMobileWorkspaceTouchEnd}
@@ -3827,44 +3891,66 @@ export default function App() {
</div> </div>
) : null} ) : null}
{isChatSettingsOpen ? ( {isChatSettingsOpen ? (
<div <dialog
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6" ref={chatSettingsDialogRef}
aria-labelledby="chat-settings-title"
className="fixed inset-0 m-0 flex h-dvh max-h-none w-full max-w-none items-center justify-center overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-black/70 backdrop:backdrop-blur-md md:p-6"
onCancel={(event) => {
event.preventDefault();
if (!isSavingChatSettings) setIsChatSettingsOpen(false);
}}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false); if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
}} }}
> >
<form <form
role="dialog" className="glass-panel flex h-full min-h-0 w-full flex-col overflow-hidden md:h-auto md:max-h-full md:max-w-2xl md:rounded-2xl md:border md:border-violet-300/24"
aria-modal="true"
aria-labelledby="chat-settings-title"
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
onSubmit={(event) => void handleChatSettingsSubmit(event)} onSubmit={(event) => void handleChatSettingsSubmit(event)}
> >
<div className="mb-4 flex items-center justify-between gap-3"> <div className="chat-settings-header grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-b border-violet-300/16 md:flex md:justify-between md:gap-3">
<Button
type="button"
variant="ghost"
className="h-11 justify-self-start px-2 text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
onClick={() => setIsChatSettingsOpen(false)}
disabled={isSavingChatSettings}
>
Cancel
</Button>
<div className="min-w-0"> <div className="min-w-0">
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50"> <h2 id="chat-settings-title" className="text-base font-semibold text-violet-50 md:text-sm">
Chat settings Chat settings
</h2> </h2>
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p> <p className="mt-1 hidden truncate text-xs text-muted-foreground md:block">{chatSettingsTitleDraft.trim() || "New chat"}</p>
</div> </div>
<Button <Button
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8" className="hidden h-8 w-8 shrink-0 md:inline-flex"
onClick={() => setIsChatSettingsOpen(false)} onClick={() => setIsChatSettingsOpen(false)}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
aria-label="Close chat settings" aria-label="Close chat settings"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
<Button
type="submit"
variant="ghost"
className="h-11 justify-self-end gap-1.5 px-2 font-semibold text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
disabled={isSavingChatSettings}
>
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
Save
</Button>
</div> </div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1"> <div className="chat-settings-content min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain md:space-y-4">
<div> <div>
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span> <label for="chat-settings-chat-title" className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
id="chat-settings-chat-title"
value={chatSettingsTitleDraft} value={chatSettingsTitleDraft}
onInput={(event) => { onInput={(event) => {
setChatSettingsTitleDraft(event.currentTarget.value); setChatSettingsTitleDraft(event.currentTarget.value);
@@ -3872,7 +3958,7 @@ export default function App() {
}} }}
maxLength={120} maxLength={120}
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"} placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70" className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:text-sm"
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
/> />
{chatSettingsChatId ? ( {chatSettingsChatId ? (
@@ -3900,7 +3986,7 @@ export default function App() {
<label className="block"> <label className="block">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span> <span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
<select <select
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70" className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:h-10 md:text-sm"
value={chatSettingsProviderDraft} value={chatSettingsProviderDraft}
onChange={(event) => { onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider; const nextProvider = event.currentTarget.value as Provider;
@@ -3922,6 +4008,7 @@ export default function App() {
<label className="block min-w-0"> <label className="block min-w-0">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span> <span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
<ModelCombobox <ModelCombobox
className="h-11 min-w-0 bg-background/70 text-base md:h-10 md:text-sm"
options={chatSettingsProviderModelOptions} options={chatSettingsProviderModelOptions}
value={chatSettingsModelDraft} value={chatSettingsModelDraft}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
@@ -3943,7 +4030,7 @@ export default function App() {
if (chatSettingsError) setChatSettingsError(null); if (chatSettingsError) setChatSettingsError(null);
}} }}
placeholder="Add per-chat instructions" placeholder="Add per-chat instructions"
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45" className="min-h-32 resize-y border-violet-300/24 bg-background/70 text-base text-violet-50 placeholder:text-violet-200/45 md:text-sm"
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
/> />
</label> </label>
@@ -3957,6 +4044,7 @@ export default function App() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="h-11 gap-1.5 md:h-8"
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))} onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
> >
@@ -3967,6 +4055,7 @@ export default function App() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="h-11 gap-1.5 md:h-8"
onClick={() => setChatSettingsEnabledToolsDraft([])} onClick={() => setChatSettingsEnabledToolsDraft([])}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
> >
@@ -3976,31 +4065,30 @@ export default function App() {
</div> </div>
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="grid gap-2 md:grid-cols-2">
{availableChatTools.length ? ( {availableChatTools.length ? (
availableChatTools.map((tool) => { availableChatTools.map((tool) => {
const checked = chatSettingsEnabledToolsDraft.includes(tool.name); const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
return ( return (
<label <label
key={tool.name} key={tool.name}
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8" title={tool.description}
className="flex min-h-11 min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8 focus-within:border-violet-300/45 focus-within:ring-1 focus-within:ring-ring/70"
> >
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
onChange={() => toggleChatSettingsTool(tool.name)} onChange={() => toggleChatSettingsTool(tool.name)}
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400" className="h-4 w-4 shrink-0 rounded border-violet-300/35 bg-background/80 accent-violet-400"
aria-description={tool.description}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
/> />
<span className="min-w-0"> <span className="min-w-0 break-words text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
</span>
</label> </label>
); );
}) })
) : ( ) : (
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground"> <p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground md:col-span-2">
No chat tools are available. No chat tools are available.
</p> </p>
)} )}
@@ -4008,8 +4096,8 @@ export default function App() {
</section> </section>
</div> </div>
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null} {chatSettingsError ? <p role="alert" className="chat-settings-error shrink-0 break-words border-t border-violet-300/16 text-sm text-rose-300">{chatSettingsError}</p> : null}
<div className="mt-4 flex justify-end gap-2"> <div className="hidden shrink-0 justify-end gap-2 border-t border-violet-300/16 p-5 md:flex">
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}> <Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
Cancel Cancel
</Button> </Button>
@@ -4019,7 +4107,7 @@ export default function App() {
</Button> </Button>
</div> </div>
</form> </form>
</div> </dialog>
) : null} ) : null}
{renameChatDialog ? ( {renameChatDialog ? (
<div <div
@@ -4078,7 +4166,7 @@ export default function App() {
) : null} ) : null}
{isQuickQuestionOpen ? ( {isQuickQuestionOpen ? (
<div <div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6" className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-3 md:p-6"
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false); if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
}} }}
@@ -4156,7 +4244,7 @@ export default function App() {
const nextProvider = event.currentTarget.value as Provider; const nextProvider = event.currentTarget.value as Provider;
setQuickProvider(nextProvider); setQuickProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider); const options = getModelOptions(modelCatalog, nextProvider);
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider])); setQuickModel(pickQuickQuestionModel(nextProvider, options, quickProviderModelPreferences[nextProvider]));
}} }}
disabled={isQuickQuestionSending || isConvertingQuickQuestion} disabled={isQuickQuestionSending || isConvertingQuickQuestion}
aria-label="Quick question provider" aria-label="Quick question provider"
+146 -6
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";
@@ -377,13 +378,13 @@ function ToolCallStack({
); );
})} })}
{!expanded && hiddenCount ? ( {!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm"> <span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount} +{hiddenCount}
</span> </span>
) : null} ) : null}
<button <button
type="button" type="button"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full" className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
aria-expanded={expanded ? "true" : "false"} aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`} aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`} title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
@@ -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 (
@@ -465,7 +603,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
className={cn( className={cn(
"max-w-[85%] space-y-3", "max-w-[85%] space-y-3",
isUser isUser
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm" ? "chat-user-message border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50" : "text-base leading-7 text-violet-50"
)} )}
onContextMenu={ onContextMenu={
@@ -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]" />
@@ -0,0 +1,96 @@
/// <reference lib="es2022.intl" />
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu;
const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) {
const text = source.replace(/\s+/gu, " ").trim();
const containerRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const ellipsisRef = useRef<HTMLSpanElement>(null);
const [kept, setKept] = useState<number | null>(null);
const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]);
const runs = useMemo(() => {
const terms = (query.match(words) ?? []).map(fold).filter(Boolean);
const result: Array<{ start: number; end: number; match: boolean }> = [];
let cursor = 0;
for (const word of text.matchAll(words)) {
if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false });
cursor = word.index + word[0].length;
result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) });
}
if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false });
return result;
}, [text, query]);
const renderRange = (start: number, end: number) => runs.flatMap((run) => {
const value = text.slice(Math.max(start, run.start), Math.min(end, run.end));
if (run.end <= start || run.start >= end) return [];
return [run.match
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
: <span key={`${start}:${run.start}`}>{value}</span>];
});
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
const ellipsis = ellipsisRef.current;
if (!container || !measure || !ellipsis) return;
let disposed = false;
const update = () => {
if (disposed) return;
const width = container.getBoundingClientRect().width;
if (measure.getBoundingClientRect().width <= width) { setKept(null); return; }
// Measure the styled DOM, including bold runs, instead of guessing a
// character limit. Grapheme boundaries keep accents and emoji intact.
const nodes = Array.from(measure.children, (child) => child.firstChild as Text);
const range = document.createRange();
const measureRange = (start: number, end: number) => {
if (start >= end) return 0;
let offset = 0;
let started = false;
for (const node of nodes) {
const next = offset + node.length;
if (!started && start <= next) { range.setStart(node, start - offset); started = true; }
if (end <= next) { range.setEnd(node, end - offset); break; }
offset = next;
}
return range.getBoundingClientRect().width;
};
const count = boundaries.length - 1;
const ellipsisWidth = ellipsis.getBoundingClientRect().width;
let low = 0;
let high = count - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const prefix = boundaries[Math.ceil(middle / 2)];
const suffix = boundaries[count - Math.floor(middle / 2)];
if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle;
else high = middle - 1;
}
setKept(low);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
void document.fonts.ready.then(update);
document.fonts.addEventListener("loadingdone", update);
return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); };
}, [text, runs, boundaries]);
const count = boundaries.length - 1;
const truncated = kept !== null && kept < count;
return (
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
</span>
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
</span>
</span>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "preact/hooks";
import { searchWorkspaceItems, type WorkspaceItem, type WorkspaceSearchResponse } from "@/lib/api";
import { scheduleWorkspaceSearch } from "@/lib/workspace-search";
type SearchState = WorkspaceSearchResponse & { query: string; revision: number; error: string | null };
const EMPTY_ITEMS: WorkspaceItem[] = [];
export function useWorkspaceSearch(query: string, enabled: boolean, revision: number) {
const trimmed = query.trim();
const [state, setState] = useState<SearchState | null>(null);
const validationError = trimmed.length > 200 ? "Use 200 characters or fewer." : null;
useEffect(() => {
setState(null);
if (!enabled || !trimmed || validationError) return;
return scheduleWorkspaceSearch(trimmed, searchWorkspaceItems,
(result) => setState({ ...result, query: trimmed, revision, error: null }),
(error) => setState({ items: [], hasMore: false, query: trimmed, revision, error: error instanceof Error ? error.message : String(error) })
);
}, [trimmed, enabled, revision, validationError]);
// Hide obsolete results immediately, even before effect cleanup runs.
const current = enabled && state?.query === trimmed && state.revision === revision ? state : null;
return {
items: current?.items ?? EMPTY_ITEMS,
hasMore: current?.hasMore ?? false,
error: validationError ?? current?.error ?? null,
isLoading: enabled && Boolean(trimmed) && !validationError && !current,
};
}
+139 -26
View File
@@ -31,7 +31,7 @@
--secondary-foreground: 254 38% 94%; --secondary-foreground: 254 38% 94%;
--accent: 188 86% 50%; --accent: 188 86% 50%;
--accent-foreground: 230 45% 8%; --accent-foreground: 230 45% 8%;
--radius: 0.5rem; --radius: 0px;
} }
* { * {
@@ -72,6 +72,7 @@ select,
textarea { textarea {
font: inherit; font: inherit;
color-scheme: dark; color-scheme: dark;
border-radius: var(--radius);
} }
::selection { ::selection {
@@ -113,13 +114,39 @@ textarea {
max(0.75rem, var(--safe-area-left)); max(0.75rem, var(--safe-area-left));
} }
.chat-settings-header {
padding:
calc(0.5rem + var(--safe-area-top))
max(0.75rem, var(--safe-area-right))
0.5rem
max(0.75rem, var(--safe-area-left));
}
.chat-settings-content {
padding:
1rem
max(1rem, var(--safe-area-right))
max(1.5rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
scroll-padding-block: 1rem;
}
.chat-settings-error {
padding:
0.75rem
max(1rem, var(--safe-area-right))
max(0.75rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
}
@media (min-width: 768px) { @media (min-width: 768px) {
.app-safe-frame { .chat-settings-header,
padding: .chat-settings-content {
max(0.5rem, var(--safe-area-top)) padding: 1.25rem;
max(0.5rem, var(--safe-area-right)) }
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left)); .chat-settings-error {
padding: 0.75rem 1.25rem;
} }
.app-search-safe-pad { .app-search-safe-pad {
@@ -132,19 +159,11 @@ textarea {
} }
@media (horizontal-viewport-segments: 2) { @media (horizontal-viewport-segments: 2) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
}
.workspace-shell { .workspace-shell {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left))) calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right))); calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0)); column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
} }
@@ -153,14 +172,10 @@ textarea {
width: 100%; width: 100%;
max-width: none; max-width: none;
transform: none; transform: none;
border-width: 1px;
border-radius: 1rem;
} }
.workspace-content { .workspace-content {
min-width: 0; min-width: 0;
border-width: 1px;
border-radius: 1rem;
touch-action: auto; touch-action: auto;
} }
@@ -179,6 +194,13 @@ textarea {
0 14px 36px hsl(240 80% 2% / 0.28); 0 14px 36px hsl(240 80% 2% / 0.28);
} }
.chat-user-message {
/* Preserve the original bubble radius independently of the square UI theme.
Browsers without corner-shape support fall back to circular corners. */
border-radius: 0.75rem;
corner-shape: squircle;
}
.tool-call-stack-shell { .tool-call-stack-shell {
perspective: 900px; perspective: 900px;
transform-style: preserve-3d; transform-style: preserve-3d;
@@ -321,6 +343,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;
} }
@@ -339,7 +452,7 @@ textarea {
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86); border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem; border-radius: var(--radius);
background: hsl(246 34% 10% / 0.76); background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06); box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
} }
@@ -358,7 +471,7 @@ textarea {
} }
.md-table-scroll::-webkit-scrollbar-thumb { .md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px; border-radius: var(--radius);
background: hsl(263 78% 72% / 0.34); background: hsl(263 78% 72% / 0.34);
} }
@@ -470,7 +583,7 @@ textarea {
.md-content code { .md-content code {
background: hsl(249 40% 10% / 0.78); background: hsl(249 40% 10% / 0.78);
border-radius: 0.3rem; border-radius: var(--radius);
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
font-size: 0.86em; font-size: 0.86em;
box-decoration-break: clone; box-decoration-break: clone;
@@ -480,7 +593,7 @@ textarea {
.md-content pre { .md-content pre {
overflow-x: auto; overflow-x: auto;
border: 1px solid hsl(253 31% 29% / 0.72); border: 1px solid hsl(253 31% 29% / 0.72);
border-radius: 0.625rem; border-radius: var(--radius);
background: hsl(249 40% 10% / 0.82); background: hsl(249 40% 10% / 0.82);
padding: 0.75rem; padding: 0.75rem;
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05); box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
@@ -504,7 +617,7 @@ textarea {
.md-cite-token { .md-cite-token {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border-radius: 9999px; border-radius: var(--radius);
border: 1px solid hsl(274 40% 55%); border: 1px solid hsl(274 40% 55%);
background: hsl(277 33% 21%); background: hsl(277 33% 21%);
color: hsl(304 42% 96%); color: hsl(304 42% 96%);
+7 -1
View File
@@ -33,7 +33,9 @@ export type SearchWorkspaceItem = SearchSummary & {
type: "search"; type: "search";
}; };
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem; export type WorkspaceItem = (ChatWorkspaceItem | SearchWorkspaceItem) & { searchSnippet?: string | null };
export type WorkspaceSearchResponse = { items: WorkspaceItem[]; hasMore: boolean };
export type Message = { export type Message = {
id: string; id: string;
@@ -272,6 +274,10 @@ export async function listWorkspaceItems() {
return data.items; return data.items;
} }
export function searchWorkspaceItems(query: string, signal?: AbortSignal) {
return api<WorkspaceSearchResponse>(`/v1/workspace-items?${new URLSearchParams({ q: query })}`, { signal });
}
export async function verifySession() { export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session"); return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
} }
+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,
}));
}
+8
View File
@@ -1,5 +1,13 @@
import type { Provider } from "./api"; import type { Provider } from "./api";
export const QUICK_QUESTION_DEFAULT_MODEL = "chat-latest";
export function pickQuickQuestionModel(provider: Provider, options: string[], preferred: string | null = null) {
if (preferred?.trim()) return preferred.trim();
if (provider === "openai") return QUICK_QUESTION_DEFAULT_MODEL;
return options[0] ?? "";
}
export function buildQuickQuestionRequest({ export function buildQuickQuestionRequest({
provider, provider,
model, model,
+10
View File
@@ -2,6 +2,16 @@ export type SidebarSelection = { kind: "chat" | "search"; id: string };
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string }; type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
export function retainSelectedHistoryItem<T extends WorkspaceSelectionItem>(
items: T[], selection: SidebarSelection | null, retained: T | null
): T[] {
if (retained && selection?.kind === retained.type && selection.id === retained.id &&
!items.some((item) => item.type === retained.type && item.id === retained.id)) {
return [...items, retained];
}
return items;
}
type ResolveSidebarSelectionOptions = { type ResolveSidebarSelectionOptions = {
initialSelection?: SidebarSelection; initialSelection?: SidebarSelection;
selectFallback?: boolean; selectFallback?: boolean;
+24
View File
@@ -0,0 +1,24 @@
import type { WorkspaceSearchResponse } from "./api";
// Cleanup cancels both the debounce and fetch, and also rejects late responses
// from transports that finish after cancellation.
export function scheduleWorkspaceSearch(
query: string,
request: (query: string, signal: AbortSignal) => Promise<WorkspaceSearchResponse>,
onResult: (result: WorkspaceSearchResponse) => void,
onError: (error: unknown) => void
) {
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const result = await request(query, controller.signal);
if (!controller.signal.aborted) onResult(result);
} catch (error) {
if (!controller.signal.aborted) onError(error);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}
+7 -2
View File
@@ -29,9 +29,14 @@ const config: Config = {
}, },
}, },
borderRadius: { borderRadius: {
// Keep surfaces square at every size; rounded-full is reserved for dots.
DEFAULT: "var(--radius)",
sm: "var(--radius)",
md: "var(--radius)",
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", xl: "var(--radius)",
sm: "calc(var(--radius) - 4px)", "2xl": "var(--radius)",
"3xl": "var(--radius)",
}, },
}, },
}, },
+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);
});
+22 -1
View File
@@ -1,6 +1,27 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { buildQuickQuestionRequest } from "../src/lib/quick-question.ts"; import { buildQuickQuestionRequest, pickQuickQuestionModel } from "../src/lib/quick-question.ts";
test("quick questions default to chat-latest before and after the model catalog loads", () => {
for (const options of [[], ["gpt-4.1-mini"], ["gpt-4.1-mini", "chat-latest"]]) {
const model = pickQuickQuestionModel("openai", options);
assert.equal(model, "chat-latest");
assert.deepEqual(buildQuickQuestionRequest({ provider: "openai", model, content: "What is UTC?" }), {
provider: "openai",
model: "chat-latest",
question: "What is UTC?",
});
}
});
test("quick questions preserve saved models and use provider-specific choices when switching providers", () => {
assert.equal(pickQuickQuestionModel("openai", ["chat-latest"], " gpt-4.1-mini "), "gpt-4.1-mini");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini"], " "), "chat-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"]), "claude-3-5-sonnet-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"], "claude-3-haiku"), "claude-3-haiku");
assert.equal(pickQuickQuestionModel("anthropic", []), "");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini", "chat-latest"]), "chat-latest");
});
test("quick question requests use the dedicated server endpoint shape", () => { test("quick question requests use the dedicated server endpoint shape", () => {
const request = buildQuickQuestionRequest({ const request = buildQuickQuestionRequest({
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import { scheduleWorkspaceSearch } from "../src/lib/workspace-search.ts";
import { retainSelectedHistoryItem, resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
test("typing is debounced and superseded requests never start", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const queries = [];
const results = [];
const request = async (query) => { queries.push(query); return { items: [], hasMore: false }; };
const reportError = (error) => assert.fail(String(error));
const cancelFirst = scheduleWorkspaceSearch("ne", request, (result) => results.push(result), reportError);
t.mock.timers.tick(299);
assert.deepEqual(queries, []);
cancelFirst();
const cancelLast = scheduleWorkspaceSearch("nebula", request, (result) => results.push(result), reportError);
t.mock.timers.tick(300);
await flush();
assert.deepEqual(queries, ["nebula"]);
assert.deepEqual(results, [{ items: [], hasMore: false }]);
cancelLast();
});
test("cancellation aborts the transport and discards stale results and errors", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let resolveFirst;
let rejectSecond;
let signal;
const results = [];
const errors = [];
const first = scheduleWorkspaceSearch("first", (_query, value) => {
signal = value;
return new Promise((resolve) => { resolveFirst = resolve; });
}, (result) => results.push(result), (error) => errors.push(error));
t.mock.timers.tick(300);
first();
assert.equal(signal.aborted, true);
const second = scheduleWorkspaceSearch("second", () => new Promise((_resolve, reject) => { rejectSecond = reject; }),
(result) => results.push(result), (error) => errors.push(error));
t.mock.timers.tick(300);
second();
resolveFirst({ items: [{ id: "stale" }], hasMore: true });
rejectSecond(new Error("stale failure"));
await flush();
assert.deepEqual(results, []);
assert.deepEqual(errors, []);
});
test("search results retain excerpts and truncation info; failures reach the UI", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const expected = { items: [{ type: "chat", id: "old", searchSnippet: "A nebula in the sky" }], hasMore: true };
let received;
let error;
const cancel = scheduleWorkspaceSearch("nebula", async () => expected, (result) => { received = result; }, assert.fail);
t.mock.timers.tick(300);
await flush();
assert.deepEqual(received, expected);
cancel();
const cancelFailure = scheduleWorkspaceSearch("nebula", async () => { throw new Error("Offline"); }, assert.fail, (result) => { error = result; });
t.mock.timers.tick(300);
await flush();
assert.equal(error.message, "Offline");
cancelFailure();
});
test("opening older chat/search results survives collection refresh without restoring deleted or unrelated items", () => {
const recent = [{ type: "chat", id: "recent" }];
for (const kind of ["chat", "search"]) {
const selection = { kind, id: "old" };
const retained = { type: kind, id: "old" };
const refreshed = retainSelectedHistoryItem(recent, selection, retained);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(selection, refreshed), selection);
assert.equal(retainSelectedHistoryItem(refreshed, selection, retained), refreshed);
assert.equal(retainSelectedHistoryItem(recent, { kind, id: "different" }, retained), recent);
assert.equal(retainSelectedHistoryItem(recent, selection, null), recent);
assert.equal(retainSelectedHistoryItem(recent, null, retained), recent);
}
});