quick question feature

This commit is contained in:
2026-05-02 23:48:01 -07:00
parent 6fbcaecbf8
commit 29e340fd08
8 changed files with 748 additions and 106 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
import { Check, ChevronDown, Globe2, 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";
@@ -92,9 +92,15 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
};
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
type ProviderModelPreferences = Record<Provider, string | null>;
type QuickQuestionModelSelection = {
provider: Provider;
modelPreferences: ProviderModelPreferences;
};
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
openai: null,
anthropic: null,
@@ -292,6 +298,37 @@ function loadStoredModelPreferences() {
}
}
function normalizeStoredProvider(value: unknown): Provider {
return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai";
}
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
const parsed = value as Partial<Record<Provider, unknown>>;
return {
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
};
}
function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection {
if (typeof window === "undefined") {
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
}
try {
const raw = window.localStorage.getItem(QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY);
if (!raw) return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
const parsed = JSON.parse(raw) as { provider?: unknown; modelPreferences?: unknown };
return {
provider: normalizeStoredProvider(parsed.provider),
modelPreferences: normalizeStoredModelPreferences(parsed.modelPreferences),
};
} catch {
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
}
}
function pickProviderModel(options: string[], preferred: string | null) {
if (preferred?.trim()) return preferred.trim();
return options[0] ?? "";
@@ -620,6 +657,22 @@ export default function App() {
const stored = loadStoredModelPreferences();
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
});
const [quickProvider, setQuickProvider] = useState<Provider>(() => loadStoredQuickQuestionModelSelection().provider);
const [quickProviderModelPreferences, setQuickProviderModelPreferences] = useState<ProviderModelPreferences>(
() => loadStoredQuickQuestionModelSelection().modelPreferences
);
const [quickModel, setQuickModel] = useState(() => {
const stored = loadStoredQuickQuestionModelSelection();
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0];
});
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
const [quickPrompt, setQuickPrompt] = useState("");
const [quickSubmittedPrompt, setQuickSubmittedPrompt] = useState<string | null>(null);
const [quickSubmittedModelSelection, setQuickSubmittedModelSelection] = useState<{ provider: Provider; model: string } | null>(null);
const [quickQuestionMessages, setQuickQuestionMessages] = useState<Message[]>([]);
const [isQuickQuestionSending, setIsQuickQuestionSending] = useState(false);
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
const transcriptContainerRef = useRef<HTMLDivElement>(null);
@@ -631,6 +684,7 @@ export default function App() {
const selectedItemRef = useRef<SidebarSelection | null>(null);
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
const searchRunAbortRef = useRef<AbortController | null>(null);
const quickQuestionAbortRef = useRef<AbortController | null>(null);
const searchRunCounterRef = useRef(0);
const shouldAutoScrollRef = useRef(true);
const wasSendingRef = useRef(false);
@@ -713,6 +767,12 @@ export default function App() {
setPendingChatState(null);
setComposer("");
setPendingAttachments([]);
setIsQuickQuestionOpen(false);
setQuickPrompt("");
setQuickSubmittedPrompt(null);
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
setError(null);
};
@@ -846,6 +906,7 @@ export default function App() {
}, [isAuthenticated, selectedItem]);
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
useEffect(() => {
if (model.trim()) return;
@@ -859,6 +920,46 @@ export default function App() {
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
}, [providerModelPreferences]);
useEffect(() => {
if (quickModel.trim()) return;
setQuickModel((current) => {
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
});
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(
QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY,
JSON.stringify({
provider: quickProvider,
modelPreferences: quickProviderModelPreferences,
} satisfies QuickQuestionModelSelection)
);
}, [quickProvider, quickProviderModelPreferences]);
useEffect(() => {
if (!isQuickQuestionOpen || typeof window === "undefined") return;
window.requestAnimationFrame(() => {
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
if (!textarea) return;
textarea.focus();
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
if (textarea.value.length > 0) {
textarea.select();
}
});
}, [isQuickQuestionOpen]);
useEffect(() => {
if (typeof document === "undefined") return;
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
if (!textarea) return;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
}, [quickPrompt, isQuickQuestionOpen]);
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
const isChatReplyStreamingInView =
isSending &&
@@ -933,6 +1034,8 @@ export default function App() {
return () => {
searchRunAbortRef.current?.abort();
searchRunAbortRef.current = null;
quickQuestionAbortRef.current?.abort();
quickQuestionAbortRef.current = null;
};
}, []);
@@ -960,6 +1063,18 @@ export default function App() {
}
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
}, [isSearchMode, messages, pendingChatState, selectedItem]);
const quickAnswerText = useMemo(() => {
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
const message = quickQuestionMessages[index];
if (message.role === "assistant") return message.content;
}
return "";
}, [quickQuestionMessages]);
const canConvertQuickQuestion =
Boolean(quickSubmittedPrompt?.trim()) &&
Boolean(quickSubmittedModelSelection?.model.trim()) &&
Boolean(quickAnswerText.trim()) &&
!isQuickQuestionSending;
const selectedChatSummary = useMemo(() => {
if (!selectedItem || selectedItem.kind !== "chat") return null;
@@ -1028,6 +1143,12 @@ export default function App() {
setIsMobileSidebarOpen(false);
};
const handleOpenQuickQuestion = () => {
setQuickQuestionError(null);
setIsQuickQuestionOpen(true);
setIsMobileSidebarOpen(false);
};
const handleCreateSearch = () => {
setError(null);
setContextMenu(null);
@@ -1068,6 +1189,15 @@ export default function App() {
if (!hasPrimaryModifier || event.altKey) return;
const key = event.key.toLowerCase();
if (key === "i" && !event.shiftKey) {
event.preventDefault();
setQuickQuestionError(null);
setIsQuickQuestionOpen((current) => !current);
return;
}
if (isQuickQuestionOpen) return;
if (key === "j") {
event.preventDefault();
if (event.shiftKey) {
@@ -1087,7 +1217,7 @@ export default function App() {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [filteredSidebarItems, isAuthenticated]);
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
event.preventDefault();
@@ -1138,6 +1268,17 @@ export default function App() {
};
}, [contextMenu]);
useEffect(() => {
if (!isQuickQuestionOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setIsQuickQuestionOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isQuickQuestionOpen]);
const handleOpenAttachmentPicker = () => {
fileInputRef.current?.click();
};
@@ -1587,6 +1728,182 @@ export default function App() {
}
};
const handleSendQuickQuestion = async () => {
const content = quickPrompt.trim();
if (!content || isQuickQuestionSending || isConvertingQuickQuestion) return;
const selectedModel = quickModel.trim();
if (!selectedModel) {
setQuickQuestionError("No model available for selected provider");
return;
}
const now = new Date().toISOString();
const optimisticAssistantMessage: Message = {
id: `temp-assistant-quick-${Date.now()}`,
createdAt: now,
role: "assistant",
content: "",
name: null,
metadata: null,
};
quickQuestionAbortRef.current?.abort();
const abortController = new AbortController();
quickQuestionAbortRef.current = abortController;
setQuickQuestionError(null);
setQuickSubmittedPrompt(content);
setQuickSubmittedModelSelection({ provider: quickProvider, model: selectedModel });
setQuickQuestionMessages([optimisticAssistantMessage]);
setIsQuickQuestionSending(true);
let streamErrorMessage: string | null = null;
try {
await runCompletionStream(
{
persist: false,
provider: quickProvider,
model: selectedModel,
messages: [{ role: "user", content }],
},
{
onToolCall: (payload) => {
setQuickQuestionMessages((current) => {
if (
current.some(
(message) =>
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
)
) {
return current;
}
const toolMessage = buildOptimisticToolMessage(payload);
const assistantIndex = current.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-")
);
if (assistantIndex < 0) return current.concat(toolMessage);
return [
...current.slice(0, assistantIndex),
toolMessage,
...current.slice(assistantIndex),
];
});
},
onDelta: (payload) => {
if (!payload.text) return;
setQuickQuestionMessages((current) => {
let updated = false;
const nextMessages = current.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
if (!isTarget) return message;
updated = true;
return { ...message, content: message.content + payload.text };
});
return updated ? nextMessages : current;
});
},
onDone: (payload) => {
setQuickQuestionMessages((current) => {
let updated = false;
const nextMessages = current.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
if (!isTarget) return message;
updated = true;
return { ...message, content: payload.text };
});
return updated ? nextMessages : current;
});
},
onError: (payload) => {
streamErrorMessage = payload.message;
},
},
{ signal: abortController.signal }
);
if (streamErrorMessage) {
throw new Error(streamErrorMessage);
}
} catch (err) {
if (abortController.signal.aborted) return;
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setQuickQuestionError(message);
}
} finally {
if (quickQuestionAbortRef.current === abortController) {
quickQuestionAbortRef.current = null;
}
if (!abortController.signal.aborted) {
setIsQuickQuestionSending(false);
}
}
};
const handleConvertQuickQuestionToChat = async () => {
const question = quickSubmittedPrompt?.trim();
const answer = quickAnswerText.trim();
const selection = quickSubmittedModelSelection;
if (!question || !answer || !selection || isQuickQuestionSending || isConvertingQuickQuestion) return;
setQuickQuestionError(null);
setIsConvertingQuickQuestion(true);
try {
const title = question.split(/\r?\n/)[0]?.trim().slice(0, 48) || "Quick question";
const chat = await createChat({
title,
provider: selection.provider,
model: selection.model,
messages: [
{ role: "user", content: question },
{ role: "assistant", content: answer },
],
});
setDraftKind(null);
setPendingChatState(null);
setComposer("");
setPendingAttachments([]);
setIsQuickQuestionOpen(false);
setProvider(selection.provider);
setModel(selection.model);
setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting];
});
setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
messages: [],
});
setSelectedSearch(null);
await refreshCollections({ kind: "chat", id: chat.id });
await refreshChat(chat.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setQuickQuestionError(message);
}
} finally {
setIsConvertingQuickQuestion(false);
}
};
const handleSend = async () => {
const content = composer.trim();
const attachments = pendingAttachments;
@@ -1683,13 +2000,30 @@ export default function App() {
</div>
<div className="space-y-3 px-3 pb-3">
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
<Plus className="h-4 w-4" />
New chat
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
{primaryShortcutModifier} J
</span>
</Button>
<div className="flex gap-2">
<Button className="h-11 min-w-0 flex-1 justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
<Plus className="h-4 w-4" />
New chat
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
{primaryShortcutModifier} J
</span>
</Button>
<div className="group relative">
<Button
className="h-11 w-11 rounded-lg"
onClick={handleOpenQuickQuestion}
size="icon"
variant="secondary"
title={`${primaryShortcutModifier}+i`}
aria-label="Quick question"
>
<Rabbit className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-md border border-violet-300/22 bg-[hsl(238_48%_7%)] px-2 py-1 text-xs font-semibold text-violet-100/90 opacity-0 shadow-xl shadow-black/35 transition group-hover:opacity-100 group-focus-within:opacity-100">
{primaryShortcutModifier}+i
</span>
</div>
</div>
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
<Search className="h-4 w-4" />
New search
@@ -1961,6 +2295,136 @@ export default function App() {
</button>
</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"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="quick-question-title"
className="glass-panel flex max-h-[88vh] w-full max-w-3xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 id="quick-question-title" className="text-sm font-semibold text-violet-50">
Quick question
</h2>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => setIsQuickQuestionOpen(false)}
aria-label="Close quick question"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3">
<Textarea
id="quick-question-input"
rows={2}
value={quickPrompt}
onInput={(event) => {
const textarea = event.currentTarget;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
const nextPrompt = textarea.value;
if (nextPrompt !== quickPrompt) {
quickQuestionAbortRef.current?.abort();
quickQuestionAbortRef.current = null;
setIsQuickQuestionSending(false);
setQuickSubmittedPrompt(null);
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
}
setQuickPrompt(nextPrompt);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSendQuickQuestion();
}
}}
placeholder="Ask Sybil..."
className="max-h-36 min-h-[4.75rem] resize-none overflow-y-auto border-violet-300/24 bg-background/72 text-base text-violet-50 placeholder:text-violet-200/45"
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
/>
<div className="h-[min(34vh,22rem)] overflow-y-auto rounded-xl border border-violet-300/16 bg-background/38 px-3 py-4">
{quickQuestionMessages.length ? (
<ChatMessagesPanel messages={quickQuestionMessages} isLoading={false} isSending={isQuickQuestionSending} />
) : null}
{quickQuestionError ? (
<p className="text-sm text-rose-300">{quickQuestionError}</p>
) : null}
</div>
</div>
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<select
className="h-10 min-w-32 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"
value={quickProvider}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setQuickProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider);
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
}}
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
aria-label="Quick question provider"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
<ModelCombobox
options={quickProviderModelOptions}
value={quickModel}
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
onChange={(nextModel) => {
const normalizedModel = nextModel.trim();
setQuickModel(normalizedModel);
setQuickProviderModelPreferences((current) => ({
...current,
[quickProvider]: normalizedModel || null,
}));
}}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="secondary"
className="gap-2"
onClick={() => void handleConvertQuickQuestionToChat()}
disabled={!canConvertQuickQuestion || isConvertingQuickQuestion}
>
<MessageSquare className="h-4 w-4" />
Convert to chat
</Button>
<Button
type="button"
className="h-10 w-10 rounded-lg"
onClick={() => void handleSendQuickQuestion()}
size="icon"
disabled={isQuickQuestionSending || isConvertingQuickQuestion || !quickPrompt.trim()}
aria-label="Ask quick question"
>
<SendHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</section>
</div>
) : null}
</div>
);
}