diff --git a/dashboard/src/__tests__/TTLSelector.test.tsx b/dashboard/src/__tests__/TTLSelector.test.tsx new file mode 100644 index 00000000..8ed741a7 --- /dev/null +++ b/dashboard/src/__tests__/TTLSelector.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TTLSelector } from '../components/TTLSelector'; + +describe('TTLSelector', () => { + const mockOnChange = vi.fn(); + + it('renders preset buttons', () => { + const {} = render( + , + ); + + expect(screen.getByRole('button', { name: '15m' })).toBeDefined(); + expect(screen.getByRole('button', { name: '1h' })).toBeDefined(); + expect(screen.getByRole('button', { name: '4h' })).toBeDefined(); + expect(screen.getByRole('button', { name: '8h' })).toBeDefined(); + }); + + it('highlights the selected preset', () => { + const {} = render( + , + ); + + const btn15m = screen.getByRole('button', { name: '15m' }) as HTMLButtonElement; + expect(btn15m.className).toContain('bg-[#00e5ff]/10'); + expect(btn15m.className).toContain('text-[#00e5ff]'); + }); + + it('calls onChange when a preset is clicked', () => { + const onChange = vi.fn(); + render(); + + const btn1h = screen.getByRole('button', { name: '1h' }); + fireEvent.click(btn1h); + + expect(onChange).toHaveBeenCalledWith(60 * 60); + }); + + it('allows custom duration entry', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('Custom minutes…') as HTMLInputElement; + fireEvent.change(input, { target: { value: '30' } }); + + expect(onChange).toHaveBeenCalledWith(30 * 60); + }); + + it('displays formatted duration for custom input', () => { + render(); + + const input = screen.getByPlaceholderText('Custom minutes…') as HTMLInputElement; + fireEvent.change(input, { target: { value: '90' } }); + + expect(screen.getByText('1h 30m')).toBeDefined(); + }); + + it('displays current TTL value', () => { + render(); + + expect(screen.getByText(/TTL: 4h/)).toBeDefined(); + }); + + it('clears custom input when preset is clicked', () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + + const input = screen.getByPlaceholderText('Custom minutes…') as HTMLInputElement; + fireEvent.change(input, { target: { value: '30' } }); + expect(input.value).toBe('30'); + + const btn1h = screen.getByRole('button', { name: '1h' }); + fireEvent.click(btn1h); + + rerender(); + expect(input.value).toBe(''); + }); + + it('clears TTL when custom input is emptied', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('Custom minutes…') as HTMLInputElement; + fireEvent.change(input, { target: { value: '30' } }); + expect(onChange).toHaveBeenCalledWith(30 * 60); + + fireEvent.change(input, { target: { value: '' } }); + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it('ignores invalid custom input', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('Custom minutes…') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'invalid' } }); + + // onChange should not be called with invalid input + expect(onChange).not.toHaveBeenCalledWith(expect.any(Number)); + }); + + it('shows custom value as selected when value is not a preset', () => { + render(); + + // Should show the custom input with the value + const input = screen.getByPlaceholderText('Custom minutes…') as HTMLInputElement; + expect(input.value).toBe('37'); + + // Should display the formatted duration + expect(screen.getByText('37m')).toBeDefined(); + }); +}); diff --git a/dashboard/src/components/CreateSessionModal.tsx b/dashboard/src/components/CreateSessionModal.tsx index e6ea3e9a..bbc7e77d 100644 --- a/dashboard/src/components/CreateSessionModal.tsx +++ b/dashboard/src/components/CreateSessionModal.tsx @@ -6,6 +6,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { X, Loader2, Plus, Trash2 } from 'lucide-react'; import { createSession, batchCreateSessions } from '../api/client'; +import { TTLSelector } from './TTLSelector'; interface CreateSessionModalProps { open: boolean; @@ -86,12 +87,14 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal const [name, setName] = useState(''); const [prompt, setPrompt] = useState(''); const [permissionMode, setPermissionMode] = useState('default'); + const [ttl, setTtl] = useState(undefined); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [mode, setMode] = useState<'single' | 'batch'>('single'); const [batchRows, setBatchRows] = useState([makeRow(), makeRow()]); const [sharedPrompt, setSharedPrompt] = useState(''); + const [batchTtl, setBatchTtl] = useState(undefined); const [batchResult, setBatchResult] = useState<{ sessions: Array<{ id: string; name: string }>; created: number; @@ -104,10 +107,12 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal setName(''); setPrompt(''); setPermissionMode('default'); + setTtl(undefined); setLoading(false); setError(null); setBatchRows([makeRow(), makeRow()]); setSharedPrompt(''); + setBatchTtl(undefined); setBatchResult(null); setMode('single'); } @@ -149,6 +154,7 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal name: row.name.trim() || undefined, prompt: (row.prompt.trim() || sharedPrompt.trim()) || undefined, permissionMode, + ttl_seconds: batchTtl, })), signal: controller.signal, }); @@ -185,6 +191,7 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal name: name.trim() || undefined, prompt: prompt.trim() || undefined, permissionMode, + ttl_seconds: ttl, signal: controller.signal, }); resetForm(); @@ -312,6 +319,9 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal + {/* TTL */} + + {/* Error */} {error && (
@@ -432,6 +442,9 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal
+ {/* TTL */} + + {/* Error */} {error && (
diff --git a/dashboard/src/components/TTLSelector.tsx b/dashboard/src/components/TTLSelector.tsx new file mode 100644 index 00000000..140a3c0b --- /dev/null +++ b/dashboard/src/components/TTLSelector.tsx @@ -0,0 +1,110 @@ +/** + * components/TTLSelector.tsx — Time-to-live selector component. + * + * Allows users to select session TTL with presets (15m, 1h, 4h, 8h) or custom duration. + */ + +import { useState } from 'react'; +import { Clock } from 'lucide-react'; + +interface TTLSelectorProps { + /** Selected TTL in seconds. undefined = no TTL set. */ + value: number | undefined; + /** Callback when TTL is changed. */ + onChange: (ttl: number | undefined) => void; +} + +const TTL_PRESETS = [ + { label: '15m', seconds: 15 * 60 }, + { label: '1h', seconds: 60 * 60 }, + { label: '4h', seconds: 4 * 60 * 60 }, + { label: '8h', seconds: 8 * 60 * 60 }, +] as const; + +export function TTLSelector({ value, onChange }: TTLSelectorProps) { + // Track whether we're in custom mode (value is not one of the presets) + const isCustom = value !== undefined && !TTL_PRESETS.some(p => p.seconds === value); + const [customInput, setCustomInput] = useState( + isCustom ? String(Math.floor((value ?? 0) / 60)) : '' + ); + + function handlePresetClick(seconds: number) { + onChange(seconds); + setCustomInput(''); + } + + function handleCustomChange(e: React.ChangeEvent) { + const input = e.target.value; + setCustomInput(input); + + if (!input.trim()) { + onChange(undefined); + } else { + const minutes = parseInt(input, 10); + if (!isNaN(minutes) && minutes > 0) { + onChange(minutes * 60); + } + } + } + + return ( +
+
+ + +
+ + {/* Preset buttons */} +
+ {TTL_PRESETS.map((preset) => ( + + ))} +
+ + {/* Custom input */} +
+ + {customInput && !isNaN(parseInt(customInput, 10)) && ( +

+ {formatDuration(parseInt(customInput, 10) * 60)} +

+ )} +
+ + {/* Current value display */} + {value !== undefined && ( +

+ TTL: {formatDuration(value)} +

+ )} +
+ ); +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts index 6ef0c58b..18e897c9 100644 --- a/dashboard/src/types/index.ts +++ b/dashboard/src/types/index.ts @@ -220,6 +220,7 @@ export interface CreateSessionRequest { permissionMode?: string; /** @deprecated Use permissionMode. */ autoApprove?: boolean; + ttl_seconds?: number; } // ── Pane ────────────────────────────────────────────────────────