From 2c340f453694bc2f0b84a308d4bd23ef825d3060 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 3 Apr 2026 22:57:57 -0500 Subject: [PATCH 1/5] fix: dynamic SwitchConfirmation labels and hide search for new conversation The SwitchConfirmation component now accepts a variant prop ("switch" | "new") to display context-appropriate titles and button labels. The "+" button dropdown hides the search box and shows "New conversation?" / "Save & Start New" / "Start New" instead of the generic switch wording. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- src/__tests__/App.test.tsx | 26 +++++----- src/components/HistoryPanel.tsx | 26 +++++----- src/components/SwitchConfirmation.tsx | 49 ++++++++++++++----- .../__tests__/HistoryPanel.test.tsx | 28 +++++++++++ .../__tests__/SwitchConfirmation.test.tsx | 32 ++++++------ 5 files changed, 110 insertions(+), 51 deletions(-) diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index b9a09cea..a0fb154d 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( @@ -991,7 +991,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 +1023,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 +1069,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/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index e431a0f2..b04be969 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -79,6 +79,7 @@ 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. */ @@ -233,17 +234,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,6 +257,7 @@ export function HistoryPanel({ /> ) : pendingNewConversation ? ( 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}

diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index f0e332ee..9f16ea55 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(); diff --git a/src/components/__tests__/ConversationItem.test.tsx b/src/components/__tests__/ConversationItem.test.tsx index be2b9b61..f20f6fdf 100644 --- a/src/components/__tests__/ConversationItem.test.tsx +++ b/src/components/__tests__/ConversationItem.test.tsx @@ -7,7 +7,7 @@ const SUMMARY: ConversationSummary = { id: 'conv-1', title: 'How does React work?', model: 'llama3.2:3b', - updated_at: Math.floor(Date.now() / 1000), + updated_at: Date.now(), message_count: 6, }; @@ -34,7 +34,7 @@ describe('ConversationItem', () => { expect(screen.getByText('Untitled')).toBeInTheDocument(); }); - it('renders message count', () => { + it('renders relative timestamp', () => { render( { onDelete={vi.fn()} />, ); - expect(screen.getByText(/6 msgs/)).toBeInTheDocument(); + expect(screen.getByText('just now')).toBeInTheDocument(); }); it('calls onSelect with conversation id when clicked', () => { diff --git a/src/components/__tests__/HistoryPanel.test.tsx b/src/components/__tests__/HistoryPanel.test.tsx index 133f0ee4..08fefbd6 100644 --- a/src/components/__tests__/HistoryPanel.test.tsx +++ b/src/components/__tests__/HistoryPanel.test.tsx @@ -3,9 +3,9 @@ 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 NOW = Date.now(); +const YESTERDAY = NOW - 86_400_000; +const OLDER = NOW - 86_400_000 * 3; const CONVERSATIONS: ConversationSummary[] = [ { diff --git a/src/utils/__tests__/formatRelativeTime.test.ts b/src/utils/__tests__/formatRelativeTime.test.ts new file mode 100644 index 00000000..b05f9cc8 --- /dev/null +++ b/src/utils/__tests__/formatRelativeTime.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { formatRelativeTime } from '../formatRelativeTime'; + +const NOW = 1_700_000_000_000; // milliseconds + +describe('formatRelativeTime', () => { + it('returns "just now" for timestamps less than 60 seconds ago', () => { + expect(formatRelativeTime(NOW, NOW)).toBe('just now'); + expect(formatRelativeTime(NOW - 30_000, NOW)).toBe('just now'); + expect(formatRelativeTime(NOW - 59_000, NOW)).toBe('just now'); + }); + + it('returns minutes for timestamps less than 1 hour ago', () => { + expect(formatRelativeTime(NOW - 60_000, NOW)).toBe('1m ago'); + expect(formatRelativeTime(NOW - 120_000, NOW)).toBe('2m ago'); + expect(formatRelativeTime(NOW - 3_599_000, NOW)).toBe('59m ago'); + }); + + it('returns hours for timestamps less than 24 hours ago', () => { + expect(formatRelativeTime(NOW - 3_600_000, NOW)).toBe('1h ago'); + expect(formatRelativeTime(NOW - 7_200_000, NOW)).toBe('2h ago'); + expect(formatRelativeTime(NOW - 86_399_000, NOW)).toBe('23h ago'); + }); + + it('returns days for timestamps less than 14 days ago', () => { + expect(formatRelativeTime(NOW - 86_400_000, NOW)).toBe('1d ago'); + expect(formatRelativeTime(NOW - 86_400_000 * 7, NOW)).toBe('7d ago'); + expect(formatRelativeTime(NOW - 86_400_000 * 13, NOW)).toBe('13d ago'); + }); + + it('returns weeks for timestamps 14+ days ago', () => { + expect(formatRelativeTime(NOW - 86_400_000 * 14, NOW)).toBe('2w ago'); + expect(formatRelativeTime(NOW - 86_400_000 * 21, NOW)).toBe('3w ago'); + expect(formatRelativeTime(NOW - 86_400_000 * 60, NOW)).toBe('8w ago'); + }); + + it('clamps negative diffs to "just now"', () => { + expect(formatRelativeTime(NOW + 100_000, NOW)).toBe('just now'); + }); + + it('defaults to Date.now() when nowMillis is omitted', () => { + const recent = Date.now() - 5_000; + expect(formatRelativeTime(recent)).toBe('just now'); + }); +}); diff --git a/src/utils/formatRelativeTime.ts b/src/utils/formatRelativeTime.ts new file mode 100644 index 00000000..ad7268fa --- /dev/null +++ b/src/utils/formatRelativeTime.ts @@ -0,0 +1,26 @@ +/** + * Formats a Unix timestamp (milliseconds) as a human-readable relative time string. + * + * Examples: "just now", "2m ago", "5h ago", "3d ago", "2w ago" + */ +export function formatRelativeTime( + unixMillis: number, + nowMillis?: number, +): string { + const now = nowMillis ?? Date.now(); + const diffSec = Math.max(0, Math.floor((now - unixMillis) / 1000)); + + if (diffSec < 60) return 'just now'; + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 14) return `${diffDay}d ago`; + + const diffWeek = Math.floor(diffDay / 7); + return `${diffWeek}w ago`; +} From b5aa9a23cf98e571150fb0ed4ac567095686b54c Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 3 Apr 2026 23:28:20 -0500 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20code=20review=20=E2=80=94=20coverage?= =?UTF-8?q?=20gap=20and=20stale=20JSDoc=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add App-level test for onCancelNew callback (restores 100% coverage) - Fix stale JSDoc: ConversationItem "message count" → "relative timestamp" - Fix stale JSDoc: HistoryPanel prop comments to match new button labels - Fix stale JSDoc: types/history.ts updated_at "seconds" → "milliseconds" Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- src/__tests__/App.test.tsx | 47 +++++++++++++++++++++++++++++ src/components/ConversationItem.tsx | 6 ++-- src/components/HistoryPanel.tsx | 4 +-- src/types/history.ts | 2 +- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index a0fb154d..ea11251f 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -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: [], diff --git a/src/components/ConversationItem.tsx b/src/components/ConversationItem.tsx index f3c74784..b815178b 100644 --- a/src/components/ConversationItem.tsx +++ b/src/components/ConversationItem.tsx @@ -34,9 +34,9 @@ interface ConversationItemProps { /** * 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, diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index 9f16ea55..8cb174ca 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -82,9 +82,9 @@ interface HistoryPanelProps { * 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; diff --git a/src/types/history.ts b/src/types/history.ts index 343a4ab3..c35f94f6 100644 --- a/src/types/history.ts +++ b/src/types/history.ts @@ -11,7 +11,7 @@ export interface ConversationSummary { title: string | null; /** Ollama model name used for this conversation. */ model: string; - /** Unix timestamp (seconds) of the last message. */ + /** Unix timestamp (milliseconds) of the last message. */ updated_at: number; /** Total number of messages in this conversation. */ message_count: number;