From d95a4becfbea3f8918dbc5e011c42ccfdc05e7de Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 3 Apr 2026 21:25:18 +0200 Subject: [PATCH 1/3] fix: resolve conflict in SessionHeader.tsx and CreateSessionModal.tsx --- dashboard/src/api/client.ts | 44 ++++ .../src/components/CreateSessionModal.tsx | 163 ++++++++++++-- .../src/components/SaveTemplateModal.tsx | 205 ++++++++++++++++++ .../src/components/session/SessionHeader.tsx | 9 +- dashboard/src/pages/SessionDetailPage.tsx | 9 + dashboard/src/types/index.ts | 19 ++ src/server.ts | 137 +++++++++++- src/template-store.ts | 150 +++++++++++++ 8 files changed, 716 insertions(+), 20 deletions(-) create mode 100644 dashboard/src/components/SaveTemplateModal.tsx create mode 100644 src/template-store.ts diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 20e86210..55dcd4fe 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -585,3 +585,47 @@ export function revokeAuthKey(id: string): Promise { schemaContext: 'revokeAuthKey', }); } + +// ── Session Templates (Issue #467) ────────────────────────────── + +import type { SessionTemplate } from '../types/index.js'; + +export function createTemplate(opts: { + name: string; + description?: string; + sessionId?: string; + workDir?: string; + prompt?: string; + claudeCommand?: string; + env?: Record; + stallThresholdMs?: number; + permissionMode?: string; + autoApprove?: boolean; + memoryKeys?: string[]; +}): Promise { + return request('/v1/templates', { + method: 'POST', + body: JSON.stringify(opts), + }); +} + +export function getTemplates(): Promise { + return request('/v1/templates'); +} + +export function getTemplate(id: string): Promise { + return request(`/v1/templates/${encodeURIComponent(id)}`); +} + +export function updateTemplate(id: string, updates: Partial[0]>): Promise { + return request(`/v1/templates/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); +} + +export function deleteTemplate(id: string): Promise { + return request(`/v1/templates/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); +} diff --git a/dashboard/src/components/CreateSessionModal.tsx b/dashboard/src/components/CreateSessionModal.tsx index f5b6101a..ab75ac11 100644 --- a/dashboard/src/components/CreateSessionModal.tsx +++ b/dashboard/src/components/CreateSessionModal.tsx @@ -5,8 +5,8 @@ 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'; +import { createSession, batchCreateSessions, getTemplates } from '../api/client'; +import type { SessionTemplate } from '../types'; interface CreateSessionModalProps { open: boolean; @@ -83,18 +83,30 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal } }, [open]); + // Load templates + const [templates, setTemplates] = useState([]); + const [templatesLoading, setTemplatesLoading] = useState(false); + + useEffect(() => { + if (!open) return; + setTemplatesLoading(true); + getTemplates() + .then(setTemplates) + .catch(() => setTemplates([])) + .finally(() => setTemplatesLoading(false)); + }, [open]); + const [workDir, setWorkDir] = useState(''); 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 [mode, setMode] = useState<'single' | 'batch' | 'template'>('single'); + const [selectedTemplateId, setSelectedTemplateId] = useState(''); 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; @@ -107,14 +119,13 @@ 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'); + setSelectedTemplateId(''); } function addBatchRow(): void { @@ -154,7 +165,6 @@ 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, }); @@ -191,7 +201,6 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal name: name.trim() || undefined, prompt: prompt.trim() || undefined, permissionMode, - ttl_seconds: ttl, signal: controller.signal, }); resetForm(); @@ -245,6 +254,19 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal > Batch + {templates.length > 0 && ( + + )} + + + + )} + {/* Batch results */} {batchResult && (
diff --git a/dashboard/src/components/SaveTemplateModal.tsx b/dashboard/src/components/SaveTemplateModal.tsx new file mode 100644 index 00000000..3caf9f21 --- /dev/null +++ b/dashboard/src/components/SaveTemplateModal.tsx @@ -0,0 +1,205 @@ +/** + * components/SaveTemplateModal.tsx — Modal dialog for saving a session as a template. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { X, Loader2 } from 'lucide-react'; +import { createTemplate } from '../api/client'; +import { useToastStore } from '../store/useToastStore'; + +interface SaveTemplateModalProps { + open: boolean; + onClose: () => void; + sessionId: string; +} + +export default function SaveTemplateModal({ open, onClose, sessionId }: SaveTemplateModalProps) { + const abortRef = useRef(null); + const modalRef = useRef(null); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const addToast = useToastStore((t) => t.addToast); + + const handleClose = useCallback((): void => { + setName(''); + setDescription(''); + setError(null); + setLoading(false); + onClose(); + }, [onClose]); + + // Close on Escape key + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + abortRef.current?.abort(); + handleClose(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, handleClose]); + + // Focus trap + useEffect(() => { + if (!open) return; + const modal = modalRef.current; + if (!modal) return; + + const FOCUSABLE_SELECTOR = 'input, textarea, button, [tabindex]:not([tabindex="-1"])'; + + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + const focusable = Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + modal.addEventListener('keydown', handler); + return () => modal.removeEventListener('keydown', handler); + }, [open]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!name.trim()) { + setError('Template name is required'); + return; + } + + setLoading(true); + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + try { + await createTemplate({ + name: name.trim(), + description: description.trim() || undefined, + sessionId, + }); + addToast('success', 'Template saved', `"${name.trim()}" created successfully`); + handleClose(); + } catch (err) { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : 'Failed to save template'); + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + setLoading(false); + } + } + } + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

Save as Template

+ +
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + placeholder="My template name" + className="w-full px-3 py-2 text-sm rounded bg-[#0a0a0f] border border-[#1a1a2e] text-gray-100 placeholder-gray-600 focus:outline-none focus:border-[#00e5ff] transition-colors" + disabled={loading} + /> +
+ +
+ +