diff --git a/src/App.tsx b/src/App.tsx index ee269ff3..bc234268 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -835,6 +835,7 @@ function App() { pendingNewConversation={pendingNewConversation} onSaveAndNew={handleSaveAndNew} onJustNew={handleJustNew} + onCancelNew={() => setIsHistoryOpen(false)} /> ) : null} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index b9a09cea..ea11251f 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -902,7 +902,7 @@ describe('App', () => { ).toBeInTheDocument(); }); - it('handleNewConversation shows SwitchConfirmation when unsaved, resets on Just Switch', async () => { + it('handleNewConversation shows SwitchConfirmation when unsaved, resets on Start New', async () => { enableChannelCaptureWithResponses({ list_conversations: [], }); @@ -932,14 +932,14 @@ describe('App', () => { ); }); - // SwitchConfirmation should be visible + // SwitchConfirmation should be visible with "new" variant expect( - screen.getByRole('button', { name: 'Just Switch' }), + screen.getByRole('button', { name: 'Start New' }), ).toBeInTheDocument(); - // Click "Just Switch" → should reset to ask-bar mode + // Click "Start New" → should reset to ask-bar mode await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Just Switch' })); + fireEvent.click(screen.getByRole('button', { name: 'Start New' })); }); expect( @@ -947,6 +947,53 @@ describe('App', () => { ).toBeInTheDocument(); }); + it('handleNewConversation Cancel closes the history dropdown', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [], + }); + + 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' }), + ); + }); + + expect( + screen.getByRole('button', { name: 'Cancel' }), + ).toBeInTheDocument(); + + // Click Cancel → dropdown closes, still in chat mode + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + }); + + // SwitchConfirmation should be gone + expect( + screen.queryByRole('button', { name: 'Cancel' }), + ).not.toBeInTheDocument(); + // Still showing the conversation + expect(screen.getByText('question')).toBeInTheDocument(); + }); + it('handleNewConversation resets directly when conversation is already saved', async () => { enableChannelCaptureWithResponses({ list_conversations: [], @@ -991,7 +1038,7 @@ describe('App', () => { ).toBeInTheDocument(); }); - it('handleNewConversation saves then resets on Save & Switch', async () => { + it('handleNewConversation saves then resets on Save & Start New', async () => { enableChannelCaptureWithResponses({ list_conversations: [], save_conversation: 'saved-id', @@ -1023,12 +1070,14 @@ describe('App', () => { }); expect( - screen.getByRole('button', { name: 'Save & Switch' }), + screen.getByRole('button', { name: 'Save & Start New' }), ).toBeInTheDocument(); - // Click "Save & Switch" → saves then resets to ask-bar mode + // Click "Save & Start New" → saves then resets to ask-bar mode await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Save & Switch' })); + fireEvent.click( + screen.getByRole('button', { name: 'Save & Start New' }), + ); }); expect( @@ -1067,9 +1116,11 @@ describe('App', () => { ); }); - // Click "Save & Switch" — save fails → should stay in chat mode + // Click "Save & Start New" — save fails → should stay in chat mode await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Save & Switch' })); + fireEvent.click( + screen.getByRole('button', { name: 'Save & Start New' }), + ); }); // Still in chat mode (save_conversation threw, reset was aborted) diff --git a/src/components/ConversationItem.tsx b/src/components/ConversationItem.tsx index 680adfaa..b815178b 100644 --- a/src/components/ConversationItem.tsx +++ b/src/components/ConversationItem.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import type { ConversationSummary } from '../types/history'; +import { formatRelativeTime } from '../utils/formatRelativeTime'; /** Hoisted static delete icon — avoids re-allocation on every render. */ const DELETE_ICON = ( @@ -26,19 +27,22 @@ interface ConversationItemProps { onSelect: (id: string) => void; /** Called with the conversation id when the delete button is clicked. */ onDelete: (id: string) => void; + /** When true, renders the row with an active/highlighted style. */ + isActive?: boolean; } /** * 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. + * Displays the conversation title (falling back to "Untitled"), a relative + * timestamp, 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, + isActive = false, }: ConversationItemProps) { const title = conversation.title ?? 'Untitled'; @@ -48,13 +52,20 @@ export const ConversationItem = memo(function ConversationItem({ type="button" onClick={() => onSelect(conversation.id)} aria-label={title} - className="flex-1 min-w-0 flex flex-col gap-0.5 text-left px-3 py-2 rounded-lg transition-colors duration-150 hover:bg-white/5 cursor-pointer" + aria-current={isActive ? 'true' : undefined} + className={`flex-1 min-w-0 flex flex-col gap-0.5 text-left px-3 py-2 rounded-lg transition-colors duration-150 cursor-pointer ${ + isActive + ? 'bg-primary/10 border-l-2 border-primary' + : 'hover:bg-white/5' + }`} > - + {title} - {conversation.message_count} msgs + {formatRelativeTime(conversation.updated_at)} diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index e431a0f2..8cb174ca 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -13,10 +13,10 @@ const SEARCH_DEBOUNCE_MS = 200; function groupByDate( conversations: ConversationSummary[], ): [string, ConversationSummary[]][] { - const nowSec = Math.floor(Date.now() / 1000); - const DAY = 86400; + const nowMs = Date.now(); + const DAY = 86_400_000; - const todayStart = nowSec - (nowSec % DAY); + const todayStart = nowMs - (nowMs % DAY); const yesterdayStart = todayStart - DAY; const buckets = new Map(); @@ -79,12 +79,15 @@ interface HistoryPanelProps { /** * When true, replaces the conversation list with a SwitchConfirmation prompt * asking whether to save before starting a new conversation. + * Also hides the search box since only the confirmation is shown. */ pendingNewConversation?: boolean; - /** Called when the user confirms "Save & Switch" for a new conversation. */ + /** Called when the user confirms "Save & Start New" for a new conversation. */ onSaveAndNew?: () => void; - /** Called when the user confirms "Just Switch" for a new conversation. */ + /** Called when the user confirms "Start New" for a new conversation. */ onJustNew?: () => void; + /** Called when the user cancels the new-conversation confirmation. */ + onCancelNew?: () => void; } /** @@ -111,6 +114,7 @@ export function HistoryPanel({ pendingNewConversation = false, onSaveAndNew, onJustNew, + onCancelNew, }: HistoryPanelProps) { const [conversations, setConversations] = useState([]); const [search, setSearch] = useState(''); @@ -233,17 +237,19 @@ export function HistoryPanel({ return (
- {/* Search input — always visible, auto-focused via CSS autofocus attribute */} -
- -
+ {/* Search input — hidden when only showing the new-conversation confirmation */} + {!pendingNewConversation && ( +
+ +
+ )} {/* Switch confirmation — overlays the list when pending */} {pendingId !== null ? ( @@ -254,9 +260,10 @@ export function HistoryPanel({ /> ) : pendingNewConversation ? ( ) : (
@@ -281,6 +288,7 @@ export function HistoryPanel({ diff --git a/src/components/SwitchConfirmation.tsx b/src/components/SwitchConfirmation.tsx index 026d6abe..e1c3593c 100644 --- a/src/components/SwitchConfirmation.tsx +++ b/src/components/SwitchConfirmation.tsx @@ -1,34 +1,59 @@ import { memo } from 'react'; +type SwitchConfirmationVariant = 'switch' | 'new'; + interface SwitchConfirmationProps { - /** Called when the user wants to save the current session then load the new one. */ + /** Called when the user wants to save the current session then proceed. */ onSaveAndSwitch: () => void; - /** Called when the user wants to discard the current session and load the new one. */ + /** Called when the user wants to discard the current session and proceed. */ onJustSwitch: () => void; /** Called when the user wants to go back without switching. */ onCancel: () => void; + /** + * Controls the title and button labels. + * - `"switch"` (default) — "Switch conversations?" / "Save & Switch" / "Just Switch" + * - `"new"` — "New conversation?" / "Save & Start New" / "Start New" + */ + variant?: SwitchConfirmationVariant; } +const VARIANT_TEXT: Record< + SwitchConfirmationVariant, + { title: string; save: string; proceed: string } +> = { + switch: { + title: 'Switch conversations?', + save: 'Save & Switch', + proceed: 'Just Switch', + }, + new: { + title: 'New conversation?', + save: 'Save & Start New', + proceed: 'Start New', + }, +}; + /** * Inline confirmation prompt displayed inside the history panel when the user - * selects a conversation while an unsaved (or saved) session is active. + * needs to decide what to do with the current conversation before proceeding. * - * Presents two primary actions: - * - **Save & Switch** — persists the current conversation before loading. - * - **Just Switch** — discards the current conversation and loads immediately. + * Two variants: + * - **switch** — loading an existing conversation. + * - **new** — starting a fresh conversation via the "+" button. * - * A **Cancel** action returns the user to the history list. + * A **Cancel** action returns the user to the previous view. */ export const SwitchConfirmation = memo(function SwitchConfirmation({ onSaveAndSwitch, onJustSwitch, onCancel, + variant = 'switch', }: SwitchConfirmationProps) { + const text = VARIANT_TEXT[variant]; + return (
-

- Switch conversations? -

+

{text.title}