Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,7 @@ function App() {
pendingNewConversation={pendingNewConversation}
onSaveAndNew={handleSaveAndNew}
onJustNew={handleJustNew}
onCancelNew={() => setIsHistoryOpen(false)}
/>
</motion.div>
) : null}
Expand Down
73 changes: 62 additions & 11 deletions src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
});
Expand Down Expand Up @@ -932,21 +932,68 @@ 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(
screen.getByPlaceholderText('Ask Thuki anything...'),
).toBeInTheDocument();
});

it('handleNewConversation Cancel closes the history dropdown', async () => {
enableChannelCaptureWithResponses({
list_conversations: [],
});

render(<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 + → 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: [],
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions src/components/ConversationItem.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
Expand All @@ -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';

Expand All @@ -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'
}`}
>
<span className="text-xs text-text-primary truncate leading-snug">
<span
className={`text-xs truncate leading-snug ${isActive ? 'text-primary font-medium' : 'text-text-primary'}`}
>
{title}
</span>
<span className="text-[10px] text-text-secondary leading-none">
{conversation.message_count} msgs
{formatRelativeTime(conversation.updated_at)}
</span>
</button>

Expand Down
42 changes: 25 additions & 17 deletions src/components/HistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConversationSummary[]>();
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -111,6 +114,7 @@ export function HistoryPanel({
pendingNewConversation = false,
onSaveAndNew,
onJustNew,
onCancelNew,
}: HistoryPanelProps) {
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [search, setSearch] = useState('');
Expand Down Expand Up @@ -233,17 +237,19 @@ export function HistoryPanel({

return (
<div className="history-panel flex flex-col w-full">
{/* Search input — always visible, auto-focused via CSS autofocus attribute */}
<div className="px-3 pt-3 pb-2 border-b border-surface-border">
<input
type="text"
value={search}
onChange={handleSearchChange}
placeholder="Search past chats…"
autoFocus
className="w-full bg-transparent text-xs text-text-primary placeholder:text-text-secondary outline-none"
/>
</div>
{/* Search input — hidden when only showing the new-conversation confirmation */}
{!pendingNewConversation && (
<div className="px-3 pt-3 pb-2 border-b border-surface-border">
<input
type="text"
value={search}
onChange={handleSearchChange}
placeholder="Search past chats…"
autoFocus
className="w-full bg-transparent text-xs text-text-primary placeholder:text-text-secondary outline-none"
/>
</div>
)}

{/* Switch confirmation — overlays the list when pending */}
{pendingId !== null ? (
Expand All @@ -254,9 +260,10 @@ export function HistoryPanel({
/>
) : pendingNewConversation ? (
<SwitchConfirmation
variant="new"
onSaveAndSwitch={onSaveAndNew!}
onJustSwitch={onJustNew!}
onCancel={handleCancelSwitch}
onCancel={onCancelNew!}
/>
) : (
<div className="overflow-y-auto py-1 max-h-[280px]">
Expand All @@ -281,6 +288,7 @@ export function HistoryPanel({
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === currentConversationId}
onSelect={handleSelect}
onDelete={handleDelete}
/>
Expand Down
49 changes: 37 additions & 12 deletions src/components/SwitchConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,75 @@
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 (
<div className="px-3 py-3 flex flex-col gap-2.5">
<p className="text-xs text-text-secondary leading-snug">
Switch conversations?
</p>
<p className="text-xs text-text-secondary leading-snug">{text.title}</p>

<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={onSaveAndSwitch}
className="w-full text-left px-3 py-2 rounded-lg text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors duration-150 cursor-pointer"
>
Save &amp; Switch
{text.save}
</button>

<button
type="button"
onClick={onJustSwitch}
className="w-full text-left px-3 py-2 rounded-lg text-xs text-text-primary hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
Just Switch
{text.proceed}
</button>

<button
Expand Down
Loading
Loading