[aidan] ux: sidebar naming bug fix

This commit is contained in:
abccodes
2026-06-12 19:27:14 -07:00
parent 953b9fca73
commit f890d6528a
10 changed files with 113 additions and 22 deletions
+1 -1
View File
@@ -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"
@@ -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,
});
}
@@ -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<Props> = ({ open, onClose }) => {
const sessionMap = new Map<string, SessionResult>();
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,
@@ -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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography noWrap sx={{ color: c.text.primary, fontWeight: 600 }}>{session.name}</Typography>
<Typography noWrap sx={{ color: c.text.primary, fontWeight: 600 }}>{displaySessionName(session.name)}</Typography>
{!isDraft && statusStyle && session.status !== 'completed' && session.status !== 'stopped' && (
// Status speaks only when it needs the user; finished work sits quiet.
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
@@ -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<Props> = ({
}}
>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.name}
{displaySessionName(session.name)}
</Typography>
{/* Status speaks only when it needs the user; finished work sits quiet. */}
{session.status !== 'completed' && session.status !== 'stopped' && (
@@ -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<string | null>(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]);
}
+51 -9
View File
@@ -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<Props> = ({ 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<Props> = ({ output }) => {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastPollRef = useRef<string>('');
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ output }) => {
>
<TextField
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => { nameSetByUserRef.current = true; setName(e.target.value); }}
placeholder="App name"
variant="standard"
sx={{
+11 -6
View File
@@ -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 ?? {},
};
}
@@ -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;
}
+2 -1
View File
@@ -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,