mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[aidan] ux: sidebar naming bug fix
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user