Teach it back: the AI becomes the curious student
A new mode where the user explains a topic in their own words and the AI plays a curious classmate: short reactions, exactly one follow-up question at a time, never "correct"/"wrong", never lecturing, and everything it knows comes from the user's own decks and notes. - Entry points: a sidebar row next to Exam mode and a "Teach it back" action in every deck's menu (which prefills the topic and scope). - Start screen: deck or typed topic, friendly/skeptical student, the same context and model chips as Chat (/teach?deck=<id> pre-selects the deck). - Session: the student prompt is a mode of the existing /api/chat route, so model resolution, allowlists, rate limits and the streaming client are the same code; the personality lives in lib/prompts.ts (TEACH_*). The deck's cards travel as a numbered checklist and the student is told to ask about the ideas that were skipped; it must say so rather than guess when the notes cover nothing. One constant (TEACH_MAX_EXCHANGES = 10) ends the session, or the Finish button does. - Recap: a teach_summary call (JSON, one repair retry) yields well / gaps with card references / next steps; each gap links to the card it means. "Add missed cards to review" schedules those activities due now; "Turn gaps into cards" generates one flashcard per gap from the deck's notes and shows an editable review list before saving. - Sessions are chat conversations with a mode:"teach" marker + session record: same IndexedDB history, backup and sync, filtered out of the Chat screens; the recent list reopens a recap or continues a session. - Reused pieces: MessageRow, Composer (now optional attach/scope chips and a mic), Markdown/KaTeX, AiDisclaimer, ContextPicker/ModelPicker, plus a shared useChatModels hook and useKeyboardInset extracted from Chat. - Dictation: Web Speech API recognition (lib/speech-input.ts); the mic only renders where the browser supports it, and dictated phrases land in the draft. - All strings in en + cs. teach-test: 44 checks (API prompts and guards, both entry points, the session, recap actions, history, exchange limit, 360/390px layouts, mic on/off). Version 0.8.1, SW v103.
This commit is contained in:
parent
3fb491c54d
commit
6036238bf0
25 changed files with 2821 additions and 181 deletions
|
|
@ -10,28 +10,24 @@ import { HistorySheet } from "./HistoryPanel";
|
|||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
|
||||
import { PROVIDERS, providerInfo } from "@/lib/providers";
|
||||
import { modelsFor, useProviders } from "@/lib/providers-client";
|
||||
import { providerInfo } from "@/lib/providers";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ApiError } from "@/lib/client-api";
|
||||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||
import { useChatModels } from "@/lib/chat-models";
|
||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||
import { cn, prettyModelName } from "@/lib/utils";
|
||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { chatActive } from "@/lib/chat-active";
|
||||
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||
import { useKeyboardInset } from "@/lib/use-keyboard-inset";
|
||||
import {
|
||||
chatMessageId,
|
||||
readStoredChatModel,
|
||||
readStoredChatProvider,
|
||||
storeChatModel,
|
||||
storeChatProvider,
|
||||
titleFromMessage,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type ChatModelChoice,
|
||||
type ChatScope,
|
||||
} from "@/lib/chat";
|
||||
import { setDeckSeed } from "@/lib/deck-seed";
|
||||
|
|
@ -44,7 +40,6 @@ import { showToast } from "@/lib/toast";
|
|||
import { deckStore, upsertDeck } from "@/lib/storage";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import type { ChatConversation } from "@/lib/schemas";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { useOnline } from "@/lib/use-online";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
|
|
@ -65,7 +60,8 @@ export function ChatScreen() {
|
|||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const folders = useSyncExternalStore(folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot);
|
||||
const settings = useSyncExternalStore(settingsStore.subscribe, settingsStore.getSnapshot, settingsStore.getServerSnapshot);
|
||||
const { health } = useHealth();
|
||||
const chatModel = useChatModels();
|
||||
useKeyboardInset();
|
||||
|
||||
const deckParam = search.get("deck");
|
||||
|
||||
|
|
@ -77,8 +73,6 @@ export function ChatScreen() {
|
|||
const [draft, setDraft] = useState("");
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [attachBusy, setAttachBusy] = useState(false);
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("ollama");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
const [scopeOpen, setScopeOpen] = useState(false);
|
||||
|
|
@ -108,11 +102,6 @@ export function ChatScreen() {
|
|||
if (taken.deckId) setScope({ kind: "decks", deckIds: [taken.deckId] });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(readStoredChatModel() ?? "");
|
||||
setProvider(readStoredChatProvider());
|
||||
}, []);
|
||||
|
||||
/* History: hydrate once, then open ?c=<id> conversations. */
|
||||
useEffect(() => {
|
||||
void ensureChatsHydrated();
|
||||
|
|
@ -123,20 +112,23 @@ export function ChatScreen() {
|
|||
}, []);
|
||||
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
const loadConversation = useCallback((found: ChatConversation) => {
|
||||
setConversationId(found.id);
|
||||
setTitle(found.title);
|
||||
setMessages(conversationMessages(found));
|
||||
setScope(found.scope);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
if (found.model) setModel(found.model);
|
||||
if (found.provider) setProvider(found.provider);
|
||||
loadedRef.current = found.id;
|
||||
chatActive.set(found.id);
|
||||
}, []);
|
||||
const { restore: restoreModel } = chatModel;
|
||||
const loadConversation = useCallback(
|
||||
(found: ChatConversation) => {
|
||||
setConversationId(found.id);
|
||||
setTitle(found.title);
|
||||
setMessages(conversationMessages(found));
|
||||
setScope(found.scope);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
restoreModel(found.model, found.provider);
|
||||
loadedRef.current = found.id;
|
||||
chatActive.set(found.id);
|
||||
},
|
||||
[restoreModel],
|
||||
);
|
||||
|
||||
const conversationParam = search.get("c");
|
||||
useEffect(() => {
|
||||
|
|
@ -146,69 +138,9 @@ export function ChatScreen() {
|
|||
loadConversation(found);
|
||||
}, [conversationParam, conversations, loadConversation]);
|
||||
|
||||
/* Keyboard: keep the composer above the on-screen keyboard. */
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
const apply = () => {
|
||||
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
||||
document.documentElement.style.setProperty("--chat-keyboard", `${Math.round(inset)}px`);
|
||||
};
|
||||
apply();
|
||||
viewport.addEventListener("resize", apply);
|
||||
viewport.addEventListener("scroll", apply);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", apply);
|
||||
viewport.removeEventListener("scroll", apply);
|
||||
document.documentElement.style.removeProperty("--chat-keyboard");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const providerState = useProviders();
|
||||
const chatModels = useMemo<ChatModelChoice[]>(() => {
|
||||
const fromHealth = health?.chatModels ?? [];
|
||||
const list =
|
||||
fromHealth.length > 0
|
||||
? fromHealth
|
||||
: [
|
||||
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
|
||||
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
|
||||
];
|
||||
const ollama = list.filter((entry) => entry.name);
|
||||
// Every enabled cloud provider contributes its models (live listing first,
|
||||
// the built-in seeds otherwise), labelled with the provider.
|
||||
const cloud: ChatModelChoice[] = [];
|
||||
for (const info of PROVIDERS) {
|
||||
if (!providerState.keys[info.id]) continue;
|
||||
const models = modelsFor(providerState, info.id).slice(0, 400);
|
||||
if (models.length === 0) continue;
|
||||
// Only a live /models listing proves the key can use them; the built-in
|
||||
// seeds stay badge-less until the first successful test.
|
||||
const listed = (providerState.models[info.id]?.length ?? 0) > 0;
|
||||
for (const name of models) {
|
||||
cloud.push({ name, badge: "text", available: listed ? true : null, provider: info.id });
|
||||
}
|
||||
}
|
||||
return [...ollama, ...cloud];
|
||||
}, [health, providerState]);
|
||||
|
||||
const activeProvider = provider !== "ollama" && !providerState.keys[provider] ? "ollama" : provider;
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === activeProvider)) {
|
||||
return model;
|
||||
}
|
||||
// The server puts the default first, but a different server may not have
|
||||
// it — fall back to the first model that is actually available.
|
||||
const firstAvailable = chatModels.find(
|
||||
(entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false,
|
||||
);
|
||||
return (
|
||||
firstAvailable ??
|
||||
chatModels.find((entry) => (entry.provider ?? "ollama") === activeProvider) ??
|
||||
chatModels[0]
|
||||
)?.name ?? model;
|
||||
}, [model, chatModels, activeProvider]);
|
||||
const chatModels = chatModel.models;
|
||||
const activeProvider = chatModel.provider;
|
||||
const activeModel = chatModel.model;
|
||||
|
||||
const scopeText = useMemo(() => {
|
||||
if (scope.kind === "all") return t("chat.scope.all");
|
||||
|
|
@ -775,12 +707,8 @@ export function ChatScreen() {
|
|||
value={activeModel}
|
||||
valueProvider={activeProvider}
|
||||
onChange={(next, nextProvider) => {
|
||||
const chosenProvider = nextProvider ?? "ollama";
|
||||
setProvider(chosenProvider);
|
||||
storeChatProvider(chosenProvider);
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
if (messages.length > 0) persist(messages, { model: next, provider: chosenProvider });
|
||||
chatModel.choose(next, nextProvider);
|
||||
if (messages.length > 0) persist(messages, { model: next, provider: nextProvider ?? "ollama" });
|
||||
}}
|
||||
/>
|
||||
<ContextPicker
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
||||
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
|
||||
import { ChatIcon, LayersIcon, MicIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
|
|
@ -16,6 +16,10 @@ export type PendingAttachment = { attachment: ChatAttachment; text: string };
|
|||
* The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
|
||||
* context + model chips. Enter sends on desktop, newline on phones; the
|
||||
* keyboard inset is handled by the screen around it.
|
||||
*
|
||||
* Teach it back reuses it with the scope chip and the attach button off and
|
||||
* the mic on (when the browser can dictate), so the two screens share one
|
||||
* input, one disclaimer and one chip language.
|
||||
*/
|
||||
export function Composer({
|
||||
value,
|
||||
|
|
@ -35,6 +39,11 @@ export function Composer({
|
|||
attachBusy,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
placeholder,
|
||||
onVoice,
|
||||
listening,
|
||||
voiceTitle,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
|
@ -42,23 +51,33 @@ export function Composer({
|
|||
onStop: () => void;
|
||||
busy: boolean;
|
||||
online: boolean;
|
||||
scopeLabel: string;
|
||||
/** Shown on the context chip; the chip is hidden without it. */
|
||||
scopeLabel?: string;
|
||||
modelLabel: string;
|
||||
/** The raw model name, shown as the chip's tooltip. */
|
||||
modelTitle?: string;
|
||||
scopeOpen: boolean;
|
||||
scopeOpen?: boolean;
|
||||
modelOpen: boolean;
|
||||
onOpenScope: () => void;
|
||||
onOpenScope?: () => void;
|
||||
onOpenModel: () => void;
|
||||
onAttachFile: (file: File) => void;
|
||||
attachBusy: boolean;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
/** Omitted → no attach button (Teach it back takes no attachments). */
|
||||
onAttachFile?: (file: File) => void;
|
||||
attachBusy?: boolean;
|
||||
attachments?: PendingAttachment[];
|
||||
onRemoveAttachment?: (index: number) => void;
|
||||
placeholder?: string;
|
||||
/** Dictation (Teach it back): shown only when the browser supports it. */
|
||||
onVoice?: () => void;
|
||||
listening?: boolean;
|
||||
voiceTitle?: string;
|
||||
/** Extra row above the chips (e.g. the teach mode's hint). */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const pending = attachments ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const element = textarea.current;
|
||||
|
|
@ -71,9 +90,9 @@ export function Composer({
|
|||
|
||||
return (
|
||||
<div className="chat-composer-band glass-panel px-4 pb-safe pt-2 lg:px-10">
|
||||
{attachments.length > 0 ? (
|
||||
{pending.length > 0 ? (
|
||||
<ul className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((entry, index) => (
|
||||
{pending.map((entry, index) => (
|
||||
<li
|
||||
key={`${entry.attachment.name}-${index}`}
|
||||
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
|
||||
|
|
@ -84,7 +103,7 @@ export function Composer({
|
|||
type="button"
|
||||
aria-label={t("chat.attachRemove")}
|
||||
title={t("chat.attachRemove")}
|
||||
onClick={() => onRemoveAttachment(index)}
|
||||
onClick={() => onRemoveAttachment?.(index)}
|
||||
className="grid size-6 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
|
|
@ -95,29 +114,52 @@ export function Composer({
|
|||
) : null}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attach")}
|
||||
title={t("chat.attach")}
|
||||
disabled={attachBusy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
|
||||
>
|
||||
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onAttachFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{onAttachFile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attach")}
|
||||
title={t("chat.attach")}
|
||||
disabled={attachBusy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
|
||||
>
|
||||
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onAttachFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{onVoice ? (
|
||||
<button
|
||||
type="button"
|
||||
data-voice-button
|
||||
aria-pressed={listening ?? false}
|
||||
aria-label={listening ? t("chat.voiceStop") : t("chat.voiceStart")}
|
||||
title={voiceTitle ?? (listening ? t("chat.voiceStop") : t("chat.voiceStart"))}
|
||||
onClick={onVoice}
|
||||
className={cn(
|
||||
"touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border transition",
|
||||
listening
|
||||
? "border-rose-400/70 bg-rose-500/15 text-rose-600 dark:text-rose-300"
|
||||
: "border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
<MicIcon className={cn("size-5", listening && "animate-pulse motion-reduce:animate-none")} />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
ref={textarea}
|
||||
|
|
@ -130,8 +172,8 @@ export function Composer({
|
|||
if (canSend) onSend();
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("chat.placeholder")}
|
||||
aria-label={t("chat.placeholder")}
|
||||
placeholder={placeholder ?? t("chat.placeholder")}
|
||||
aria-label={placeholder ?? t("chat.placeholder")}
|
||||
className="no-scrollbar max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
|
||||
/>
|
||||
|
||||
|
|
@ -160,29 +202,21 @@ export function Composer({
|
|||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={scopeOpen}
|
||||
onClick={onOpenScope}
|
||||
className={pickerChipClass(scopeOpen)}
|
||||
>
|
||||
<LayersIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{scopeLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={modelOpen}
|
||||
onClick={onOpenModel}
|
||||
{scopeLabel && onOpenScope ? (
|
||||
<PickerChip
|
||||
icon={<LayersIcon className="size-4 shrink-0" />}
|
||||
label={scopeLabel}
|
||||
open={scopeOpen ?? false}
|
||||
onClick={onOpenScope}
|
||||
/>
|
||||
) : null}
|
||||
<PickerChip
|
||||
icon={<ChatIcon className="size-4 shrink-0" />}
|
||||
label={modelLabel}
|
||||
title={modelTitle ?? modelLabel}
|
||||
className={pickerChipClass(modelOpen)}
|
||||
>
|
||||
<ChatIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{modelLabel}</span>
|
||||
</button>
|
||||
open={modelOpen}
|
||||
onClick={onOpenModel}
|
||||
/>
|
||||
{!online ? (
|
||||
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
|
||||
{t("chat.offlineNote")}
|
||||
|
|
@ -190,19 +224,47 @@ export function Composer({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<AiDisclaimer plain className="mt-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip style for the two popover triggers (context, model): the shared chip
|
||||
* language, read as pressed while the chip's own popover is open (the same
|
||||
* chip opens and closes it).
|
||||
* The chip that opens a picker (context, model): the shared chip language,
|
||||
* read as pressed while its own popover is open. Exported so screens that are
|
||||
* not the full chat composer (Teach it back's start screen) offer the exact
|
||||
* same trigger.
|
||||
*/
|
||||
function pickerChipClass(open: boolean): string {
|
||||
return cn(
|
||||
"chip min-w-0 px-3 py-2 text-[12.5px] font-medium",
|
||||
open && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
|
||||
export function PickerChip({
|
||||
icon,
|
||||
label,
|
||||
title,
|
||||
open,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
title?: string;
|
||||
open: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={onClick}
|
||||
title={title ?? label}
|
||||
className={cn(
|
||||
"chip min-w-0 px-3 py-2 text-[12.5px] font-medium",
|
||||
open && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="max-w-[190px] truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ export function ChatHistoryList({
|
|||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const visible = conversations.filter((conversation) =>
|
||||
conversationMatches(conversation.title, conversation.messages, query),
|
||||
const visible = conversations.filter(
|
||||
(conversation) =>
|
||||
/* Teach it back sessions live in another screen's history. */
|
||||
conversation.mode !== "teach" && conversationMatches(conversation.title, conversation.messages, query),
|
||||
);
|
||||
|
||||
const startRename = (conversation: ChatConversation) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue