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
114 changes: 114 additions & 0 deletions dashboard/src/__tests__/TTLSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TTLSelector value={undefined} onChange={mockOnChange} />,
);

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(
<TTLSelector value={15 * 60} onChange={mockOnChange} />,
);

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(<TTLSelector value={undefined} onChange={onChange} />);

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(<TTLSelector value={undefined} onChange={onChange} />);

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(<TTLSelector value={undefined} onChange={vi.fn()} />);

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(<TTLSelector value={4 * 60 * 60} onChange={vi.fn()} />);

expect(screen.getByText(/TTL: 4h/)).toBeDefined();
});

it('clears custom input when preset is clicked', () => {
const onChange = vi.fn();
const { rerender } = render(
<TTLSelector value={undefined} onChange={onChange} />,
);

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(<TTLSelector value={60 * 60} onChange={onChange} />);
expect(input.value).toBe('');
});

it('clears TTL when custom input is emptied', () => {
const onChange = vi.fn();
render(<TTLSelector value={undefined} onChange={onChange} />);

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(<TTLSelector value={undefined} onChange={onChange} />);

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(<TTLSelector value={37 * 60} onChange={vi.fn()} />);

// 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();
});
});
13 changes: 13 additions & 0 deletions dashboard/src/components/CreateSessionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<number | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const [mode, setMode] = useState<'single' | 'batch'>('single');
const [batchRows, setBatchRows] = useState<BatchRow[]>([makeRow(), makeRow()]);
const [sharedPrompt, setSharedPrompt] = useState('');
const [batchTtl, setBatchTtl] = useState<number | undefined>(undefined);
const [batchResult, setBatchResult] = useState<{
sessions: Array<{ id: string; name: string }>;
created: number;
Expand All @@ -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');
}
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -312,6 +319,9 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal
</select>
</div>

{/* TTL */}
<TTLSelector value={ttl} onChange={setTtl} />

{/* Error */}
{error && (
<div className="text-xs text-[#ff3366] bg-[#ff3366]/10 border border-[#ff3366]/20 rounded px-3 py-2">
Expand Down Expand Up @@ -432,6 +442,9 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal
</select>
</div>

{/* TTL */}
<TTLSelector value={batchTtl} onChange={setBatchTtl} />

{/* Error */}
{error && (
<div className="text-xs text-[#ff3366] bg-[#ff3366]/10 border border-[#ff3366]/20 rounded px-3 py-2">
Expand Down
110 changes: 110 additions & 0 deletions dashboard/src/components/TTLSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(
isCustom ? String(Math.floor((value ?? 0) / 60)) : ''
);

function handlePresetClick(seconds: number) {
onChange(seconds);
setCustomInput('');
}

function handleCustomChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-500" />
<label className="block text-xs font-medium text-gray-400">
Session TTL <span className="text-gray-600">(optional)</span>
</label>
</div>

{/* Preset buttons */}
<div className="grid grid-cols-4 gap-2">
{TTL_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => handlePresetClick(preset.seconds)}
className={`py-2 px-2 text-xs rounded transition-colors border ${
value === preset.seconds
? 'bg-[#00e5ff]/10 border-[#00e5ff] text-[#00e5ff]'
: 'border-[#1a1a2e] text-gray-400 hover:text-gray-300 hover:border-[#2a2a3e]'
}`}
>
{preset.label}
</button>
))}
</div>

{/* Custom input */}
<div>
<input
type="number"
value={customInput}
onChange={handleCustomChange}
placeholder="Custom minutes…"
min="1"
className="w-full min-h-[44px] px-3 py-2.5 text-sm bg-[#0a0a0f] border border-[#1a1a2e] rounded text-gray-200 placeholder-gray-600 focus:outline-none focus:border-[#00e5ff]"
/>
{customInput && !isNaN(parseInt(customInput, 10)) && (
<p className="text-xs text-gray-500 mt-1">
{formatDuration(parseInt(customInput, 10) * 60)}
</p>
)}
</div>

{/* Current value display */}
{value !== undefined && (
<p className="text-xs text-gray-500">
TTL: {formatDuration(value)}
</p>
)}
</div>
);
}

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`;
}
1 change: 1 addition & 0 deletions dashboard/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export interface CreateSessionRequest {
permissionMode?: string;
/** @deprecated Use permissionMode. */
autoApprove?: boolean;
ttl_seconds?: number;
}

// ── Pane ────────────────────────────────────────────────────────
Expand Down