diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index f2b9f59d..972e9beb 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -4,7 +4,7 @@ from datetime import datetime from uuid import uuid4 class AgentConfig(BaseModel): - name: str = Field(default_factory=lambda: f"Agent-{uuid4().hex[:6]}") + name: str = "" model: str = "sonnet" mode: str = "agent" provider: str = "anthropic" diff --git a/frontend/src/app/components/overlays/DynamicIsland.tsx b/frontend/src/app/components/overlays/DynamicIsland.tsx index 0333b8c7..2ccfce7e 100644 --- a/frontend/src/app/components/overlays/DynamicIsland.tsx +++ b/frontend/src/app/components/overlays/DynamicIsland.tsx @@ -27,6 +27,7 @@ import { AgentSession, HistorySession, } from '@/shared/state/agentsSlice'; +import { displaySessionName } from '@/shared/state/sessionDisplay'; import { API_BASE, getAuthToken } from '@/shared/config'; import { store } from '@/shared/state/store'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; @@ -299,7 +300,7 @@ const DynamicIsland: React.FC = () => { if (session.pending_approvals?.length > 0) { result.push({ sessionId, - sessionName: session.name || 'Agent', + sessionName: displaySessionName(session.name), approvals: session.pending_approvals, }); } diff --git a/frontend/src/app/components/overlays/GlobalSearchPalette.tsx b/frontend/src/app/components/overlays/GlobalSearchPalette.tsx index b9c67482..4b1ab44f 100644 --- a/frontend/src/app/components/overlays/GlobalSearchPalette.tsx +++ b/frontend/src/app/components/overlays/GlobalSearchPalette.tsx @@ -10,6 +10,7 @@ import BoltIcon from '@mui/icons-material/Bolt'; import { useNavigate } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { searchHistory, resumeSession, HistorySession } from '@/shared/state/agentsSlice'; +import { displaySessionName } from '@/shared/state/sessionDisplay'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { createDashboard } from '@/shared/state/dashboardsSlice'; import { openSettingsModal } from '@/shared/state/settingsSlice'; @@ -111,11 +112,12 @@ const GlobalSearchPalette: React.FC = ({ open, onClose }) => { const sessionMap = new Map(); for (const s of Object.values(sessions)) { - if (q && !(s.name || '').toLowerCase().includes(q)) continue; + const sessionDisplayName = displaySessionName(s.name); + if (q && !sessionDisplayName.toLowerCase().includes(q)) continue; sessionMap.set(s.id, { kind: 'session', id: s.id, - name: s.name || 'Untitled', + name: sessionDisplayName, dashboardId: s.dashboard_id || null, status: s.status, closedAt: null, diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 5fc18742..02f6b3e7 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -39,6 +39,7 @@ import { clearSessionMessages, clearMcpSuggestions, } from '@/shared/state/agentsSlice'; +import { displaySessionName } from '@/shared/state/sessionDisplay'; import { store } from '@/shared/state/store'; import { fetchModes } from '@/shared/state/modesSlice'; import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager'; @@ -963,7 +964,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose > - {session.name} + {displaySessionName(session.name)} {!isDraft && statusStyle && session.status !== 'completed' && session.status !== 'stopped' && ( // Status speaks only when it needs the user; finished work sits quiet. diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index a9a383db..7714eef0 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -20,6 +20,7 @@ import { collapseSession, closeSession, } from '@/shared/state/agentsSlice'; +import { displaySessionName } from '@/shared/state/sessionDisplay'; import { setCardPosition, setCardSize, @@ -745,7 +746,7 @@ const AgentCard: React.FC = ({ }} > - {session.name} + {displaySessionName(session.name)} {/* Status speaks only when it needs the user; finished work sits quiet. */} {session.status !== 'completed' && session.status !== 'stopped' && ( diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index cd008b2e..b0b51f65 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -18,6 +18,7 @@ import { type ViewCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; +import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; @@ -251,4 +252,19 @@ export function useDashboardLifecycle({ if (!outputs[outputId]) dispatch(removeViewCard(outputId)); } }, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]); + + const namedOnFirstMessageRef = useRef(null); + useEffect(() => { + if (!dashboardId || !layoutInitialized) return; + if (namedOnFirstMessageRef.current === dashboardId) return; + const dash = store.getState().dashboards.items[dashboardId]; + if (!dash) return; + if (!dash.auto_named && dash.name !== 'Untitled Dashboard') return; + const hasUserMessage = Object.values(sessions).some( + (s) => s.dashboard_id === dashboardId && s.messages?.some((m) => m.role === 'user'), + ); + if (!hasUserMessage) return; + namedOnFirstMessageRef.current = dashboardId; + dispatch(generateDashboardName(dashboardId)); + }, [sessions, dashboardId, layoutInitialized, dispatch]); } diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index 67c7feee..a3fdc59b 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -29,8 +29,9 @@ import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import Collapse from '@mui/material/Collapse'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { store } from '@/shared/state/store'; import { createDraftSession, removeDraftSession, fetchSession } from '@/shared/state/agentsSlice'; -import { createOutput, updateOutput, fetchOutputs, Output, SERVE_BASE } from '@/shared/state/outputsSlice'; +import { createOutput, updateOutput, upsertOutput, fetchOutputs, Output, SERVE_BASE } from '@/shared/state/outputsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import AgentChat from '../AgentChat/AgentChat'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -546,6 +547,14 @@ const ViewEditor: React.FC = ({ output }) => { return state.agents.sessions[effectiveSessionId]?.status ?? null; }); + // Aux-LLM-generated session title from the user's first prompt. Used as an + // early signal for the App's display name so the sidebar doesn't sit at + // "Untitled App" while the agent is still working toward its first meta.json write. + const sessionName = useAppSelector((state) => { + if (!effectiveSessionId) return ''; + return state.agents.sessions[effectiveSessionId]?.name ?? ''; + }); + const isLaunched = !!effectiveSessionId && effectiveSessionId !== initialDraftId; const isAgentActive = agentStatus === 'running' || agentStatus === 'waiting_approval'; @@ -562,7 +571,11 @@ const ViewEditor: React.FC = ({ output }) => { const pollRef = useRef | null>(null); const lastPollRef = useRef(''); - const nameSetByMeta = useRef(false); + // Tracks whether the user has explicitly typed in the name input. Once set, + // session-title and meta.json syncs leave the name alone so we don't clobber + // a user-chosen name. + const nameSetByUserRef = useRef(false); + const [fileVersion, setFileVersion] = useState(0); const pollWorkspace = useCallback(async () => { @@ -580,17 +593,29 @@ const ViewEditor: React.FC = ({ output }) => { setFileVersion(v => v + 1); } - if (data.meta) { - if (data.meta.name && !nameSetByMeta.current) { - nameSetByMeta.current = true; - setName((prev) => prev || data.meta.name); + if (data.meta && !nameSetByUserRef.current) { + const eid = output?.id ?? createdIdRef.current; + if (data.meta.name) { + setName(data.meta.name); + if (eid) { + const row = store.getState().outputs.items[eid]; + if (row && row.name !== data.meta.name) { + dispatch(upsertOutput({ ...row, name: data.meta.name })); + } + } } if (data.meta.description) { - setDescription((prev) => prev || data.meta.description); + setDescription(data.meta.description); + if (eid) { + const row = store.getState().outputs.items[eid]; + if (row && row.description !== data.meta.description) { + dispatch(upsertOutput({ ...row, description: data.meta.description })); + } + } } } } catch {} - }, [workspaceId]); + }, [workspaceId, output?.id, dispatch]); useEffect(() => { if (!workspaceId) return; @@ -621,6 +646,23 @@ const ViewEditor: React.FC = ({ output }) => { }; }, [workspaceId, pollWorkspace, isAgentActive]); + // Mirror the aux-LLM session title into the App's name as soon as the chat + // gets one, so the sidebar reflects what the user just asked for instead of + // sitting at "Untitled App" while the agent works. A later meta.json write + // still wins because the user hasn't typed a name themselves. + useEffect(() => { + if (nameSetByUserRef.current) return; + if (!sessionName) return; + const eid = output?.id ?? createdIdRef.current; + if (!eid) return; + const row = store.getState().outputs.items[eid]; + if (!row) return; + if (row.name === sessionName) return; + if (row.name !== '' && row.name !== 'Untitled App') return; + dispatch(upsertOutput({ ...row, name: sessionName })); + setName((prev) => (prev === '' || prev === 'Untitled App') ? sessionName : prev); + }, [sessionName, output?.id, dispatch]); + const prevAgentActive = useRef(false); useEffect(() => { if (prevAgentActive.current && !isAgentActive && workspaceId) { @@ -1118,7 +1160,7 @@ const ViewEditor: React.FC = ({ output }) => { > setName(e.target.value)} + onChange={(e) => { nameSetByUserRef.current = true; setName(e.target.value); }} placeholder="App name" variant="standard" sx={{ diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 2e1d5b4c..abb3256e 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { API_BASE } from '@/shared/config'; +import { normalizeSessionName } from './sessionDisplay'; const AGENTS_API = `${API_BASE}/agents`; @@ -588,7 +589,7 @@ const agentsSlice = createSlice({ updateSessionName(state, action: PayloadAction<{ sessionId: string; name: string }>) { const session = state.sessions[action.payload.sessionId]; if (session) { - session.name = action.payload.name; + session.name = normalizeSessionName(action.payload.name); } }, @@ -634,6 +635,7 @@ const agentsSlice = createSlice({ : action.payload.pending_approvals ?? []; state.sessions[action.payload.id] = { ...action.payload, + name: normalizeSessionName(action.payload.name), pending_approvals: mergedApprovals, tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta }, }; @@ -1035,6 +1037,7 @@ const agentsSlice = createSlice({ const existing = state.sessions[s.id]; state.sessions[s.id] = { ...s, + name: normalizeSessionName(s.name), // This is a METADATA poll (status/name); the chat owns its messages // via fetchSession + the WS stream. A poll response computed before a // just-sent user turn must NOT clobber the live array, that intermittently @@ -1058,7 +1061,7 @@ const agentsSlice = createSlice({ state.loading = false; }) .addCase(launchAgent.fulfilled, (state, action) => { - state.sessions[action.payload.id] = { ...action.payload, tool_group_meta: action.payload.tool_group_meta ?? {} }; + state.sessions[action.payload.id] = { ...action.payload, name: normalizeSessionName(action.payload.name), tool_group_meta: action.payload.tool_group_meta ?? {} }; state.activeSessionId = action.payload.id; if (!state.expandedSessionIds.includes(action.payload.id)) { state.expandedSessionIds.push(action.payload.id); @@ -1071,7 +1074,7 @@ const agentsSlice = createSlice({ const { draftId, session } = action.payload; const shouldExpand = action.meta.arg.expand !== false; delete state.sessions[draftId]; - state.sessions[session.id] = { ...session, tool_group_meta: session.tool_group_meta ?? {} }; + state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {} }; state.activeSessionId = session.id; state.draftLaunchMap[draftId] = session.id; state.expandedSessionIds = state.expandedSessionIds.map((id) => (id === draftId ? session.id : id)); @@ -1085,7 +1088,7 @@ const agentsSlice = createSlice({ .addCase(generateTitle.fulfilled, (state, action) => { const session = state.sessions[action.payload.sessionId]; if (session) { - session.name = action.payload.title; + session.name = normalizeSessionName(action.payload.title); } }) .addCase(generateGroupMeta.fulfilled, (state, action) => { @@ -1160,7 +1163,7 @@ const agentsSlice = createSlice({ }) .addCase(duplicateSession.fulfilled, (state, action) => { const session = action.payload; - state.sessions[session.id] = session; + state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name) }; }) .addCase(closeSession.fulfilled, (state, action) => { const sessionId = action.payload; @@ -1227,7 +1230,7 @@ const agentsSlice = createSlice({ }) .addCase(resumeSession.fulfilled, (state, action) => { const session = action.payload; - state.sessions[session.id] = { ...session, tool_group_meta: session.tool_group_meta ?? {} }; + state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {} }; delete state.history[session.id]; state.activeSessionId = session.id; if (!state.expandedSessionIds.includes(session.id)) { @@ -1285,6 +1288,7 @@ const agentsSlice = createSlice({ } state.sessions[session.id] = { ...session, + name: normalizeSessionName(session.name), messages: mergedMessages, pending_approvals: session.pending_approvals ?? existing?.pending_approvals ?? [], tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {}, @@ -1315,6 +1319,7 @@ const agentsSlice = createSlice({ if (!state.sessions[session.id]) { state.sessions[session.id] = { ...session, + name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, }; } diff --git a/frontend/src/shared/state/sessionDisplay.ts b/frontend/src/shared/state/sessionDisplay.ts new file mode 100644 index 00000000..8dd26a08 --- /dev/null +++ b/frontend/src/shared/state/sessionDisplay.ts @@ -0,0 +1,22 @@ +// Placeholder shown when a session has no real title yet (draft state, or the +// ~500ms-2s window between launch and the aux-LLM title landing). +export const SESSION_NAME_PLACEHOLDER = 'New chat'; + +// Old backend default was `Agent-<6-hex>`. Catch any session loaded from a +// pre-fix on-disk record so the hex id never reaches the UI. +const LEGACY_AUTO_NAME = /^Agent-[a-f0-9]{4,8}$/i; + +export function isLegacyAutoName(name: string | null | undefined): boolean { + return !!name && LEGACY_AUTO_NAME.test(name); +} + +export function displaySessionName(name: string | null | undefined): string { + if (!name || isLegacyAutoName(name)) return SESSION_NAME_PLACEHOLDER; + return name; +} + +// Used by reducers to normalize the legacy auto-name out at intake. +export function normalizeSessionName(name: string | null | undefined): string { + if (!name || isLegacyAutoName(name)) return ''; + return name; +} diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 5156d085..9a77de51 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -25,6 +25,7 @@ import { import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; +import { displaySessionName } from '../state/sessionDisplay'; import { getAuthToken } from '../config'; import { notifyAgentCompletion } from '../notifications'; @@ -480,7 +481,7 @@ class WebSocketManager { .find((m: any) => m.role === 'assistant' && typeof m.content === 'string'); notifyAgentCompletion({ sessionId: session_id, - sessionName: sess.name || 'Agent', + sessionName: displaySessionName(sess.name), dashboardId: sess.dashboard_id, status: data.status as 'completed' | 'error', bodyExcerpt: lastAssistant ? String(lastAssistant.content) : undefined,