Compare commits
1 Commits
600bc3befc
...
wip/spacer
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d69cb4979 |
5
dist/default.conf
vendored
5
dist/default.conf
vendored
@@ -17,11 +17,6 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ Chat upload limits:
|
||||
```
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||
- `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.
|
||||
|
||||
## Active Runs
|
||||
|
||||
@@ -58,47 +57,6 @@ Behavior notes:
|
||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||
- The lists are not durable across server restarts.
|
||||
|
||||
## Workspace Items
|
||||
|
||||
### `GET /v1/workspace-items`
|
||||
- Response: `{ "items": WorkspaceItem[] }`
|
||||
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"type": "chat",
|
||||
"id": "chat-id",
|
||||
"title": "optional title",
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": true,
|
||||
"starredAt": "2026-02-14T01:00:00.000Z",
|
||||
"initiatedProvider": "openai",
|
||||
"initiatedModel": "gpt-4.1-mini",
|
||||
"lastUsedProvider": "openai",
|
||||
"lastUsedModel": "gpt-4.1-mini"
|
||||
},
|
||||
{
|
||||
"type": "search",
|
||||
"id": "search-id",
|
||||
"title": "optional title",
|
||||
"query": "search query",
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": false,
|
||||
"starredAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||
- The response currently combines up to 100 chats and up to 100 searches.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
## Chats
|
||||
|
||||
### `GET /v1/chats`
|
||||
@@ -131,20 +89,8 @@ Behavior notes:
|
||||
### `PATCH /v1/chats/:chatId`
|
||||
- Body: `{ "title": string }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||
- Renaming updates the returned chat's `updatedAt`.
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
### `PATCH /v1/chats/:chatId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the chat transcript or chat `updatedAt`.
|
||||
|
||||
### `POST /v1/chats/title/suggest`
|
||||
- Body:
|
||||
```json
|
||||
@@ -157,8 +103,7 @@ Behavior notes:
|
||||
|
||||
Behavior notes:
|
||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- Response: `{ "deleted": true }`
|
||||
@@ -294,24 +239,8 @@ Behavior notes:
|
||||
- Response: `{ "searches": SearchSummary[] }`
|
||||
|
||||
### `POST /v1/searches`
|
||||
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
|
||||
- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }`
|
||||
|
||||
Behavior notes:
|
||||
- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior.
|
||||
- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate.
|
||||
- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search.
|
||||
- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run.
|
||||
|
||||
### `PATCH /v1/searches/:searchId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Body: `{ "title"?: string, "query"?: string }`
|
||||
- Response: `{ "search": SearchSummary }`
|
||||
- Not found: `404 { "message": "search not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the search results or search `updatedAt`.
|
||||
|
||||
### `DELETE /v1/searches/:searchId`
|
||||
- Response: `{ "deleted": true }`
|
||||
@@ -385,8 +314,6 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
@@ -435,8 +362,6 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
@@ -447,7 +372,7 @@ Behavior notes:
|
||||
|
||||
`SearchSummary`
|
||||
```json
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
|
||||
```
|
||||
|
||||
`SearchDetail`
|
||||
@@ -458,8 +383,6 @@ Behavior notes:
|
||||
"query": "...",
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"requestId": "...",
|
||||
"latencyMs": 123,
|
||||
"error": null,
|
||||
|
||||
@@ -24,8 +24,8 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: 1.9
|
||||
CURRENT_PROJECT_VERSION: 10
|
||||
MARKETING_VERSION: 1.7
|
||||
CURRENT_PROJECT_VERSION: 8
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
@@ -44,11 +44,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||
}
|
||||
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem] {
|
||||
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
|
||||
return response.items
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||
return response.chats
|
||||
@@ -74,26 +69,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/star",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -138,16 +113,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
||||
let response = try await request(
|
||||
"/v1/searches/\(searchID)/star",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
||||
responseType: SearchCreateResponse.self
|
||||
)
|
||||
return response.search
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -670,14 +635,6 @@ private struct ChatCreateBody: Encodable {
|
||||
var messages: [CompletionRequestMessage]?
|
||||
}
|
||||
|
||||
private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
private struct SearchCreateBody: Encodable {
|
||||
var title: String?
|
||||
var query: String?
|
||||
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
|
||||
protocol SybilAPIClienting: Sendable {
|
||||
func verifySession() async throws -> AuthSession
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||
func listChats() async throws -> [ChatSummary]
|
||||
func createChat(
|
||||
title: String?,
|
||||
@@ -11,15 +10,12 @@ protocol SybilAPIClienting: Sendable {
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
func getChat(chatID: String) async throws -> ChatDetail
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||
func listSearches() async throws -> [SearchSummary]
|
||||
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||
func getSearch(searchID: String) async throws -> SearchDetail
|
||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
|
||||
func deleteSearch(searchID: String) async throws
|
||||
func listModels() async throws -> ModelCatalogResponse
|
||||
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||
|
||||
@@ -7,6 +7,9 @@ struct SybilChatTranscriptView: View {
|
||||
var isSending: Bool
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var tailSpacerHeight: CGFloat = 0
|
||||
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
|
||||
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
|
||||
|
||||
private var hasPendingAssistant: Bool {
|
||||
messages.contains { message in
|
||||
@@ -20,6 +23,16 @@ struct SybilChatTranscriptView: View {
|
||||
ForEach(messages.reversed()) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isStreamingPendingAssistant(message) {
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(
|
||||
key: SybilPendingAssistantHeightPreferenceKey.self,
|
||||
value: proxy.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
@@ -33,13 +46,39 @@ struct SybilChatTranscriptView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + bottomContentInset)
|
||||
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
|
||||
.padding(.bottom, 18 + topContentInset)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
onViewportHeightChange?(proxy.size.height)
|
||||
}
|
||||
.onChange(of: proxy.size.height) { _, height in
|
||||
onViewportHeightChange?(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
|
||||
onPendingAssistantHeightChange?(height)
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
|
||||
isSending && message.id.hasPrefix("temp-assistant-")
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageBubble: View {
|
||||
|
||||
@@ -154,8 +154,6 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var title: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
@@ -168,87 +166,6 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var query: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
}
|
||||
|
||||
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
|
||||
case chat
|
||||
case search
|
||||
}
|
||||
|
||||
public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var type: WorkspaceItemType
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var query: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
public var lastUsedModel: String?
|
||||
|
||||
public init(chat: ChatSummary) {
|
||||
self.type = .chat
|
||||
self.id = chat.id
|
||||
self.title = chat.title
|
||||
self.query = nil
|
||||
self.createdAt = chat.createdAt
|
||||
self.updatedAt = chat.updatedAt
|
||||
self.starred = chat.starred
|
||||
self.starredAt = chat.starredAt
|
||||
self.initiatedProvider = chat.initiatedProvider
|
||||
self.initiatedModel = chat.initiatedModel
|
||||
self.lastUsedProvider = chat.lastUsedProvider
|
||||
self.lastUsedModel = chat.lastUsedModel
|
||||
}
|
||||
|
||||
public init(search: SearchSummary) {
|
||||
self.type = .search
|
||||
self.id = search.id
|
||||
self.title = search.title
|
||||
self.query = search.query
|
||||
self.createdAt = search.createdAt
|
||||
self.updatedAt = search.updatedAt
|
||||
self.starred = search.starred
|
||||
self.starredAt = search.starredAt
|
||||
self.initiatedProvider = nil
|
||||
self.initiatedModel = nil
|
||||
self.lastUsedProvider = nil
|
||||
self.lastUsedModel = nil
|
||||
}
|
||||
|
||||
public var chatSummary: ChatSummary? {
|
||||
guard type == .chat else { return nil }
|
||||
return ChatSummary(
|
||||
id: id,
|
||||
title: title,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
starred: starred,
|
||||
starredAt: starredAt,
|
||||
initiatedProvider: initiatedProvider,
|
||||
initiatedModel: initiatedModel,
|
||||
lastUsedProvider: lastUsedProvider,
|
||||
lastUsedModel: lastUsedModel
|
||||
)
|
||||
}
|
||||
|
||||
public var searchSummary: SearchSummary? {
|
||||
guard type == .search else { return nil }
|
||||
return SearchSummary(
|
||||
id: id,
|
||||
title: title,
|
||||
query: query,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
starred: starred,
|
||||
starredAt: starredAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
@@ -391,8 +308,6 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var title: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
@@ -431,8 +346,6 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var query: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var requestId: String?
|
||||
public var latencyMs: Int?
|
||||
public var error: String?
|
||||
@@ -611,10 +524,6 @@ struct SearchListResponse: Codable {
|
||||
var searches: [SearchSummary]
|
||||
}
|
||||
|
||||
struct WorkspaceListResponse: Codable {
|
||||
var items: [WorkspaceItem]
|
||||
}
|
||||
|
||||
struct ChatDetailResponse: Codable {
|
||||
var chat: ChatDetail
|
||||
}
|
||||
|
||||
@@ -219,11 +219,6 @@ struct SybilQuickQuestionView: View {
|
||||
}
|
||||
|
||||
private func submitQuestion() {
|
||||
guard viewModel.canSendQuickQuestion else {
|
||||
return
|
||||
}
|
||||
|
||||
promptFocused = false
|
||||
_ = viewModel.sendQuickQuestion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,108 +111,59 @@ struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
|
||||
private var isRenameAlertPresented: Binding<Bool> {
|
||||
Binding {
|
||||
renameTarget != nil
|
||||
} set: { isPresented in
|
||||
if !isPresented {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
Button {
|
||||
onSelect(item)
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
Button {
|
||||
onSelect(item)
|
||||
} label: {
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteItem(item.selection)
|
||||
}
|
||||
} label: {
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.setItemStarred(item.selection, starred: !item.starred)
|
||||
}
|
||||
} label: {
|
||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||
}
|
||||
|
||||
if item.kind == .chat {
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
} label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteItem(item.selection)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
||||
TextField("Title", text: $renameTitle)
|
||||
Button("Cancel", role: .cancel) {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
.refreshable {
|
||||
await viewModel.refreshVisibleContent(
|
||||
refreshCollections: true,
|
||||
refreshSelection: false
|
||||
)
|
||||
}
|
||||
Button("Save") {
|
||||
let target = renameTarget
|
||||
let title = renameTitle
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
|
||||
if let target, case let .chat(chatID) = target.selection {
|
||||
Task {
|
||||
await viewModel.renameChat(chatID: chatID, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,12 +204,6 @@ struct SybilSidebarRow: View {
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
|
||||
if item.starred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if item.isRunning {
|
||||
|
||||
@@ -34,8 +34,6 @@ struct SidebarItem: Identifiable, Hashable {
|
||||
var kind: Kind
|
||||
var title: String
|
||||
var updatedAt: Date
|
||||
var starred: Bool
|
||||
var starredAt: Date?
|
||||
var initiatedLabel: String?
|
||||
var isRunning: Bool
|
||||
}
|
||||
@@ -97,7 +95,6 @@ final class SybilViewModel {
|
||||
|
||||
var chats: [ChatSummary] = []
|
||||
var searches: [SearchSummary] = []
|
||||
var workspaceItems: [WorkspaceItem] = []
|
||||
|
||||
var selectedItem: SidebarSelection?
|
||||
var selectedChat: ChatDetail?
|
||||
@@ -391,44 +388,40 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var sidebarItems: [SidebarItem] {
|
||||
workspaceItems.map { item in
|
||||
switch item.type {
|
||||
case .chat:
|
||||
let initiatedLabel: String?
|
||||
if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||
if let provider = item.initiatedProvider {
|
||||
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||
} else {
|
||||
initiatedLabel = model
|
||||
}
|
||||
let chatItems: [SidebarItem] = chats.map { chat in
|
||||
let initiatedLabel: String?
|
||||
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||
if let provider = chat.initiatedProvider {
|
||||
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||
} else {
|
||||
initiatedLabel = nil
|
||||
initiatedLabel = model
|
||||
}
|
||||
|
||||
return SidebarItem(
|
||||
selection: .chat(item.id),
|
||||
kind: .chat,
|
||||
title: chatTitle(title: item.title, messages: nil),
|
||||
updatedAt: item.updatedAt,
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
initiatedLabel: initiatedLabel,
|
||||
isRunning: isChatRowRunning(item.id)
|
||||
)
|
||||
|
||||
case .search:
|
||||
return SidebarItem(
|
||||
selection: .search(item.id),
|
||||
kind: .search,
|
||||
title: searchTitle(title: item.title, query: item.query),
|
||||
updatedAt: item.updatedAt,
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
initiatedLabel: "exa",
|
||||
isRunning: isSearchRowRunning(item.id)
|
||||
)
|
||||
} else {
|
||||
initiatedLabel = nil
|
||||
}
|
||||
|
||||
return SidebarItem(
|
||||
selection: .chat(chat.id),
|
||||
kind: .chat,
|
||||
title: chatTitle(title: chat.title, messages: nil),
|
||||
updatedAt: chat.updatedAt,
|
||||
initiatedLabel: initiatedLabel,
|
||||
isRunning: isChatRowRunning(chat.id)
|
||||
)
|
||||
}
|
||||
|
||||
let searchItems: [SidebarItem] = searches.map { search in
|
||||
SidebarItem(
|
||||
selection: .search(search.id),
|
||||
kind: .search,
|
||||
title: searchTitle(title: search.title, query: search.query),
|
||||
updatedAt: search.updatedAt,
|
||||
initiatedLabel: "exa",
|
||||
isRunning: isSearchRowRunning(search.id)
|
||||
)
|
||||
}
|
||||
|
||||
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
var selectedChatSummary: ChatSummary? {
|
||||
@@ -509,7 +502,6 @@ final class SybilViewModel {
|
||||
authMode = nil
|
||||
chats = []
|
||||
searches = []
|
||||
workspaceItems = []
|
||||
selectedItem = .settings
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
@@ -679,7 +671,6 @@ final class SybilViewModel {
|
||||
setProvider(submittedProvider, model: submittedModel)
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
upsertWorkspaceChat(chat)
|
||||
draftKind = nil
|
||||
selectedItem = .chat(chat.id)
|
||||
selectedChat = ChatDetail(
|
||||
@@ -687,8 +678,6 @@ final class SybilViewModel {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -859,57 +848,6 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func renameChat(chatID: String, title: String) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedTitle.isEmpty else {
|
||||
errorMessage = "Enter a chat title."
|
||||
return
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
||||
applyChatSummary(updated, moveToFront: true)
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
|
||||
guard case .settings = selection else {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
switch selection {
|
||||
case let .chat(chatID):
|
||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
||||
applyChatSummary(updated, moveToFront: false)
|
||||
case let .search(searchID):
|
||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||
applySearchSummary(updated, moveToFront: false)
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAfterSettingsChange() async {
|
||||
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
||||
settings.persist()
|
||||
@@ -973,23 +911,6 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshSidebarCollectionsFromPullToRefresh() async {
|
||||
guard isAuthenticated, !isCheckingSession else {
|
||||
return
|
||||
}
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.ui,
|
||||
"Sidebar pull-to-refresh requested"
|
||||
)
|
||||
|
||||
let preferredSelection = selectedItem
|
||||
let refreshTask = Task { @MainActor in
|
||||
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
|
||||
}
|
||||
await refreshTask.value
|
||||
}
|
||||
|
||||
func sendComposer() async {
|
||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let attachments = composerAttachments
|
||||
@@ -1096,7 +1017,6 @@ final class SybilViewModel {
|
||||
guard selectedItem == sourceSelection, draftKind == nil else {
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
upsertWorkspaceChat(chat)
|
||||
isCreatingSearchChat = false
|
||||
return
|
||||
}
|
||||
@@ -1108,7 +1028,6 @@ final class SybilViewModel {
|
||||
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
upsertWorkspaceChat(chat)
|
||||
|
||||
selectedItem = .chat(chat.id)
|
||||
selectedSearch = nil
|
||||
@@ -1212,16 +1131,18 @@ final class SybilViewModel {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||
async let chatsValue = client.listChats()
|
||||
async let searchesValue = client.listSearches()
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
||||
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
chats = nextChats
|
||||
searches = nextSearches
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
"Loaded collections: \(chats.count) chats, \(searches.count) searches"
|
||||
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
)
|
||||
|
||||
do {
|
||||
@@ -1238,7 +1159,7 @@ final class SybilViewModel {
|
||||
if case .settings = selectedItem {
|
||||
nextSelection = .settings
|
||||
} else if let currentSelection = selectedItem,
|
||||
hasSelection(currentSelection, chats: chats, searches: searches) {
|
||||
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) {
|
||||
nextSelection = currentSelection
|
||||
} else {
|
||||
nextSelection = sidebarItems.first?.selection
|
||||
@@ -1310,16 +1231,18 @@ final class SybilViewModel {
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||
async let chatsValue = client.listChats()
|
||||
async let searchesValue = client.listSearches()
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
||||
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
chats = nextChats
|
||||
searches = nextSearches
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
"Refreshed collections: \(chats.count) chats, \(searches.count) searches"
|
||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
)
|
||||
errorMessage = nil
|
||||
|
||||
@@ -1337,10 +1260,10 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
if let preferredSelection,
|
||||
hasSelection(preferredSelection, chats: chats, searches: searches) {
|
||||
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) {
|
||||
selectedItem = preferredSelection
|
||||
} else if let existing = selectedItem,
|
||||
hasSelection(existing, chats: chats, searches: searches) {
|
||||
hasSelection(existing, chats: nextChats, searches: nextSearches) {
|
||||
selectedItem = existing
|
||||
} else {
|
||||
selectedItem = sidebarItems.first?.selection
|
||||
@@ -1353,9 +1276,7 @@ final class SybilViewModel {
|
||||
attachToVisibleActiveRunIfNeeded()
|
||||
}
|
||||
} catch {
|
||||
if isCancellation(error) {
|
||||
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
|
||||
} else if shouldSuppressInactiveTransportError(error) {
|
||||
if shouldSuppressInactiveTransportError(error) {
|
||||
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
||||
} else {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
@@ -1434,75 +1355,6 @@ final class SybilViewModel {
|
||||
serverActiveSearchIDs = Set(activeRuns.searches)
|
||||
}
|
||||
|
||||
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
|
||||
workspaceItems = items
|
||||
chats = items.compactMap(\.chatSummary)
|
||||
searches = items.compactMap(\.searchSummary)
|
||||
}
|
||||
|
||||
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
|
||||
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
|
||||
chats.remove(at: existingIndex)
|
||||
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
|
||||
} else {
|
||||
chats.insert(chat, at: 0)
|
||||
}
|
||||
|
||||
upsertWorkspaceChat(chat, moveToFront: moveToFront)
|
||||
|
||||
if selectedChat?.id == chat.id {
|
||||
selectedChat?.title = chat.title
|
||||
selectedChat?.updatedAt = chat.updatedAt
|
||||
selectedChat?.starred = chat.starred
|
||||
selectedChat?.starredAt = chat.starredAt
|
||||
selectedChat?.initiatedProvider = chat.initiatedProvider
|
||||
selectedChat?.initiatedModel = chat.initiatedModel
|
||||
selectedChat?.lastUsedProvider = chat.lastUsedProvider
|
||||
selectedChat?.lastUsedModel = chat.lastUsedModel
|
||||
}
|
||||
}
|
||||
|
||||
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
|
||||
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
|
||||
searches.remove(at: existingIndex)
|
||||
searches.insert(search, at: moveToFront ? 0 : existingIndex)
|
||||
} else {
|
||||
searches.insert(search, at: 0)
|
||||
}
|
||||
|
||||
upsertWorkspaceSearch(search, moveToFront: moveToFront)
|
||||
|
||||
if selectedSearch?.id == search.id {
|
||||
selectedSearch?.title = search.title
|
||||
selectedSearch?.query = search.query
|
||||
selectedSearch?.updatedAt = search.updatedAt
|
||||
selectedSearch?.starred = search.starred
|
||||
selectedSearch?.starredAt = search.starredAt
|
||||
}
|
||||
}
|
||||
|
||||
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
|
||||
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
|
||||
}
|
||||
|
||||
private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) {
|
||||
upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront)
|
||||
}
|
||||
|
||||
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
||||
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||
workspaceItems.remove(at: existingIndex)
|
||||
if moveToFront {
|
||||
workspaceItems.insert(item, at: 0)
|
||||
} else {
|
||||
workspaceItems.insert(item, at: existingIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
workspaceItems.insert(item, at: 0)
|
||||
}
|
||||
|
||||
private func attachToVisibleActiveRunIfNeeded() {
|
||||
guard draftKind == nil else {
|
||||
return
|
||||
@@ -1834,7 +1686,6 @@ final class SybilViewModel {
|
||||
|
||||
chats.removeAll(where: { $0.id == created.id })
|
||||
chats.insert(created, at: 0)
|
||||
upsertWorkspaceChat(created)
|
||||
|
||||
if shouldShowCreatedChat {
|
||||
draftKind = nil
|
||||
@@ -1845,8 +1696,6 @@ final class SybilViewModel {
|
||||
title: created.title,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
starredAt: created.starredAt,
|
||||
initiatedProvider: created.initiatedProvider,
|
||||
initiatedModel: created.initiatedModel,
|
||||
lastUsedProvider: created.lastUsedProvider,
|
||||
@@ -1907,7 +1756,17 @@ final class SybilViewModel {
|
||||
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
||||
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
||||
await MainActor.run {
|
||||
self.applyChatSummary(updated, moveToFront: false)
|
||||
self.chats = self.chats.map { existing in
|
||||
if existing.id == updated.id {
|
||||
return updated
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
if self.selectedChat?.id == updated.id {
|
||||
self.selectedChat?.title = updated.title
|
||||
self.selectedChat?.updatedAt = updated.updatedAt
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
||||
@@ -2040,7 +1899,6 @@ final class SybilViewModel {
|
||||
|
||||
searches.removeAll(where: { $0.id == created.id })
|
||||
searches.insert(created, at: 0)
|
||||
upsertWorkspaceSearch(created)
|
||||
|
||||
if shouldShowCreatedSearch {
|
||||
draftKind = nil
|
||||
@@ -2066,8 +1924,6 @@ final class SybilViewModel {
|
||||
query: query,
|
||||
createdAt: currentSelectedSearch?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
starred: currentSelectedSearch?.starred ?? false,
|
||||
starredAt: currentSelectedSearch?.starredAt,
|
||||
requestId: nil,
|
||||
latencyMs: nil,
|
||||
error: nil,
|
||||
|
||||
@@ -26,6 +26,10 @@ struct SybilWorkspaceView: View {
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||
@State private var isComposerDropTargeted = false
|
||||
@State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
@State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
@State private var transcriptViewportHeight: CGFloat = 0
|
||||
@State private var pendingAssistantBaselineHeight: CGFloat?
|
||||
@State private var newChatSwipeOffset: CGFloat = 0
|
||||
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
||||
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||
@@ -38,6 +42,10 @@ struct SybilWorkspaceView: View {
|
||||
private let customWorkspaceNavigationContentInset: CGFloat = 96
|
||||
private let composerOverlayContentInset: CGFloat = 112
|
||||
|
||||
private var visibleTranscriptTailSpacerHeight: CGFloat {
|
||||
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
|
||||
}
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
return true
|
||||
@@ -145,6 +153,17 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
.onChange(of: transcriptScrollContextID) { _, _ in
|
||||
handleTranscriptContextChange()
|
||||
}
|
||||
.onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in
|
||||
handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending)
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if message != nil && !viewModel.isSendingVisibleChat {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
}
|
||||
}
|
||||
.task(id: composerFocusPolicyID) {
|
||||
await applyComposerFocusPolicy()
|
||||
}
|
||||
@@ -194,7 +213,14 @@ struct SybilWorkspaceView: View {
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
|
||||
onViewportHeightChange: { height in
|
||||
handleTranscriptViewportHeightChange(height)
|
||||
},
|
||||
onPendingAssistantHeightChange: { height in
|
||||
handlePendingAssistantHeightChange(height)
|
||||
}
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -232,7 +258,13 @@ struct SybilWorkspaceView: View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
|
||||
customWorkspaceNavigationTitle
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
workspaceNavigationTrailingControl
|
||||
}
|
||||
@@ -245,32 +277,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedProviderModelSubtitle: String {
|
||||
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedModel.isEmpty else {
|
||||
return viewModel.provider.displayName
|
||||
}
|
||||
return "\(viewModel.provider.displayName) • \(selectedModel)"
|
||||
}
|
||||
|
||||
private var customWorkspaceNavigationTitle: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
|
||||
Text(selectedProviderModelSubtitle)
|
||||
.font(.sybil(size: 10, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationLeadingControl: some View {
|
||||
switch navigationLeadingControl {
|
||||
@@ -305,6 +311,86 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTranscriptContextChange() {
|
||||
resetTranscriptTailSpacer(animated: false)
|
||||
}
|
||||
|
||||
private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) {
|
||||
guard !viewModel.isSearchMode else {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if isSending {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
if wasSending {
|
||||
if viewModel.errorMessage != nil {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
}
|
||||
pendingAssistantBaselineHeight = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTranscriptViewportHeightChange(_ height: CGFloat) {
|
||||
transcriptViewportHeight = height
|
||||
|
||||
if viewModel.isSendingVisibleChat,
|
||||
transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePendingAssistantHeightChange(_ height: CGFloat) {
|
||||
guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
if pendingAssistantBaselineHeight == nil {
|
||||
pendingAssistantBaselineHeight = height
|
||||
}
|
||||
|
||||
let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight(
|
||||
targetHeight: transcriptTailSpacerTargetHeight,
|
||||
baselineAssistantHeight: pendingAssistantBaselineHeight ?? height,
|
||||
currentAssistantHeight: height
|
||||
)
|
||||
let nextHeight = min(transcriptTailSpacerHeight, measuredHeight)
|
||||
setTranscriptTailSpacer(nextHeight, animated: false)
|
||||
}
|
||||
|
||||
private func prepareTranscriptTailSpacerForReply(animated: Bool) {
|
||||
let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight)
|
||||
transcriptTailSpacerTargetHeight = targetHeight
|
||||
pendingAssistantBaselineHeight = nil
|
||||
setTranscriptTailSpacer(targetHeight, animated: animated)
|
||||
}
|
||||
|
||||
private func resetTranscriptTailSpacer(animated: Bool) {
|
||||
transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
pendingAssistantBaselineHeight = nil
|
||||
setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated)
|
||||
}
|
||||
|
||||
private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) {
|
||||
let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height)
|
||||
guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = {
|
||||
transcriptTailSpacerHeight = nextHeight
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.22), update)
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
@@ -722,8 +808,14 @@ struct SybilWorkspaceView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if !viewModel.isSearchMode {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
composerFocused = false
|
||||
if !viewModel.isSearchMode {
|
||||
composerFocused = false
|
||||
}
|
||||
#endif
|
||||
|
||||
Task {
|
||||
@@ -789,6 +881,37 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum SybilTranscriptTailSpacer {
|
||||
static let minimumHeight: CGFloat = 20
|
||||
static let replyBufferMin: CGFloat = 288
|
||||
static let replyBufferMax: CGFloat = 576
|
||||
static let replyBufferViewportRatio: CGFloat = 0.52
|
||||
|
||||
static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat {
|
||||
guard viewportHeight > 0 else {
|
||||
return replyBufferMin
|
||||
}
|
||||
|
||||
return min(
|
||||
replyBufferMax,
|
||||
max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded())
|
||||
)
|
||||
}
|
||||
|
||||
static func clampedHeight(_ height: CGFloat) -> CGFloat {
|
||||
max(minimumHeight, height.rounded(.up))
|
||||
}
|
||||
|
||||
static func placeholderHeight(
|
||||
targetHeight: CGFloat,
|
||||
baselineAssistantHeight: CGFloat,
|
||||
currentAssistantHeight: CGFloat
|
||||
) -> CGFloat {
|
||||
let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0)
|
||||
return clampedHeight(targetHeight - consumedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
enum NewChatSwipeMetrics {
|
||||
static let referenceWidth: CGFloat = 390
|
||||
static let horizontalActivationDistance: CGFloat = 18
|
||||
|
||||
@@ -4,14 +4,10 @@ import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
var listWorkspaceItems = 0
|
||||
var listChats = 0
|
||||
var listSearches = 0
|
||||
var createChat = 0
|
||||
var getChat = 0
|
||||
var updateChatTitle = 0
|
||||
var updateChatStar = 0
|
||||
var updateSearchStar = 0
|
||||
var getSearch = 0
|
||||
var getActiveRuns = 0
|
||||
var runCompletionStream = 0
|
||||
@@ -31,21 +27,15 @@ private struct UnexpectedClientCall: Error {}
|
||||
private actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatsResponse: [ChatSummary]
|
||||
private let searchesResponse: [SearchSummary]
|
||||
private let workspaceItemsResponse: [WorkspaceItem]
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
private let updateChatTitleResponses: [String: ChatSummary]
|
||||
private let updateChatStarResponses: [String: ChatSummary]
|
||||
private let updateSearchStarResponses: [String: SearchSummary]
|
||||
private let activeRunsResponse: ActiveRunsResponse
|
||||
|
||||
private var snapshot = MockClientCallSnapshot()
|
||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||
private var getChatDelayNanoseconds: UInt64 = 0
|
||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||
private var completionStreamNetworkErrorMessage: String?
|
||||
@@ -63,28 +53,16 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil,
|
||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
self.updateChatTitleResponses = updateChatTitleResponses
|
||||
self.updateChatStarResponses = updateChatStarResponses
|
||||
self.updateSearchStarResponses = updateSearchStarResponses
|
||||
self.activeRunsResponse = activeRunsResponse
|
||||
}
|
||||
|
||||
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
|
||||
(chats.map { WorkspaceItem(chat: $0) } + searches.map { WorkspaceItem(search: $0) }).sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
func currentSnapshot() -> MockClientCallSnapshot {
|
||||
snapshot
|
||||
}
|
||||
@@ -107,11 +85,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
|
||||
listChatsDelayNanoseconds = chats
|
||||
listSearchesDelayNanoseconds = searches
|
||||
}
|
||||
|
||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||
getChatDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
@@ -147,20 +120,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
AuthSession(authenticated: true, mode: "open")
|
||||
}
|
||||
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem] {
|
||||
snapshot.listWorkspaceItems += 1
|
||||
let delay = max(listChatsDelayNanoseconds, listSearchesDelayNanoseconds)
|
||||
if delay > 0 {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
}
|
||||
return workspaceItemsResponse
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
snapshot.listChats += 1
|
||||
if listChatsDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
|
||||
}
|
||||
return chatsResponse
|
||||
}
|
||||
|
||||
@@ -194,22 +155,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
return detail
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
snapshot.updateChatTitle += 1
|
||||
guard let summary = updateChatTitleResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
snapshot.updateChatStar += 1
|
||||
guard let summary = updateChatStarResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -220,9 +165,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func listSearches() async throws -> [SearchSummary] {
|
||||
snapshot.listSearches += 1
|
||||
if listSearchesDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
|
||||
}
|
||||
return searchesResponse
|
||||
}
|
||||
|
||||
@@ -245,14 +187,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
||||
snapshot.updateSearchStar += 1
|
||||
guard let summary = updateSearchStarResponses[searchID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -442,41 +376,13 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 1)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.listChats == 1)
|
||||
#expect(snapshot.listSearches == 1)
|
||||
#expect(snapshot.getChat == 0)
|
||||
#expect(snapshot.getSearch == 0)
|
||||
#expect(viewModel.selectedItem == .chat("chat-1"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_050)
|
||||
let chat = makeChatSummary(id: "chat-cancelled", date: date)
|
||||
let search = makeSearchSummary(id: "search-cancelled", date: date)
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
searchesResponse: [search]
|
||||
)
|
||||
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
|
||||
let refreshTask = Task {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
refreshTask.cancel()
|
||||
await refreshTask.value
|
||||
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(!viewModel.isLoadingCollections)
|
||||
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
|
||||
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||
@@ -490,84 +396,12 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 1)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
||||
let original = makeChatSummary(id: "chat-rename", date: date)
|
||||
let renamed = ChatSummary(
|
||||
id: "chat-rename",
|
||||
title: "Renamed chat",
|
||||
createdAt: date,
|
||||
updatedAt: date.addingTimeInterval(60),
|
||||
initiatedProvider: .openai,
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: .openai,
|
||||
lastUsedModel: "gpt-4.1-mini"
|
||||
)
|
||||
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [original],
|
||||
updateChatTitleResponses: ["chat-rename": renamed]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [original]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: original)]
|
||||
viewModel.selectedItem = .chat("chat-rename")
|
||||
viewModel.selectedChat = detail
|
||||
|
||||
await viewModel.renameChat(chatID: "chat-rename", title: " Renamed chat ")
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatTitle == 1)
|
||||
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
||||
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
||||
let chat = makeChatSummary(id: "chat-star", date: date)
|
||||
let search = makeSearchSummary(id: "search-star", date: date)
|
||||
var starredChat = chat
|
||||
starredChat.starred = true
|
||||
starredChat.starredAt = date.addingTimeInterval(5)
|
||||
var starredSearch = search
|
||||
starredSearch.starred = true
|
||||
starredSearch.starredAt = date.addingTimeInterval(10)
|
||||
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
searchesResponse: [search],
|
||||
updateChatStarResponses: ["chat-star": starredChat],
|
||||
updateSearchStarResponses: ["search-star": starredSearch]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [chat]
|
||||
viewModel.searches = [search]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: chat), WorkspaceItem(search: search)]
|
||||
|
||||
await viewModel.setItemStarred(.chat("chat-star"), starred: true)
|
||||
await viewModel.setItemStarred(.search("search-star"), starred: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatStar == 1)
|
||||
#expect(snapshot.updateSearchStar == 1)
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-star") })?.starred == true)
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||
@@ -581,7 +415,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getSearch == 1)
|
||||
@@ -951,3 +784,23 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||
}
|
||||
|
||||
@Test func transcriptTailSpacerContractsAsContentGrows() async throws {
|
||||
let targetHeight: CGFloat = 320
|
||||
let baselineAssistantHeight: CGFloat = 28
|
||||
|
||||
#expect(
|
||||
SybilTranscriptTailSpacer.placeholderHeight(
|
||||
targetHeight: targetHeight,
|
||||
baselineAssistantHeight: baselineAssistantHeight,
|
||||
currentAssistantHeight: baselineAssistantHeight + 120
|
||||
) == 200
|
||||
)
|
||||
#expect(
|
||||
SybilTranscriptTailSpacer.placeholderHeight(
|
||||
targetHeight: targetHeight,
|
||||
baselineAssistantHeight: baselineAssistantHeight,
|
||||
currentAssistantHeight: baselineAssistantHeight + 500
|
||||
) == SybilTranscriptTailSpacer.minimumHeight
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"kind" TEXT NOT NULL DEFAULT 'folder',
|
||||
"title" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"chatId" TEXT,
|
||||
"searchId" TEXT,
|
||||
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Add normalized search query lookup key for cache/reuse behavior.
|
||||
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
|
||||
|
||||
UPDATE "Search"
|
||||
SET "queryNormalized" = lower(trim("query"))
|
||||
WHERE "query" IS NOT NULL AND trim("query") != '';
|
||||
|
||||
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");
|
||||
@@ -27,11 +27,6 @@ enum SearchSource {
|
||||
exa
|
||||
}
|
||||
|
||||
enum ProjectKind {
|
||||
starred
|
||||
folder
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -42,7 +37,6 @@ model User {
|
||||
|
||||
chats Chat[]
|
||||
searches Search[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model Chat {
|
||||
@@ -62,7 +56,6 @@ model Chat {
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -118,7 +111,6 @@ model Search {
|
||||
|
||||
title String?
|
||||
query String?
|
||||
queryNormalized String?
|
||||
|
||||
source SearchSource @default(exa)
|
||||
|
||||
@@ -137,10 +129,8 @@ model Search {
|
||||
userId String?
|
||||
|
||||
results SearchResult[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([updatedAt])
|
||||
@@index([queryNormalized, updatedAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@@ -166,40 +156,3 @@ model SearchResult {
|
||||
|
||||
@@index([searchId, rank])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
kind ProjectKind @default(folder)
|
||||
title String
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
|
||||
items ProjectItem[]
|
||||
|
||||
@@index([kind])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ProjectItem {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
|
||||
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
chatId String?
|
||||
|
||||
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
|
||||
searchId String?
|
||||
|
||||
@@unique([projectId, chatId])
|
||||
@@unique([projectId, searchId])
|
||||
@@index([projectId, createdAt])
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.js";
|
||||
import { ensureDatabaseReady } from "./db-init.js";
|
||||
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -21,7 +21,6 @@ const app = Fastify({
|
||||
|
||||
await ensureDatabaseReady(app.log);
|
||||
await warmModelCatalog(app.log);
|
||||
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
@@ -81,10 +80,6 @@ app.setErrorHandler((err, req, reply) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
stopModelCatalogRefreshLoop();
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
|
||||
@@ -13,7 +13,6 @@ export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapsho
|
||||
|
||||
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
||||
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelCatalog: ModelCatalogSnapshot = {
|
||||
openai: { models: [], loadedAt: null, error: null },
|
||||
@@ -21,8 +20,6 @@ const modelCatalog: ModelCatalogSnapshot = {
|
||||
xai: { models: [], loadedAt: null, error: null },
|
||||
};
|
||||
|
||||
let catalogRefreshPromise: Promise<void> | null = null;
|
||||
|
||||
function getCatalogProviders(): Provider[] {
|
||||
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
|
||||
}
|
||||
@@ -89,42 +86,17 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
||||
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? String(err);
|
||||
const previous = modelCatalog[provider];
|
||||
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
||||
modelCatalog[provider] = {
|
||||
models: previous?.models.length ? previous.models : fallbackModels,
|
||||
loadedAt: previous?.loadedAt ?? null,
|
||||
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [],
|
||||
loadedAt: new Date().toISOString(),
|
||||
error: message,
|
||||
};
|
||||
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
|
||||
if (catalogRefreshPromise) return catalogRefreshPromise;
|
||||
|
||||
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
catalogRefreshPromise = null;
|
||||
});
|
||||
|
||||
return catalogRefreshPromise;
|
||||
}
|
||||
|
||||
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
|
||||
await refreshModelCatalog(logger);
|
||||
}
|
||||
|
||||
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
|
||||
const timer = setInterval(() => {
|
||||
void refreshModelCatalog(logger);
|
||||
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
|
||||
timer.unref?.();
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
await Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)));
|
||||
}
|
||||
|
||||
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||
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 type { ChatAttachment } from "./llm/types.js";
|
||||
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||
@@ -322,151 +321,11 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||
|
||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||
const STARRED_PROJECT_ID = "starred";
|
||||
|
||||
const starredProjectItemsSelect = {
|
||||
where: { projectId: STARRED_PROJECT_ID },
|
||||
select: { createdAt: true },
|
||||
take: 1,
|
||||
} as const;
|
||||
|
||||
const chatSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
const searchSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
query: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
function getErrorMessage(err: unknown) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) {
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
}
|
||||
|
||||
function serializeStarFields(item: { projectItems?: Array<{ createdAt: Date }> }) {
|
||||
const star = item.projectItems?.[0];
|
||||
return {
|
||||
starred: Boolean(star),
|
||||
starredAt: star?.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeChatLike<T extends Record<string, any>>(chat: T) {
|
||||
const { projectItems: _projectItems, ...rest } = chat;
|
||||
return {
|
||||
...serializeProviderFields(rest),
|
||||
...serializeStarFields(chat),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSearchLike<T extends Record<string, any>>(search: T) {
|
||||
const { projectItems: _projectItems, queryNormalized: _queryNormalized, ...rest } = search;
|
||||
return {
|
||||
...rest,
|
||||
...serializeStarFields(search),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureStarredProject() {
|
||||
await prisma.project.upsert({
|
||||
where: { id: STARRED_PROJECT_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: STARRED_PROJECT_ID,
|
||||
kind: "starred" as any,
|
||||
title: "Starred",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getChatSummary(chatId: string) {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return chat ? serializeChatLike(chat) : null;
|
||||
}
|
||||
|
||||
async function getSearchSummary(searchId: string) {
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
select: searchSummarySelect,
|
||||
});
|
||||
return search ? serializeSearchLike(search) : null;
|
||||
}
|
||||
|
||||
async function setChatStarred(chatId: string, starred: boolean) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
|
||||
if (starred) {
|
||||
await ensureStarredProject();
|
||||
await prisma.projectItem.upsert({
|
||||
where: { projectId_chatId: { projectId: STARRED_PROJECT_ID, chatId } },
|
||||
update: {},
|
||||
create: { projectId: STARRED_PROJECT_ID, chatId },
|
||||
});
|
||||
} else {
|
||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, chatId } });
|
||||
}
|
||||
|
||||
return getChatSummary(chatId);
|
||||
}
|
||||
|
||||
async function setSearchStarred(searchId: string, starred: boolean) {
|
||||
const exists = await prisma.search.findUnique({ where: { id: searchId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
|
||||
if (starred) {
|
||||
await ensureStarredProject();
|
||||
await prisma.projectItem.upsert({
|
||||
where: { projectId_searchId: { projectId: STARRED_PROJECT_ID, searchId } },
|
||||
update: {},
|
||||
create: { projectId: STARRED_PROJECT_ID, searchId },
|
||||
});
|
||||
} else {
|
||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, searchId } });
|
||||
}
|
||||
|
||||
return getSearchSummary(searchId);
|
||||
}
|
||||
|
||||
async function listWorkspaceItems() {
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: searchSummarySelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
return [
|
||||
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
|
||||
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
|
||||
].sort(compareUpdatedAtDesc);
|
||||
}
|
||||
|
||||
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
||||
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
||||
reply.raw.write(`event: ${event.event}\n`);
|
||||
@@ -650,7 +509,6 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
queryNormalized: normalizeSearchQuery(query),
|
||||
title: normalizedTitle,
|
||||
requestId: searchResponse?.requestId ?? null,
|
||||
rawResponse: searchResponse as any,
|
||||
@@ -671,15 +529,12 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) {
|
||||
stream.complete({ event: "error", data: { message: "search not found" } });
|
||||
} else {
|
||||
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
|
||||
stream.complete({ event: "done", data: { search } });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
@@ -688,7 +543,6 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
queryNormalized: normalizeSearchQuery(query),
|
||||
title: normalizedTitle,
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
error: message,
|
||||
@@ -724,19 +578,23 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/v1/workspace-items", async (req) => {
|
||||
requireAdmin(req);
|
||||
return { items: await listWorkspaceItems() };
|
||||
});
|
||||
|
||||
app.get("/v1/chats", async (req) => {
|
||||
requireAdmin(req);
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats", async (req) => {
|
||||
@@ -785,9 +643,18 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
return { chat: serializeChatLike(chat) };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
@@ -804,21 +671,21 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||
|
||||
const chat = await getChatSummary(chatId);
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId/star", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Body = z.object({ starred: z.boolean() });
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const chat = await setChatStarred(chatId, body.starred);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/title/suggest", async (req) => {
|
||||
@@ -831,24 +698,40 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const existing = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
const suggestedRaw = await generateChatTitle(body.content);
|
||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||
|
||||
await prisma.chat.updateMany({
|
||||
where: { id: body.chatId, title: existing.title },
|
||||
const chat = await prisma.chat.update({
|
||||
where: { id: body.chatId },
|
||||
data: { title },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
|
||||
const chat = await getChatSummary(body.chatId);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.delete("/v1/chats/:chatId", async (req) => {
|
||||
@@ -873,69 +756,24 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const searches = await prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: searchSummarySelect,
|
||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||
});
|
||||
return { searches: searches.map((search) => serializeSearchLike(search)) };
|
||||
return { searches };
|
||||
});
|
||||
|
||||
app.post("/v1/searches", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Body = z.object({
|
||||
title: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
reuseByQuery: z.boolean().optional(),
|
||||
});
|
||||
const Body = z.object({ title: z.string().optional(), query: z.string().optional() });
|
||||
const body = Body.parse(req.body ?? {});
|
||||
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
|
||||
const query = body.query?.trim() || null;
|
||||
const queryNormalized = normalizeSearchQuery(query);
|
||||
|
||||
if (body.reuseByQuery && queryNormalized) {
|
||||
const existing = await prisma.search.findFirst({
|
||||
where: { queryNormalized },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: {
|
||||
...searchSummarySelect,
|
||||
answerText: true,
|
||||
_count: { select: { results: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const { _count, answerText: _answerText, ...search } = existing;
|
||||
return {
|
||||
search: serializeSearchLike(search),
|
||||
reused: true,
|
||||
cacheHit: isFreshSearchCacheHit({
|
||||
updatedAt: existing.updatedAt,
|
||||
resultCount: _count.results,
|
||||
answerText: existing.answerText,
|
||||
isActive: activeSearchStreams.has(existing.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const search = await prisma.search.create({
|
||||
data: {
|
||||
title: title || null,
|
||||
query,
|
||||
queryNormalized,
|
||||
},
|
||||
select: searchSummarySelect,
|
||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||
});
|
||||
return { search: serializeSearchLike(search), reused: false, cacheHit: false };
|
||||
});
|
||||
|
||||
app.patch("/v1/searches/:searchId/star", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ searchId: z.string() });
|
||||
const Body = z.object({ starred: z.boolean() });
|
||||
const { searchId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const search = await setSearchStarred(searchId, body.starred);
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search };
|
||||
});
|
||||
|
||||
@@ -962,13 +800,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search: serializeSearchLike(search) };
|
||||
return { search };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||
@@ -1004,10 +839,19 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { chat: serializeChatLike(chat) };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||
@@ -1068,7 +912,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
queryNormalized: normalizeSearchQuery(query),
|
||||
title: normalizedTitle,
|
||||
requestId: searchResponse?.requestId ?? null,
|
||||
rawResponse: searchResponse as any,
|
||||
@@ -1093,13 +936,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search: serializeSearchLike(search) };
|
||||
return { search };
|
||||
} catch (err: any) {
|
||||
await prisma.search.update({
|
||||
where: { id: searchId },
|
||||
@@ -1154,14 +994,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
calls: { orderBy: { createdAt: "desc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
||||
});
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat: serializeChatLike(chat) };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function normalizeSearchQuery(value: string | null | undefined) {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
|
||||
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
|
||||
}
|
||||
|
||||
export function isFreshSearchCacheHit(
|
||||
candidate: {
|
||||
updatedAt: Date | string;
|
||||
resultCount: number;
|
||||
answerText?: string | null;
|
||||
isActive?: boolean;
|
||||
},
|
||||
now = new Date(),
|
||||
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
|
||||
) {
|
||||
if (candidate.isActive) return false;
|
||||
if (!hasReusableSearchPayload(candidate)) return false;
|
||||
|
||||
const updatedAtMs = new Date(candidate.updatedAt).getTime();
|
||||
if (!Number.isFinite(updatedAtMs)) return false;
|
||||
|
||||
return now.getTime() - updatedAtMs <= ttlMs;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
|
||||
|
||||
test("normalizeSearchQuery trims and lowercases query text", () => {
|
||||
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
|
||||
assert.equal(normalizeSearchQuery(" "), null);
|
||||
assert.equal(normalizeSearchQuery(null), null);
|
||||
});
|
||||
|
||||
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
|
||||
const now = new Date("2026-05-31T12:00:00.000Z");
|
||||
|
||||
assert.equal(
|
||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
|
||||
false
|
||||
);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
SearchStreamHandlers,
|
||||
SearchSummary,
|
||||
SessionStatus,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type RequestOptions = {
|
||||
@@ -42,11 +41,6 @@ export class SybilApiClient {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
async listWorkspaceItems() {
|
||||
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async createChat(title?: string) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
||||
method: "POST",
|
||||
@@ -60,22 +54,6 @@ export class SybilApiClient {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async updateChatTitle(chatId: string, title: string) {
|
||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||
method: "PATCH",
|
||||
body: { title },
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -106,14 +84,6 @@ export class SybilApiClient {
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async deleteSearch(searchId: string) {
|
||||
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
247
tui/src/index.ts
247
tui/src/index.ts
@@ -11,7 +11,6 @@ import type {
|
||||
SearchDetail,
|
||||
SearchSummary,
|
||||
ToolCallEvent,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
@@ -20,8 +19,6 @@ type SidebarItem = SidebarSelection & {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -96,67 +93,33 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
||||
return "New search";
|
||||
}
|
||||
|
||||
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||
return { type: "chat", ...chat };
|
||||
}
|
||||
|
||||
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
|
||||
return { type: "search", ...search };
|
||||
}
|
||||
|
||||
function splitWorkspaceItems(items: WorkspaceItem[]) {
|
||||
const chats: ChatSummary[] = [];
|
||||
const searches: SearchSummary[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type === "chat") {
|
||||
const { type: _type, ...chat } = item;
|
||||
chats.push(chat);
|
||||
} else {
|
||||
const { type: _type, ...search } = item;
|
||||
searches.push(search);
|
||||
}
|
||||
}
|
||||
return { chats, searches };
|
||||
}
|
||||
|
||||
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
||||
return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)];
|
||||
}
|
||||
|
||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
return items.map((item) => {
|
||||
if (item.type === "chat") {
|
||||
const chat = item;
|
||||
return {
|
||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [
|
||||
...chats.map((chat) => ({
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
};
|
||||
}
|
||||
|
||||
const search = item;
|
||||
return {
|
||||
})),
|
||||
...searches.map((search) => ({
|
||||
kind: "search" as const,
|
||||
id: search.id,
|
||||
title: getSearchTitle(search),
|
||||
updatedAt: search.updatedAt,
|
||||
createdAt: search.createdAt,
|
||||
starred: search.starred,
|
||||
starredAt: search.starredAt,
|
||||
initiatedProvider: null,
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: null,
|
||||
lastUsedModel: null,
|
||||
};
|
||||
});
|
||||
})),
|
||||
];
|
||||
|
||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
}
|
||||
|
||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||
@@ -232,7 +195,6 @@ async function main() {
|
||||
let authMode: "open" | "token" | null = null;
|
||||
let chats: ChatSummary[] = [];
|
||||
let searches: SearchSummary[] = [];
|
||||
let workspaceItems: WorkspaceItem[] = [];
|
||||
let selectedItem: SidebarSelection | null = null;
|
||||
let selectedChat: ChatDetail | null = null;
|
||||
let selectedSearch: SearchDetail | null = null;
|
||||
@@ -260,7 +222,6 @@ async function main() {
|
||||
let renderedSidebarItems: SidebarItem[] = [];
|
||||
let renderedSidebarLines: string[] = [];
|
||||
let suppressedSidebarSelectEvents = 0;
|
||||
let isRenamePromptOpen = false;
|
||||
|
||||
const screen = blessed.screen({
|
||||
smartCSR: true,
|
||||
@@ -368,26 +329,6 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const renamePrompt = (blessed as any).prompt({
|
||||
parent: screen,
|
||||
label: " Rename chat ",
|
||||
border: "line",
|
||||
tags: true,
|
||||
keys: true,
|
||||
vi: true,
|
||||
mouse: true,
|
||||
top: "center",
|
||||
left: "center",
|
||||
width: "50%",
|
||||
height: "shrink",
|
||||
hidden: true,
|
||||
style: {
|
||||
border: { fg: "cyan" },
|
||||
label: { fg: "cyan" },
|
||||
fg: "white",
|
||||
},
|
||||
});
|
||||
|
||||
const focusables = [sidebar, transcript, composer] as const;
|
||||
|
||||
function getTranscriptViewportHeight() {
|
||||
@@ -436,7 +377,7 @@ async function main() {
|
||||
}
|
||||
|
||||
function getSidebarItems() {
|
||||
return buildSidebarItems(workspaceItems);
|
||||
return buildSidebarItems(chats, searches);
|
||||
}
|
||||
|
||||
function getSelectedChatSummary() {
|
||||
@@ -527,13 +468,12 @@ async function main() {
|
||||
? ["No chats/searches yet. Press n or /. "]
|
||||
: items.map((item) => {
|
||||
const kind = item.kind === "chat" ? "C" : "S";
|
||||
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
|
||||
const title = truncate(item.title, 36);
|
||||
const initiatedLabel =
|
||||
item.kind === "chat" && item.initiatedModel
|
||||
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
||||
: "";
|
||||
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
});
|
||||
|
||||
const linesChanged =
|
||||
@@ -708,7 +648,7 @@ async function main() {
|
||||
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
||||
|
||||
let controls =
|
||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit";
|
||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
|
||||
if (!isSearchMode) {
|
||||
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
|
||||
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
|
||||
@@ -761,7 +701,6 @@ async function main() {
|
||||
function resetWorkspaceState() {
|
||||
chats = [];
|
||||
searches = [];
|
||||
workspaceItems = [];
|
||||
selectedItem = null;
|
||||
selectedChat = null;
|
||||
selectedSearch = null;
|
||||
@@ -828,13 +767,11 @@ async function main() {
|
||||
updateUI();
|
||||
|
||||
try {
|
||||
const nextWorkspaceItems = await api.listWorkspaceItems();
|
||||
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||
workspaceItems = nextWorkspaceItems;
|
||||
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]);
|
||||
chats = nextChats;
|
||||
searches = nextSearches;
|
||||
|
||||
const nextItems = buildSidebarItems(nextWorkspaceItems);
|
||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
||||
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
||||
selectedItem = options.preferredSelection;
|
||||
draftKind = null;
|
||||
@@ -870,27 +807,6 @@ async function main() {
|
||||
composer.readInput();
|
||||
}
|
||||
|
||||
function shouldIgnoreGlobalShortcut() {
|
||||
return isRenamePromptOpen || isTextInputFocused(screen, composer);
|
||||
}
|
||||
|
||||
function promptForChatTitle(currentTitle: string) {
|
||||
isRenamePromptOpen = true;
|
||||
updateUI();
|
||||
return new Promise<string | null>((resolve) => {
|
||||
renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => {
|
||||
isRenamePromptOpen = false;
|
||||
renamePrompt.hide();
|
||||
screen.render();
|
||||
if (err || value === null || value === undefined) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cycleFocus(step: 1 | -1) {
|
||||
const focused = screen.focused;
|
||||
const currentIndex = focusables.findIndex((node) => node === focused);
|
||||
@@ -959,20 +875,9 @@ async function main() {
|
||||
pendingTitleGeneration.add(chatId);
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||
}
|
||||
updateUI();
|
||||
} catch {
|
||||
@@ -1015,7 +920,6 @@ async function main() {
|
||||
chatId = chat.id;
|
||||
draftKind = null;
|
||||
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
|
||||
selectedItem = { kind: "chat", id: chat.id };
|
||||
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
||||
selectedChat = {
|
||||
@@ -1023,8 +927,6 @@ async function main() {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -1183,7 +1085,6 @@ async function main() {
|
||||
draftKind = null;
|
||||
selectedItem = { kind: "search", id: searchId };
|
||||
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
|
||||
selectedChat = null;
|
||||
forceScrollToBottom = true;
|
||||
updateUI();
|
||||
@@ -1201,8 +1102,6 @@ async function main() {
|
||||
query,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -1365,88 +1264,6 @@ async function main() {
|
||||
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
||||
}
|
||||
|
||||
async function handleRenameSelection() {
|
||||
if (!selectedItem || selectedItem.kind !== "chat") return;
|
||||
|
||||
const chatId = selectedItem.id;
|
||||
const summary = chats.find((chat) => chat.id === chatId);
|
||||
const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat";
|
||||
const value = await promptForChatTitle(currentTitle);
|
||||
const title = value?.trim();
|
||||
if (!title) {
|
||||
updateUI();
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const updated = await api.updateChatTitle(chatId, title);
|
||||
chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated));
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
async function handleToggleStarSelection() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
||||
const nextStarred = !currentItem?.starred;
|
||||
setError(null);
|
||||
|
||||
if (selectedItem.kind === "chat") {
|
||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
|
||||
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
|
||||
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedSearch?.id === updated.id) {
|
||||
selectedSearch = {
|
||||
...selectedSearch,
|
||||
title: updated.title,
|
||||
query: updated.query,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function cycleProvider() {
|
||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||
@@ -1532,18 +1349,18 @@ async function main() {
|
||||
});
|
||||
|
||||
screen.key(["q"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
screen.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
screen.key(["tab"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
cycleFocus(1);
|
||||
});
|
||||
|
||||
screen.key(["S-tab", "backtab"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
cycleFocus(-1);
|
||||
});
|
||||
|
||||
@@ -1560,50 +1377,36 @@ async function main() {
|
||||
});
|
||||
|
||||
screen.key(["n"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
handleCreateChat();
|
||||
});
|
||||
|
||||
screen.key(["/"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
handleCreateSearch();
|
||||
});
|
||||
|
||||
screen.key(["d"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
void runAction(async () => {
|
||||
await handleDeleteSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["s"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleToggleStarSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["p"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (getIsSearchMode() || isSending) return;
|
||||
cycleProvider();
|
||||
});
|
||||
|
||||
screen.key(["m"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (getIsSearchMode() || isSending) return;
|
||||
cycleModel();
|
||||
});
|
||||
|
||||
screen.key(["r"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleRenameSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["C-r"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
void runAction(async () => {
|
||||
await refreshCollections({ loadSelection: true });
|
||||
await refreshModels();
|
||||
|
||||
@@ -15,8 +15,6 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -29,20 +27,8 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -70,8 +56,6 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -101,8 +85,6 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Sybil chat and search workspace" />
|
||||
<meta name="application-name" content="Sybil" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
|
||||
<title>Sybil</title>
|
||||
</head>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB |
@@ -1,32 +1,9 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Sybil",
|
||||
"short_name": "Sybil",
|
||||
"description": "Sybil chat and search workspace",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"background_color": "#0b0718",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f172a"
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
413
web/src/App.tsx
413
web/src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
|
||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -20,13 +20,11 @@ import {
|
||||
getChat,
|
||||
listModels,
|
||||
getSearch,
|
||||
listWorkspaceItems,
|
||||
listChats,
|
||||
listSearches,
|
||||
runCompletionStream,
|
||||
runSearchStream,
|
||||
suggestChatTitle,
|
||||
updateChatTitle,
|
||||
updateChatStar,
|
||||
updateSearchStar,
|
||||
getMessageAttachments,
|
||||
type ChatAttachment,
|
||||
type ActiveRunsResponse,
|
||||
@@ -39,7 +37,6 @@ import {
|
||||
type SearchDetail,
|
||||
type SearchSummary,
|
||||
type ToolCallEvent,
|
||||
type WorkspaceItem,
|
||||
} from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -50,8 +47,6 @@ type SidebarItem = SidebarSelection & {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -62,9 +57,6 @@ type ContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
type RenameChatDialogState = {
|
||||
chatId: string;
|
||||
};
|
||||
type PendingChatState = {
|
||||
messages: Message[];
|
||||
};
|
||||
@@ -596,76 +588,33 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
||||
return "New search";
|
||||
}
|
||||
|
||||
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||
return { type: "chat", ...chat };
|
||||
}
|
||||
|
||||
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
|
||||
return { type: "search", ...search };
|
||||
}
|
||||
|
||||
function splitWorkspaceItems(items: WorkspaceItem[]) {
|
||||
const chats: ChatSummary[] = [];
|
||||
const searches: SearchSummary[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type === "chat") {
|
||||
const { type: _type, ...chat } = item;
|
||||
chats.push(chat);
|
||||
} else {
|
||||
const { type: _type, ...search } = item;
|
||||
searches.push(search);
|
||||
}
|
||||
}
|
||||
return { chats, searches };
|
||||
}
|
||||
|
||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
return items.map((item) => {
|
||||
if (item.type === "chat") {
|
||||
const chat = item;
|
||||
return {
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
};
|
||||
}
|
||||
|
||||
const search = item;
|
||||
return {
|
||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [
|
||||
...chats.map((chat) => ({
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
})),
|
||||
...searches.map((search) => ({
|
||||
kind: "search" as const,
|
||||
id: search.id,
|
||||
title: getSearchTitle(search),
|
||||
updatedAt: search.updatedAt,
|
||||
createdAt: search.createdAt,
|
||||
starred: search.starred,
|
||||
starredAt: search.starredAt,
|
||||
initiatedProvider: null,
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: null,
|
||||
lastUsedModel: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
})),
|
||||
];
|
||||
|
||||
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem, moveToFront = true) {
|
||||
const withoutExisting = items.filter((existing) => existing.type !== item.type || existing.id !== item.id);
|
||||
if (moveToFront) {
|
||||
return [item, ...withoutExisting];
|
||||
}
|
||||
|
||||
const existingIndex = items.findIndex((existing) => existing.type === item.type && existing.id === item.id);
|
||||
if (existingIndex < 0) return [item, ...items];
|
||||
const next = [...items];
|
||||
next[existingIndex] = item;
|
||||
return next;
|
||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
}
|
||||
|
||||
function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState {
|
||||
@@ -698,13 +647,7 @@ function getSidebarSectionLabel(value: string) {
|
||||
}
|
||||
|
||||
function buildSidebarSections(items: SidebarItem[]) {
|
||||
const starred = items
|
||||
.filter((item) => item.starred)
|
||||
.sort((a, b) => new Date(b.starredAt ?? b.updatedAt).getTime() - new Date(a.starredAt ?? a.updatedAt).getTime());
|
||||
const unstarred = items.filter((item) => !item.starred);
|
||||
|
||||
const sections = starred.length ? [{ label: "STARRED", items: starred }] : [];
|
||||
return unstarred.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||
const label = getSidebarSectionLabel(item.updatedAt);
|
||||
const section = sections.find((candidate) => candidate.label === label);
|
||||
if (section) {
|
||||
@@ -713,7 +656,7 @@ function buildSidebarSections(items: SidebarItem[]) {
|
||||
sections.push({ label, items: [item] });
|
||||
}
|
||||
return sections;
|
||||
}, sections);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
@@ -732,7 +675,6 @@ export default function App() {
|
||||
|
||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||
const [workspaceItems, setWorkspaceItems] = useState<WorkspaceItem[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
||||
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
||||
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
||||
@@ -770,15 +712,10 @@ export default function App() {
|
||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [renameChatDialog, setRenameChatDialog] = useState<RenameChatDialogState | null>(null);
|
||||
const [renameChatDraft, setRenameChatDraft] = useState("");
|
||||
const [renameChatError, setRenameChatError] = useState<string | null>(null);
|
||||
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||
@@ -864,7 +801,7 @@ export default function App() {
|
||||
pendingAttachmentsRef.current = pendingAttachments;
|
||||
}, [pendingAttachments]);
|
||||
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||
const filteredSidebarItems = useMemo(() => {
|
||||
const query = sidebarQuery.trim().toLowerCase();
|
||||
if (!query) return sidebarItems;
|
||||
@@ -880,7 +817,6 @@ export default function App() {
|
||||
const resetWorkspaceState = () => {
|
||||
setChats([]);
|
||||
setSearches([]);
|
||||
setWorkspaceItems([]);
|
||||
setSelectedItem(null);
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
@@ -905,11 +841,6 @@ export default function App() {
|
||||
setQuickSubmittedModelSelection(null);
|
||||
setQuickQuestionMessages([]);
|
||||
setQuickQuestionError(null);
|
||||
setContextMenu(null);
|
||||
setRenameChatDialog(null);
|
||||
setRenameChatDraft("");
|
||||
setRenameChatError(null);
|
||||
setIsRenamingChat(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
@@ -921,16 +852,15 @@ export default function App() {
|
||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
||||
setIsLoadingCollections(true);
|
||||
try {
|
||||
const nextWorkspaceItems = await listWorkspaceItems();
|
||||
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||
setWorkspaceItems(nextWorkspaceItems);
|
||||
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]);
|
||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
||||
setChats(nextChats);
|
||||
setSearches(nextSearches);
|
||||
|
||||
setSelectedItem((current) => {
|
||||
const hasItem = (candidate: SidebarSelection | null) => {
|
||||
if (!candidate) return false;
|
||||
return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
|
||||
return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id);
|
||||
};
|
||||
|
||||
if (preferredSelection && hasItem(preferredSelection)) {
|
||||
@@ -939,8 +869,8 @@ export default function App() {
|
||||
if (hasItem(current)) {
|
||||
return current;
|
||||
}
|
||||
const first = nextWorkspaceItems[0];
|
||||
return first ? { kind: first.type, id: first.id } : null;
|
||||
const first = nextItems[0];
|
||||
return first ? { kind: first.kind, id: first.id } : null;
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -1267,11 +1197,6 @@ export default function App() {
|
||||
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
||||
}, [chats, selectedItem]);
|
||||
|
||||
const selectedSidebarItem = useMemo(() => {
|
||||
if (!selectedItem) return null;
|
||||
return sidebarItems.find((item) => item.kind === selectedItem.kind && item.id === selectedItem.id) ?? null;
|
||||
}, [selectedItem, sidebarItems]);
|
||||
|
||||
const selectedSearchSummary = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.kind !== "search") return null;
|
||||
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||
@@ -1410,136 +1335,16 @@ export default function App() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
||||
|
||||
const getRenameSeedTitle = (chatId: string) => {
|
||||
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);
|
||||
return sidebarItem?.title ?? "New chat";
|
||||
};
|
||||
|
||||
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
|
||||
if (moveToFront) return [updatedChat, ...withoutExisting];
|
||||
const existingIndex = current.findIndex((chat) => chat.id === updatedChat.id);
|
||||
if (existingIndex < 0) return [updatedChat, ...current];
|
||||
const next = [...current];
|
||||
next[existingIndex] = updatedChat;
|
||||
return next;
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), moveToFront));
|
||||
setSelectedChat((current) => {
|
||||
if (!current || current.id !== updatedChat.id) return current;
|
||||
return {
|
||||
...current,
|
||||
title: updatedChat.title,
|
||||
updatedAt: updatedChat.updatedAt,
|
||||
starred: updatedChat.starred,
|
||||
starredAt: updatedChat.starredAt,
|
||||
initiatedProvider: updatedChat.initiatedProvider,
|
||||
initiatedModel: updatedChat.initiatedModel,
|
||||
lastUsedProvider: updatedChat.lastUsedProvider,
|
||||
lastUsedModel: updatedChat.lastUsedModel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
|
||||
setSearches((current) => {
|
||||
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
|
||||
if (moveToFront) return [updatedSearch, ...withoutExisting];
|
||||
const existingIndex = current.findIndex((search) => search.id === updatedSearch.id);
|
||||
if (existingIndex < 0) return [updatedSearch, ...current];
|
||||
const next = [...current];
|
||||
next[existingIndex] = updatedSearch;
|
||||
return next;
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(updatedSearch), moveToFront));
|
||||
setSelectedSearch((current) => {
|
||||
if (!current || current.id !== updatedSearch.id) return current;
|
||||
return {
|
||||
...current,
|
||||
title: updatedSearch.title,
|
||||
query: updatedSearch.query,
|
||||
updatedAt: updatedSearch.updatedAt,
|
||||
starred: updatedSearch.starred,
|
||||
starredAt: updatedSearch.starredAt,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const openRenameChatDialog = (chatId: string) => {
|
||||
setContextMenu(null);
|
||||
setRenameChatDraft(getRenameSeedTitle(chatId));
|
||||
setRenameChatError(null);
|
||||
setRenameChatDialog({ chatId });
|
||||
};
|
||||
|
||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||
event.preventDefault();
|
||||
const menuWidth = 176;
|
||||
const menuHeight = item.kind === "chat" ? 120 : 80;
|
||||
const menuWidth = 160;
|
||||
const menuHeight = 40;
|
||||
const padding = 8;
|
||||
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
||||
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
||||
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
||||
};
|
||||
|
||||
const handleRenameChatSubmit = async (event?: Event) => {
|
||||
event?.preventDefault();
|
||||
if (!renameChatDialog || isRenamingChat) return;
|
||||
|
||||
const title = renameChatDraft.trim();
|
||||
if (!title) {
|
||||
setRenameChatError("Enter a chat title.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRenamingChat(true);
|
||||
setRenameChatError(null);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedChat = await updateChatTitle(renameChatDialog.chatId, title);
|
||||
applyChatSummary(updatedChat);
|
||||
setRenameChatDialog(null);
|
||||
setRenameChatDraft("");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setRenameChatError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsRenamingChat(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async (target: SidebarSelection) => {
|
||||
const current = sidebarItems.find((item) => item.kind === target.kind && item.id === target.id);
|
||||
const nextStarred = !current?.starred;
|
||||
setContextMenu(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (target.kind === "chat") {
|
||||
const updatedChat = await updateChatStar(target.id, nextStarred);
|
||||
applyChatSummary(updatedChat, false);
|
||||
} else {
|
||||
const updatedSearch = await updateSearchStar(target.id, nextStarred);
|
||||
applySearchSummary(updatedSearch, false);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromContextMenu = async () => {
|
||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||
const target = contextMenu.item;
|
||||
@@ -1579,15 +1384,6 @@ export default function App() {
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renameChatDialog) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
renameChatInputRef.current?.focus();
|
||||
renameChatInputRef.current?.select();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [renameChatDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuickQuestionOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1755,15 +1551,12 @@ export default function App() {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||
setSelectedItem({ kind: "chat", id: chatId });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -1823,7 +1616,6 @@ export default function App() {
|
||||
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||
})
|
||||
);
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false));
|
||||
setSelectedChat((current) => {
|
||||
if (!current || current.id !== updatedChat.id) return current;
|
||||
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||
@@ -1956,11 +1748,6 @@ export default function App() {
|
||||
searchId = search.id;
|
||||
setDraftKind(null);
|
||||
setSelectedItem({ kind: "search", id: searchId });
|
||||
setSearches((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== search.id);
|
||||
return [search, ...withoutExisting];
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(search)));
|
||||
}
|
||||
|
||||
if (!searchId) {
|
||||
@@ -1984,8 +1771,6 @@ export default function App() {
|
||||
query,
|
||||
createdAt: currentSearch?.createdAt ?? nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: currentSearch?.starred ?? false,
|
||||
starredAt: currentSearch?.starredAt ?? null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -2336,15 +2121,12 @@ export default function App() {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||
setSelectedItem({ kind: "chat", id: chat.id });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -2514,15 +2296,12 @@ export default function App() {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||
setSelectedItem({ kind: "chat", id: chat.id });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -2617,7 +2396,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-grid-surface app-safe-frame h-full">
|
||||
<div className="app-grid-surface h-full p-0 md:p-2">
|
||||
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
||||
{isMobileSidebarOpen ? (
|
||||
<button
|
||||
@@ -2731,7 +2510,7 @@ export default function App() {
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
||||
type="button"
|
||||
>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
||||
@@ -2742,12 +2521,6 @@ export default function App() {
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold">{item.title}</span>
|
||||
{item.starred ? (
|
||||
<Star
|
||||
className={cn("h-3.5 w-3.5 shrink-0 fill-amber-300", active ? "text-amber-200" : "text-amber-300/90")}
|
||||
aria-label="Starred"
|
||||
/>
|
||||
) : null}
|
||||
{itemIsRunning ? (
|
||||
<LoaderCircle
|
||||
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
|
||||
@@ -2755,15 +2528,11 @@ export default function App() {
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="col-start-2 flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-xs text-secondary-foreground/70">{formatDate(item.updatedAt)}</span>
|
||||
{initiatedLabel ? (
|
||||
<span className="ml-auto min-w-0 truncate text-right text-xs text-secondary-foreground/70">
|
||||
{initiatedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
{initiatedLabel ? (
|
||||
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -2786,34 +2555,8 @@ export default function App() {
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||
{draftKind === null && selectedItem ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
|
||||
onClick={() => void handleToggleStar(selectedItem)}
|
||||
title={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
||||
aria-label={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
||||
>
|
||||
<Star className={cn("h-3.5 w-3.5", selectedSidebarItem?.starred ? "fill-amber-300 text-amber-300" : "")} />
|
||||
</Button>
|
||||
) : null}
|
||||
{draftKind === null && selectedItem?.kind === "chat" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
|
||||
onClick={() => openRenameChatDialog(selectedItem.id)}
|
||||
title="Rename chat"
|
||||
aria-label="Rename chat"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||
@@ -2985,31 +2728,6 @@ export default function App() {
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
||||
onClick={() => void handleToggleStar(contextMenu.item)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred
|
||||
? "fill-amber-300 text-amber-300"
|
||||
: ""
|
||||
)}
|
||||
/>
|
||||
{sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred ? "Unstar" : "Star"}
|
||||
</button>
|
||||
{contextMenu.item.kind === "chat" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
||||
onClick={() => openRenameChatDialog(contextMenu.item.id)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
||||
@@ -3021,61 +2739,6 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{renameChatDialog ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !isRenamingChat) setRenameChatDialog(null);
|
||||
}}
|
||||
>
|
||||
<form
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="rename-chat-title"
|
||||
className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
||||
onSubmit={(event) => void handleRenameChatSubmit(event)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 id="rename-chat-title" className="text-sm font-semibold text-violet-50">
|
||||
Rename chat
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setRenameChatDialog(null)}
|
||||
disabled={isRenamingChat}
|
||||
aria-label="Close rename dialog"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={renameChatInputRef}
|
||||
value={renameChatDraft}
|
||||
onInput={(event) => {
|
||||
setRenameChatDraft(event.currentTarget.value);
|
||||
if (renameChatError) setRenameChatError(null);
|
||||
}}
|
||||
maxLength={120}
|
||||
className="h-11 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)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
aria-label="Chat title"
|
||||
disabled={isRenamingChat}
|
||||
/>
|
||||
{renameChatError ? <p className="mt-2 text-sm text-rose-300">{renameChatError}</p> : null}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setRenameChatDialog(null)} disabled={isRenamingChat}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isRenamingChat || !renameChatDraft.trim()}>
|
||||
{isRenamingChat ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
{isQuickQuestionOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
|
||||
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
||||
return (
|
||||
<div className="app-grid-surface app-safe-pad flex h-full items-center justify-center">
|
||||
<div className="app-grid-surface flex h-full items-center justify-center p-4">
|
||||
<div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
|
||||
<div className="mb-6">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--background: 235 45% 4%;
|
||||
--foreground: 258 36% 96%;
|
||||
--muted: 246 30% 13%;
|
||||
@@ -44,15 +40,6 @@ html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -62,8 +49,6 @@ body {
|
||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -93,44 +78,6 @@ textarea {
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.app-safe-frame {
|
||||
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom) var(--safe-area-left);
|
||||
}
|
||||
|
||||
.app-safe-pad {
|
||||
padding:
|
||||
max(1rem, var(--safe-area-top))
|
||||
max(1rem, var(--safe-area-right))
|
||||
max(1rem, var(--safe-area-bottom))
|
||||
max(1rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
padding:
|
||||
max(1.5rem, var(--safe-area-top))
|
||||
max(0.75rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(0.75rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.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));
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
padding:
|
||||
max(1.5rem, var(--safe-area-top))
|
||||
max(1.5rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(1.5rem, var(--safe-area-left));
|
||||
}
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background:
|
||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||
|
||||
@@ -3,8 +3,6 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -17,20 +15,8 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -58,8 +44,6 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -89,8 +73,6 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
@@ -185,18 +167,6 @@ type CreateChatRequest = {
|
||||
messages?: CompletionRequestMessage[];
|
||||
};
|
||||
|
||||
type CreateSearchRequest = {
|
||||
title?: string;
|
||||
query?: string;
|
||||
reuseByQuery?: boolean;
|
||||
};
|
||||
|
||||
type CreateSearchResponse = {
|
||||
search: SearchSummary;
|
||||
reused: boolean;
|
||||
cacheHit: boolean;
|
||||
};
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
||||
let authToken: string | null = ENV_ADMIN_TOKEN;
|
||||
@@ -244,11 +214,6 @@ export async function listChats() {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
export async function listWorkspaceItems() {
|
||||
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function verifySession() {
|
||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||
}
|
||||
@@ -283,14 +248,6 @@ export async function updateChatTitle(chatId: string, title: string) {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function suggestChatTitle(body: { chatId: string; content: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -308,35 +265,19 @@ export async function listSearches() {
|
||||
return data.searches;
|
||||
}
|
||||
|
||||
async function postSearch(body?: CreateSearchRequest) {
|
||||
return api<CreateSearchResponse>("/v1/searches", {
|
||||
export async function createSearch(body?: { title?: string; query?: string }) {
|
||||
const data = await api<{ search: SearchSummary }>("/v1/searches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSearch(body?: CreateSearchRequest) {
|
||||
const data = await postSearch(body);
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createReusableSearch(body: Omit<CreateSearchRequest, "reuseByQuery">) {
|
||||
return postSearch({ ...body, reuseByQuery: true });
|
||||
}
|
||||
|
||||
export async function getSearch(searchId: string) {
|
||||
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { render } from "preact";
|
||||
import { RootRouter } from "@/root-router";
|
||||
import { registerServiceWorker } from "@/pwa";
|
||||
import "./index.css";
|
||||
|
||||
registerServiceWorker();
|
||||
|
||||
render(<RootRouter />, document.getElementById("app")!);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createReusableSearch, getSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||
import { createSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
|
||||
function readQueryFromUrl() {
|
||||
@@ -85,16 +85,14 @@ export default function SearchRoutePage() {
|
||||
|
||||
const runQuery = async (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
const requestId = ++requestCounterRef.current;
|
||||
streamAbortRef.current?.abort();
|
||||
|
||||
if (!trimmed) {
|
||||
setSearch(null);
|
||||
setError(null);
|
||||
setIsRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++requestCounterRef.current;
|
||||
streamAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
streamAbortRef.current = abortController;
|
||||
let wasInterrupted = false;
|
||||
@@ -108,8 +106,6 @@ export default function SearchRoutePage() {
|
||||
query: trimmed,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -121,11 +117,10 @@ export default function SearchRoutePage() {
|
||||
});
|
||||
|
||||
try {
|
||||
const createdResult = await createReusableSearch({
|
||||
const created = await createSearch({
|
||||
query: trimmed,
|
||||
title: trimmed.slice(0, 80),
|
||||
});
|
||||
const created = createdResult.search;
|
||||
if (requestId !== requestCounterRef.current) return;
|
||||
|
||||
setSearch((current) =>
|
||||
@@ -137,19 +132,10 @@ export default function SearchRoutePage() {
|
||||
query: created.query,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
starredAt: created.starredAt,
|
||||
}
|
||||
: current
|
||||
);
|
||||
|
||||
if (createdResult.cacheHit) {
|
||||
const cached = await getSearch(created.id);
|
||||
if (requestId !== requestCounterRef.current) return;
|
||||
setSearch(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
await runSearchStream(
|
||||
created.id,
|
||||
{
|
||||
@@ -262,7 +248,7 @@ export default function SearchRoutePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-search-safe-pad h-full overflow-y-auto">
|
||||
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-5">
|
||||
<form
|
||||
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user