From 556e57a9e6a258b909c48bc3c7d9cc9b4a6adbd3 Mon Sep 17 00:00:00 2001 From: vprudnikoff Date: Fri, 5 Jun 2026 16:41:37 +0100 Subject: [PATCH 1/3] feat(web): add optional Session Name field to the Spawn Agent dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Spawn Agent dialog had no way to set a session name, so every web-UI session got an auto-generated cao- name. That hash is the only label the session list shows and searches by, and there is no rename — so the spawn dialog is the one place a user can give a session a readable identity. The backend POST /sessions and the CLI --session-name already accept a custom name; this just exposes it in the UI. - AgentPanel: add an optional "Session Name" input, pass it to createSession - store: thread sessionName through createSession (it was hardcoded undefined) - api: url-encode session_name in the query string (mirrors working_directory) - test: createSession includes the url-encoded session_name --- web/src/api.ts | 2 +- web/src/components/AgentPanel.tsx | 21 +++++++++++++++++++-- web/src/store.ts | 6 +++--- web/src/test/api.test.ts | 9 +++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/web/src/api.ts b/web/src/api.ts index 6257bdae..5fb69cb9 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=${provider}&agent_profile=${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 diff --git a/web/src/components/AgentPanel.tsx b/web/src/components/AgentPanel.tsx index a19174bf..cccefb21 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' @@ -45,6 +45,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') @@ -138,11 +139,12 @@ export function AgentPanel() { const handleCreate = async () => { if (!profile.trim()) return setCreating(true) - await createSession(provider, profile.trim(), workingDirectory.trim() || undefined) + await createSession(provider, profile.trim(), workingDirectory.trim() || undefined, sessionName.trim() || undefined) setCreating(false) setShowSpawnModal(false) setProfile('') setWorkingDirectory('') + setSessionName('') } const openTerminal = (terminalId: string, provider?: string, agentProfile?: string | null) => { @@ -563,6 +565,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..6cade575 100644 --- a/web/src/test/api.test.ts +++ b/web/src/test/api.test.ts @@ -85,6 +85,15 @@ 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('deleteSession sends DELETE', async () => { mockResponse({ success: true, deleted: [], errors: [] }) await api.deleteSession('s1') From ee900b20865654942dc7c0a77fafcef912b96b6c Mon Sep 17 00:00:00 2001 From: vprudnikoff Date: Sat, 6 Jun 2026 14:48:08 +0000 Subject: [PATCH 2/3] fix(web): url-encode provider and agent_profile in session creation agent_profile can be free-text when no profiles are loaded, so a space or reserved character would corrupt the query string. encodeURIComponent both provider and agent_profile, matching the existing session_name and working_directory encoding. Applied to createSession and the sibling addTerminalToSession, which shared the same raw interpolation. --- web/src/api.ts | 4 ++-- web/src/test/api.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/web/src/api.ts b/web/src/api.ts index 5fb69cb9..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=${encodeURIComponent(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/test/api.test.ts b/web/src/test/api.test.ts index 6cade575..814c2ad5 100644 --- a/web/src/test/api.test.ts +++ b/web/src/test/api.test.ts @@ -94,6 +94,15 @@ describe('API wrapper', () => { ) }) + 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') From d90c783c887b84a0a1d95f0d27a3ac917431d66a Mon Sep 17 00:00:00 2001 From: vprudnikoff Date: Fri, 19 Jun 2026 13:36:34 +0100 Subject: [PATCH 3/3] fix(web): guard against duplicate session creation on rapid submit handleCreate is reachable via the Spawn button and via Enter in the modal inputs; the inputs bypass the button's disabled state, so a rapid double-press could fire a second createSession before the `creating` state re-renders. Add a synchronous useRef in-flight lock, and move the success-side resets into a try/finally so `creating` is always cleared (even if createSession throws) and the dialog stays open with its fields preserved on failure. --- web/src/components/AgentPanel.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/web/src/components/AgentPanel.tsx b/web/src/components/AgentPanel.tsx index cccefb21..d8baf829 100644 --- a/web/src/components/AgentPanel.tsx +++ b/web/src/components/AgentPanel.tsx @@ -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) @@ -137,14 +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, sessionName.trim() || undefined) - setCreating(false) - setShowSpawnModal(false) - setProfile('') - setWorkingDirectory('') - setSessionName('') + 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) => {