From dd6cd1ea7b65b5747acd22debaa30f6ebf16353f Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 7 Oct 2026 22:05:49 -0700 Subject: [PATCH] Add indexed history search across iOS and web --- docs/api/rest.md | 11 + .../Sybil/Sources/Sybil/SybilAPIClient.swift | 22 +- .../Sources/Sybil/SybilAPIClienting.swift | 1 + .../Sybil/Sources/Sybil/SybilModels.swift | 8 + .../Sources/Sybil/SybilPhoneShellView.swift | 8 + .../Sources/Sybil/SybilSidebarView.swift | 111 ++++++++++- .../Sybil/Sources/Sybil/SybilViewModel.swift | 122 +++++++++++- .../Sybil/Tests/SybilTests/SybilTests.swift | 181 +++++++++++++++++ ios/README.md | 8 + .../migration.sql | 70 +++++++ server/prisma/schema.prisma | 1 + server/src/routes.ts | 32 +++ server/src/workspace-search.ts | 74 +++++++ server/tests/chat-forks.test.ts | 3 +- server/tests/workspace-search.test.ts | 188 ++++++++++++++++++ web/README.md | 7 + web/src/App.tsx | 132 +++++++++--- .../components/search/search-match-label.tsx | 96 +++++++++ web/src/hooks/use-workspace-search.ts | 30 +++ web/src/lib/api.ts | 8 +- web/src/lib/sidebar-selection.ts | 10 + web/src/lib/workspace-search.ts | 24 +++ web/tests/workspace-search.test.mjs | 80 ++++++++ 23 files changed, 1174 insertions(+), 53 deletions(-) create mode 100644 server/prisma/migrations/20261007000000_add_workspace_full_text_search/migration.sql create mode 100644 server/src/workspace-search.ts create mode 100644 server/tests/workspace-search.test.ts create mode 100644 web/src/components/search/search-match-label.tsx create mode 100644 web/src/hooks/use-workspace-search.ts create mode 100644 web/src/lib/workspace-search.ts create mode 100644 web/tests/workspace-search.test.mjs diff --git a/docs/api/rest.md b/docs/api/rest.md index 58d1955..256d201 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -81,6 +81,8 @@ Behavior notes: ### `GET /v1/workspace-items` - 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: ```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. - `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 ### `GET /v1/chats` diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index f483b59..04d89f3 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -49,6 +49,15 @@ actor SybilAPIClient: SybilAPIClienting { 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] { let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self) return response.chats @@ -290,9 +299,10 @@ actor SybilAPIClient: SybilAPIClienting { _ path: String, method: String, body: AnyEncodable? = nil, + queryItems: [URLQueryItem] = [], responseType: Response.Type ) 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.network, @@ -344,9 +354,10 @@ actor SybilAPIClient: SybilAPIClienting { path: String, method: String, body: AnyEncodable?, - acceptsSSE: Bool + acceptsSSE: Bool, + queryItems: [URLQueryItem] = [] ) throws -> URLRequest { - let url = try buildURL(path: path) + let url = try buildURL(path: path, queryItems: queryItems) var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 120 @@ -369,7 +380,7 @@ actor SybilAPIClient: SybilAPIClienting { 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 { throw APIError.invalidBaseURL } @@ -380,6 +391,9 @@ actor SybilAPIClient: SybilAPIClienting { basePath.removeLast() } components.path = "\(basePath)/\(trimmedPath)" + if !queryItems.isEmpty { + components.queryItems = (components.queryItems ?? []) + queryItems + } guard let url = components.url else { throw APIError.invalidBaseURL diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift index cbb48d0..18bd213 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -3,6 +3,7 @@ import Foundation protocol SybilAPIClienting: Sendable { func verifySession() async throws -> AuthSession func listWorkspaceItems() async throws -> [WorkspaceItem] + func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse func listChats() async throws -> [ChatSummary] func createChat( title: String?, diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index 7bcdc31..e8a3939 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -219,6 +219,7 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable { public var id: String public var title: String? public var query: String? + public var searchSnippet: String? = nil public var parentChatId: String? = nil public var titleGenerationPending = false public var createdAt: Date @@ -302,6 +303,7 @@ extension WorkspaceItem { case id case title case query + case searchSnippet case parentChatId case titleGenerationPending case createdAt @@ -320,6 +322,7 @@ extension WorkspaceItem { id = try container.decode(String.self, forKey: .id) title = try container.decodeIfPresent(String.self, forKey: .title) query = try container.decodeIfPresent(String.self, forKey: .query) + searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet) parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId) titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false createdAt = try container.decode(Date.self, forKey: .createdAt) @@ -730,6 +733,11 @@ struct SearchListResponse: Codable { var searches: [SearchSummary] } +struct WorkspaceSearchResponse: Codable, Sendable { + var items: [WorkspaceItem] + var hasMore: Bool +} + struct WorkspaceListResponse: Codable { var items: [WorkspaceItem] } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index 58b8961..bfdc211 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -242,6 +242,7 @@ struct SybilPhoneShellView: View { } private func showRoute(_ nextRoute: PhoneRoute) { + dismissSidebarKeyboard() let update = { route = nextRoute } @@ -271,12 +272,19 @@ struct SybilPhoneShellView: View { } private func hideSidebarOverlay() { + dismissSidebarKeyboard() withAnimation(.easeOut(duration: 0.18)) { isSidebarOverlayPresented = 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) { if openingSelectionRequestID != nil, sidebarHighlightSelection == selection { return diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 02e9db2..0e11ad0 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -115,6 +115,7 @@ struct SybilSidebarItemList: View { var onOpenForkedChat: ((String) -> Void)? = nil @State private var renameTarget: SidebarItem? @State private var renameTitle = "" + @FocusState private var isSearchFocused: Bool private var isRenameAlertPresented: Binding { Binding { @@ -128,37 +129,98 @@ struct SybilSidebarItemList: 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 { - if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { + if viewModel.isSearchingSidebar || + (!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) { VStack(alignment: .leading, spacing: 8) { ProgressView() .tint(SybilTheme.primary) - Text("Loading conversations…") + Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…") .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .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) { - Image(systemName: "message.badge") + Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge") .font(.system(size: 20, weight: .medium)) .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)) .multilineTextAlignment(.center) .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) .padding(16) } else { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.sidebarItems) { item in + ForEach(viewModel.visibleSidebarItems) { item in Button { + isSearchFocused = false onSelect(item) } label: { - SybilSidebarRow(item: item, isSelected: isSelected(item)) + SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery) } .buttonStyle(.plain) .contextMenu { @@ -201,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) } + .scrollDismissesKeyboard(.interactively) .refreshable { await viewModel.refreshSidebarCollectionsFromPullToRefresh() } @@ -257,6 +326,25 @@ struct SybilSidebarItemList: View { struct SybilSidebarRow: View { var item: SidebarItem 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..? + @ObservationIgnored + private var sidebarSearchRequestID = UUID() var selectedItem: SidebarSelection? var selectedChat: ChatDetail? @@ -482,7 +492,19 @@ final class SybilViewModel { } 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 { case .chat: let initiatedLabel: String? @@ -496,7 +518,8 @@ final class SybilViewModel { initiatedLabel = nil } 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 return SidebarItem( @@ -507,7 +530,8 @@ final class SybilViewModel { starred: starOwner.starred, starredAt: starOwner.starredAt, initiatedLabel: initiatedLabel, - isRunning: isChatRowRunning(item.id) + isRunning: isChatRowRunning(item.id), + searchSnippet: item.searchSnippet ) case .search: @@ -519,20 +543,70 @@ final class SybilViewModel { starred: item.starred, starredAt: item.starredAt, 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? { 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? { 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 { @@ -559,6 +633,7 @@ final class SybilViewModel { } func reconnect() async { + setSidebarSearchQuery("") isCheckingSession = true authError = nil errorMessage = nil @@ -865,6 +940,15 @@ final class SybilViewModel { private func beginSelecting(_ selection: SidebarSelection) -> Task? { 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 { errorMessage = nil if case .search = selection { @@ -915,7 +999,7 @@ final class SybilViewModel { } private func selectAdjacentSidebarItem(offset: Int) { - let items = sidebarItems + let items = visibleSidebarItems guard !items.isEmpty else { return } @@ -951,6 +1035,7 @@ final class SybilViewModel { break } await refreshCollections(preferredSelection: nil) + await refreshSidebarSearch() } catch { errorMessage = normalizeAPIError(error) SybilLog.error(SybilLog.ui, "Delete failed", error: error) @@ -1006,6 +1091,7 @@ final class SybilViewModel { do { let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle) applyChatSummary(updated, moveToFront: true) + await refreshSidebarSearch() } catch { errorMessage = normalizeAPIError(error) SybilLog.error(SybilLog.ui, "Rename failed", error: error) @@ -1105,6 +1191,10 @@ final class SybilViewModel { } func refreshSidebarCollectionsFromPullToRefresh() async { + if isSidebarSearchActive { + await refreshSidebarSearch() + return + } guard isAuthenticated, !isCheckingSession else { return } @@ -1445,7 +1535,15 @@ final class SybilViewModel { async let activeRunsValue = client.getActiveRuns() 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) SybilLog.info( @@ -1623,6 +1721,11 @@ final class SybilViewModel { } 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 }) { workspaceItems.remove(at: existingIndex) if moveToFront { @@ -1646,6 +1749,9 @@ final class SybilViewModel { if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId { return parentChatID } + if let parentChatID = sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId { + return parentChatID + } return chatID } diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 2898ae1..23a8b5c 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -9,6 +9,7 @@ import UIKit private struct MockClientCallSnapshot: Sendable { var listWorkspaceItems = 0 + var workspaceSearchQueries: [String] = [] var listChats = 0 var listSearches = 0 var createChat = 0 @@ -43,6 +44,7 @@ private actor MockSybilClient: SybilAPIClienting { private let chatsResponse: [ChatSummary] private let searchesResponse: [SearchSummary] private let workspaceItemsResponse: [WorkspaceItem] + private let workspaceSearchHandler: @Sendable (String) async throws -> WorkspaceSearchResponse private let chatDetails: [String: ChatDetail] private let searchDetails: [String: SearchDetail] private let createChatResponse: ChatSummary? @@ -86,11 +88,15 @@ private actor MockSybilClient: SybilAPIClienting { updateSearchStarResponses: [String: SearchSummary] = [:], activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(), workspaceItemsResponse: [WorkspaceItem]? = nil, + workspaceSearchHandler: @escaping @Sendable (String) async throws -> WorkspaceSearchResponse = { _ in + WorkspaceSearchResponse(items: [], hasMore: false) + }, modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:]) ) { self.chatsResponse = chatsResponse self.searchesResponse = searchesResponse self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse) + self.workspaceSearchHandler = workspaceSearchHandler self.chatDetails = chatDetails self.searchDetails = searchDetails self.createChatResponse = createChatResponse @@ -186,6 +192,11 @@ private actor MockSybilClient: SybilAPIClienting { return workspaceItemsResponse } + func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse { + snapshot.workspaceSearchQueries.append(query) + return try await workspaceSearchHandler(query) + } + func listChats() async throws -> [ChatSummary] { snapshot.listChats += 1 if listChatsDelayNanoseconds > 0 { @@ -1998,3 +2009,173 @@ private final class MockQuickQuestionLifecycle { controller.stop() } #endif + +// Deliberately ignores cancellation so stale-response protection is exercised. +private actor WorkspaceSearchGate { + private var pending: [String: CheckedContinuation] = [:] + private var observers: [String: CheckedContinuation] = [:] + + 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) +} diff --git a/ios/README.md b/ios/README.md index 9b79dbc..d4b1003 100644 --- a/ios/README.md +++ b/ios/README.md @@ -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; 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 While the Catalyst app is running, **Option+Space** toggles a floating Quick diff --git a/server/prisma/migrations/20261007000000_add_workspace_full_text_search/migration.sql b/server/prisma/migrations/20261007000000_add_workspace_full_text_search/migration.sql new file mode 100644 index 0000000..95a2f03 --- /dev/null +++ b/server/prisma/migrations/20261007000000_add_workspace_full_text_search/migration.sql @@ -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"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7b205c4..08800bb 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -76,6 +76,7 @@ model Chat { @@index([userId]) @@index([parentChatId]) + @@index([updatedAt]) } model Message { diff --git a/server/src/routes.ts b/server/src/routes.ts index 538d8fa..29ee56b 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -16,6 +16,7 @@ import { openaiClient } from "./llm/providers.js"; import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js"; import { exaClient } from "./search/exa.js"; import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js"; +import { searchWorkspace } from "./workspace-search.js"; import type { ChatAttachment } from "./llm/types.js"; 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) => { 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>([ + ...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() }; }); diff --git a/server/src/workspace-search.ts b/server/src/workspace-search.ts new file mode 100644 index 0000000..b9f8f92 --- /dev/null +++ b/server/src/workspace-search.ts @@ -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(workspaceSearchSQL(expression)); + return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT }; +} diff --git a/server/tests/chat-forks.test.ts b/server/tests/chat-forks.test.ts index 25b9c3b..644f00c 100644 --- a/server/tests/chat-forks.test.ts +++ b/server/tests/chat-forks.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; 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 { dirname, join, resolve } from "node:path"; import test from "node:test"; @@ -11,6 +11,7 @@ import sensible from "@fastify/sensible"; const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-")); process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`; +writeFileSync(join(databaseDir, "test.db"), ""); process.env.OPENAI_API_KEY = ""; delete process.env.ADMIN_TOKEN; diff --git a/server/tests/workspace-search.test.ts b/server/tests/workspace-search.test.ts new file mode 100644 index 0000000..a748d5b --- /dev/null +++ b/server/tests/workspace-search.test.ts @@ -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>(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"); +}); diff --git a/web/README.md b/web/README.md index 6871f84..6a81dd0 100644 --- a/web/README.md +++ b/web/README.md @@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173` ## UI - 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: - Chat mode: transcript + provider/model controls. - Search mode: top AI answer block + Google-style Exa results view. diff --git a/web/src/App.tsx b/web/src/App.tsx index 97a02f6..7b150e7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -26,6 +26,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { ChatComposer } from "@/components/chat/chat-composer"; import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel"; +import { SearchMatchLabel } from "@/components/search/search-match-label"; import { SybilCharacter } from "@/components/sybil-character"; import { createChat, @@ -66,17 +67,18 @@ import { type WorkspaceItem, } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; +import { useWorkspaceSearch } from "@/hooks/use-workspace-search"; import { getChatModelSelection, getChatModelSelectionSyncKey, } from "@/lib/chat-model-selection"; import { - filterSidebarItemsWithForkGroups, getForkGroupPresentation, groupForkedChatItems, shouldRequestChatTitle, } from "@/lib/chat-forking"; import { + retainSelectedHistoryItem, resolveSidebarSelectionAfterRefresh, type SidebarSelection, } from "@/lib/sidebar-selection"; @@ -96,6 +98,7 @@ import { cn } from "@/lib/utils"; type DraftSelectionKind = "chat" | "search"; type SidebarItem = SidebarSelection & { title: string; + searchSnippet: string | null; parentChatId: string | null; updatedAt: string; createdAt: string; @@ -843,6 +846,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { kind: "chat" as const, id: chat.id, title: getChatTitle(chat), + searchSnippet: chat.searchSnippet ?? null, parentChatId: chat.parentChatId, updatedAt: chat.updatedAt, createdAt: chat.createdAt, @@ -860,6 +864,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { kind: "search" as const, id: search.id, title: getSearchTitle(search), + searchSnippet: search.searchSnippet ?? null, parentChatId: null, updatedAt: search.updatedAt, createdAt: search.createdAt, @@ -1052,6 +1057,10 @@ export default function App() { const [contextMenu, setContextMenu] = useState(null); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [sidebarQuery, setSidebarQuery] = useState(""); + const [sidebarSearchRevision, setSidebarSearchRevision] = useState(0); + const sidebarSearch = useWorkspaceSearch(sidebarQuery, isAuthenticated, sidebarSearchRevision); + const isSidebarSearchActive = Boolean(sidebarQuery.trim()); + const selectedHistoryItemRef = useRef(null); const initialRouteSelectionRef = useRef(readSidebarSelectionFromUrl()); const hasSyncedSelectionHistoryRef = useRef(false); @@ -1117,19 +1126,16 @@ export default function App() { }, [pendingAttachments]); const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]); - const filteredSidebarItems = useMemo(() => { - const query = sidebarQuery.trim().toLowerCase(); - if (!query) return groupForkedChatItems(sidebarItems).flat(); - return filterSidebarItemsWithForkGroups(sidebarItems, (item) => { - const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase(); - return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel] - .filter(Boolean) - .some((value) => String(value).toLowerCase().includes(query)); - }); - }, [sidebarItems, sidebarQuery]); + const searchSidebarItems = useMemo(() => buildSidebarItems(sidebarSearch.items), [sidebarSearch.items]); + const knownSidebarItems = useMemo(() => { + const matches = new Set(searchSidebarItems.map((item) => `${item.kind}:${item.id}`)); + return [...searchSidebarItems, ...sidebarItems.filter((item) => !matches.has(`${item.kind}:${item.id}`))]; + }, [sidebarItems, searchSidebarItems]); const sidebarSections = useMemo( - () => buildSidebarSections(filteredSidebarItems, sidebarItems), - [filteredSidebarItems, sidebarItems] + () => isSidebarSearchActive + ? (searchSidebarItems.length ? [{ label: "SEARCH RESULTS", items: searchSidebarItems }] : []) + : buildSidebarSections(sidebarItems), + [isSidebarSearchActive, searchSidebarItems, sidebarItems] ); const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]); @@ -1148,6 +1154,8 @@ export default function App() { setChats([]); setSearches([]); setWorkspaceItems([]); + setSidebarQuery(""); + selectedHistoryItemRef.current = null; setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); @@ -1199,6 +1207,10 @@ export default function App() { resetWorkspaceState(); }; + useEffect(() => { + if (sidebarSearch.error?.includes("bearer token")) handleAuthFailure(sidebarSearch.error); + }, [sidebarSearch.error]); + const refreshCollections = async ({ initialSelection, reportTransientError = true, @@ -1211,12 +1223,14 @@ export default function App() { }; setIsLoadingCollections(true); try { - const nextWorkspaceItems = await listWorkspaceItems(); + let nextWorkspaceItems = await listWorkspaceItems(); if (requestId !== collectionLoadRequestRef.current) return; + nextWorkspaceItems = retainSelectedHistoryItem(nextWorkspaceItems, selectedItemRef.current, selectedHistoryItemRef.current); const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems); setWorkspaceItems(nextWorkspaceItems); setChats(nextChats); setSearches(nextSearches); + setSidebarSearchRevision((current) => current + 1); setSelectedItem((current) => { const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, { @@ -1512,16 +1526,16 @@ export default function App() { const isItemRunning = (item: SidebarSelection) => item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id]; 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 []; const rootId = chat.parentChatId ?? chat.id; - return sidebarItems.filter( + return knownSidebarItems.filter( (item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId) ); }; const isRootChat = (item: SidebarSelection) => { 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; }; const isDeleteBlocked = (item: SidebarSelection) => { @@ -1530,9 +1544,10 @@ export default function App() { }; const isSidebarTargetStarred = (item: SidebarSelection) => { 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 current = selectedItemRef.current; @@ -1928,10 +1943,21 @@ export default function App() { : Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction)); const nextItem = orderedSidebarItems[nextIndex]; const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id }; + rememberHistoryResult(target); selectedItemRef.current = 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(() => { if (!isAuthenticated) return; @@ -1975,11 +2001,15 @@ export default function App() { if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages); const summary = chats.find((chat) => chat.id === chatId); 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"; }; 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(); setChats((current) => { const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id); @@ -2012,6 +2042,10 @@ export default function App() { }; 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(); setSearches((current) => { const withoutExisting = current.filter((search) => search.id !== updatedSearch.id); @@ -2198,7 +2232,7 @@ export default function App() { const family = getChatFamilyItems(chatId); const root = family.find((item) => item.parentChatId === null) ?? family[0]; 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) => { @@ -2209,7 +2243,7 @@ export default function App() { if (target.kind === "chat") { await toggleChatFamilyStar(target.id); } 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); applySearchSummary(updatedSearch, false); } @@ -2301,6 +2335,11 @@ export default function App() { } else { 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 }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -3503,33 +3542,55 @@ export default function App() { setSidebarQuery(event.currentTarget.value)} - placeholder="Search chats" - 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" + placeholder="Search chats and web searches" + 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 ? ( + + ) : null}
- {isLoadingCollections && sidebarItems.length === 0 ?

Loading conversations...

: null} - {!isLoadingCollections && sidebarItems.length === 0 ? ( + {!isSidebarSearchActive && isLoadingCollections && sidebarItems.length === 0 ?

Loading conversations...

: null} + {!isSidebarSearchActive && !isLoadingCollections && sidebarItems.length === 0 ? (
Start a chat or run your first search.
) : null} - {!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? ( -

No chats found.

+ {isSidebarSearchActive ? ( +
+ {sidebarSearch.isLoading ?

Searching history…

: null} + {sidebarSearch.error ? ( +
+

{sidebarSearch.error}

+ +
+ ) : !sidebarSearch.isLoading && searchSidebarItems.length === 0 ? ( +

No matching chats or web searches. Try a title, query, or words from a conversation.

+ ) : null} +
) : null} {sidebarSections.map((section) => (

{section.label}

{section.items.map((item) => { const active = selectedItem?.kind === item.kind && selectedItem.id === item.id; - const isForkChild = item.kind === "chat" && item.parentChatId !== null; - const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item]; + const isForkChild = !isSidebarSearchActive && item.kind === "chat" && item.parentChatId !== null; + const familyItems = !isSidebarSearchActive && item.kind === "chat" ? getChatFamilyItems(item.id) : [item]; const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem)); const itemIsStarred = - familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false; + familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? item.starred; const itemUpdatedAt = familyItems.reduce( (newest, familyItem) => new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime() @@ -3555,6 +3616,7 @@ export default function App() { draftKindRef.current = null; setDraftKind(null); const target = { kind: item.kind, id: item.id } as SidebarSelection; + rememberHistoryResult(target); selectedItemRef.current = target; setSelectedItem(target); setIsMobileSidebarOpen(false); @@ -3573,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" )} > - {item.kind === "chat" ? : } + {item.kind === "chat" ? (item.parentChatId ? : ) : } {item.title} @@ -3598,6 +3660,9 @@ export default function App() { ) : null} + {isSidebarSearchActive ? ( + + ) : null}
)} @@ -3605,6 +3670,9 @@ export default function App() { })}
))} + {isSidebarSearchActive && sidebarSearch.hasMore ? ( +

Showing the 100 most recent matches. Refine your search to find more.

+ ) : null} diff --git a/web/src/components/search/search-match-label.tsx b/web/src/components/search/search-match-label.tsx new file mode 100644 index 0000000..fee0b75 --- /dev/null +++ b/web/src/components/search/search-match-label.tsx @@ -0,0 +1,96 @@ +/// +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(null); + const measureRef = useRef(null); + const ellipsisRef = useRef(null); + const [kept, setKept] = useState(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 + ? {value} + : {value}]; + }); + + 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 ( + + {text} + + + + ); +} diff --git a/web/src/hooks/use-workspace-search.ts b/web/src/hooks/use-workspace-search.ts new file mode 100644 index 0000000..e71405c --- /dev/null +++ b/web/src/hooks/use-workspace-search.ts @@ -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(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, + }; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 95cddd4..def398f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -33,7 +33,9 @@ export type SearchWorkspaceItem = SearchSummary & { 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 = { id: string; @@ -272,6 +274,10 @@ export async function listWorkspaceItems() { return data.items; } +export function searchWorkspaceItems(query: string, signal?: AbortSignal) { + return api(`/v1/workspace-items?${new URLSearchParams({ q: query })}`, { signal }); +} + export async function verifySession() { return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session"); } diff --git a/web/src/lib/sidebar-selection.ts b/web/src/lib/sidebar-selection.ts index a3aee2a..d2573ca 100644 --- a/web/src/lib/sidebar-selection.ts +++ b/web/src/lib/sidebar-selection.ts @@ -2,6 +2,16 @@ export type SidebarSelection = { kind: "chat" | "search"; id: string }; type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string }; +export function retainSelectedHistoryItem( + 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 = { initialSelection?: SidebarSelection; selectFallback?: boolean; diff --git a/web/src/lib/workspace-search.ts b/web/src/lib/workspace-search.ts new file mode 100644 index 0000000..3077b88 --- /dev/null +++ b/web/src/lib/workspace-search.ts @@ -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, + 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(); + }; +} diff --git a/web/tests/workspace-search.test.mjs b/web/tests/workspace-search.test.mjs new file mode 100644 index 0000000..8ca272f --- /dev/null +++ b/web/tests/workspace-search.test.mjs @@ -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); + } +});