Compare commits

...
6 Commits
Author SHA1 Message Date
buzzert 4ef48fad82 remove "open mode" extra text 2026-09-27 22:56:07 -07:00
buzzert 21beab26f1 Replace quick question backdrop blur with dimming 2026-09-27 00:51:38 -07:00
buzzert 050a46d7a8 Better mobile-optimized settings UI 2026-09-19 18:55:17 -07:00
buzzert 3217c3f66e ios: add copy session ID to chat menu
TestFlight / Build and upload (push) Successful in 1m45s
2026-09-06 14:17:42 -07:00
buzzert 3a9c9bb74a ios: add chat fork actions
TestFlight / Build and upload (push) Successful in 1m39s
2026-09-06 14:03:30 -07:00
buzzert 04ac39499f ios: submit quick questions with return 2026-09-06 13:36:18 -07:00
12 changed files with 347 additions and 57 deletions
@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat return response.chat
} }
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/fork",
method: "POST",
body: AnyEncodable(ChatForkBody(messageId: messageID)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary { func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
let response = try await request( let response = try await request(
"/v1/chats/\(chatID)", "/v1/chats/\(chatID)",
@@ -704,6 +714,10 @@ private struct ChatTitleUpdateBody: Encodable {
var title: String var title: String
} }
private struct ChatForkBody: Encodable {
var messageId: String?
}
private struct StarUpdateBody: Encodable { private struct StarUpdateBody: Encodable {
var starred: Bool var starred: Bool
} }
@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
messages: [CompletionRequestMessage]? messages: [CompletionRequestMessage]?
) async throws -> ChatSummary ) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail func getChat(chatID: String) async throws -> ChatDetail
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
func deleteChat(chatID: String) async throws func deleteChat(chatID: String) async throws
@@ -1,5 +1,6 @@
import MarkdownUI import MarkdownUI
import SwiftUI import SwiftUI
import UIKit
struct SybilChatTranscriptView: View { struct SybilChatTranscriptView: View {
var messages: [Message] var messages: [Message]
@@ -9,6 +10,8 @@ struct SybilChatTranscriptView: View {
var topContentInset: CGFloat = 0 var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0 var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0 var bottomPinRequestID: Int = 0
var isForking: Bool = false
var onForkResponse: ((String) -> Void)? = nil
@State private var hasTrackedToolCallMessages = false @State private var hasTrackedToolCallMessages = false
@State private var knownToolCallMessageIDs: Set<String> = [] @State private var knownToolCallMessageIDs: Set<String> = []
@@ -45,7 +48,11 @@ struct SybilChatTranscriptView: View {
MessageBubble( MessageBubble(
message: message, message: message,
isSending: isSending, isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: {
onForkResponse?(message.id)
}
) )
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
case let .toolGroup(id, messages): case let .toolGroup(id, messages):
@@ -146,6 +153,8 @@ private struct MessageBubble: View {
var message: Message var message: Message
var isSending: Bool var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation? var streamingAssistantPresentation: StreamingAssistantPresentation?
var isForking: Bool
var onFork: (() -> Void)?
private var toolCallMetadata: ToolCallMetadata? { private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata message.toolCallMetadata
@@ -164,7 +173,37 @@ private struct MessageBubble: View {
return streamingAssistantPresentation return streamingAssistantPresentation
} }
private var canFork: Bool {
message.role == .assistant &&
!message.id.hasPrefix("temp-") &&
onFork != nil
}
var body: some View { var body: some View {
Group {
if canFork {
bubbleContent
.contextMenu {
Button {
UIPasteboard.general.string = message.content
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onFork?()
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(isForking)
}
} else {
bubbleContent
}
}
}
private var bubbleContent: some View {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
leadingSpacer leadingSpacer
@@ -130,7 +130,10 @@ struct SybilPhoneShellView: View {
route: route, route: route,
onRequestBack: { _ in showSidebarOverlay() }, onRequestBack: { _ in showSidebarOverlay() },
onRequestNewChat: sidebarWorkspaceNewChatAction, onRequestNewChat: sidebarWorkspaceNewChatAction,
onShowSidebar: showSidebarOverlay onShowSidebar: showSidebarOverlay,
onOpenForkedChat: { chatID in
showRoute(.chat(chatID))
}
) )
.background(SybilTheme.background) .background(SybilTheme.background)
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress)) .blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
@@ -511,6 +514,9 @@ private struct SybilPhoneSidebarRoot: View {
}, },
onSelect: { item in onSelect: { item in
onSelect(item.selection) onSelect(item.selection)
},
onOpenForkedChat: { chatID in
onSelect(.chat(chatID))
} }
) )
} }
@@ -585,6 +591,7 @@ private struct SybilPhoneDestinationView: View {
let onRequestBack: (_ animateNavigation: Bool) -> Void let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: (() -> Void)? let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void let onShowSidebar: () -> Void
let onOpenForkedChat: (String) -> Void
var body: some View { var body: some View {
SybilWorkspaceView( SybilWorkspaceView(
@@ -593,7 +600,8 @@ private struct SybilPhoneDestinationView: View {
navigationLeadingControl: .showSidebar, navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar, onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack, onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat onRequestNewChat: onRequestNewChat,
onOpenForkedChat: onOpenForkedChat
) )
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) { .task(id: route) {
@@ -102,13 +102,12 @@ struct SybilQuickQuestionView: View {
set: { viewModel.updateQuickQuestionPrompt($0) } set: { viewModel.updateQuickQuestionPrompt($0) }
), ),
selection: $promptSelection, selection: $promptSelection,
axis: .vertical axis: .horizontal
) )
.focused($promptFocused) .focused($promptFocused)
.font(.body) .font(.body)
.textInputAutocapitalization(.sentences) .textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false) .autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send) .submitLabel(.send)
.onSubmit(submitQuestion) .onSubmit(submitQuestion)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -1,5 +1,6 @@
import Observation import Observation
import SwiftUI import SwiftUI
import UIKit
struct SybilSidebarView: View { struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
@@ -111,6 +112,7 @@ struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void var onSelect: (SidebarItem) -> Void
var onOpenForkedChat: ((String) -> Void)? = nil
@State private var renameTarget: SidebarItem? @State private var renameTarget: SidebarItem?
@State private var renameTitle = "" @State private var renameTitle = ""
@@ -168,13 +170,26 @@ struct SybilSidebarItemList: View {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star") Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
} }
if item.kind == .chat { if case let .chat(chatID) = item.selection {
Button {
forkChat(item)
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(viewModel.isForkingChat || item.isRunning)
Button { Button {
renameTarget = item renameTarget = item
renameTitle = item.title renameTitle = item.title
} label: { } label: {
Label("Rename", systemImage: "pencil") Label("Rename", systemImage: "pencil")
} }
Button {
UIPasteboard.general.string = chatID
} label: {
Label("Copy Session ID", systemImage: "doc.on.doc")
}
} }
Button(role: .destructive) { Button(role: .destructive) {
@@ -215,6 +230,28 @@ struct SybilSidebarItemList: View {
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
} }
private func forkChat(_ item: SidebarItem) {
guard case let .chat(chatID) = item.selection else {
return
}
let selectionAtStart = viewModel.selectedItem
let draftKindAtStart = viewModel.draftKind
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == draftKindAtStart else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
} }
struct SybilSidebarRow: View { struct SybilSidebarRow: View {
@@ -170,6 +170,7 @@ final class SybilViewModel {
var isLoadingCollections = false var isLoadingCollections = false
var isLoadingSelection = false var isLoadingSelection = false
var isCreatingSearchChat = false var isCreatingSearchChat = false
var isForkingChat = false
var chatBottomPinRequestID = 0 var chatBottomPinRequestID = 0
var errorMessage: String? var errorMessage: String?
@@ -958,6 +959,36 @@ final class SybilViewModel {
} }
} }
@discardableResult
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
guard isAuthenticated, !isForkingChat else {
return nil
}
guard messageID != nil || !isChatRowRunning(chatID) else {
errorMessage = "Wait for the response to finish before forking this chat."
return nil
}
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
errorMessage = nil
isForkingChat = true
defer {
isForkingChat = false
}
do {
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
applyChatSummary(forkedChat, moveToFront: true)
return forkedChat
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
return nil
}
}
func renameChat(chatID: String, title: String) async { func renameChat(chatID: String, title: String) async {
guard isAuthenticated else { guard isAuthenticated else {
return return
@@ -19,6 +19,7 @@ struct SybilWorkspaceView: View {
var onShowSidebar: (() -> Void)? = nil var onShowSidebar: (() -> Void)? = nil
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
var onRequestNewChat: (() -> Void)? = nil var onRequestNewChat: (() -> Void)? = nil
var onOpenForkedChat: ((String) -> Void)? = nil
@FocusState private var composerFocused: Bool @FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var isShowingAttachmentOptions = false @State private var isShowingAttachmentOptions = false
@@ -196,7 +197,9 @@ struct SybilWorkspaceView: View {
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation, streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0, bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
bottomPinRequestID: viewModel.chatBottomPinRequestID bottomPinRequestID: viewModel.chatBottomPinRequestID,
isForking: viewModel.isForkingChat,
onForkResponse: forkResponse
) )
.id(transcriptScrollContextID) .id(transcriptScrollContextID)
} }
@@ -230,6 +233,28 @@ struct SybilWorkspaceView: View {
} }
} }
private func forkResponse(messageID: String) {
guard viewModel.draftKind == nil,
case let .chat(chatID) = viewModel.selectedItem else {
return
}
let selectionAtStart = viewModel.selectedItem
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == nil else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
private var customWorkspaceNavigationBar: some View { private var customWorkspaceNavigationBar: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
workspaceNavigationLeadingControl workspaceNavigationLeadingControl
@@ -13,6 +13,9 @@ private struct MockClientCallSnapshot: Sendable {
var listSearches = 0 var listSearches = 0
var createChat = 0 var createChat = 0
var getChat = 0 var getChat = 0
var forkChat = 0
var lastForkChatID: String?
var lastForkMessageID: String?
var updateChatTitle = 0 var updateChatTitle = 0
var suggestChatTitle = 0 var suggestChatTitle = 0
var updateChatStar = 0 var updateChatStar = 0
@@ -43,6 +46,7 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatDetails: [String: ChatDetail] private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail] private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary? private let createChatResponse: ChatSummary?
private let forkChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary] private let updateChatTitleResponses: [String: ChatSummary]
private let suggestChatTitleResponses: [String: ChatSummary] private let suggestChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary] private let updateChatStarResponses: [String: ChatSummary]
@@ -74,6 +78,7 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:], chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:], searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil, createChatResponse: ChatSummary? = nil,
forkChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:], updateChatTitleResponses: [String: ChatSummary] = [:],
suggestChatTitleResponses: [String: ChatSummary] = [:], suggestChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:], updateChatStarResponses: [String: ChatSummary] = [:],
@@ -87,6 +92,7 @@ private actor MockSybilClient: SybilAPIClienting {
self.chatDetails = chatDetails self.chatDetails = chatDetails
self.searchDetails = searchDetails self.searchDetails = searchDetails
self.createChatResponse = createChatResponse self.createChatResponse = createChatResponse
self.forkChatResponse = forkChatResponse
self.updateChatTitleResponses = updateChatTitleResponses self.updateChatTitleResponses = updateChatTitleResponses
self.suggestChatTitleResponses = suggestChatTitleResponses self.suggestChatTitleResponses = suggestChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses self.updateChatStarResponses = updateChatStarResponses
@@ -220,6 +226,16 @@ private actor MockSybilClient: SybilAPIClienting {
return detail return detail
} }
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary {
snapshot.forkChat += 1
snapshot.lastForkChatID = chatID
snapshot.lastForkMessageID = messageID
guard let forkChatResponse else {
throw UnexpectedClientCall()
}
return forkChatResponse
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary { func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
snapshot.updateChatTitle += 1 snapshot.updateChatTitle += 1
guard let summary = updateChatTitleResponses[chatID] else { guard let summary = updateChatTitleResponses[chatID] else {
@@ -775,6 +791,63 @@ private func makeCompletionToolCall(
#expect(viewModel.errorMessage == nil) #expect(viewModel.errorMessage == nil)
} }
@MainActor
@Test func wholeChatForkAddsReturnedChatToWorkspace() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_160)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "chat-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of Chat chat-source"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
viewModel.selectedItem = .chat(source.id)
let result = await viewModel.forkChat(chatID: source.id)
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == nil)
#expect(result == forked)
#expect(viewModel.chats.first == forked)
#expect(viewModel.workspaceItems.first == WorkspaceItem(chat: forked))
#expect(viewModel.selectedItem == .chat(source.id))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func responseForkPassesAssistantMessageID() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_165)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "response-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of 'A useful response'"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
let result = await viewModel.forkChat(chatID: source.id, messageID: "assistant-message")
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == "assistant-message")
#expect(result == forked)
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat(forked.id) }))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor @MainActor
@Test func starringItemsUpdatesSidebarState() async throws { @Test func starringItemsUpdatesSidebarState() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_175) let date = Date(timeIntervalSince1970: 1_700_000_175)
+6 -2
View File
@@ -17,7 +17,9 @@ Hiding the panel preserves the draft and answer and lets an in-flight answer fin
**Open in Chat** saves the question and answer in the regular workspace. **Open in Chat** saves the question and answer in the regular workspace.
The regular Mac chat/search composer uses the same Return/Shift+Return rules. The regular Mac chat/search composer uses the same Return/Shift+Return rules.
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged. Holding Return does not repeatedly submit. On iOS, Return submits from Quick
Question, while the normal chat/search composer continues to use Return for a
new line.
Closing the last Mac window leaves Sybil running, so Option+Space still works. Closing the last Mac window leaves Sybil running, so Option+Space still works.
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
@@ -66,7 +68,9 @@ action, and does not build or embed the macOS helper.
answer remain available. Reopen the workspace from the panel, then explicitly answer remain available. Reopen the workspace from the panel, then explicitly
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys. quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace - In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline. it. On iOS, verify Return submits Quick Question while Return in the normal
prompt adds a newline. In the Mac workspace, verify Return submits and
Shift+Return adds a newline.
- Check Command+N both with a workspace open and after closing every workspace. - Check Command+N both with a workspace open and after closing every workspace.
Command+Shift+N must open another window even when one already exists, and Command+Shift+N must open another window even when one already exists, and
Command+Option+N must start a search without opening a window. Command+Option+N must start a search without opening a window.
+73 -48
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { TargetedTouchEvent } from "preact"; import type { TargetedTouchEvent } from "preact";
import { import {
Check, Check,
@@ -654,9 +654,10 @@ type ModelComboboxProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
className?: string;
}; };
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) { function ModelCombobox({ options, value, onChange, disabled = false, className }: ModelComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [draftValue, setDraftValue] = useState(value); const [draftValue, setDraftValue] = useState(value);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@@ -710,7 +711,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
return ( return (
<div className="relative" ref={rootRef}> <div className="relative" ref={rootRef}>
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]"> <div
className={cn(
"flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]",
className
)}
>
<input <input
ref={inputRef} ref={inputRef}
value={draftValue} value={draftValue}
@@ -948,7 +954,6 @@ export default function App() {
isCheckingSession, isCheckingSession,
isSigningIn, isSigningIn,
isAuthenticated, isAuthenticated,
authMode,
authError, authError,
handleAuthFailure: baseHandleAuthFailure, handleAuthFailure: baseHandleAuthFailure,
handleSignIn, handleSignIn,
@@ -1018,6 +1023,7 @@ export default function App() {
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
const renameChatInputRef = useRef<HTMLInputElement>(null); const renameChatInputRef = useRef<HTMLInputElement>(null);
const chatSettingsDialogRef = useRef<HTMLDialogElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]); const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
@@ -1930,6 +1936,7 @@ export default function App() {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (chatSettingsDialogRef.current?.open) return;
const hasPrimaryModifier = event.metaKey || event.ctrlKey; const hasPrimaryModifier = event.metaKey || event.ctrlKey;
if (!hasPrimaryModifier || event.altKey) return; if (!hasPrimaryModifier || event.altKey) return;
@@ -2331,16 +2338,16 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [renameChatDialog]); }, [renameChatDialog]);
useEffect(() => { useLayoutEffect(() => {
if (!isChatSettingsOpen) return; if (!isChatSettingsOpen) return;
const handleKeyDown = (event: KeyboardEvent) => { const dialog = chatSettingsDialogRef.current;
if (event.key !== "Escape" || isSavingChatSettings) return; const previouslyFocused = document.activeElement;
event.preventDefault(); dialog?.showModal();
setIsChatSettingsOpen(false); return () => {
dialog?.close();
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus({ preventScroll: true });
}; };
window.addEventListener("keydown", handleKeyDown); }, [isChatSettingsOpen]);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isChatSettingsOpen, isSavingChatSettings]);
useEffect(() => { useEffect(() => {
if (!isQuickQuestionOpen) return; if (!isQuickQuestionOpen) return;
@@ -3449,15 +3456,9 @@ export default function App() {
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
<div className="relative min-h-24 px-4 pb-4 pt-5"> <div className="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
<div className="pr-24"> <div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent"> SYBIL
SYBIL
</div>
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
</p>
</div> </div>
<SybilCharacter <SybilCharacter
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]" className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
@@ -3822,44 +3823,66 @@ export default function App() {
</div> </div>
) : null} ) : null}
{isChatSettingsOpen ? ( {isChatSettingsOpen ? (
<div <dialog
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6" ref={chatSettingsDialogRef}
aria-labelledby="chat-settings-title"
className="fixed inset-0 m-0 flex h-dvh max-h-none w-full max-w-none items-center justify-center overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-black/70 backdrop:backdrop-blur-md md:p-6"
onCancel={(event) => {
event.preventDefault();
if (!isSavingChatSettings) setIsChatSettingsOpen(false);
}}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false); if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
}} }}
> >
<form <form
role="dialog" className="glass-panel flex h-full min-h-0 w-full flex-col overflow-hidden md:h-auto md:max-h-full md:max-w-2xl md:rounded-2xl md:border md:border-violet-300/24"
aria-modal="true"
aria-labelledby="chat-settings-title"
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
onSubmit={(event) => void handleChatSettingsSubmit(event)} onSubmit={(event) => void handleChatSettingsSubmit(event)}
> >
<div className="mb-4 flex items-center justify-between gap-3"> <div className="chat-settings-header grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-b border-violet-300/16 md:flex md:justify-between md:gap-3">
<Button
type="button"
variant="ghost"
className="h-11 justify-self-start px-2 text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
onClick={() => setIsChatSettingsOpen(false)}
disabled={isSavingChatSettings}
>
Cancel
</Button>
<div className="min-w-0"> <div className="min-w-0">
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50"> <h2 id="chat-settings-title" className="text-base font-semibold text-violet-50 md:text-sm">
Chat settings Chat settings
</h2> </h2>
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p> <p className="mt-1 hidden truncate text-xs text-muted-foreground md:block">{chatSettingsTitleDraft.trim() || "New chat"}</p>
</div> </div>
<Button <Button
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8" className="hidden h-8 w-8 shrink-0 md:inline-flex"
onClick={() => setIsChatSettingsOpen(false)} onClick={() => setIsChatSettingsOpen(false)}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
aria-label="Close chat settings" aria-label="Close chat settings"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
<Button
type="submit"
variant="ghost"
className="h-11 justify-self-end gap-1.5 px-2 font-semibold text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
disabled={isSavingChatSettings}
>
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
Save
</Button>
</div> </div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1"> <div className="chat-settings-content min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain md:space-y-4">
<div> <div>
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span> <label for="chat-settings-chat-title" className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
id="chat-settings-chat-title"
value={chatSettingsTitleDraft} value={chatSettingsTitleDraft}
onInput={(event) => { onInput={(event) => {
setChatSettingsTitleDraft(event.currentTarget.value); setChatSettingsTitleDraft(event.currentTarget.value);
@@ -3867,7 +3890,7 @@ export default function App() {
}} }}
maxLength={120} maxLength={120}
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"} placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70" className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:text-sm"
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
/> />
{chatSettingsChatId ? ( {chatSettingsChatId ? (
@@ -3895,7 +3918,7 @@ export default function App() {
<label className="block"> <label className="block">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span> <span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
<select <select
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70" className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:h-10 md:text-sm"
value={chatSettingsProviderDraft} value={chatSettingsProviderDraft}
onChange={(event) => { onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider; const nextProvider = event.currentTarget.value as Provider;
@@ -3917,6 +3940,7 @@ export default function App() {
<label className="block min-w-0"> <label className="block min-w-0">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span> <span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
<ModelCombobox <ModelCombobox
className="h-11 min-w-0 bg-background/70 text-base md:h-10 md:text-sm"
options={chatSettingsProviderModelOptions} options={chatSettingsProviderModelOptions}
value={chatSettingsModelDraft} value={chatSettingsModelDraft}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
@@ -3938,7 +3962,7 @@ export default function App() {
if (chatSettingsError) setChatSettingsError(null); if (chatSettingsError) setChatSettingsError(null);
}} }}
placeholder="Add per-chat instructions" placeholder="Add per-chat instructions"
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45" className="min-h-32 resize-y border-violet-300/24 bg-background/70 text-base text-violet-50 placeholder:text-violet-200/45 md:text-sm"
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
/> />
</label> </label>
@@ -3952,6 +3976,7 @@ export default function App() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="h-11 gap-1.5 md:h-8"
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))} onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
> >
@@ -3962,6 +3987,7 @@ export default function App() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="h-11 gap-1.5 md:h-8"
onClick={() => setChatSettingsEnabledToolsDraft([])} onClick={() => setChatSettingsEnabledToolsDraft([])}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
> >
@@ -3971,31 +3997,30 @@ export default function App() {
</div> </div>
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="grid gap-2 md:grid-cols-2">
{availableChatTools.length ? ( {availableChatTools.length ? (
availableChatTools.map((tool) => { availableChatTools.map((tool) => {
const checked = chatSettingsEnabledToolsDraft.includes(tool.name); const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
return ( return (
<label <label
key={tool.name} key={tool.name}
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8" title={tool.description}
className="flex min-h-11 min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8 focus-within:border-violet-300/45 focus-within:ring-1 focus-within:ring-ring/70"
> >
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
onChange={() => toggleChatSettingsTool(tool.name)} onChange={() => toggleChatSettingsTool(tool.name)}
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400" className="h-4 w-4 shrink-0 rounded border-violet-300/35 bg-background/80 accent-violet-400"
aria-description={tool.description}
disabled={isSavingChatSettings} disabled={isSavingChatSettings}
/> />
<span className="min-w-0"> <span className="min-w-0 break-words text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
</span>
</label> </label>
); );
}) })
) : ( ) : (
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground"> <p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground md:col-span-2">
No chat tools are available. No chat tools are available.
</p> </p>
)} )}
@@ -4003,8 +4028,8 @@ export default function App() {
</section> </section>
</div> </div>
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null} {chatSettingsError ? <p role="alert" className="chat-settings-error shrink-0 break-words border-t border-violet-300/16 text-sm text-rose-300">{chatSettingsError}</p> : null}
<div className="mt-4 flex justify-end gap-2"> <div className="hidden shrink-0 justify-end gap-2 border-t border-violet-300/16 p-5 md:flex">
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}> <Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
Cancel Cancel
</Button> </Button>
@@ -4014,7 +4039,7 @@ export default function App() {
</Button> </Button>
</div> </div>
</form> </form>
</div> </dialog>
) : null} ) : null}
{renameChatDialog ? ( {renameChatDialog ? (
<div <div
@@ -4073,7 +4098,7 @@ export default function App() {
) : null} ) : null}
{isQuickQuestionOpen ? ( {isQuickQuestionOpen ? (
<div <div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6" className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-3 md:p-6"
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false); if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
}} }}
+34
View File
@@ -113,7 +113,41 @@ textarea {
max(0.75rem, var(--safe-area-left)); max(0.75rem, var(--safe-area-left));
} }
.chat-settings-header {
padding:
calc(0.5rem + var(--safe-area-top))
max(0.75rem, var(--safe-area-right))
0.5rem
max(0.75rem, var(--safe-area-left));
}
.chat-settings-content {
padding:
1rem
max(1rem, var(--safe-area-right))
max(1.5rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
scroll-padding-block: 1rem;
}
.chat-settings-error {
padding:
0.75rem
max(1rem, var(--safe-area-right))
max(0.75rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
}
@media (min-width: 768px) { @media (min-width: 768px) {
.chat-settings-header,
.chat-settings-content {
padding: 1.25rem;
}
.chat-settings-error {
padding: 0.75rem 1.25rem;
}
.app-safe-frame { .app-safe-frame {
padding: padding:
max(0.5rem, var(--safe-area-top)) max(0.5rem, var(--safe-area-top))