diff --git a/docs/superpowers/specs/2026-04-01-conversation-history-design.md b/docs/superpowers/specs/2026-04-01-conversation-history-design.md deleted file mode 100644 index ea40d283..00000000 --- a/docs/superpowers/specs/2026-04-01-conversation-history-design.md +++ /dev/null @@ -1,205 +0,0 @@ -# Conversation History — Design Spec - -**Date:** 2026-04-01 -**Status:** Draft -**Depends on:** PR #16 (multi-turn conversation via /api/chat) — merged - -## Problem - -Thuki is a floating macOS secretary activated by double-tapping Command. Conversations are currently ephemeral — stored only in memory (Rust `Mutex>` + React `useState`). Everything is wiped on overlay close or app restart. Users have no way to revisit past conversations. - -## Solution - -Add opt-in conversation persistence with a dropdown history UI. Conversations are ephemeral by default — users explicitly save conversations worth keeping via a save button. This prevents quick one-shot Q&As from cluttering the history. - -## Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Storage engine | SQLite via `tauri-plugin-sql` | Industry standard for desktop apps. Cursor, Open WebUI use it. Handles millions of rows. | -| Storage location | `~/.thuki/thuki.db` | Consistent with CLI tool conventions (~/.claude, ~/.gemini). Easy to find, backup, reason about. | -| History UI | Dropdown/popover from header icon | Preserves Thuki's compact spotlight feel. History accessible but hidden by default. | -| Save behavior | Opt-in (explicit save button) | Thuki is for quick answers. Most interactions don't need persistence. Keeps history intentional. | -| Title generation | AI-generated via Ollama on save | Background request after save: "Summarize in 5 words or fewer." First-message preview as placeholder. | -| Activation behavior | Always start fresh | Double-tap Command opens empty input bar. Users pick past conversations from dropdown. | -| Conversation cap | None — unlimited | SQLite handles 100K+ conversations trivially. Add cleanup options later if needed. | -| Conversation deletion | Delete only (no archive) | Hover-reveal trash icon per item in dropdown. | -| Search | Basic title filter in v1 | Search field at top of dropdown filters by title substring. No FTS5. | - -## Schema - -```sql -PRAGMA journal_mode = WAL; - -CREATE TABLE conversations ( - id TEXT PRIMARY KEY, -- UUID - title TEXT, -- AI-generated or placeholder - model TEXT NOT NULL, -- e.g. "llama3.2:3b" - created_at INTEGER NOT NULL, -- unix timestamp ms - updated_at INTEGER NOT NULL, -- unix timestamp ms - meta TEXT -- JSON blob for future extensibility -); - -CREATE TABLE messages ( - id TEXT PRIMARY KEY, -- UUID - conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - role TEXT NOT NULL, -- 'user' | 'assistant' - content TEXT NOT NULL, - quoted_text TEXT, -- optional context from host app - created_at INTEGER NOT NULL -- unix timestamp ms -); - -CREATE INDEX idx_messages_conversation ON messages(conversation_id, created_at); -CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC); -``` - -## Data Flow - -### Normal Usage (ephemeral — no save) - -1. User double-taps Command, types a question -2. Frontend calls `ask_ollama`, streaming works as today -3. Messages live in React state + Rust `ConversationHistory` -4. User activates Thuki again or closes overlay — conversation is gone - -### Save Flow - -1. User has a conversation worth keeping -2. Taps the save icon (bookmark) in the chat header -3. Frontend calls a new Tauri command `save_conversation` -4. Backend creates a `conversations` row + writes all current messages to `messages` table -5. Conversation ID is stored in frontend state — conversation is now "saved" -6. Background: fires Ollama request to generate a title, updates `conversations.title` on completion -7. Subsequent messages in this saved conversation auto-persist on each completed exchange - -### Loading a Past Conversation - -1. User clicks history icon in header -2. Dropdown opens, reads conversation list from SQLite (sorted by `updated_at DESC`) -3. Search field filters by title substring -4. User clicks a conversation -5. Frontend reads messages from SQLite via `load_conversation` command -6. Messages populate React state -7. Backend `ConversationHistory` is synced with loaded messages -8. User can continue the conversation — new messages auto-persist - -### Deleting a Conversation - -1. User hovers over a conversation in the dropdown -2. Trash icon appears -3. Click triggers `delete_conversation` command -4. Backend deletes conversation + cascading messages from SQLite -5. Dropdown refreshes - -## UI Spec - -### Save Button - -- Appears in the chat header area once `messages.length >= 2` (at least one exchange) -- Icon: bookmark outline (unsaved) / filled bookmark (saved) -- Position: right side of the chat header, near the existing controls -- Clicking toggles the conversation to "saved" state -- Visual feedback: icon fills in, brief animation - -### History Dropdown - -- **Trigger**: clock or hamburger icon next to the Thuki logo in the input bar header -- **Position**: drops down from the icon, left-aligned -- **Width**: ~260px -- **Max height**: ~360px with scroll -- **Contents** (top to bottom): - 1. Search input field with placeholder "Search conversations..." - 2. "+ New conversation" button (green accent) - 3. Scrollable list of saved conversations -- **Each conversation item**: - - Title (truncated with ellipsis) - - Relative timestamp ("2m", "1h", "Yesterday") - - Hover: reveals trash icon on the right -- **Empty state**: "No saved conversations yet" - -### Interaction States - -- **Fresh activation**: empty input bar, no conversation loaded, save button hidden -- **In conversation (unsaved)**: messages visible, save button shows (outline) -- **In conversation (saved)**: messages visible, save button filled, new messages auto-persist -- **Viewing history**: dropdown open over the chat, clicking outside closes it -- **Loading past conversation**: messages populate, save button shows filled, can continue chatting - -## New Tauri Commands - -| Command | Params | Returns | Description | -|---------|--------|---------|-------------| -| `save_conversation` | `messages: Vec`, `model: String` | `conversation_id: String` | Creates conversation + writes all messages | -| `persist_message` | `conversation_id: String`, `message: Message` | `()` | Appends a single message to a saved conversation | -| `list_conversations` | `search: Option` | `Vec` | Lists conversations, optional title filter | -| `load_conversation` | `conversation_id: String` | `Vec` | Reads all messages for a conversation | -| `delete_conversation` | `conversation_id: String` | `()` | Deletes conversation + cascading messages | -| `generate_title` | `conversation_id: String`, `messages: Vec` | `()` | Background: asks Ollama for title, updates DB | - -### ConversationSummary - -```rust -struct ConversationSummary { - id: String, - title: Option, - model: String, - updated_at: i64, - message_count: i64, -} -``` - -## Frontend Changes - -### New State in `useOllama` (or new hook) - -- `conversationId: string | null` — null when unsaved, set after save -- `isSaved: boolean` — drives save button appearance - -### New Hook: `useConversationHistory` - -- `conversations: ConversationSummary[]` — list for dropdown -- `searchQuery: string` — filter input -- `loadConversation(id: string)` — loads messages, syncs backend -- `deleteConversation(id: string)` — removes from DB + list -- `saveConversation()` — persists current messages -- `refreshConversations()` — re-reads from DB - -### New Components - -- `HistoryDropdown` — the popover with search + conversation list -- `SaveButton` — bookmark icon in chat header -- `ConversationItem` — single row in the dropdown list - -### Modified Components - -- `App.tsx` — integrates history dropdown trigger, save button, conversation loading -- `useOllama.ts` — adds `conversationId` tracking, auto-persist logic for saved conversations - -## Directory Structure (new files) - -``` -~/.thuki/ - thuki.db - -src/ - components/ - HistoryDropdown.tsx - SaveButton.tsx - ConversationItem.tsx - hooks/ - useConversationHistory.ts - -src-tauri/src/ - database.rs -- SQLite setup, migrations, queries - commands.rs -- new commands added here (or split to history_commands.rs) -``` - -## Out of Scope - -- Archive functionality -- Folders, tags, or pinning -- FTS5 full-text search (title substring filter only) -- Conversation branching or forking -- Export/import -- Auto-save logic or smart thresholds -- Conversation cap or auto-cleanup diff --git a/src/App.tsx b/src/App.tsx index 307629df..7da1a3e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,11 +6,16 @@ import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { LogicalSize } from '@tauri-apps/api/dpi'; import { useOllama } from './hooks/useOllama'; +import { useConversationHistory } from './hooks/useConversationHistory'; import { ConversationView } from './view/ConversationView'; import { AskBarView } from './view/AskBarView'; +import { HistoryPanel } from './components/HistoryPanel'; import { quote } from './config'; import './App.css'; +/** Ollama model used for this session — must match the Rust DEFAULT_MODEL_NAME. */ +const MODEL_NAME = 'llama3.2:3b'; + const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; /** @@ -51,6 +56,46 @@ type OverlayState = 'visible' | 'hidden' | 'hiding'; function App() { const [query, setQuery] = useState(''); const [overlayState, setOverlayState] = useState('hidden'); + + /** + * Whether the ask-bar history panel is currently open. + * Distinct from the chat-mode history dropdown (controlled by the same toggle + * but rendered differently based on `isChatMode`). + */ + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + + /** + * Direct reference to the morphing container DOM node, stored alongside the + * ResizeObserver so the dropdown sync effect can mutate `style.minHeight` + * without going through React state (direct DOM mutation + CSS transition). + */ + const morphingContainerNodeRef = useRef(null); + + const { + conversationId, + isSaved, + save, + persistTurn, + loadConversation, + deleteConversation, + listConversations, + reset: resetHistory, + } = useConversationHistory(); + + /** + * Persist a completed user/assistant turn to SQLite if the conversation + * has been saved. Passed as `onTurnComplete` to `useOllama`. + */ + const handleTurnComplete = useCallback( + async ( + userMsg: Parameters[0], + assistantMsg: Parameters[1], + ) => { + await persistTurn(userMsg, assistantMsg); + }, + [persistTurn], + ); + const { messages, streamingContent, @@ -59,7 +104,8 @@ function App() { isGenerating, error, reset, - } = useOllama(); + loadMessages, + } = useOllama(handleTurnComplete); const inputRef = useRef(null); @@ -84,6 +130,13 @@ function App() { * to chat-window mode are animated via Framer Motion `layout` prop. */ const isChatMode = messages.length > 0 || isGenerating; + + /** + * The bookmark save button is active once the AI has produced at least one + * complete response. We check for an assistant message rather than any message + * so the button never appears during the very first user-only half-turn. + */ + const canSave = messages.some((m) => m.role === 'assistant'); const shouldRenderOverlay = overlayState === 'visible'; /** @@ -117,6 +170,8 @@ function App() { * as the conversation grows. */ const setContainerRef = useCallback((node: HTMLDivElement | null) => { + morphingContainerNodeRef.current = node; + if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; @@ -190,10 +245,12 @@ function App() { setSessionId((id) => id + 1); setQuery(''); setSelectedContext(context); + setIsHistoryOpen(false); reset(); + resetHistory(); setOverlayState('visible'); }, - [reset], + [reset, resetHistory], ); /** @@ -212,6 +269,160 @@ function App() { }); }, []); + /** Ref attached to the chat-mode history dropdown for click-outside detection. */ + const historyDropdownRef = useRef(null); + + /** Toggles the history panel open/closed. */ + const handleHistoryToggle = useCallback(() => { + setIsHistoryOpen((prev) => !prev); + }, []); + + /** + * Close the chat-mode history dropdown when the user clicks outside it. + * Clicks on the toggle button itself are excluded so the button's own + * onClick handler (handleHistoryToggle) can manage the toggle normally. + */ + useEffect(() => { + if (!(isChatMode && isHistoryOpen)) return; + + const handleMouseDown = (e: MouseEvent) => { + const target = e.target as Element; + if ( + historyDropdownRef.current?.contains(target) || + target.closest?.('[data-history-toggle]') + ) { + return; + } + setIsHistoryOpen(false); + }; + + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, [isChatMode, isHistoryOpen]); + + /** + * Observes the dropdown's height while it's open and mutates the morphing + * container's `min-height` style directly (bypassing React state) so the + * native window grows exactly as tall as the dropdown needs. A CSS transition + * on the container drives the smooth resize; the existing ResizeObserver fires + * per-frame and calls `setSize()` as the transition runs. + * + * Direct DOM mutation avoids the React state → Framer Motion → ResizeObserver + * indirect chain that broke timing. ResizeObserver tracks async conversation + * list load so `min-height` stays accurate as content populates. + */ + useEffect(() => { + /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ + if (!isChatMode || !isHistoryOpen) { + if (morphingContainerNodeRef.current) { + morphingContainerNodeRef.current.style.minHeight = ''; + } + return; + } + + const dropdown = historyDropdownRef.current; + const container = morphingContainerNodeRef.current; + if (!dropdown || !container) return; + + const sync = () => { + container.style.minHeight = `${dropdown.offsetTop + dropdown.offsetHeight + 8}px`; + }; + + sync(); + const ro = new ResizeObserver(sync); + ro.observe(dropdown); + return () => ro.disconnect(); + /* v8 ignore stop */ + }, [isChatMode, isHistoryOpen]); + + /** Saves the current conversation to SQLite. */ + const handleSave = useCallback(async () => { + try { + await save(messages, MODEL_NAME); + } catch { + // Save failed — bookmark state stays unchanged; the error is surfaced by + // the Tauri runtime. No UI banner here; save is a user-initiated fire-and- + // forget action with visible feedback via the bookmark icon state. + } + }, [save, messages]); + + /** + * Loads a conversation from history, replacing the current session. + * + * Closes the history panel regardless of success or failure: on success the + * loaded messages replace the current session; on failure the current session + * is preserved and the panel is dismissed so the user is not left in a + * half-open state. + */ + const handleLoadConversation = useCallback( + async (id: string) => { + try { + const loaded = await loadConversation(id); + loadMessages(loaded); + } catch { + // Load failed — current session is preserved intact. + } finally { + setIsHistoryOpen(false); + } + }, + [loadConversation, loadMessages], + ); + + /** + * Saves the current unsaved session then loads the requested conversation. + * + * If save fails the operation is aborted — we do not load the target + * conversation because the current session has not been persisted yet. + * If save succeeds but load fails the panel is still dismissed; the + * current session has been saved so no data is lost. + */ + const handleSaveAndLoad = useCallback( + async (id: string) => { + try { + await save(messages, MODEL_NAME); + } catch { + // Save failed — abort to avoid leaving the current session unprotected. + return; + } + try { + const loaded = await loadConversation(id); + loadMessages(loaded); + } catch { + // Load failed — save already committed; dismiss panel, keep current view. + } finally { + setIsHistoryOpen(false); + } + }, + [save, messages, loadConversation, loadMessages], + ); + + /** + * Deletes a conversation from the history panel. + * + * When the deleted conversation is the currently active one, both the + * message history (`reset`) and the persistence state (`resetHistory`) are + * cleared so the UI returns to the blank ask-bar state. The error is + * re-thrown so `HistoryPanel` can roll back its optimistic removal. + */ + const handleDeleteConversation = useCallback( + async (id: string) => { + await deleteConversation(id); + if (id === conversationId) { + reset(); + resetHistory(); + } + }, + [deleteConversation, conversationId, reset, resetHistory], + ); + + /** Starts a fresh conversation from within conversation view. */ + const handleNewConversation = useCallback(() => { + reset(); + resetHistory(); + setIsHistoryOpen(false); + setQuery(''); + }, [reset, resetHistory]); + const handleSubmit = useCallback(() => { if (query.trim().length === 0 || isGenerating) return; // Sanitize externally-sourced context: strip control characters and enforce @@ -377,40 +588,126 @@ function App() { transition={{ type: 'spring', stiffness: 260, damping: 24 }} className="w-full max-w-2xl px-4 py-2 overflow-visible" > - {/* Morphing Container — flex column ensures the input bar - always sticks to the bottom without spring animation lag */} -
- {/* Chat Messages Area — morphs in when in chat mode */} + {/* Relative wrapper — serves as the positioning context for the + chat-mode history dropdown so it can sit outside the morphing + container's overflow-hidden boundary without being clipped. */} +
+ {/* Morphing Container — flex column ensures the input bar + always sticks to the bottom without spring animation lag. + A CSS `transition: min-height` drives smooth window growth + when the chat-mode history dropdown is open; the existing + ResizeObserver fires per-frame and calls setSize() so the + native window tracks the animation. The dropdown is a sibling + (not a child) so overflow-hidden never clips it. */} +
+ {/* Chat Messages Area — morphs in when in chat mode */} + + {isChatMode ? ( + + ) : null} + + + {/* Ask-bar mode history panel — inline below the input bar. + The !isChatMode gate lives OUTSIDE AnimatePresence so that when + a conversation is loaded (isChatMode → true) the panel unmounts + instantly — no exit animation runs alongside ConversationView + mounting. Without this, AnimatePresence would hold the panel in + the DOM during its exit while ConversationView is also present, + causing two rapid ResizeObserver → setSize() calls (jitter). + AnimatePresence is still used for the manual toggle (isHistoryOpen) + so the drawer height-animates smoothly open and closed. */} + {!isChatMode && ( + + {isHistoryOpen ? ( + + + + ) : null} + + )} + + {/* Input Bar — always pinned to the bottom */} + +
+ + {/* Chat-mode history dropdown — sibling of the morphing container so + it is never clipped by its overflow-hidden. Positioned absolutely + within this relative wrapper (same coordinate space as the + container). The container's minHeight animation grows the native + window tall enough to reveal the full dropdown. */} - {isChatMode ? ( - + {isChatMode && isHistoryOpen ? ( + + 0 && !isSaved} + currentConversationId={conversationId} + showNewConversation={true} + onNewConversation={handleNewConversation} + /> + ) : null} - - {/* Input Bar — always pinned to the bottom */} -
) : null} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 837553dd..96549bcd 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -5,6 +5,7 @@ import { invoke, emitTauriEvent, enableChannelCapture, + enableChannelCaptureWithResponses, getLastChannel, } from '../testUtils/mocks/tauri'; import { __mockWindow } from '../testUtils/mocks/tauri-window'; @@ -624,6 +625,692 @@ describe('App', () => { expect(document.querySelector('.morphing-container')).toBeNull(); }); + // ─── History integration ───────────────────────────────────────────────────── + + describe('history integration', () => { + it('shows history icon button in ask-bar mode', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + expect( + screen.getByRole('button', { name: /open history/i }), + ).toBeInTheDocument(); + }); + + it('shows history panel when history icon is clicked in ask-bar mode', async () => { + invoke.mockResolvedValue([]); // list_conversations returns empty + + render(); + await act(async () => {}); + await showOverlay(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + + expect( + screen.getByPlaceholderText(/search past chats/i), + ).toBeInTheDocument(); + }); + + it('closes history panel when a conversation is loaded', async () => { + invoke.mockResolvedValueOnce([]); // list_conversations + invoke.mockResolvedValueOnce([ + // load_conversation + { + id: 'm1', + role: 'user', + content: 'Hello', + quoted_text: null, + created_at: 1, + }, + { + id: 'm2', + role: 'assistant', + content: 'Hi', + quoted_text: null, + created_at: 2, + }, + ]); + + render(); + await act(async () => {}); + await showOverlay(); + + // Open history + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + + // Wait for empty list to render + await act(async () => {}); + + // Panel should be visible but no conversations to click + // (list is empty, so just verify panel closes on a second click) + // Close via second click on history icon + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + + expect(screen.queryByPlaceholderText(/search past chats/i)).toBeNull(); + }); + + it('shows save button in conversation view when there are messages', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'test' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'Reply' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('save button calls save_conversation when clicked', async () => { + enableChannelCaptureWithResponses({ + save_conversation: { conversation_id: 'conv-test' }, + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'question' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'answer' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /save conversation/i }), + ); + }); + + expect(invoke).toHaveBeenCalledWith( + 'save_conversation', + expect.objectContaining({ + model: expect.any(String), + messages: expect.any(Array), + }), + ); + }); + + it('resets history state on overlay reopen', async () => { + enableChannelCaptureWithResponses({ + save_conversation: { conversation_id: 'conv-123' }, + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Send message + Done + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'hello' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'Hi' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Save + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /save conversation/i }), + ); + }); + + // Reopen — bookmark should reset (save button enabled again) + enableChannelCapture(); + await showOverlay(); + + // In ask-bar mode now — no save button visible, but history icon is + expect( + screen.getByRole('button', { name: /open history/i }), + ).toBeInTheDocument(); + }); + + it('handleNewConversation resets to ask-bar mode', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Get into chat mode + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'question' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'answer' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Open history dropdown in chat mode + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /history/i })); + }); + + // Click "+ New conversation" + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /new conversation/i }), + ); + }); + + // Should be back in ask-bar mode (no chat bubbles) + expect( + screen.getByPlaceholderText('Ask Thuki anything...'), + ).toBeInTheDocument(); + }); + + it('handleSaveAndLoad saves unsaved conversation then switches', async () => { + const OTHER_MSGS = [ + { + id: 'm3', + role: 'user', + content: 'Old q', + quoted_text: null, + created_at: 1, + }, + { + id: 'm4', + role: 'assistant', + content: 'Old a', + quoted_text: null, + created_at: 2, + }, + ]; + enableChannelCaptureWithResponses({ + save_conversation: { conversation_id: 'conv-new' }, + load_conversation: OTHER_MSGS, + list_conversations: [ + { + id: 'conv-other2', + title: 'Other chat', + model: 'llama3.2:3b', + updated_at: 1, + message_count: 2, + }, + ], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Complete a turn (unsaved) + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'q' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'a' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Open chat history WITHOUT saving + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /history/i })); + }); + + // Click a different conversation → SwitchConfirmation + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /other chat/i })); + }); + + // Save & Switch — isSaved is FALSE so save_conversation should be called + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save & switch/i })); + }); + + expect(invoke).toHaveBeenCalledWith( + 'save_conversation', + expect.objectContaining({ + model: expect.any(String), + }), + ); + }); + + it('handleSaveAndLoad aborts load when save_conversation fails', async () => { + // Bug: without the early return on save failure, the load would still run + // and could overwrite the current session with an unrelated conversation. + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_conversations') + return [ + { + id: 'c2', + title: 'Other chat', + model: 'llama3.2:3b', + updated_at: 1, + message_count: 1, + }, + ]; + if (cmd === 'save_conversation') throw new Error('disk full'); + // load_conversation must NOT be called + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Complete a turn so isSaved = false + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'q' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'a' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Open history → click another conversation → SwitchConfirmation + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /other chat/i })); + }); + + // Confirm "Save & Switch" — save_conversation will throw + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save & switch/i })); + }); + + // load_conversation must NOT have been called (early return after save failure) + expect(invoke).not.toHaveBeenCalledWith( + 'load_conversation', + expect.anything(), + ); + }); + + it('clicking a conversation loads it directly when already saved (no dialog)', async () => { + const OTHER_MSGS = [ + { + id: 'm3', + role: 'user', + content: 'Old q', + quoted_text: null, + created_at: 1, + }, + { + id: 'm4', + role: 'assistant', + content: 'Old a', + quoted_text: null, + created_at: 2, + }, + ]; + enableChannelCaptureWithResponses({ + save_conversation: { conversation_id: 'conv-current' }, + load_conversation: OTHER_MSGS, + list_conversations: [ + { + id: 'conv-other', + title: 'Switch target', + model: 'llama3.2:3b', + updated_at: 1, + message_count: 2, + }, + ], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Complete a turn + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'q' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'a' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Save the conversation → isSaved = true + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /save conversation/i }), + ); + }); + + // Open chat history + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /history/i })); + }); + + // Click a different conversation — isSaved=true means no dialog, loads directly + invoke.mockClear(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /switch target/i })); + }); + + // No SwitchConfirmation dialog — save_conversation NOT called again + expect(invoke).not.toHaveBeenCalledWith( + 'save_conversation', + expect.anything(), + ); + // load_conversation IS called directly + expect(invoke).toHaveBeenCalledWith('load_conversation', { + conversationId: 'conv-other', + }); + }); + + it('handleDeleteConversation resets history when current conversation is deleted', async () => { + const LOADED_MSGS = [ + { + id: 'm1', + role: 'user', + content: 'Hi', + quoted_text: null, + created_at: 1, + }, + { + id: 'm2', + role: 'assistant', + content: 'Hello', + quoted_text: null, + created_at: 2, + }, + ]; + enableChannelCaptureWithResponses({ + load_conversation: LOADED_MSGS, + list_conversations: [ + { + id: 'conv-target', + title: 'My chat', + model: 'llama3.2:3b', + updated_at: 1, + message_count: 2, + }, + ], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Load a conversation from ask-bar history → conversationId = 'conv-target' + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /my chat/i })); + }); + + // In chat mode; open chat history + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + + // Delete the same conversation that is currently loaded (id matches conversationId) + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete conversation/i }), + ); + }); + + // delete_conversation was called with the matching id + expect(invoke).toHaveBeenCalledWith('delete_conversation', { + conversationId: 'conv-target', + }); + }); + + it('clicking outside the chat history dropdown closes it', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Complete a turn to enter chat mode + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'q' } }); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'a' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Open history dropdown + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + expect( + screen.getByPlaceholderText('Search past chats…'), + ).toBeInTheDocument(); + + // Click outside — should close the dropdown + await act(async () => { + fireEvent.mouseDown(document.body); + }); + expect(screen.queryByPlaceholderText('Search past chats…')).toBeNull(); + }); + + it('clicking inside the chat history dropdown does not close it', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Complete a turn to enter chat mode + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'q' } }); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'a' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Open history dropdown + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + + const searchInput = screen.getByPlaceholderText('Search past chats…'); + expect(searchInput).toBeInTheDocument(); + + // Click inside the dropdown — should NOT close it + await act(async () => { + fireEvent.mouseDown(searchInput); + }); + expect( + screen.getByPlaceholderText('Search past chats…'), + ).toBeInTheDocument(); + }); + + it('handleDeleteConversation clears messages when the active conversation is deleted', async () => { + // Bug: resetHistory() clears conversationId but not messages — the chat + // view remains populated after the active conversation is deleted. + enableChannelCaptureWithResponses({ + load_conversation: [ + { + id: 'm1', + role: 'user', + content: 'Hi', + quoted_text: null, + created_at: 1, + }, + { + id: 'm2', + role: 'assistant', + content: 'Hello', + quoted_text: null, + created_at: 2, + }, + ], + list_conversations: [ + { + id: 'conv-active', + title: 'Active chat', + model: 'llama3.2:3b', + updated_at: 1, + message_count: 2, + }, + ], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Load the conversation from ask-bar history → enters chat mode with messages + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /active chat/i })); + }); + + expect(screen.getByText('Hi')).toBeInTheDocument(); + + // Re-open history in chat mode and delete the active conversation + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete conversation/i }), + ); + }); + + // Messages must be gone — UI returns to ask-bar mode + expect(screen.queryByText('Hi')).toBeNull(); + expect( + screen.getByPlaceholderText('Ask Thuki anything...'), + ).toBeInTheDocument(); + }); + + it('handleLoadConversation closes history panel when load_conversation fails', async () => { + // Bug: without try/catch, setIsHistoryOpen(false) is never reached when + // loadConversation() throws, leaving the panel open on failure. + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_conversations') + return [ + { + id: 'c1', + title: 'Chat', + model: 'llama3.2:3b', + updated_at: 1, + message_count: 1, + }, + ]; + if (cmd === 'load_conversation') throw new Error('load failed'); + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + + // Click the conversation — load_conversation will throw + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^chat$/i })); + }); + + // Panel must close even on failure; app must still be running + expect(screen.queryByPlaceholderText(/search past chats/i)).toBeNull(); + expect( + screen.getByPlaceholderText('Ask Thuki anything...'), + ).toBeInTheDocument(); + }); + + it('handleDeleteConversation does not reset history when a different conversation is deleted', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [ + { + id: 'conv-unrelated', + title: 'Unrelated', + model: 'llama3.2:3b', + updated_at: 1, + message_count: 2, + }, + ], + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Open ask-bar history (no conversation loaded — conversationId is null) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /open history/i })); + }); + + // Delete a conversation while conversationId is null (id !== conversationId → false branch) + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete conversation/i }), + ); + }); + + expect(invoke).toHaveBeenCalledWith('delete_conversation', { + conversationId: 'conv-unrelated', + }); + }); + }); + it('resets session on overlay reopen', async () => { render(); await act(async () => {}); diff --git a/src/components/ConversationItem.tsx b/src/components/ConversationItem.tsx new file mode 100644 index 00000000..680adfaa --- /dev/null +++ b/src/components/ConversationItem.tsx @@ -0,0 +1,71 @@ +import { memo } from 'react'; +import type { ConversationSummary } from '../types/history'; + +/** Hoisted static delete icon — avoids re-allocation on every render. */ +const DELETE_ICON = ( + +); + +interface ConversationItemProps { + /** The conversation summary to render. */ + conversation: ConversationSummary; + /** Called with the conversation id when the row is clicked. */ + onSelect: (id: string) => void; + /** Called with the conversation id when the delete button is clicked. */ + onDelete: (id: string) => void; +} + +/** + * Renders a single conversation row in the history panel. + * + * Displays the conversation title (falling back to "Untitled"), message + * count, and a delete button revealed on hover. The entire row is a button + * for keyboard accessibility. + */ +export const ConversationItem = memo(function ConversationItem({ + conversation, + onSelect, + onDelete, +}: ConversationItemProps) { + const title = conversation.title ?? 'Untitled'; + + return ( +
+ + + +
+ ); +}); diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx new file mode 100644 index 00000000..4211181b --- /dev/null +++ b/src/components/HistoryPanel.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { ConversationItem } from './ConversationItem'; +import { SwitchConfirmation } from './SwitchConfirmation'; +import type { ConversationSummary } from '../types/history'; + +/** Debounce delay in ms before firing a search query. */ +const SEARCH_DEBOUNCE_MS = 200; + +/** + * Groups a flat list of conversations into date buckets for display. + * Returns an ordered array of `[label, items]` pairs. + */ +function groupByDate( + conversations: ConversationSummary[], +): [string, ConversationSummary[]][] { + const nowSec = Math.floor(Date.now() / 1000); + const DAY = 86400; + + const todayStart = nowSec - (nowSec % DAY); + const yesterdayStart = todayStart - DAY; + + const buckets = new Map(); + + for (const conv of conversations) { + let label: string; + if (conv.updated_at >= todayStart) { + label = 'Today'; + } else if (conv.updated_at >= yesterdayStart) { + label = 'Yesterday'; + } else { + label = 'Earlier'; + } + + const existing = buckets.get(label); + if (existing) { + existing.push(conv); + } else { + buckets.set(label, [conv]); + } + } + + return Array.from(buckets.entries()); +} + +interface HistoryPanelProps { + /** + * Called to fetch the conversation list, optionally filtered by a search + * term. Must return a promise resolving to `ConversationSummary[]`. + */ + listConversations: (search?: string) => Promise; + /** + * Called when the user selects a conversation and either has no current + * messages, or confirmed "Just Switch". + */ + onLoadConversation: (id: string) => void; + /** + * Called when the user confirms "Save & Switch" from the switch prompt. + */ + onSaveAndLoad: (id: string) => void; + /** Called when the user clicks the delete button on a row. */ + onDeleteConversation: (id: string) => Promise; + /** + * True when the current session has unsaved messages. Causes a + * `SwitchConfirmation` to appear before loading. + */ + hasCurrentMessages: boolean; + /** + * The id of the conversation currently loaded. When the user clicks the row + * matching this id, the action is a no-op (already viewing it). + */ + currentConversationId?: string | null; + /** + * When true, renders a "+ New conversation" footer button. Pass `false` + * in ask-bar mode (the input itself starts a new conversation). + */ + showNewConversation: boolean; + /** Called when the user clicks "+ New conversation". */ + onNewConversation?: () => void; +} + +/** + * Search-first conversation history panel, shared by ask-bar mode (inline) + * and conversation-view mode (dropdown). + * + * - Fetches and groups conversations by date on mount. + * - Debounces search input at 200 ms. + * - Shows a `SwitchConfirmation` prompt before loading when the user has an + * active session (`hasCurrentMessages`). + * - Optimistically removes deleted conversations from the list. + * - Conditionally renders a "+ New conversation" footer via `showNewConversation`. + */ +export function HistoryPanel({ + listConversations, + onLoadConversation, + onSaveAndLoad, + onDeleteConversation, + hasCurrentMessages, + currentConversationId, + showNewConversation, + onNewConversation, +}: HistoryPanelProps) { + const [conversations, setConversations] = useState([]); + const [search, setSearch] = useState(''); + const [loadError, setLoadError] = useState(false); + /** Id of the conversation the user clicked when confirmation is needed. */ + const [pendingId, setPendingId] = useState(null); + + const debounceRef = useRef | null>(null); + + /** Fetches (or re-fetches) the conversation list with an optional search term. */ + const fetchList = useCallback( + async (term?: string) => { + setLoadError(false); + try { + const results = await listConversations(term); + setConversations(results); + } catch { + setLoadError(true); + } + }, + [listConversations], + ); + + // Initial load on mount. + useEffect(() => { + void fetchList(); + }, [fetchList]); + + // Debounced search: fires 200 ms after the user stops typing. + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setSearch(value); + + if (debounceRef.current !== null) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + void fetchList(value || undefined); + }, SEARCH_DEBOUNCE_MS); + }, + [fetchList], + ); + + // Cleanup debounce timer on unmount. + useEffect(() => { + return () => { + if (debounceRef.current !== null) { + clearTimeout(debounceRef.current); + } + }; + }, []); + + const handleSelect = useCallback( + (id: string) => { + if (id === currentConversationId) { + return; + } + if (hasCurrentMessages) { + setPendingId(id); + } else { + onLoadConversation(id); + } + }, + [hasCurrentMessages, onLoadConversation, currentConversationId], + ); + + const handleSaveAndSwitch = useCallback(() => { + /* v8 ignore start -- SwitchConfirmation only renders when pendingId !== null */ + if (pendingId !== null) { + onSaveAndLoad(pendingId); + setPendingId(null); + } + /* v8 ignore stop */ + }, [pendingId, onSaveAndLoad]); + + const handleJustSwitch = useCallback(() => { + /* v8 ignore start -- SwitchConfirmation only renders when pendingId !== null */ + if (pendingId !== null) { + onLoadConversation(pendingId); + setPendingId(null); + } + /* v8 ignore stop */ + }, [pendingId, onLoadConversation]); + + const handleCancelSwitch = useCallback(() => { + setPendingId(null); + }, []); + + const handleDelete = useCallback( + async (id: string) => { + // Capture snapshot for rollback before optimistic removal. + // find() always returns a match (called via ConversationItem on a known id). + // The ?? null and the snapshot !== null guard are defensive only. + /* v8 ignore start */ + const snapshot = conversations.find((c) => c.id === id) ?? null; + /* v8 ignore stop */ + setConversations((prev) => prev.filter((c) => c.id !== id)); + try { + await onDeleteConversation(id); + } catch { + // Backend rejected — restore the item in its original sort position. + /* v8 ignore start */ + if (snapshot !== null) { + setConversations((prev) => + // Item was just removed optimistically; can't already be present. + prev.some((c) => c.id === id) + ? prev + : [...prev, snapshot].sort((a, b) => b.updated_at - a.updated_at), + ); + } + /* v8 ignore stop */ + } + }, + [onDeleteConversation, conversations], + ); + + const groups = groupByDate(conversations); + const isEmpty = conversations.length === 0 && !loadError; + + return ( +
+ {/* Search input — always visible, auto-focused via CSS autofocus attribute */} +
+ +
+ + {/* Switch confirmation — overlays the list when pending */} + {pendingId !== null ? ( + + ) : ( +
+ {loadError && ( +

+ Couldn't load history — try again. +

+ )} + + {isEmpty && !loadError && ( +

+ No conversations yet. +

+ )} + + {groups.map(([label, items]) => ( +
+

+ {label} +

+ {items.map((conv) => ( + + ))} +
+ ))} +
+ )} + + {/* Optional footer — only shown in conversation-view mode */} + {showNewConversation && pendingId === null && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/SwitchConfirmation.tsx b/src/components/SwitchConfirmation.tsx new file mode 100644 index 00000000..026d6abe --- /dev/null +++ b/src/components/SwitchConfirmation.tsx @@ -0,0 +1,61 @@ +import { memo } from 'react'; + +interface SwitchConfirmationProps { + /** Called when the user wants to save the current session then load the new one. */ + onSaveAndSwitch: () => void; + /** Called when the user wants to discard the current session and load the new one. */ + onJustSwitch: () => void; + /** Called when the user wants to go back without switching. */ + onCancel: () => void; +} + +/** + * Inline confirmation prompt displayed inside the history panel when the user + * selects a conversation while an unsaved (or saved) session is active. + * + * Presents two primary actions: + * - **Save & Switch** — persists the current conversation before loading. + * - **Just Switch** — discards the current conversation and loads immediately. + * + * A **Cancel** action returns the user to the history list. + */ +export const SwitchConfirmation = memo(function SwitchConfirmation({ + onSaveAndSwitch, + onJustSwitch, + onCancel, +}: SwitchConfirmationProps) { + return ( +
+

+ Switch conversations? +

+ +
+ + + + + +
+
+ ); +}); diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index 2246c092..0692ca1a 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -14,9 +14,80 @@ import { memo } from 'react'; +/** Hoisted bookmark icon — save/saved state toggled via fill class. */ +const BOOKMARK_ICON_EMPTY = ( + +); + +const BOOKMARK_ICON_FILLED = ( + +); + +/** Hoisted history (clock) icon. */ +const HISTORY_ICON = ( + +); + interface WindowControlsProps { /** Triggers the overlay hide animation sequence. */ onClose: () => void; + /** + * Called when the user clicks the bookmark (save) icon. + * Omit to hide the save button entirely. + */ + onSave?: () => void; + /** + * True when the conversation has been saved to SQLite. + * Renders the bookmark in its filled/confirmed state and disables the button. + */ + isSaved?: boolean; + /** + * True when there is at least one completed AI response to save. + * When false, the save button is disabled. + */ + canSave?: boolean; + /** + * Called when the user clicks the "History ▾" button. + * Omit to hide the history button entirely. + */ + onHistoryOpen?: () => void; } /** Decorative dot color for inactive buttons. */ @@ -24,7 +95,13 @@ const INACTIVE_DOT = 'rgba(255, 255, 255, 0.12)'; export const WindowControls = memo(function WindowControls({ onClose, + onSave, + isSaved = false, + canSave = false, + onHistoryOpen, }: WindowControlsProps) { + const saveDisabled = isSaved || !canSave; + return (
@@ -64,6 +141,40 @@ export const WindowControls = memo(function WindowControls({ style={{ backgroundColor: INACTIVE_DOT }} aria-hidden="true" /> + + {/* Right-side header controls — save bookmark + history dropdown */} +
+ {onSave !== undefined && ( + + )} + + {onHistoryOpen !== undefined && ( + + )} +
{/* Divider between controls and chat area */} diff --git a/src/components/__tests__/ConversationItem.test.tsx b/src/components/__tests__/ConversationItem.test.tsx new file mode 100644 index 00000000..55bf13e8 --- /dev/null +++ b/src/components/__tests__/ConversationItem.test.tsx @@ -0,0 +1,88 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ConversationItem } from '../ConversationItem'; +import type { ConversationSummary } from '../../types/history'; + +const SUMMARY: ConversationSummary = { + id: 'conv-1', + title: 'How does React work?', + model: 'llama3.2:3b', + updated_at: Math.floor(Date.now() / 1000), + message_count: 6, +}; + +describe('ConversationItem', () => { + it('renders the conversation title', () => { + render( + , + ); + expect(screen.getByText('How does React work?')).toBeInTheDocument(); + }); + + it('renders "Untitled" when title is null', () => { + render( + , + ); + expect(screen.getByText('Untitled')).toBeInTheDocument(); + }); + + it('renders message count', () => { + render( + , + ); + expect(screen.getByText(/6 msgs/)).toBeInTheDocument(); + }); + + it('calls onSelect with conversation id when clicked', () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click( + screen.getByRole('button', { name: /how does react work/i }), + ); + expect(onSelect).toHaveBeenCalledWith('conv-1'); + }); + + it('calls onDelete with conversation id when delete button is clicked', () => { + const onDelete = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /delete/i })); + expect(onDelete).toHaveBeenCalledWith('conv-1'); + }); + + it('does not call onSelect when delete button is clicked', () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /delete/i })); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/HistoryPanel.test.tsx b/src/components/__tests__/HistoryPanel.test.tsx new file mode 100644 index 00000000..c019d8be --- /dev/null +++ b/src/components/__tests__/HistoryPanel.test.tsx @@ -0,0 +1,330 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { HistoryPanel } from '../HistoryPanel'; +import type { ConversationSummary } from '../../types/history'; + +const NOW = Math.floor(Date.now() / 1000); +const YESTERDAY = NOW - 86400; +const OLDER = NOW - 86400 * 3; + +const CONVERSATIONS: ConversationSummary[] = [ + { + id: 'c1', + title: 'React basics', + model: 'llama3.2:3b', + updated_at: NOW, + message_count: 4, + }, + { + id: 'c2', + title: 'Python bug fix', + model: 'llama3.2:3b', + updated_at: YESTERDAY, + message_count: 6, + }, + { + id: 'c3', + title: 'Old topic', + model: 'llama3.2:3b', + updated_at: OLDER, + message_count: 2, + }, +]; + +function makeProps( + overrides: Partial[0]> = {}, +) { + return { + listConversations: vi.fn(async () => CONVERSATIONS), + onLoadConversation: vi.fn(), + onSaveAndLoad: vi.fn(), + onDeleteConversation: vi.fn(), + hasCurrentMessages: false, + showNewConversation: false, + onNewConversation: vi.fn(), + ...overrides, + }; +} + +describe('HistoryPanel', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders a search input focused on mount', async () => { + const props = makeProps(); + render(); + + await act(async () => {}); + + const input = screen.getByPlaceholderText(/search/i); + expect(input).toBeInTheDocument(); + }); + + it('fetches conversations on mount', async () => { + const props = makeProps(); + render(); + + await act(async () => {}); + + expect(props.listConversations).toHaveBeenCalledWith(undefined); + expect(screen.getByText('React basics')).toBeInTheDocument(); + }); + + it('groups conversations by date: Today and Yesterday labels appear', async () => { + const props = makeProps(); + render(); + + await act(async () => {}); + + expect(screen.getByText('Today')).toBeInTheDocument(); + expect(screen.getByText('Yesterday')).toBeInTheDocument(); + }); + + it('shows "No conversations yet" when list is empty', async () => { + const props = makeProps({ listConversations: vi.fn(async () => []) }); + render(); + + await act(async () => {}); + + expect(screen.getByText(/no conversations yet/i)).toBeInTheDocument(); + }); + + it('filters conversations by search with debounce', async () => { + const listFn = vi.fn(async () => CONVERSATIONS); + const props = makeProps({ listConversations: listFn }); + render(); + + await act(async () => {}); + listFn.mockClear(); + + const input = screen.getByPlaceholderText(/search/i); + fireEvent.change(input, { target: { value: 'react' } }); + + // debounce not yet fired + expect(listFn).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(listFn).toHaveBeenCalledWith('react'); + }); + + it('passes undefined (not empty string) to listConversations when search is cleared', async () => { + const listFn = vi.fn(async () => CONVERSATIONS); + const props = makeProps({ listConversations: listFn }); + render(); + + await act(async () => {}); + listFn.mockClear(); + + const input = screen.getByPlaceholderText(/search/i); + + // Type something, then clear it + fireEvent.change(input, { target: { value: 'react' } }); + fireEvent.change(input, { target: { value: '' } }); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + // Empty string maps to `undefined` so listFn receives no search arg + expect(listFn).toHaveBeenLastCalledWith(undefined); + }); + + it('calls onLoadConversation when no current messages', async () => { + const props = makeProps({ hasCurrentMessages: false }); + render(); + + await act(async () => {}); + + fireEvent.click(screen.getByRole('button', { name: /react basics/i })); + expect(props.onLoadConversation).toHaveBeenCalledWith('c1'); + }); + + it('shows SwitchConfirmation when hasCurrentMessages is true and item clicked', async () => { + const props = makeProps({ hasCurrentMessages: true }); + render(); + + await act(async () => {}); + + fireEvent.click(screen.getByRole('button', { name: /react basics/i })); + + expect(screen.getByText(/switch conversations/i)).toBeInTheDocument(); + expect(props.onLoadConversation).not.toHaveBeenCalled(); + }); + + it('calls onSaveAndLoad from SwitchConfirmation Save & Switch', async () => { + const props = makeProps({ hasCurrentMessages: true }); + render(); + + await act(async () => {}); + + fireEvent.click(screen.getByRole('button', { name: /react basics/i })); + fireEvent.click(screen.getByRole('button', { name: /save & switch/i })); + + expect(props.onSaveAndLoad).toHaveBeenCalledWith('c1'); + }); + + it('calls onLoadConversation from SwitchConfirmation Just Switch', async () => { + const props = makeProps({ hasCurrentMessages: true }); + render(); + + await act(async () => {}); + + fireEvent.click(screen.getByRole('button', { name: /react basics/i })); + fireEvent.click(screen.getByRole('button', { name: /just switch/i })); + + expect(props.onLoadConversation).toHaveBeenCalledWith('c1'); + }); + + it('dismisses SwitchConfirmation when Cancel is clicked', async () => { + const props = makeProps({ hasCurrentMessages: true }); + render(); + + await act(async () => {}); + + fireEvent.click(screen.getByRole('button', { name: /react basics/i })); + expect(screen.getByText(/switch conversations/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(screen.queryByText(/switch conversations/i)).toBeNull(); + }); + + it('calls onDeleteConversation when delete is clicked', async () => { + const props = makeProps(); + render(); + + await act(async () => {}); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + fireEvent.click(deleteButtons[0]); + + expect(props.onDeleteConversation).toHaveBeenCalledWith('c1'); + }); + + it('removes the deleted conversation from the list optimistically', async () => { + const props = makeProps(); + render(); + + await act(async () => {}); + expect(screen.getByText('React basics')).toBeInTheDocument(); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + await act(async () => { + fireEvent.click(deleteButtons[0]); + }); + + expect(screen.queryByText('React basics')).toBeNull(); + }); + + it('hides New Conversation footer when showNewConversation is false', async () => { + const props = makeProps({ showNewConversation: false }); + render(); + + await act(async () => {}); + + expect( + screen.queryByRole('button', { name: /new conversation/i }), + ).toBeNull(); + }); + + it('shows New Conversation footer when showNewConversation is true', async () => { + const props = makeProps({ showNewConversation: true }); + render(); + + await act(async () => {}); + + expect( + screen.getByRole('button', { name: /new conversation/i }), + ).toBeInTheDocument(); + }); + + it('calls onNewConversation when footer button is clicked', async () => { + const props = makeProps({ showNewConversation: true }); + render(); + + await act(async () => {}); + + fireEvent.click(screen.getByRole('button', { name: /new conversation/i })); + expect(props.onNewConversation).toHaveBeenCalledOnce(); + }); + + it('debounce: rapid successive keystrokes cancel previous timer and fire once', async () => { + const listFn = vi.fn(async () => CONVERSATIONS); + const props = makeProps({ listConversations: listFn }); + render(); + + await act(async () => {}); + listFn.mockClear(); + + const input = screen.getByPlaceholderText(/search/i); + + // First keystroke starts a debounce timer + fireEvent.change(input, { target: { value: 'r' } }); + // Second keystroke before debounce fires — clears the first timer (line 130) + fireEvent.change(input, { target: { value: 're' } }); + + // Only after debounce delay should listFn be called — once, with 're' + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(listFn).toHaveBeenCalledTimes(1); + expect(listFn).toHaveBeenCalledWith('re'); + }); + + it('does not call onLoadConversation when clicking the current conversation', async () => { + const props = makeProps({ + hasCurrentMessages: false, + currentConversationId: 'c1', + }); + render(); + + await act(async () => {}); + + fireEvent.click(screen.getByRole('button', { name: /react basics/i })); + expect(props.onLoadConversation).not.toHaveBeenCalled(); + }); + + it('restores deleted conversation when onDeleteConversation rejects', async () => { + // Bug: optimistic removal has no rollback — if the backend delete fails the + // item disappears from the UI but still exists in SQLite, reappearing on next open. + const props = makeProps({ + onDeleteConversation: vi.fn(async () => { + throw new Error('delete failed'); + }), + }); + render(); + + await act(async () => {}); + + expect(screen.getByText('React basics')).toBeInTheDocument(); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + await act(async () => { + fireEvent.click(deleteButtons[0]); + }); + + // After the backend rejects, the conversation must be restored to the list + expect(screen.getByText('React basics')).toBeInTheDocument(); + }); + + it('shows error message when listConversations rejects', async () => { + const props = makeProps({ + listConversations: vi.fn(async () => { + throw new Error('DB error'); + }), + }); + render(); + + await act(async () => {}); + + expect(screen.getByText(/couldn't load history/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/SwitchConfirmation.test.tsx b/src/components/__tests__/SwitchConfirmation.test.tsx new file mode 100644 index 00000000..2eca8fd9 --- /dev/null +++ b/src/components/__tests__/SwitchConfirmation.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { SwitchConfirmation } from '../SwitchConfirmation'; + +describe('SwitchConfirmation', () => { + it('renders the confirmation prompt text', () => { + render( + , + ); + expect(screen.getByText(/switch conversations/i)).toBeInTheDocument(); + }); + + it('renders Save & Switch button', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: /save & switch/i }), + ).toBeInTheDocument(); + }); + + it('renders Just Switch button', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: /just switch/i }), + ).toBeInTheDocument(); + }); + + it('calls onSaveAndSwitch when Save & Switch is clicked', () => { + const onSaveAndSwitch = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /save & switch/i })); + expect(onSaveAndSwitch).toHaveBeenCalledOnce(); + }); + + it('calls onJustSwitch when Just Switch is clicked', () => { + const onJustSwitch = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /just switch/i })); + expect(onJustSwitch).toHaveBeenCalledOnce(); + }); + + it('calls onCancel when cancel/back is clicked', () => { + const onCancel = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/__tests__/useConversationHistory.test.tsx b/src/hooks/__tests__/useConversationHistory.test.tsx new file mode 100644 index 00000000..1d96f120 --- /dev/null +++ b/src/hooks/__tests__/useConversationHistory.test.tsx @@ -0,0 +1,335 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useConversationHistory } from '../useConversationHistory'; +import { invoke } from '../../testUtils/mocks/tauri'; +import type { Message } from '../useOllama'; + +const MESSAGES: Message[] = [ + { id: 'u1', role: 'user', content: 'Hello', quotedText: undefined }, + { id: 'a1', role: 'assistant', content: 'Hi there' }, +]; + +const MODEL = 'llama3.2:3b'; + +describe('useConversationHistory', () => { + beforeEach(() => { + invoke.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts with isSaved false and conversationId null', () => { + const { result } = renderHook(() => useConversationHistory()); + expect(result.current.isSaved).toBe(false); + expect(result.current.conversationId).toBeNull(); + }); + + it('save() invokes save_conversation with correct payload', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); + invoke.mockResolvedValue(undefined); // generate_title + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + expect(invoke).toHaveBeenCalledWith('save_conversation', { + messages: [ + { role: 'user', content: 'Hello', quoted_text: null }, + { role: 'assistant', content: 'Hi there', quoted_text: null }, + ], + model: MODEL, + }); + }); + + it('save() sets isSaved to true and stores conversationId', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + expect(result.current.isSaved).toBe(true); + expect(result.current.conversationId).toBe('conv-123'); + }); + + it('save() fires generate_title as fire-and-forget after saving', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + expect(invoke).toHaveBeenCalledWith('generate_title', { + conversationId: 'conv-123', + messages: [ + { role: 'user', content: 'Hello', quoted_text: null }, + { role: 'assistant', content: 'Hi there', quoted_text: null }, + ], + }); + }); + + it('save() is a no-op when already saved', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + invoke.mockClear(); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it('persistTurn() is a no-op when not saved', async () => { + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.persistTurn(MESSAGES[0], MESSAGES[1]); + }); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it('persistTurn() invokes persist_message for both messages when saved', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + invoke.mockClear(); + + const userMsg: Message = { + id: 'u2', + role: 'user', + content: 'Follow up', + quotedText: 'ctx', + }; + const assistantMsg: Message = { + id: 'a2', + role: 'assistant', + content: 'Reply', + }; + + await act(async () => { + await result.current.persistTurn(userMsg, assistantMsg); + }); + + expect(invoke).toHaveBeenCalledWith('persist_message', { + conversationId: 'conv-123', + role: 'user', + content: 'Follow up', + quotedText: 'ctx', + }); + expect(invoke).toHaveBeenCalledWith('persist_message', { + conversationId: 'conv-123', + role: 'assistant', + content: 'Reply', + quotedText: null, + }); + }); + + it('persistTurn() passes null for undefined quotedText on userMsg', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-999' }); + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + invoke.mockClear(); + + // userMsg has NO quotedText — should map to null + const userMsg: Message = { + id: 'u3', + role: 'user', + content: 'No context', + quotedText: undefined, + }; + const assistantMsg: Message = { + id: 'a3', + role: 'assistant', + content: 'Sure', + quotedText: 'assistant ctx', + }; + + await act(async () => { + await result.current.persistTurn(userMsg, assistantMsg); + }); + + expect(invoke).toHaveBeenCalledWith( + 'persist_message', + expect.objectContaining({ + quotedText: null, // undefined → null + }), + ); + expect(invoke).toHaveBeenCalledWith( + 'persist_message', + expect.objectContaining({ + quotedText: 'assistant ctx', + }), + ); + }); + + it('loadConversation() invokes load_conversation and returns mapped Messages', async () => { + invoke.mockResolvedValueOnce([ + { + id: 'm1', + role: 'user', + content: 'Saved question', + quoted_text: null, + created_at: 1, + }, + { + id: 'm2', + role: 'assistant', + content: 'Saved answer', + quoted_text: 'ctx', + created_at: 2, + }, + ]); + + const { result } = renderHook(() => useConversationHistory()); + let loaded: Message[] = []; + + await act(async () => { + loaded = await result.current.loadConversation('conv-456'); + }); + + expect(invoke).toHaveBeenCalledWith('load_conversation', { + conversationId: 'conv-456', + }); + + expect(loaded).toEqual([ + { + id: 'm1', + role: 'user', + content: 'Saved question', + quotedText: undefined, + }, + { + id: 'm2', + role: 'assistant', + content: 'Saved answer', + quotedText: 'ctx', + }, + ]); + }); + + it('loadConversation() sets conversationId to the loaded id', async () => { + invoke.mockResolvedValueOnce([]); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.loadConversation('conv-789'); + }); + + expect(result.current.conversationId).toBe('conv-789'); + expect(result.current.isSaved).toBe(true); + }); + + it('deleteConversation() invokes delete_conversation with correct id', async () => { + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.deleteConversation('conv-123'); + }); + + expect(invoke).toHaveBeenCalledWith('delete_conversation', { + conversationId: 'conv-123', + }); + }); + + it('listConversations() invokes list_conversations without search', async () => { + invoke.mockResolvedValue([]); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.listConversations(); + }); + + expect(invoke).toHaveBeenCalledWith('list_conversations', { search: null }); + }); + + it('listConversations() invokes list_conversations with search term', async () => { + invoke.mockResolvedValue([]); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.listConversations('react'); + }); + + expect(invoke).toHaveBeenCalledWith('list_conversations', { + search: 'react', + }); + }); + + it('reset() clears conversationId and isSaved', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + expect(result.current.isSaved).toBe(true); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isSaved).toBe(false); + expect(result.current.conversationId).toBeNull(); + }); + + it('reset() does not call reset_conversation (caller is responsible)', async () => { + invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); + invoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.save(MESSAGES, MODEL); + }); + + invoke.mockClear(); + + act(() => { + result.current.reset(); + }); + + expect(invoke).not.toHaveBeenCalledWith( + 'reset_conversation', + expect.anything(), + ); + }); +}); diff --git a/src/hooks/__tests__/useOllama.test.tsx b/src/hooks/__tests__/useOllama.test.tsx index 923d1524..ddb4dfa6 100644 --- a/src/hooks/__tests__/useOllama.test.tsx +++ b/src/hooks/__tests__/useOllama.test.tsx @@ -1,5 +1,5 @@ import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useOllama } from '../useOllama'; import { invoke, @@ -471,6 +471,112 @@ describe('useOllama', () => { }); }); + // ─── onTurnComplete callback ───────────────────────────────────────────────── + + describe('onTurnComplete callback', () => { + it('is called with user and assistant messages on Done', async () => { + const onTurnComplete = vi.fn(); + const { result } = renderHook(() => useOllama(onTurnComplete)); + + await act(async () => { + await result.current.ask('ping'); + }); + + const channel = getChannel(); + act(() => { + channel!.simulateMessage({ type: 'Token', data: 'pong' }); + channel!.simulateMessage({ type: 'Done' }); + }); + + expect(onTurnComplete).toHaveBeenCalledOnce(); + const [userMsg, assistantMsg] = onTurnComplete.mock.calls[0]; + expect(userMsg).toMatchObject({ role: 'user', content: 'ping' }); + expect(assistantMsg).toMatchObject({ + role: 'assistant', + content: 'pong', + }); + }); + + it('is not called when Cancelled', async () => { + const onTurnComplete = vi.fn(); + const { result } = renderHook(() => useOllama(onTurnComplete)); + + await act(async () => { + await result.current.ask('ping'); + }); + + const channel = getChannel(); + act(() => { + channel!.simulateMessage({ type: 'Token', data: 'partial' }); + channel!.simulateMessage({ type: 'Cancelled' }); + }); + + expect(onTurnComplete).not.toHaveBeenCalled(); + }); + + it('is not called when an Error chunk is received', async () => { + const onTurnComplete = vi.fn(); + const { result } = renderHook(() => useOllama(onTurnComplete)); + + await act(async () => { + await result.current.ask('ping'); + }); + + const channel = getChannel(); + act(() => { + channel!.simulateMessage({ type: 'Error', data: 'failure' }); + }); + + expect(onTurnComplete).not.toHaveBeenCalled(); + }); + }); + + // ─── loadMessages() ────────────────────────────────────────────────────────── + + describe('loadMessages()', () => { + it('replaces messages state with provided array', async () => { + const { result } = renderHook(() => useOllama()); + + await act(async () => { + await result.current.ask('original question'); + }); + const channel = getChannel(); + act(() => { + channel!.simulateMessage({ type: 'Done' }); + }); + expect(result.current.messages).toHaveLength(2); + + const loaded = [ + { id: 'l1', role: 'user' as const, content: 'loaded question' }, + { id: 'l2', role: 'assistant' as const, content: 'loaded answer' }, + ]; + + act(() => { + result.current.loadMessages(loaded); + }); + + expect(result.current.messages).toEqual(loaded); + }); + + it('clears streaming and error state when loading messages', async () => { + invoke.mockRejectedValueOnce(new Error('boom')); + const { result } = renderHook(() => useOllama()); + + await act(async () => { + await result.current.ask('fail'); + }); + expect(result.current.error).not.toBeNull(); + + act(() => { + result.current.loadMessages([]); + }); + + expect(result.current.streamingContent).toBe(''); + expect(result.current.isGenerating).toBe(false); + expect(result.current.error).toBeNull(); + }); + }); + // ─── History ───────────────────────────────────────────────────────────────── describe('history', () => { diff --git a/src/hooks/useConversationHistory.ts b/src/hooks/useConversationHistory.ts new file mode 100644 index 00000000..82fdf286 --- /dev/null +++ b/src/hooks/useConversationHistory.ts @@ -0,0 +1,189 @@ +import { useState, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { Message } from './useOllama'; +import type { + ConversationSummary, + PersistedMessage, + SaveConversationResponse, + SaveMessagePayload, +} from '../types/history'; + +/** + * Maps a frontend `Message` to the `SaveMessagePayload` shape expected by + * the `save_conversation` and `generate_title` Tauri commands. + */ +function toPayload(msg: Message): SaveMessagePayload { + return { + role: msg.role, + content: msg.content, + quoted_text: msg.quotedText ?? null, + }; +} + +/** + * Maps a `PersistedMessage` returned by `load_conversation` back to a + * frontend `Message`, preserving optional `quotedText`. + */ +function fromPersisted(msg: PersistedMessage): Message { + return { + id: msg.id, + role: msg.role as 'user' | 'assistant', + content: msg.content, + quotedText: msg.quoted_text ?? undefined, + }; +} + +/** + * Manages conversation persistence state for the current session. + * + * Tracks whether the active conversation has been saved to SQLite and provides + * typed wrappers around all history-related Tauri commands. Intentionally has + * no knowledge of streaming state or window management — those live in App.tsx + * and `useOllama`. + * + * @returns An object containing the current persistence state and all + * history operation callbacks. + */ +export function useConversationHistory() { + const [conversationId, setConversationId] = useState(null); + + /** True once the conversation has been saved to SQLite for the first time. */ + const isSaved = conversationId !== null; + + /** + * Persists the current conversation to SQLite for the first time. + * Subsequent calls while `isSaved` is true are no-ops — the bookmark + * icon on the frontend enforces single-save semantics. + * + * Fires `generate_title` as a fire-and-forget background task after saving; + * the frontend should schedule a `listConversations` refresh to pick up the + * AI-generated title once it arrives (~2-5 seconds). + * + * @param messages The complete message history to persist. + * @param model The Ollama model name used in this session. + */ + const save = useCallback( + async (messages: Message[], model: string): Promise => { + if (isSaved) return; + + const payloads = messages.map(toPayload); + + const response = await invoke( + 'save_conversation', + { + messages: payloads, + model, + }, + ); + + setConversationId(response.conversation_id); + + // Fire-and-forget: ask Rust to generate an AI title for the conversation. + // The frontend can poll `list_conversations` after a delay to pick up the result. + void invoke('generate_title', { + conversationId: response.conversation_id, + messages: payloads, + }); + }, + [isSaved], + ); + + /** + * Appends a completed user/assistant turn to the already-saved conversation. + * No-op if the conversation has not been saved yet — partial conversations + * are only persisted after an explicit save. + * + * @param userMsg The user message from the completed turn. + * @param assistantMsg The assistant response from the completed turn. + */ + const persistTurn = useCallback( + async (userMsg: Message, assistantMsg: Message): Promise => { + if (!isSaved || conversationId === null) return; + + await Promise.all([ + invoke('persist_message', { + conversationId, + role: userMsg.role, + content: userMsg.content, + quotedText: userMsg.quotedText ?? null, + }), + invoke('persist_message', { + conversationId, + role: assistantMsg.role, + content: assistantMsg.content, + quotedText: assistantMsg.quotedText ?? null, + }), + ]); + }, + [isSaved, conversationId], + ); + + /** + * Loads a saved conversation from SQLite. + * + * Calls the `load_conversation` Tauri command, which atomically syncs the + * backend `ConversationHistory` state and bumps the epoch counter so any + * in-flight streaming turn cannot corrupt the newly loaded history. + * + * @param id The UUID of the conversation to load. + * @returns The conversation messages mapped to frontend `Message` shape. + */ + const loadConversation = useCallback( + async (id: string): Promise => { + const persisted = await invoke('load_conversation', { + conversationId: id, + }); + setConversationId(id); + return persisted.map(fromPersisted); + }, + [], + ); + + /** + * Permanently deletes a saved conversation and all its messages from SQLite. + * + * @param id The UUID of the conversation to delete. + */ + const deleteConversation = useCallback(async (id: string): Promise => { + await invoke('delete_conversation', { conversationId: id }); + }, []); + + /** + * Fetches the list of saved conversations, optionally filtered by title. + * + * @param search Optional case-insensitive search term applied against + * conversation titles. + * @returns An array of `ConversationSummary` objects ordered by most-recently + * updated. + */ + const listConversations = useCallback( + async (search?: string): Promise => { + return invoke('list_conversations', { + search: search ?? null, + }); + }, + [], + ); + + /** + * Clears the local persistence state, marking the session as unsaved. + * + * Does NOT call `reset_conversation` on the backend — that is the + * responsibility of `useOllama.reset()`, which is called in conjunction + * with this function from App.tsx. + */ + const reset = useCallback(() => { + setConversationId(null); + }, []); + + return { + conversationId, + isSaved, + save, + persistTurn, + loadConversation, + deleteConversation, + listConversations, + reset, + }; +} diff --git a/src/hooks/useOllama.ts b/src/hooks/useOllama.ts index f304844b..c11b3061 100644 --- a/src/hooks/useOllama.ts +++ b/src/hooks/useOllama.ts @@ -26,9 +26,15 @@ export type StreamChunk = * A custom hook that simplifies interactions with the local Ollama LLM. * It manages message history, streaming state, and sets up Rust IPC channels. * + * @param onTurnComplete Optional callback invoked after a complete user/assistant + * turn (i.e., when the `Done` chunk is received). Receives the user message + * and the finalized assistant message. Not called on `Cancelled` or `Error`. + * Used by the caller to persist completed turns to SQLite. * @returns An object containing the message history, a submit callback function, and operational states. */ -export function useOllama() { +export function useOllama( + onTurnComplete?: (userMsg: Message, assistantMsg: Message) => void, +) { const [messages, setMessages] = useState([]); const [streamingContent, setStreamingContent] = useState(''); const [isGenerating, setIsGenerating] = useState(false); @@ -48,15 +54,14 @@ export function useOllama() { async (displayContent: string, quotedText?: string) => { if (!displayContent.trim() || isGenerating) return; - setMessages((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - role: 'user', - content: displayContent, - quotedText, - }, - ]); + const userMsg: Message = { + id: crypto.randomUUID(), + role: 'user', + content: displayContent, + quotedText, + }; + + setMessages((prev) => [...prev, userMsg]); setStreamingContent(''); setIsGenerating(true); setError(null); @@ -71,16 +76,17 @@ export function useOllama() { currentContent += chunk.data; setStreamingContent(currentContent); } else if (chunk.type === 'Done') { - setMessages((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - role: 'assistant', - content: currentContent, - }, - ]); + const assistantMsg: Message = { + id: crypto.randomUUID(), + role: 'assistant', + content: currentContent, + }; + setMessages((prev) => [...prev, assistantMsg]); setStreamingContent(''); setIsGenerating(false); + // Notify the caller that a complete turn has finished so it can + // persist both messages to SQLite if the conversation is saved. + onTurnComplete?.(userMsg, assistantMsg); } else if (chunk.type === 'Cancelled') { // Finalize partial content as a complete message so the user // retains everything generated before they hit stop. @@ -131,7 +137,7 @@ export function useOllama() { setIsGenerating(false); } }, - [isGenerating], + [isGenerating, onTurnComplete], ); /** Cancels the currently active generation by signalling the Rust backend. */ @@ -149,6 +155,22 @@ export function useOllama() { void invoke('reset_conversation'); }, []); + /** + * Replaces the current message list with a previously loaded set of messages. + * + * Called after `load_conversation` returns from the backend (which already + * synced the Rust `ConversationHistory`). Does NOT call `reset_conversation` + * to avoid conflicting with the epoch bump performed by `load_conversation`. + * + * @param msgs The complete message array to load into React state. + */ + const loadMessages = useCallback((msgs: Message[]) => { + setMessages(msgs); + setStreamingContent(''); + setIsGenerating(false); + setError(null); + }, []); + return { messages, streamingContent, @@ -157,5 +179,6 @@ export function useOllama() { isGenerating, error, reset, + loadMessages, }; } diff --git a/src/testUtils/mocks/framer-motion.tsx b/src/testUtils/mocks/framer-motion.tsx index d42acbdb..b398a318 100644 --- a/src/testUtils/mocks/framer-motion.tsx +++ b/src/testUtils/mocks/framer-motion.tsx @@ -49,9 +49,11 @@ export const motion = { div: ({ children, className, + ref, ...props - }: React.HTMLAttributes & Record) => ( -
+ }: React.HTMLAttributes & + Record & { ref?: React.Ref }) => ( +
{children}
), @@ -90,26 +92,3 @@ export const AnimatePresence = ({ }: { children: React.ReactNode; }) => <>{children}; - -/** - * Stub for `useMotionValue` — returns a minimal object with get/set methods. - * No animation in tests; the DOM renders with static values. - */ -// eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix -export function useMotionValue(initial: number) { - let value = initial; - return { - get: () => value, - set: (v: number) => { - value = v; - }, - }; -} - -/** - * Stub for `useSpring` — passthrough, no spring physics in tests. - */ -// eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix -export function useSpring(motionValue: ReturnType) { - return motionValue; -} diff --git a/src/testUtils/mocks/tauri.ts b/src/testUtils/mocks/tauri.ts index 1dccf0fd..0c19fded 100644 --- a/src/testUtils/mocks/tauri.ts +++ b/src/testUtils/mocks/tauri.ts @@ -61,6 +61,29 @@ export function resetChannelCapture() { lastChannel = null; } +/** + * Enable channel capture AND provide per-command return values. + * + * Combines `enableChannelCapture` with command-specific mock responses in a + * single `mockImplementation` call so neither overrides the other. + * + * @param responses - map of Tauri command name → resolved value + */ +export function enableChannelCaptureWithResponses( + responses: Record, +) { + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + lastChannel = args.onEvent as Channel; + } + if (Object.prototype.hasOwnProperty.call(responses, cmd)) { + return responses[cmd]; + } + }, + ); +} + // ─── listen mock ──────────────────────────────────────────────────────────── type EventCallback = (event: { payload: T }) => void; diff --git a/src/types/history.ts b/src/types/history.ts new file mode 100644 index 00000000..343a4ab3 --- /dev/null +++ b/src/types/history.ts @@ -0,0 +1,52 @@ +/* v8 ignore file -- type-only declarations, no runtime code */ + +/** + * TypeScript mirror of the Rust `ConversationSummary` struct in `database.rs`. + * Used for rendering conversation list items in the history panel. + */ +export interface ConversationSummary { + /** UUID primary key. */ + id: string; + /** AI-generated or placeholder title. Null until a title is set. */ + title: string | null; + /** Ollama model name used for this conversation. */ + model: string; + /** Unix timestamp (seconds) of the last message. */ + updated_at: number; + /** Total number of messages in this conversation. */ + message_count: number; +} + +/** + * TypeScript mirror of the Rust `PersistedMessage` struct in `database.rs`. + * Returned by `load_conversation` when restoring a saved session. + */ +export interface PersistedMessage { + /** UUID primary key. */ + id: string; + /** `'user'` or `'assistant'`. */ + role: string; + /** Full message content. */ + content: string; + /** Quoted host-app text attached to this message, if any. */ + quoted_text: string | null; + /** Unix timestamp (seconds) the message was created. */ + created_at: number; +} + +/** + * Response shape returned by the `save_conversation` Tauri command. + */ +export interface SaveConversationResponse { + conversation_id: string; +} + +/** + * Message payload shape expected by the `save_conversation` and + * `generate_title` Tauri commands. + */ +export interface SaveMessagePayload { + role: string; + content: string; + quoted_text: string | null; +} diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx index a62537bf..6b8ca491 100644 --- a/src/view/AskBarView.tsx +++ b/src/view/AskBarView.tsx @@ -86,6 +86,34 @@ const BORDER_TRACE_RING = ( ); +/** Hoisted static history (clock) icon — prevents re-allocation on every render. */ +const HISTORY_ICON = ( + +); + /** * Props for the AskBarView component. */ @@ -106,6 +134,11 @@ interface AskBarViewProps { inputRef: React.RefObject; /** Selected text from the host app captured at activation time, if any. */ selectedText?: string; + /** + * Called when the compact history icon is clicked in ask-bar mode. + * Omit to hide the history icon entirely. + */ + onHistoryOpen?: () => void; } /** @@ -123,6 +156,7 @@ export function AskBarView({ onCancel, inputRef, selectedText, + onHistoryOpen, }: AskBarViewProps) { const canSubmit = query.trim().length > 0 && !isGenerating; @@ -179,6 +213,19 @@ export function AskBarView({ draggable={false} /> + {/* Compact history entry point — ask-bar mode only. In chat mode the + history button lives in the ConversationView header. */} + {!isChatMode && onHistoryOpen && ( + + )} +