diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index 79fe2b65..db38f0f4 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -370,17 +370,17 @@ pub struct WindowPlacement { pub anchor_bottom_y: Option, } -/// Returns the bottom-center position for the no-selection spawn point. -fn bottom_center( +/// Returns the top-center position for the no-selection spawn point. +fn top_center( screen_width: f64, - screen_height: f64, + _screen_height: f64, window_width: f64, - window_height: f64, + _window_height: f64, ) -> WindowPlacement { let x_min = SCREEN_MARGIN; let x_max = (screen_width - window_width - SCREEN_MARGIN).max(x_min); let x = ((screen_width - window_width) / 2.0).clamp(x_min, x_max); - let y = screen_height - window_height - SCREEN_MARGIN - 32.0; + let y = MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0; WindowPlacement { x, y, @@ -480,11 +480,11 @@ pub fn calculate_window_position( window_height, ) } else { - bottom_center(screen_width, screen_height, window_width, window_height) + top_center(screen_width, screen_height, window_width, window_height) } } else { - // No selection → bottom center of screen. - bottom_center(screen_width, screen_height, window_width, window_height) + // No selection → top center of screen. + top_center(screen_width, screen_height, window_width, window_height) }; // Secondary check: if the flip logic above did not set an anchor, determine @@ -545,16 +545,16 @@ mod tests { const WH: f64 = 80.0; #[test] - fn no_selection_returns_bottom_center() { + fn no_selection_returns_top_center() { let p = calculate_window_position(&ctx_no_selection(), SW, SH, WW, WH); assert_eq!(p.x, (SW - WW) / 2.0); - assert_eq!(p.y, SH - WH - SCREEN_MARGIN - 32.0); - assert_eq!(p.anchor_bottom_y, Some(SH - WH - SCREEN_MARGIN - 32.0 + WH)); + assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0); + assert_eq!(p.anchor_bottom_y, None); } #[test] - fn text_with_no_bounds_and_no_mouse_falls_back_to_bottom_center() { - // Same bottom-center position → anchor pinned. + fn text_with_no_bounds_and_no_mouse_falls_back_to_top_center() { + // Same top-center position — no anchor needed since the bar grows downward. let ctx = ActivationContext { selected_text: Some("hello world".to_string()), bounds: None, @@ -564,8 +564,8 @@ mod tests { let x_min = SCREEN_MARGIN; let x_max = (SW - WW - SCREEN_MARGIN).max(x_min); assert_eq!(p.x, ((SW - WW) / 2.0).clamp(x_min, x_max)); - assert_eq!(p.y, SH - WH - SCREEN_MARGIN - 32.0); - assert_eq!(p.anchor_bottom_y, Some(SH - WH - SCREEN_MARGIN - 32.0 + WH)); + assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0); + assert_eq!(p.anchor_bottom_y, None); } #[test] @@ -688,7 +688,7 @@ mod tests { } #[test] - fn bottom_center_on_small_screen() { + fn top_center_on_small_screen() { let small_w = WW + 2.0 * SCREEN_MARGIN; let p = calculate_window_position(&ctx_no_selection(), small_w, SH, WW, WH); assert_eq!(p.x, SCREEN_MARGIN); diff --git a/src/App.tsx b/src/App.tsx index f055e5d6..ee269ff3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,12 @@ function App() { * but rendered differently based on `isChatMode`). */ const [isHistoryOpen, setIsHistoryOpen] = useState(false); + /** + * True when the user clicked + while an unsaved conversation is active. + * Causes the history dropdown to show a SwitchConfirmation prompt instead + * of the conversation list. + */ + const [pendingNewConversation, setPendingNewConversation] = useState(false); /** * Direct reference to the morphing container DOM node, stored alongside the @@ -75,6 +81,7 @@ function App() { conversationId, isSaved, save, + unsave, persistTurn, loadConversation, deleteConversation, @@ -362,6 +369,11 @@ function App() { return () => document.removeEventListener('mousedown', handleMouseDown); }, [isChatMode, isHistoryOpen]); + // Clear any pending new-conversation confirmation whenever the panel closes. + useEffect(() => { + if (!isHistoryOpen) setPendingNewConversation(false); + }, [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 @@ -397,16 +409,23 @@ function App() { /* v8 ignore stop */ }, [isChatMode, isHistoryOpen]); - /** Saves the current conversation to SQLite. */ + /** + * Toggles the save state of the current conversation. + * - Not saved → saves to SQLite (bookmark fills). + * - Already saved → deletes from SQLite, marks unsaved (bookmark empties); + * messages remain in the UI so the session can be re-saved if desired. + */ const handleSave = useCallback(async () => { try { - await save(messages, MODEL_NAME); + if (isSaved) { + await unsave(); + } else { + 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. + // State stays unchanged on failure; feedback is implicit in the icon. } - }, [save, messages]); + }, [isSaved, unsave, save, messages]); /** * Loads a conversation from history, replacing the current session. @@ -461,30 +480,76 @@ function App() { /** * 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 + * When the deleted conversation is the currently active one, only the + * persistence state (`resetHistory`) is cleared — messages remain visible + * so the user can continue chatting or re-save. The error is intentionally * 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], + [deleteConversation, conversationId, resetHistory], ); - /** Starts a fresh conversation from within conversation view. */ - const handleNewConversation = useCallback(() => { + /** + * Shared reset sequence for all "start a new conversation" paths. + * + * Mirrors what `replayEntranceAnimation` does for the anchor-mode state so + * the Tauri window shrinks back to ask-bar height regardless of whether the + * session was launched from a text-selection anchor: + * + * - `isPreExpandedRef.current = false` unblocks the ResizeObserver in anchor + * mode so it can call `set_window_frame` with the (smaller) ask-bar height. + * - Clearing `outerContainerRef.current.style.minHeight` removes the inline + * CSS constraint that was keeping the outer container at the expanded height. + */ + const resetForNewConversation = useCallback(() => { + isPreExpandedRef.current = false; + /* v8 ignore start -- DOM ref null guard */ + if (outerContainerRef.current) { + outerContainerRef.current.style.minHeight = ''; + } + /* v8 ignore stop */ reset(); resetHistory(); setIsHistoryOpen(false); setQuery(''); }, [reset, resetHistory]); + /** + * Starts a fresh conversation from within conversation view. + * If the current conversation has unsaved messages, opens the history + * dropdown and surfaces a SwitchConfirmation prompt instead of resetting + * immediately. + */ + const handleNewConversation = useCallback(() => { + if (!isSaved && messages.length > 0) { + setPendingNewConversation(true); + setIsHistoryOpen(true); + return; + } + resetForNewConversation(); + }, [isSaved, messages.length, resetForNewConversation]); + + /** Saves the current conversation then starts a fresh one. */ + const handleSaveAndNew = useCallback(async () => { + try { + await save(messages, MODEL_NAME); + } catch { + return; + } + resetForNewConversation(); + }, [save, messages, resetForNewConversation]); + + /** Discards the current conversation and starts a fresh one. */ + const handleJustNew = useCallback(() => { + resetForNewConversation(); + }, [resetForNewConversation]); + const handleSubmit = useCallback(() => { if (query.trim().length === 0 || isGenerating) return; // Sanitize externally-sourced context: strip control characters and enforce @@ -667,10 +732,10 @@ function App() { style={{ transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', }} - className={`morphing-container relative flex flex-col bg-surface-base backdrop-blur-2xl border border-surface-border ${ + className={`morphing-container relative flex flex-col bg-surface-base backdrop-blur-2xl border border-surface-border max-h-[600px] overflow-hidden ${ isChatMode - ? 'rounded-lg shadow-chat max-h-[600px] overflow-hidden' - : 'rounded-2xl shadow-bar overflow-hidden' + ? 'rounded-lg shadow-chat' + : 'rounded-2xl shadow-bar' }`} > {/* Chat Messages Area — morphs in when in chat mode */} @@ -685,6 +750,7 @@ function App() { onSave={handleSave} isSaved={isSaved} canSave={canSave} + onNewConversation={handleNewConversation} onHistoryOpen={handleHistoryToggle} /> ) : null} @@ -765,8 +831,10 @@ function App() { onDeleteConversation={handleDeleteConversation} hasCurrentMessages={messages.length > 0 && !isSaved} currentConversationId={conversationId} - showNewConversation={true} - onNewConversation={handleNewConversation} + showNewConversation={false} + pendingNewConversation={pendingNewConversation} + onSaveAndNew={handleSaveAndNew} + onJustNew={handleJustNew} /> ) : null} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 8a9a9220..b9a09cea 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -809,6 +809,59 @@ describe('App', () => { ); }); + it('clicking save button when already saved calls delete_conversation (unsave toggle)', async () => { + enableChannelCaptureWithResponses({ + save_conversation: { conversation_id: 'conv-save-toggle' }, + }); + + 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' }); + }); + + // Save the conversation first + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /save conversation/i }), + ); + }); + + // Button should now read "Remove from history" + expect( + screen.getByRole('button', { name: /remove from history/i }), + ).toBeInTheDocument(); + + invoke.mockClear(); + + // Click again to unsave + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /remove from history/i }), + ); + }); + + expect(invoke).toHaveBeenCalledWith('delete_conversation', { + conversationId: 'conv-save-toggle', + }); + + // Button reverts to "Save conversation" + expect( + screen.getByRole('button', { name: /save conversation/i }), + ).toBeInTheDocument(); + }); + it('resets history state on overlay reopen', async () => { enableChannelCaptureWithResponses({ save_conversation: { conversation_id: 'conv-123' }, @@ -849,7 +902,7 @@ describe('App', () => { ).toBeInTheDocument(); }); - it('handleNewConversation resets to ask-bar mode', async () => { + it('handleNewConversation shows SwitchConfirmation when unsaved, resets on Just Switch', async () => { enableChannelCaptureWithResponses({ list_conversations: [], }); @@ -858,6 +911,52 @@ describe('App', () => { await act(async () => {}); await showOverlay(); + // Get into chat mode with an unsaved turn + 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' }); + }); + + // Click + (unsaved conversation → history panel opens with SwitchConfirmation) + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'New conversation' }), + ); + }); + + // SwitchConfirmation should be visible + expect( + screen.getByRole('button', { name: 'Just Switch' }), + ).toBeInTheDocument(); + + // Click "Just Switch" → should reset to ask-bar mode + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Just Switch' })); + }); + + expect( + screen.getByPlaceholderText('Ask Thuki anything...'), + ).toBeInTheDocument(); + }); + + it('handleNewConversation resets directly when conversation is already saved', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [], + save_conversation: 'saved-id', + }); + + render(); + await act(async () => {}); + await showOverlay(); + // Get into chat mode const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); act(() => { @@ -872,24 +971,111 @@ describe('App', () => { getLastChannel()?.simulateMessage({ type: 'Done' }); }); - // Open history dropdown in chat mode + // Save the conversation await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /history/i })); + fireEvent.click( + screen.getByRole('button', { name: /save conversation/i }), + ); + }); + + // Click + (already saved → no confirmation, direct reset) + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'New conversation' }), + ); }); - // Click "+ New conversation" + // Should be directly back in ask-bar mode (no confirmation prompt) + expect( + screen.getByPlaceholderText('Ask Thuki anything...'), + ).toBeInTheDocument(); + }); + + it('handleNewConversation saves then resets on Save & Switch', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [], + save_conversation: 'saved-id', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Get into chat mode with an unsaved turn + 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' }); + }); + + // Click + → SwitchConfirmation appears await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /new conversation/i }), + screen.getByRole('button', { name: 'New conversation' }), ); }); - // Should be back in ask-bar mode (no chat bubbles) + expect( + screen.getByRole('button', { name: 'Save & Switch' }), + ).toBeInTheDocument(); + + // Click "Save & Switch" → saves then resets to ask-bar mode + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save & Switch' })); + }); + expect( screen.getByPlaceholderText('Ask Thuki anything...'), ).toBeInTheDocument(); }); + it('handleSaveAndNew aborts reset when save fails', async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_conversations') return []; + if (cmd === 'save_conversation') throw new Error('disk full'); + }); + + 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' }); + }); + + // Click + → SwitchConfirmation + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'New conversation' }), + ); + }); + + // Click "Save & Switch" — save fails → should stay in chat mode + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save & Switch' })); + }); + + // Still in chat mode (save_conversation threw, reset was aborted) + expect(screen.getByText('q')).toBeInTheDocument(); + }); + it('handleSaveAndLoad saves unsaved conversation then switches', async () => { const OTHER_MSGS = [ { @@ -1076,7 +1262,7 @@ describe('App', () => { // Open chat history await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /history/i })); + fireEvent.click(screen.getByRole('button', { name: /open history/i })); }); // Click a different conversation — isSaved=true means no dialog, loads directly @@ -1096,7 +1282,7 @@ describe('App', () => { }); }); - it('handleDeleteConversation resets history when current conversation is deleted', async () => { + it('handleDeleteConversation marks active conversation unsaved but keeps messages', async () => { const LOADED_MSGS = [ { id: 'm1', @@ -1138,22 +1324,31 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: /my chat/i })); }); - // In chat mode; open chat history + // Messages are visible in chat mode + expect(screen.getByText('Hi')).toBeInTheDocument(); + + // Open chat history and delete the currently-active conversation 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 + // delete_conversation was called expect(invoke).toHaveBeenCalledWith('delete_conversation', { conversationId: 'conv-target', }); + + // Messages remain — still in chat mode + expect(screen.getByText('Hi')).toBeInTheDocument(); + + // Save button reverts to unsaved state ("Save conversation") + expect( + screen.getByRole('button', { name: /save conversation/i }), + ).toBeInTheDocument(); }); it('clicking outside the chat history dropdown closes it', async () => { @@ -1230,9 +1425,9 @@ describe('App', () => { ).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. + it('handleDeleteConversation allows saving the conversation again after deletion', async () => { + // After deleting the active conversation from history, isSaved resets to + // false so the user can re-save the same messages under a new record. enableChannelCaptureWithResponses({ load_conversation: [ { @@ -1259,13 +1454,14 @@ describe('App', () => { message_count: 2, }, ], + save_conversation: { conversation_id: 'conv-new' }, }); render(); await act(async () => {}); await showOverlay(); - // Load the conversation from ask-bar history → enters chat mode with messages + // Load the conversation → isSaved = true await act(async () => { fireEvent.click(screen.getByRole('button', { name: /open history/i })); }); @@ -1273,9 +1469,12 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: /active chat/i })); }); - expect(screen.getByText('Hi')).toBeInTheDocument(); + // Verify save button shows unsave state + expect( + screen.getByRole('button', { name: /remove from history/i }), + ).toBeInTheDocument(); - // Re-open history in chat mode and delete the active conversation + // Open history and delete the active conversation await act(async () => { fireEvent.click(screen.getByRole('button', { name: /open history/i })); }); @@ -1285,11 +1484,22 @@ describe('App', () => { ); }); - // Messages must be gone — UI returns to ask-bar mode - expect(screen.queryByText('Hi')).toBeNull(); + // Messages remain, isSaved is now false — save button is re-enabled + expect(screen.getByText('Hi')).toBeInTheDocument(); expect( - screen.getByPlaceholderText('Ask Thuki anything...'), + screen.getByRole('button', { name: /save conversation/i }), ).toBeInTheDocument(); + + // User can re-save the conversation + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /save conversation/i }), + ); + }); + expect(invoke).toHaveBeenCalledWith( + 'save_conversation', + expect.objectContaining({ messages: expect.any(Array) }), + ); }); it('handleLoadConversation closes history panel when load_conversation fails', async () => { diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index 4211181b..e431a0f2 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -76,6 +76,15 @@ interface HistoryPanelProps { showNewConversation: boolean; /** Called when the user clicks "+ New conversation". */ onNewConversation?: () => void; + /** + * When true, replaces the conversation list with a SwitchConfirmation prompt + * asking whether to save before starting a new conversation. + */ + pendingNewConversation?: boolean; + /** Called when the user confirms "Save & Switch" for a new conversation. */ + onSaveAndNew?: () => void; + /** Called when the user confirms "Just Switch" for a new conversation. */ + onJustNew?: () => void; } /** @@ -86,6 +95,7 @@ interface HistoryPanelProps { * - Debounces search input at 200 ms. * - Shows a `SwitchConfirmation` prompt before loading when the user has an * active session (`hasCurrentMessages`). + * - Shows a `SwitchConfirmation` prompt when `pendingNewConversation` is true. * - Optimistically removes deleted conversations from the list. * - Conditionally renders a "+ New conversation" footer via `showNewConversation`. */ @@ -98,6 +108,9 @@ export function HistoryPanel({ currentConversationId, showNewConversation, onNewConversation, + pendingNewConversation = false, + onSaveAndNew, + onJustNew, }: HistoryPanelProps) { const [conversations, setConversations] = useState([]); const [search, setSearch] = useState(''); @@ -239,6 +252,12 @@ export function HistoryPanel({ onJustSwitch={handleJustSwitch} onCancel={handleCancelSwitch} /> + ) : pendingNewConversation ? ( + ) : (
{loadError && ( @@ -272,7 +291,7 @@ export function HistoryPanel({ )} {/* Optional footer — only shown in conversation-view mode */} - {showNewConversation && pendingId === null && ( + {showNewConversation && pendingId === null && !pendingNewConversation && (
+ + + )} + + {onNewConversation !== undefined && ( + + + )} {onHistoryOpen !== undefined && ( - + + + )}
diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx new file mode 100644 index 00000000..e0cd6761 --- /dev/null +++ b/src/components/__tests__/Tooltip.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Tooltip } from '../Tooltip'; + +describe('Tooltip', () => { + it('renders children without showing tooltip initially', () => { + render( + + + , + ); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect(screen.queryByText('Save conversation')).not.toBeInTheDocument(); + }); + + it('shows tooltip label on mouse enter', () => { + render( + + + , + ); + const wrapper = screen.getByRole('button', { name: 'Save' }).parentElement!; + fireEvent.mouseEnter(wrapper); + expect(screen.getByText('Save conversation')).toBeInTheDocument(); + }); + + it('hides tooltip on mouse leave', () => { + render( + + + , + ); + const wrapper = screen.getByRole('button', { name: 'Save' }).parentElement!; + fireEvent.mouseEnter(wrapper); + expect(screen.getByText('Save conversation')).toBeInTheDocument(); + fireEvent.mouseLeave(wrapper); + expect(screen.queryByText('Save conversation')).not.toBeInTheDocument(); + }); + + it('does not mount portal content before first hover (lazy activation)', () => { + const { container } = render( + + + , + ); + // Before any hover, no portal content should exist in the document + expect( + document.body.querySelector('[style*="position: fixed"]'), + ).toBeNull(); + // The wrapper div itself is in the container + expect(container.querySelector('.inline-flex')).not.toBeNull(); + }); + + it('renders portal content inside document.body on hover', () => { + render( + + + , + ); + const wrapper = screen.getByRole('button', { + name: 'History', + }).parentElement!; + fireEvent.mouseEnter(wrapper); + // Portal renders to document.body, outside the test container + expect(document.body).toHaveTextContent('Conversation history'); + }); + + it('remains visible on subsequent hovers after initial activation', () => { + render( + + + , + ); + const wrapper = screen.getByRole('button', { name: 'Btn' }).parentElement!; + + fireEvent.mouseEnter(wrapper); + fireEvent.mouseLeave(wrapper); + fireEvent.mouseEnter(wrapper); + + expect(screen.getByText('Open history')).toBeInTheDocument(); + }); + + it('wraps children in an inline-flex div', () => { + const { container } = render( + + + , + ); + const wrapper = container.firstElementChild; + expect(wrapper?.tagName.toLowerCase()).toBe('div'); + expect(wrapper?.classList.contains('inline-flex')).toBe(true); + }); +}); diff --git a/src/components/__tests__/WindowControls.test.tsx b/src/components/__tests__/WindowControls.test.tsx index 255a7e45..d3f65a3f 100644 --- a/src/components/__tests__/WindowControls.test.tsx +++ b/src/components/__tests__/WindowControls.test.tsx @@ -38,4 +38,38 @@ describe('WindowControls', () => { const svg = closeBtn.querySelector('svg'); expect(svg).not.toBeNull(); }); + + it('save button shows "Save conversation" aria-label when not saved', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: 'Save conversation' }), + ).toBeInTheDocument(); + }); + + it('save button shows "Remove from history" aria-label when saved', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: 'Remove from history' }), + ).toBeInTheDocument(); + }); + + it('save button calls onSave when clicked while saved', () => { + const onSave = vi.fn(); + render( + , + ); + fireEvent.click( + screen.getByRole('button', { name: 'Remove from history' }), + ); + expect(onSave).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/hooks/__tests__/useConversationHistory.test.tsx b/src/hooks/__tests__/useConversationHistory.test.tsx index 1d96f120..7d814584 100644 --- a/src/hooks/__tests__/useConversationHistory.test.tsx +++ b/src/hooks/__tests__/useConversationHistory.test.tsx @@ -311,6 +311,40 @@ describe('useConversationHistory', () => { expect(result.current.conversationId).toBeNull(); }); + it('unsave() calls delete_conversation and clears 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); + + invoke.mockClear(); + + await act(async () => { + await result.current.unsave(); + }); + + expect(invoke).toHaveBeenCalledWith('delete_conversation', { + conversationId: 'conv-123', + }); + expect(result.current.isSaved).toBe(false); + expect(result.current.conversationId).toBeNull(); + }); + + it('unsave() is a no-op when not saved', async () => { + const { result } = renderHook(() => useConversationHistory()); + + await act(async () => { + await result.current.unsave(); + }); + + expect(invoke).not.toHaveBeenCalled(); + }); + it('reset() does not call reset_conversation (caller is responsible)', async () => { invoke.mockResolvedValueOnce({ conversation_id: 'conv-123' }); invoke.mockResolvedValue(undefined); diff --git a/src/hooks/useConversationHistory.ts b/src/hooks/useConversationHistory.ts index 82fdf286..0f38a7dd 100644 --- a/src/hooks/useConversationHistory.ts +++ b/src/hooks/useConversationHistory.ts @@ -148,6 +148,17 @@ export function useConversationHistory() { await invoke('delete_conversation', { conversationId: id }); }, []); + /** + * Removes the current conversation from SQLite without clearing the + * in-memory message history. After this call `isSaved` is false and the + * session is treated as unsaved again (the user can re-save if desired). + */ + const unsave = useCallback(async (): Promise => { + if (!isSaved || conversationId === null) return; + await invoke('delete_conversation', { conversationId }); + setConversationId(null); + }, [isSaved, conversationId]); + /** * Fetches the list of saved conversations, optionally filtered by title. * @@ -168,9 +179,12 @@ export function useConversationHistory() { /** * 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. + * Does NOT call `reset_conversation` on the backend. When clearing the + * full session (new conversation), call `useOllama.reset()` alongside this + * so the backend history is also wiped. When only marking a conversation as + * unsaved while keeping messages visible (e.g. after deletion from history), + * calling this alone is correct — `persistTurn` will no-op and the backend + * context is rebuilt from the frontend messages on the next request. */ const reset = useCallback(() => { setConversationId(null); @@ -180,6 +194,7 @@ export function useConversationHistory() { conversationId, isSaved, save, + unsave, persistTurn, loadConversation, deleteConversation, diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index ccd1503e..4d2efb35 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -40,6 +40,11 @@ interface ConversationViewProps { * Omit to hide the history button. */ onHistoryOpen?: () => void; + /** + * Called when the new-conversation (+) button is clicked. + * Omit to hide the button. + */ + onNewConversation?: () => void; } /** @@ -62,6 +67,7 @@ export function ConversationView({ isSaved, canSave, onHistoryOpen, + onNewConversation, }: ConversationViewProps) { const scrollContainerRef = useRef(null); @@ -155,6 +161,7 @@ export function ConversationView({ onSave={onSave} isSaved={isSaved} canSave={canSave} + onNewConversation={onNewConversation} onHistoryOpen={onHistoryOpen} /> diff --git a/src/view/__tests__/ConversationView.test.tsx b/src/view/__tests__/ConversationView.test.tsx index c3497180..4233731a 100644 --- a/src/view/__tests__/ConversationView.test.tsx +++ b/src/view/__tests__/ConversationView.test.tsx @@ -484,7 +484,7 @@ describe('ConversationView', () => { expect(saveBtn).toBeDisabled(); }); - it('Save button is disabled when isSaved is true', () => { + it('Save button is enabled (for unsave) when isSaved is true', () => { render( { canSave={true} />, ); - const saveBtn = screen.getByRole('button', { name: /save/i }); - expect(saveBtn).toBeDisabled(); + const saveBtn = screen.getByRole('button', { + name: /remove from history/i, + }); + expect(saveBtn).not.toBeDisabled(); }); });