diff --git a/web/src/api.ts b/web/src/api.ts index 6257bdae..ac12c824 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -107,7 +107,7 @@ export const api = { listSessions: () => fetchJSON('/sessions'), getSession: (name: string) => fetchJSON(`/sessions/${name}`), createSession: (provider: string, agentProfile: string, sessionName?: string, workingDirectory?: string) => - fetchJSON(`/sessions?provider=${provider}&agent_profile=${agentProfile}${sessionName ? `&session_name=${sessionName}` : ''}${workingDirectory ? `&working_directory=${encodeURIComponent(workingDirectory)}` : ''}`, { method: 'POST', timeoutMs: 90000 }), + fetchJSON(`/sessions?provider=${encodeURIComponent(provider)}&agent_profile=${encodeURIComponent(agentProfile)}${sessionName ? `&session_name=${encodeURIComponent(sessionName)}` : ''}${workingDirectory ? `&working_directory=${encodeURIComponent(workingDirectory)}` : ''}`, { method: 'POST', timeoutMs: 90000 }), deleteSession: (name: string) => fetchJSON<{ success: boolean; deleted: string[]; errors: any[] }>(`/sessions/${name}`, { method: 'DELETE' }), // Terminals @@ -123,7 +123,7 @@ export const api = { getWorkingDirectory: (id: string) => fetchJSON<{ working_directory: string | null }>(`/terminals/${id}/working-directory`), addTerminalToSession: (sessionName: string, provider: string, agentProfile: string, workingDirectory?: string) => - fetchJSON(`/sessions/${sessionName}/terminals?provider=${provider}&agent_profile=${agentProfile}${workingDirectory ? `&working_directory=${encodeURIComponent(workingDirectory)}` : ''}`, { method: 'POST', timeoutMs: 90000 }), + fetchJSON(`/sessions/${sessionName}/terminals?provider=${encodeURIComponent(provider)}&agent_profile=${encodeURIComponent(agentProfile)}${workingDirectory ? `&working_directory=${encodeURIComponent(workingDirectory)}` : ''}`, { method: 'POST', timeoutMs: 90000 }), // Inbox getInboxMessages: (terminalId: string, limit?: number, status?: string) => diff --git a/web/src/components/AgentPanel.tsx b/web/src/components/AgentPanel.tsx index a19174bf..d8baf829 100644 --- a/web/src/components/AgentPanel.tsx +++ b/web/src/components/AgentPanel.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { useStore } from '../store' import { api, AgentProfileInfo, ProviderInfo } from '../api' -import { Bot, Play, Trash2, ChevronRight, Terminal as TermIcon, Monitor, Package, FolderOpen, Search, Mail, Plus, LogOut, Send, FileText, X } from 'lucide-react' +import { Bot, Play, Trash2, ChevronRight, Terminal as TermIcon, Monitor, Package, FolderOpen, Tag, Search, Mail, Plus, LogOut, Send, FileText, X } from 'lucide-react' import { TerminalView } from './TerminalView' import { ConfirmModal } from './ConfirmModal' import { InboxPanel } from './InboxPanel' @@ -25,6 +25,10 @@ export function AgentPanel() { const [provider, setProvider] = useState('kiro_cli') const [profile, setProfile] = useState('') const [creating, setCreating] = useState(false) + // Synchronous in-flight lock: prevents a second submit (rapid double-click or + // Enter in the form inputs, which bypass the button's disabled state) from + // firing before the `creating` state re-renders and creating a duplicate session. + const creatingRef = useRef(false) const [liveTerminal, setLiveTerminal] = useState<{ id: string; provider?: string; agentProfile?: string | null } | null>(null) const [profiles, setProfiles] = useState([]) const [loadingProfiles, setLoadingProfiles] = useState(true) @@ -45,6 +49,7 @@ export function AgentPanel() { const [sessionSearch, setSessionSearch] = useState('') const [inboxTerminalId, setInboxTerminalId] = useState(null) const [workingDirectory, setWorkingDirectory] = useState('') + const [sessionName, setSessionName] = useState('') const [terminalWorkDirs, setTerminalWorkDirs] = useState>({}) const [showAddAgent, setShowAddAgent] = useState(false) const [addProvider, setAddProvider] = useState('kiro_cli') @@ -136,13 +141,19 @@ export function AgentPanel() { }, [activeSessionDetail?.terminals.map(t => t.id).join(',')]) const handleCreate = async () => { - if (!profile.trim()) return + if (creatingRef.current || !profile.trim()) return + creatingRef.current = true setCreating(true) - await createSession(provider, profile.trim(), workingDirectory.trim() || undefined) - setCreating(false) - setShowSpawnModal(false) - setProfile('') - setWorkingDirectory('') + try { + await createSession(provider, profile.trim(), workingDirectory.trim() || undefined, sessionName.trim() || undefined) + setShowSpawnModal(false) + setProfile('') + setWorkingDirectory('') + setSessionName('') + } finally { + setCreating(false) + creatingRef.current = false + } } const openTerminal = (terminalId: string, provider?: string, agentProfile?: string | null) => { @@ -563,6 +574,21 @@ export function AgentPanel() { )} +
+ +
+ + setSessionName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreate()} + placeholder="my-session (or a random id like cao-a1b2c3d4)" + className="w-full bg-gray-900 border border-gray-700 text-gray-200 text-sm rounded-lg pl-9 pr-3 py-2.5 focus:border-emerald-500 focus:outline-none" + /> +
+
+
diff --git a/web/src/store.ts b/web/src/store.ts index b8e0bdb0..674b3336 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -21,7 +21,7 @@ interface Store { fetchSessions: () => Promise selectSession: (name: string | null) => Promise - createSession: (provider: string, agentProfile: string, workingDirectory?: string) => Promise + createSession: (provider: string, agentProfile: string, workingDirectory?: string, sessionName?: string) => Promise deleteSession: (name: string) => Promise showSnackbar: (snackbar: Snackbar) => void hideSnackbar: () => void @@ -72,9 +72,9 @@ export const useStore = create((set, get) => ({ } }, - createSession: async (provider, agentProfile, workingDirectory) => { + createSession: async (provider, agentProfile, workingDirectory, sessionName) => { try { - await api.createSession(provider, agentProfile, undefined, workingDirectory) + await api.createSession(provider, agentProfile, sessionName, workingDirectory) get().showSnackbar({ type: 'success', message: 'Session created' }) await get().fetchSessions() } catch (e: any) { diff --git a/web/src/test/api.test.ts b/web/src/test/api.test.ts index 410b2350..814c2ad5 100644 --- a/web/src/test/api.test.ts +++ b/web/src/test/api.test.ts @@ -85,6 +85,24 @@ describe('API wrapper', () => { ) }) + it('createSession includes session name (url-encoded) when provided', async () => { + mockResponse({ id: 't1' }) + await api.createSession('kiro_cli', 'developer', 'my session/1') + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('session_name=my%20session%2F1'), + expect.any(Object) + ) + }) + + it('createSession url-encodes provider and agent_profile', async () => { + mockResponse({ id: 't1' }) + await api.createSession('kiro_cli', 'my agent/v2') + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('agent_profile=my%20agent%2Fv2'), + expect.any(Object) + ) + }) + it('deleteSession sends DELETE', async () => { mockResponse({ success: true, deleted: [], errors: [] }) await api.deleteSession('s1')