Compare commits

...
Author SHA1 Message Date
buzzert 411a8cebc7 Expose chat-latest and default Quick Question to it
TestFlight / Build and upload (push) Failing after 45s
2026-10-02 13:56:44 -07:00
buzzert 14191de8f4 No rounded corners 2026-09-30 21:30:54 -07:00
buzzert 4ef48fad82 remove "open mode" extra text 2026-09-27 22:56:07 -07:00
buzzert 21beab26f1 Replace quick question backdrop blur with dimming 2026-09-27 00:51:38 -07:00
buzzert 050a46d7a8 Better mobile-optimized settings UI 2026-09-19 18:55:17 -07:00
buzzert 3217c3f66e ios: add copy session ID to chat menu
TestFlight / Build and upload (push) Successful in 1m45s
2026-09-06 14:17:42 -07:00
15 changed files with 274 additions and 98 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ Chat upload limits:
```json ```json
{ {
"providers": { "providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null }, "openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null }, "anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }, "xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null }, "gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
@@ -39,7 +39,7 @@ Chat upload limits:
} }
} }
``` ```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation. - OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids. - Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id. - `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message. - The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
+1
View File
@@ -109,6 +109,7 @@ Request body:
``` ```
Behavior notes: Behavior notes:
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
- `question` is required, trimmed by the server, and must not be empty. - `question` is required, trimmed by the server, and must not be empty.
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt. - The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata. - Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
@@ -52,9 +52,9 @@ final class SybilSettingsStore {
self.preferredModelByProvider = preferredModels self.preferredModelByProvider = preferredModels
self.quickQuestionPreferredProvider = self.quickQuestionPreferredProvider =
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.quickQuestionPreferredModelByProvider = [ self.quickQuestionPreferredModelByProvider = [
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini", .openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest", .anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini", .xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash", .gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
@@ -1,5 +1,6 @@
import Observation import Observation
import SwiftUI import SwiftUI
import UIKit
struct SybilSidebarView: View { struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
@@ -169,7 +170,7 @@ 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 { Button {
forkChat(item) forkChat(item)
} label: { } label: {
@@ -183,6 +184,12 @@ struct SybilSidebarItemList: View {
} 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) {
@@ -222,7 +222,7 @@ final class SybilViewModel {
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
private let fallbackModels: [Provider: [String]] = [ private let fallbackModels: [Provider: [String]] = [
.openai: ["gpt-4.1-mini"], .openai: ["gpt-4.1-mini", "chat-latest"],
.anthropic: ["claude-3-5-sonnet-latest"], .anthropic: ["claude-3-5-sonnet-latest"],
.xai: ["grok-3-mini"], .xai: ["grok-3-mini"],
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"], .gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
@@ -52,6 +52,7 @@ private actor MockSybilClient: SybilAPIClienting {
private let updateChatStarResponses: [String: ChatSummary] private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary] private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse private let activeRunsResponse: ActiveRunsResponse
private let modelCatalogResponse: ModelCatalogResponse
private var snapshot = MockClientCallSnapshot() private var snapshot = MockClientCallSnapshot()
private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastCreateChatCall: ChatCreateCallSnapshot?
@@ -84,7 +85,8 @@ private actor MockSybilClient: SybilAPIClienting {
updateChatStarResponses: [String: ChatSummary] = [:], updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:], updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(), activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil workspaceItemsResponse: [WorkspaceItem]? = nil,
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
) { ) {
self.chatsResponse = chatsResponse self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse self.searchesResponse = searchesResponse
@@ -98,6 +100,7 @@ private actor MockSybilClient: SybilAPIClienting {
self.updateChatStarResponses = updateChatStarResponses self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse self.activeRunsResponse = activeRunsResponse
self.modelCatalogResponse = modelCatalogResponse
} }
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] { private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
@@ -306,7 +309,7 @@ private actor MockSybilClient: SybilAPIClienting {
} }
func listModels() async throws -> ModelCatalogResponse { func listModels() async throws -> ModelCatalogResponse {
ModelCatalogResponse(providers: [:]) modelCatalogResponse
} }
func getActiveRuns() async throws -> ActiveRunsResponse { func getActiveRuns() async throws -> ActiveRunsResponse {
@@ -1257,6 +1260,7 @@ private func makeCompletionToolCall(
#expect(snapshot.runQuickQuestionStream == 1) #expect(snapshot.runQuickQuestionStream == 1)
#expect(snapshot.runCompletionStream == 0) #expect(snapshot.runCompletionStream == 0)
#expect(body?.provider == .openai) #expect(body?.provider == .openai)
#expect(body?.model == "chat-latest")
#expect(body?.question == "How do I reset my password?") #expect(body?.question == "How do I reset my password?")
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.") #expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
#expect(!viewModel.isQuickQuestionSending) #expect(!viewModel.isQuickQuestionSending)
@@ -1370,6 +1374,42 @@ private func makeCompletionToolCall(
#expect(viewModel.quickQuestionPrompt.isEmpty) #expect(viewModel.quickQuestionPrompt.isEmpty)
} }
@MainActor
@Test func quickQuestionDefaultsToChatLatestIndependentlyOfRegularChatPreferences() async {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
defaults.set("anthropic", forKey: "sybil.ios.preferredProvider")
defaults.set("gpt-4o", forKey: "sybil.ios.preferredOpenAIModel")
let settings = SybilSettingsStore(defaults: defaults)
let client = MockSybilClient(modelCatalogResponse: ModelCatalogResponse(providers: [
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "chat-latest"], loadedAt: nil, error: nil),
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest"], loadedAt: nil, error: nil)
]))
let viewModel = SybilViewModel(settings: settings) { _ in client }
#expect(viewModel.provider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
#expect(viewModel.quickQuestionProvider == .openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions.contains("chat-latest"))
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
await viewModel.bootstrap()
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions == ["gpt-4.1-mini", "chat-latest"])
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(SybilSettingsStore(defaults: defaults).quickQuestionPreferredModelByProvider[.openai] == "chat-latest")
#expect(settings.preferredProvider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
}
@MainActor @MainActor
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws { @Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
let defaults = UserDefaults(suiteName: #function)! let defaults = UserDefaults(suiteName: #function)!
+3
View File
@@ -16,6 +16,9 @@ second **Option+Space**, the close button, or clicking outside dismisses it.
Hiding the panel preserves the draft and answer and lets an in-flight answer finish. Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
**Open in Chat** saves the question and answer in the regular workspace. **Open in Chat** saves the question and answer in the regular workspace.
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
provider/model selection takes precedence and is separate from regular chat preferences.
The regular Mac chat/search composer uses the same Return/Shift+Return rules. The regular Mac chat/search composer uses the same Return/Shift+Return rules.
Holding Return does not repeatedly submit. On iOS, Return submits from Quick 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 Question, while the normal chat/search composer continues to use Return for a
+1 -1
View File
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false; if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false; if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
if (id.includes("search") || id.includes("computer-use")) return false; if (id.includes("search") || id.includes("computer-use")) return false;
return /^(gpt-|o\d|chatgpt-)/.test(id); return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
} }
function isLikelyGeminiChatModel(model: string) { function isLikelyGeminiChatModel(model: string) {
+45 -1
View File
@@ -1,6 +1,50 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js"; import { env } from "../src/env.js";
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
for (const { name, listedModels, expectedModels } of [
{
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
listedModels: [
"gpt-4.1-mini",
"chat-latest",
"o3",
"chatgpt-4o-latest",
"chat-latest",
"text-embedding-3-small",
"omni-moderation-latest",
"gpt-4o-audio-preview",
"gpt-realtime",
"gpt-4o-transcribe",
"gpt-4o-mini-tts",
"gpt-image-1",
"chatgpt-image-latest",
"gpt-4o-search-preview",
"computer-use-preview",
"sora-2",
],
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
},
{
name: "OpenAI catalog does not invent models absent from the provider response",
listedModels: ["gpt-4.1-mini"],
expectedModels: ["gpt-4.1-mini"],
},
]) {
test(name, async (t) => {
const originalKey = env.OPENAI_API_KEY;
env.OPENAI_API_KEY = "test-openai-key";
t.after(() => { env.OPENAI_API_KEY = originalKey; });
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
assert.equal(url, "https://api.openai.com/v1/models");
return Response.json({ data: listedModels.map((id) => ({ id })) });
});
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
});
}
test("provider backend registry selects chat protocol and managed-tool mode", () => { test("provider backend registry selects chat protocol and managed-tool mode", () => {
assert.deepEqual(describeProviderChatBackend("openai", []), { assert.deepEqual(describeProviderChatBackend("openai", []), {
+79 -54
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,
@@ -80,7 +80,7 @@ import {
resolveSidebarSelectionAfterRefresh, resolveSidebarSelectionAfterRefresh,
type SidebarSelection, type SidebarSelection,
} from "@/lib/sidebar-selection"; } from "@/lib/sidebar-selection";
import { buildQuickQuestionRequest } from "@/lib/quick-question"; import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question";
import { import {
appendStreamingDelta, appendStreamingDelta,
clearStreamingAssistantTrace, clearStreamingAssistantTrace,
@@ -161,7 +161,7 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
} }
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = { const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"], openai: ["gpt-4.1-mini", QUICK_QUESTION_DEFAULT_MODEL],
anthropic: ["claude-3-5-sonnet-latest"], anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"], xai: ["grok-3-mini"],
gemini: ["gemini-3.5-flash", "gemini-flash-latest"], gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
@@ -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,
@@ -986,7 +991,7 @@ export default function App() {
); );
const [quickModel, setQuickModel] = useState(() => { const [quickModel, setQuickModel] = useState(() => {
const stored = loadStoredQuickQuestionModelSelection(); const stored = loadStoredQuickQuestionModelSelection();
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0]; return pickQuickQuestionModel(stored.provider, PROVIDER_FALLBACK_MODELS[stored.provider], stored.modelPreferences[stored.provider]);
}); });
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false); const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
const [quickPrompt, setQuickPrompt] = useState(""); const [quickPrompt, setQuickPrompt] = useState("");
@@ -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[]>([]);
@@ -1443,7 +1449,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (quickModel.trim()) return; if (quickModel.trim()) return;
setQuickModel((current) => { setQuickModel((current) => {
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]); return current.trim() || pickQuickQuestionModel(quickProvider, quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
}); });
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]); }, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
@@ -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;
@@ -3433,7 +3440,7 @@ export default function App() {
return ( return (
<div className="app-grid-surface app-safe-frame h-full"> <div className="app-grid-surface app-safe-frame h-full">
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent md:gap-2"> <div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent">
{isMobileSidebarOpen ? ( {isMobileSidebarOpen ? (
<button <button
type="button" type="button"
@@ -3445,20 +3452,14 @@ export default function App() {
<aside <aside
className={cn( className={cn(
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border", "workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
<div className="relative min-h-24 px-4 pb-4 pt-5"> <div className="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
<div className="pr-24">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent"> <div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL SYBIL
</div> </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>
<SybilCharacter <SybilCharacter
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]" className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
isBusy={isActiveSelectionSending || isQuickQuestionSending} isBusy={isActiveSelectionSending || isQuickQuestionSending}
@@ -3608,7 +3609,7 @@ export default function App() {
</aside> </aside>
<main <main
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden border-violet-300/18 md:touch-auto md:rounded-2xl md:border" className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
onTouchStart={handleMobileWorkspaceTouchStart} onTouchStart={handleMobileWorkspaceTouchStart}
onTouchMove={handleMobileWorkspaceTouchMove} onTouchMove={handleMobileWorkspaceTouchMove}
onTouchEnd={handleMobileWorkspaceTouchEnd} onTouchEnd={handleMobileWorkspaceTouchEnd}
@@ -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);
}} }}
@@ -4151,7 +4176,7 @@ export default function App() {
const nextProvider = event.currentTarget.value as Provider; const nextProvider = event.currentTarget.value as Provider;
setQuickProvider(nextProvider); setQuickProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider); const options = getModelOptions(modelCatalog, nextProvider);
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider])); setQuickModel(pickQuickQuestionModel(nextProvider, options, quickProviderModelPreferences[nextProvider]));
}} }}
disabled={isQuickQuestionSending || isConvertingQuickQuestion} disabled={isQuickQuestionSending || isConvertingQuickQuestion}
aria-label="Quick question provider" aria-label="Quick question provider"
@@ -378,13 +378,13 @@ function ToolCallStack({
); );
})} })}
{!expanded && hiddenCount ? ( {!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm"> <span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount} +{hiddenCount}
</span> </span>
) : null} ) : null}
<button <button
type="button" type="button"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full" className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
aria-expanded={expanded ? "true" : "false"} aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`} aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`} title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
@@ -603,7 +603,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
className={cn( className={cn(
"max-w-[85%] space-y-3", "max-w-[85%] space-y-3",
isUser isUser
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm" ? "chat-user-message border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50" : "text-base leading-7 text-violet-50"
)} )}
onContextMenu={ onContextMenu={
+48 -26
View File
@@ -31,7 +31,7 @@
--secondary-foreground: 254 38% 94%; --secondary-foreground: 254 38% 94%;
--accent: 188 86% 50%; --accent: 188 86% 50%;
--accent-foreground: 230 45% 8%; --accent-foreground: 230 45% 8%;
--radius: 0.5rem; --radius: 0px;
} }
* { * {
@@ -72,6 +72,7 @@ select,
textarea { textarea {
font: inherit; font: inherit;
color-scheme: dark; color-scheme: dark;
border-radius: var(--radius);
} }
::selection { ::selection {
@@ -113,13 +114,39 @@ textarea {
max(0.75rem, var(--safe-area-left)); max(0.75rem, var(--safe-area-left));
} }
@media (min-width: 768px) { .chat-settings-header {
.app-safe-frame {
padding: padding:
max(0.5rem, var(--safe-area-top)) calc(0.5rem + var(--safe-area-top))
max(0.5rem, var(--safe-area-right)) max(0.75rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom)) 0.5rem
max(0.5rem, var(--safe-area-left)); 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) {
.chat-settings-header,
.chat-settings-content {
padding: 1.25rem;
}
.chat-settings-error {
padding: 0.75rem 1.25rem;
} }
.app-search-safe-pad { .app-search-safe-pad {
@@ -132,19 +159,11 @@ textarea {
} }
@media (horizontal-viewport-segments: 2) { @media (horizontal-viewport-segments: 2) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
}
.workspace-shell { .workspace-shell {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left))) calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right))); calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0)); column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
} }
@@ -153,14 +172,10 @@ textarea {
width: 100%; width: 100%;
max-width: none; max-width: none;
transform: none; transform: none;
border-width: 1px;
border-radius: 1rem;
} }
.workspace-content { .workspace-content {
min-width: 0; min-width: 0;
border-width: 1px;
border-radius: 1rem;
touch-action: auto; touch-action: auto;
} }
@@ -179,6 +194,13 @@ textarea {
0 14px 36px hsl(240 80% 2% / 0.28); 0 14px 36px hsl(240 80% 2% / 0.28);
} }
.chat-user-message {
/* Preserve the original bubble radius independently of the square UI theme.
Browsers without corner-shape support fall back to circular corners. */
border-radius: 0.75rem;
corner-shape: squircle;
}
.tool-call-stack-shell { .tool-call-stack-shell {
perspective: 900px; perspective: 900px;
transform-style: preserve-3d; transform-style: preserve-3d;
@@ -430,7 +452,7 @@ textarea {
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86); border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem; border-radius: var(--radius);
background: hsl(246 34% 10% / 0.76); background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06); box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
} }
@@ -449,7 +471,7 @@ textarea {
} }
.md-table-scroll::-webkit-scrollbar-thumb { .md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px; border-radius: var(--radius);
background: hsl(263 78% 72% / 0.34); background: hsl(263 78% 72% / 0.34);
} }
@@ -561,7 +583,7 @@ textarea {
.md-content code { .md-content code {
background: hsl(249 40% 10% / 0.78); background: hsl(249 40% 10% / 0.78);
border-radius: 0.3rem; border-radius: var(--radius);
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
font-size: 0.86em; font-size: 0.86em;
box-decoration-break: clone; box-decoration-break: clone;
@@ -571,7 +593,7 @@ textarea {
.md-content pre { .md-content pre {
overflow-x: auto; overflow-x: auto;
border: 1px solid hsl(253 31% 29% / 0.72); border: 1px solid hsl(253 31% 29% / 0.72);
border-radius: 0.625rem; border-radius: var(--radius);
background: hsl(249 40% 10% / 0.82); background: hsl(249 40% 10% / 0.82);
padding: 0.75rem; padding: 0.75rem;
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05); box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
@@ -595,7 +617,7 @@ textarea {
.md-cite-token { .md-cite-token {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border-radius: 9999px; border-radius: var(--radius);
border: 1px solid hsl(274 40% 55%); border: 1px solid hsl(274 40% 55%);
background: hsl(277 33% 21%); background: hsl(277 33% 21%);
color: hsl(304 42% 96%); color: hsl(304 42% 96%);
+8
View File
@@ -1,5 +1,13 @@
import type { Provider } from "./api"; import type { Provider } from "./api";
export const QUICK_QUESTION_DEFAULT_MODEL = "chat-latest";
export function pickQuickQuestionModel(provider: Provider, options: string[], preferred: string | null = null) {
if (preferred?.trim()) return preferred.trim();
if (provider === "openai") return QUICK_QUESTION_DEFAULT_MODEL;
return options[0] ?? "";
}
export function buildQuickQuestionRequest({ export function buildQuickQuestionRequest({
provider, provider,
model, model,
+7 -2
View File
@@ -29,9 +29,14 @@ const config: Config = {
}, },
}, },
borderRadius: { borderRadius: {
// Keep surfaces square at every size; rounded-full is reserved for dots.
DEFAULT: "var(--radius)",
sm: "var(--radius)",
md: "var(--radius)",
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", xl: "var(--radius)",
sm: "calc(var(--radius) - 4px)", "2xl": "var(--radius)",
"3xl": "var(--radius)",
}, },
}, },
}, },
+22 -1
View File
@@ -1,6 +1,27 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { buildQuickQuestionRequest } from "../src/lib/quick-question.ts"; import { buildQuickQuestionRequest, pickQuickQuestionModel } from "../src/lib/quick-question.ts";
test("quick questions default to chat-latest before and after the model catalog loads", () => {
for (const options of [[], ["gpt-4.1-mini"], ["gpt-4.1-mini", "chat-latest"]]) {
const model = pickQuickQuestionModel("openai", options);
assert.equal(model, "chat-latest");
assert.deepEqual(buildQuickQuestionRequest({ provider: "openai", model, content: "What is UTC?" }), {
provider: "openai",
model: "chat-latest",
question: "What is UTC?",
});
}
});
test("quick questions preserve saved models and use provider-specific choices when switching providers", () => {
assert.equal(pickQuickQuestionModel("openai", ["chat-latest"], " gpt-4.1-mini "), "gpt-4.1-mini");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini"], " "), "chat-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"]), "claude-3-5-sonnet-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"], "claude-3-haiku"), "claude-3-haiku");
assert.equal(pickQuickQuestionModel("anthropic", []), "");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini", "chat-latest"]), "chat-latest");
});
test("quick question requests use the dedicated server endpoint shape", () => { test("quick question requests use the dedicated server endpoint shape", () => {
const request = buildQuickQuestionRequest({ const request = buildQuickQuestionRequest({