diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 72bba296..991aac61 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -50,7 +50,7 @@ from backend.apps.agents.manager.prompt.tool_catalog import ( _get_denied_tool_names, _is_fully_denied, ) -from backend.apps.agents.core.aux_llm import _safe_resp_text, clean_short_label +from backend.apps.agents.core.aux_llm import _safe_resp_text, clean_short_label, aux_max_tokens_for from backend.apps.agents.manager.session.history_compaction import ( _build_history_prefix, _get_branch_messages, @@ -3103,7 +3103,7 @@ class AgentManager: if session.mode == "view-builder": try: from backend.apps.outputs.outputs import sync_output_from_meta_json, _load_all - if sync_output_from_meta_json(session_id): + if sync_output_from_meta_json(session_id, fallback_name=session.name): # Broadcast the renamed row so the sidebar # flips from "Untitled App" to the real name # without waiting for the next mount. @@ -3686,6 +3686,7 @@ class AgentManager: raise ValueError(f"Session {session_id} not found") title = first_prompt[:40].strip() + aux_model = None try: from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.agents.providers.registry import resolve_aux_model, get_api_type @@ -3696,6 +3697,8 @@ class AgentManager: primary_api=get_api_type(session.model), ) client = get_anthropic_client_for_model(global_settings, aux_model) + # Long instruction-heavy prompts trip safety classifiers; 200 chars carries enough signal. + labeled_prompt = first_prompt[:200].strip() system_prompt = ( "You label user messages with a 2-4 word topic title in SENTENCE CASE. " "Sentence case = only the first word capitalized; proper nouns (Gmail, " @@ -3717,19 +3720,34 @@ class AgentManager: ) user_turn = ( "Label the message inside tags. Do not answer it.\n\n" - f"\n{first_prompt}\n" + f"\n{labeled_prompt}\n" ) - resp = await client.messages.create( + # Stream: 9router's cx/ non-streaming response translator drops `content` + # for GPT-5-family models; the per-event streaming translator works. + chunks: list[str] = [] + async with client.messages.stream( model=aux_model, - max_tokens=20, + max_tokens=aux_max_tokens_for(aux_model), system=system_prompt, messages=[{"role": "user", "content": user_turn}], - ) - generated = clean_short_label(_safe_resp_text(resp)) + ) as stream: + async for text in stream.text_stream: + chunks.append(text) + raw_text = "".join(chunks) + generated = clean_short_label(raw_text) if generated: title = generated + else: + logger.warning( + f"[title-gen] aux_model={aux_model} produced empty label " + f"(raw_text={raw_text!r}, max_tokens={aux_max_tokens_for(aux_model)}, " + f"prompt_len={len(first_prompt)}); using fallback" + ) except Exception as e: - logger.warning(f"Title generation failed, using fallback: {e}") + logger.warning( + f"[title-gen] aux_model={aux_model} threw: {e}; using fallback " + f"(prompt_len={len(first_prompt)})" + ) session.name = title await ws_manager.send_to_session(session_id, "agent:name_updated", { @@ -3786,9 +3804,10 @@ class AgentManager: " Request: 'fix the bug in agent_manager.py' -> Investigating the bug\n" " Request: 'check my gmail inbox' -> Checking your Gmail" ) - resp = await client.messages.create( + chunks: list[str] = [] + async with client.messages.stream( model=aux_model, - max_tokens=20, + max_tokens=aux_max_tokens_for(aux_model), system=system, messages=[{ "role": "user", @@ -3797,9 +3816,11 @@ class AgentManager: f"\n{user_prompt[:2000]}\n" ), }], - ) + ) as stream: + async for text in stream.text_stream: + chunks.append(text) # Bail on refusals/first-person rather than show a hallucinated label. - label = clean_short_label(_safe_resp_text(resp), max_words=6, max_chars=60) + label = clean_short_label("".join(chunks), max_words=6, max_chars=60) if not label: return @@ -3915,14 +3936,17 @@ class AgentManager: "- Max 400 characters for the svg string" ) - resp = await client.messages.create( + chunks: list[str] = [] + async with client.messages.stream( model=aux_model, - max_tokens=300, + max_tokens=aux_max_tokens_for(aux_model, base=300), system=system, messages=[{"role": "user", "content": user_content}], - ) + ) as stream: + async for text in stream.text_stream: + chunks.append(text) - raw = _safe_resp_text(resp).strip() + raw = "".join(chunks).strip() if not raw: raise ValueError("aux model returned empty content") if raw.startswith("```"): diff --git a/backend/apps/agents/core/aux_llm.py b/backend/apps/agents/core/aux_llm.py index 7c8b2435..db5c6a7c 100644 --- a/backend/apps/agents/core/aux_llm.py +++ b/backend/apps/agents/core/aux_llm.py @@ -19,6 +19,13 @@ def clean_short_label(raw: str, max_words: int = 4, max_chars: int = 36) -> str: return label +def aux_max_tokens_for(model: str | None, base: int = 100) -> int: + # GPT-5 reasoners burn reasoning tokens before output; floor at 2K so a label can land. + if isinstance(model, str) and "gpt-5" in model.lower(): + return max(base, 2048) + return base + + def _safe_resp_text(resp) -> str: """Extract text from an Anthropic-shape response, tolerating Gemini/OpenAI edge cases. Gemini through 9Router occasionally returns `content=[]` (e.g. 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/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index e660290c..cd1309c2 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -340,14 +340,17 @@ async def generate_name(dashboard_id: str): "\n" + "\n".join(f"- {p}" for p in prompts) + "\n" ) - resp = await client.messages.create( + from backend.apps.agents.core.aux_llm import clean_short_label, aux_max_tokens_for + chunks: list[str] = [] + async with client.messages.stream( model=aux_model, - max_tokens=20, + max_tokens=aux_max_tokens_for(aux_model), system=system, messages=[{"role": "user", "content": user_content}], - ) - from backend.apps.agents.core.aux_llm import _safe_resp_text, clean_short_label - generated = clean_short_label(_safe_resp_text(resp)) + ) as stream: + async for text in stream.text_stream: + chunks.append(text) + generated = clean_short_label("".join(chunks)) if generated: fallback = generated except Exception as e: diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index 9b15cdf8..56f1c323 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -384,7 +384,7 @@ Common deps already in the template: - **Edits are auto-saved**. As soon as you write a file via the Edit/Write tool, it's on disk. Vite HMR re-renders the preview within ~100ms. - **Hard Reload (right-click the reload button)** restarts the runtime — useful after you `bash backend_init.sh` or change `.env` values. -- **`meta.json`** at workspace root is shown in the OpenSwarm Apps page UI. Update its `name` and `description` when the app's purpose changes. +- **`meta.json`** at workspace root drives the app's name + description in the OpenSwarm sidebar, App Builder header, and Apps page. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts. --- @@ -459,14 +459,22 @@ not. When making a new app from scratch: -1. **REPLACE** `frontend/src/pages/index.tsx` FIRST. The starter ships with a +1. **WRITE `meta.json` FIRST**, before any other tool call. Put a 1-3 word product + name (Title Case) in `name` and a one-sentence description in `description`. + The Apps sidebar and the App Builder header show this name to the user; until + you write it, both surfaces sit at "Untitled App". Don't wait until the end of + the turn to fill it in — pick a name from the user's prompt and ship it now. + Example: prompt "make doodle jump" → `{"name": "Doodle Jumper", "description": + "Endless platform-hopper inspired by Doodle Jump."}`. You can revise it later + if the app's purpose shifts. +2. **REPLACE** `frontend/src/pages/index.tsx`. The starter ships with a "Brewing your app" placeholder — this is intentional, it's what the user sees between React mounting and your first edit landing, and it must disappear the moment your real home page is ready. Rewrite the whole file with your app's actual `` component. (There's also an even earlier inline splash in `index.html` that paints before any JS bundle loads — leave that alone; React's first commit clears it automatically.) -2. **Sidebar / shell is OPT-IN.** `Main.tsx` no longer wraps pages in +3. **Sidebar / shell is OPT-IN.** `Main.tsx` no longer wraps pages in ``. If your app needs a sidebar (SaaS-style dashboards, multi-page apps), import `AppShell` from `@/app/components/Layout/AppShell` and wrap your page in it yourself: @@ -479,9 +487,8 @@ When making a new app from scratch: Most apps DON'T want a sidebar (games, canvases, single-screen tools, previewers, full-bleed visualizations) — just render your content directly and the page will be full-bleed. Don't add a shell out of habit. -3. Add additional pages under `frontend/src/pages/`. -4. If using a sidebar, update its nav entries in +4. Add additional pages under `frontend/src/pages/`. +5. If using a sidebar, update its nav entries in `frontend/src/app/components/Layout/Sidebar.tsx`. -5. Style with `useClaudeTokens()` and MUI's `sx`. -6. If you need a backend: `bash backend_init.sh`, then add a SubApp under `backend/apps//`. -7. Update `meta.json` with the app's name + description. +6. Style with `useClaudeTokens()` and MUI's `sx`. +7. If you need a backend: `bash backend_init.sh`, then add a SubApp under `backend/apps//`. diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 8e87b0cc..27923c7e 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -146,33 +146,25 @@ async def read_workspace(workspace_id: str): return {"files": files, "meta": meta, "path": os.path.abspath(folder)} -def sync_output_from_meta_json(workspace_id: str) -> bool: - """Read meta.json from the workspace folder; if it has a non-empty - name or description that differs from the linked Output row, update - the row. Returns True if anything changed. - - Idempotent and best-effort: missing workspace, missing meta.json, - malformed JSON, or no linked Output all return False silently. - - Why this exists: the Apps editor's React component polls meta.json - every few seconds and propagates name/description into the Output - via autosave. The canvas-chat App Builder launch has no such - poller, so apps stayed named "Untitled App" forever even after - the agent wrote a real name into meta.json. Calling this from the - session-complete hook closes that gap on the one event we know - fires exactly once per session. - """ +def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = None) -> bool: + """Sync the Output row's name/description from meta.json (or fallback_name when + meta.json has no name). Only overwrites placeholder values; user renames win.""" try: folder = os.path.join(WORKSPACE_DIR, workspace_id) meta_path = os.path.join(folder, "meta.json") - if not os.path.exists(meta_path): - return False - with open(meta_path) as f: - meta = json.load(f) - if not isinstance(meta, dict): - return False - name = str(meta.get("name") or "").strip() - description = str(meta.get("description") or "").strip() + name = "" + description = "" + if os.path.exists(meta_path): + try: + with open(meta_path) as f: + meta = json.load(f) + if isinstance(meta, dict): + name = str(meta.get("name") or "").strip() + description = str(meta.get("description") or "").strip() + except (OSError, json.JSONDecodeError, ValueError): + pass + if not name and fallback_name: + name = str(fallback_name).strip() if not name and not description: return False matching = [o for o in _load_all() if o.workspace_id == workspace_id] @@ -180,9 +172,6 @@ def sync_output_from_meta_json(workspace_id: str) -> bool: return False output = matching[0] changed = False - # Only overwrite the default placeholder ("Untitled App" / "") so a - # user who explicitly renamed the app in the UI isn't clobbered by - # a stale meta.json from a prior agent turn. if name and output.name in ("", "Untitled App") and output.name != name: output.name = name changed = True @@ -193,8 +182,6 @@ def sync_output_from_meta_json(workspace_id: str) -> bool: output.updated_at = datetime.now().isoformat() _save(output) return changed - except (OSError, json.JSONDecodeError, ValueError): - return False except Exception: logger.exception("sync_output_from_meta_json failed for %s", workspace_id) return False diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index aa898111..1ced6659 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -39,6 +39,7 @@ import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { shallowEqual } from 'react-redux'; import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; +import { Typewriter } from '@/app/components/feedback/Animated'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice'; import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; @@ -991,20 +992,24 @@ const AppShell: React.FC = () => { transition: 'background-color 0.12s', }} > - - {app.name} - + + {(t) => ( + + {t} + + )} + ); })} diff --git a/frontend/src/app/components/feedback/Animated.tsx b/frontend/src/app/components/feedback/Animated.tsx index 5df99d77..831721d7 100644 --- a/frontend/src/app/components/feedback/Animated.tsx +++ b/frontend/src/app/components/feedback/Animated.tsx @@ -55,6 +55,63 @@ export function CrossFadeOnChange({ value, children, durationMs }: CrossFadeP ); } +interface TypewriterProps { + value: string; + children: (current: string) => React.ReactNode; + charDelayMs?: number; + enabled?: boolean; + snapOnFirstTransition?: boolean; +} + +export function Typewriter({ value, children, charDelayMs = 14, enabled = true, snapOnFirstTransition = false }: TypewriterProps) { + const reduced = useReducedMotion(); + const [displayed, setDisplayed] = useState(value); + const targetRef = useRef(value); + const timerRef = useRef | null>(null); + const hasTransitionedRef = useRef(false); + + useEffect(() => { + targetRef.current = value; + if (!enabled || reduced) { + if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } + setDisplayed(value); + if (value !== displayed) hasTransitionedRef.current = true; + return; + } + if (value === displayed) return; + if (snapOnFirstTransition && !hasTransitionedRef.current) { + hasTransitionedRef.current = true; + setDisplayed(value); + return; + } + hasTransitionedRef.current = true; + if (timerRef.current) clearTimeout(timerRef.current); + const tick = () => { + setDisplayed((prev) => { + const target = targetRef.current; + if (prev === target) return prev; + let commonLen = 0; + while (commonLen < prev.length && commonLen < target.length && prev[commonLen] === target[commonLen]) { + commonLen++; + } + const next = prev.length > commonLen + ? prev.substring(0, prev.length - 1) + : target.substring(0, prev.length + 1); + if (next !== target) { + timerRef.current = setTimeout(tick, charDelayMs); + } + return next; + }); + }; + timerRef.current = setTimeout(tick, charDelayMs); + return () => { + if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } + }; + }, [value, enabled, reduced, charDelayMs, snapOnFirstTransition, displayed]); + + return <>{children(displayed)}; +} + interface TweeningNumberProps { value: number; /** How to render the tweened number. Default: `n.toString()`. */ 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..46e86d79 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -39,6 +39,8 @@ import { clearSessionMessages, clearMcpSuggestions, } from '@/shared/state/agentsSlice'; +import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay'; +import { Typewriter } from '@/app/components/feedback/Animated'; import { store } from '@/shared/state/store'; import { fetchModes } from '@/shared/state/modesSlice'; import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager'; @@ -963,7 +965,12 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose > - {session.name} + + {(t) => {t}} + {!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..2864222f 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -20,6 +20,8 @@ import { collapseSession, closeSession, } from '@/shared/state/agentsSlice'; +import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay'; +import { Typewriter } from '@/app/components/feedback/Animated'; import { setCardPosition, setCardSize, @@ -744,9 +746,16 @@ const AgentCard: React.FC = ({ borderRadius: 1, }} > - - {session.name} - + + {(t) => ( + + {t} + + )} + {/* 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/Dashboard/hooks/state/useDashboardThumbnail.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts index 4c996fd9..96eb55a3 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts @@ -42,8 +42,8 @@ export function useDashboardThumbnail({ }: UseDashboardThumbnailArgs) { // Screenshot the dashboard's contents for its card preview. Native Electron capturePage // (no DOM mutation, no flash). We snapshot while the dashboard is visible whenever its card - // set changes, then commit on exit only if that set differs from the last saved shot, so - // merely opening a dashboard never re-screenshots it (which would also reorder the sidebar). + // set changes and dispatch the update in-place, so the sidebar reorders as soon as the + // change settles rather than waiting for the user to navigate away. const currentSignature = useAppSelector((state) => dashboardSignature(state.dashboardLayout), ); @@ -53,23 +53,24 @@ export function useDashboardThumbnail({ const savedSignatureRef = useRef(savedSignature); savedSignatureRef.current = savedSignature; - const pendingThumbnailRef = useRef(null); - const pendingSignatureRef = useRef(null); // Baseline we compare against; seeded from the persisted signature, advanced on each commit. const lastSavedSignatureRef = useRef(savedSignature); const captureTimerRef = useRef | null>(null); const captureRetriesRef = useRef(0); const captureNow = useCallback(() => { + if (!dashboardId) return; const viewportEl = viewportRef.current; const contentEl = contentRef.current; if (!viewportEl || !contentEl) return; const layoutState = store.getState().dashboardLayout; const sig = dashboardSignature(layoutState); if (!sig) { - // Emptied dashboard: queue a clear ('') so its card falls back to the default icon. - pendingThumbnailRef.current = ''; - pendingSignatureRef.current = ''; + // Emptied dashboard: clear the preview so its card falls back to the default icon. + if (lastSavedSignatureRef.current !== '') { + store.dispatch(updateDashboardThumbnail({ id: dashboardId, thumbnail: '', signature: '' })); + lastSavedSignatureRef.current = ''; + } return; } // Capturing the dashboard composites live webview pixels; doing it while a @@ -91,18 +92,19 @@ export function useDashboardThumbnail({ viewCards: layoutState.viewCards, browserCards: layoutState.browserCards, }; + const capturingId = dashboardId; captureDashboardThumbnail(viewportEl, contentEl, allCards) .then((thumbnail) => { - if (thumbnail) { - pendingThumbnailRef.current = thumbnail; - pendingSignatureRef.current = sig; - } + if (!thumbnail) return; + if (sig === lastSavedSignatureRef.current) return; + store.dispatch(updateDashboardThumbnail({ id: capturingId, thumbnail, signature: sig })); + lastSavedSignatureRef.current = sig; }) .catch(() => {}); - }, [viewportRef, contentRef]); + }, [dashboardId, viewportRef, contentRef]); // While visible, (re)snapshot a beat after the card set changes. If it already matches the - // saved shot (or was reverted back to it), drop any pending capture instead of committing stale pixels. + // saved shot (or was reverted back to it), cancel any pending capture instead of committing stale pixels. useEffect(() => { if (!isActive || !dashboardId || !layoutInitialized) return; if (currentSignature === lastSavedSignatureRef.current) { @@ -110,8 +112,6 @@ export function useDashboardThumbnail({ clearTimeout(captureTimerRef.current); captureTimerRef.current = null; } - pendingThumbnailRef.current = null; - pendingSignatureRef.current = null; return; } if (captureTimerRef.current) clearTimeout(captureTimerRef.current); @@ -121,36 +121,10 @@ export function useDashboardThumbnail({ }; }, [isActive, dashboardId, layoutInitialized, currentSignature, captureNow]); - const commitThumbnail = useCallback((id: string) => { - if (!id) return; - const thumbnail = pendingThumbnailRef.current; - if (thumbnail === null) return; // nothing captured this session - const sig = pendingSignatureRef.current ?? ''; - if (sig === lastSavedSignatureRef.current) return; // card set unchanged since last shot - store.dispatch(updateDashboardThumbnail({ id, thumbnail, signature: sig })); - lastSavedSignatureRef.current = sig; - pendingThumbnailRef.current = null; - pendingSignatureRef.current = null; - }, []); - - // Persistent component: dashboardId is a prop. On switch, the cleanup commits the dashboard - // we're leaving; the setup re-baselines to the one we're entering. Cleanup also fires on unmount. + // Persistent component: dashboardId is a prop. Re-baseline the signature when switching dashboards. useEffect(() => { lastSavedSignatureRef.current = savedSignatureRef.current; - pendingThumbnailRef.current = null; - pendingSignatureRef.current = null; - const exitingId = dashboardId; - return () => { - commitThumbnail(exitingId); - }; - }, [dashboardId, commitThumbnail]); - - // Navigating to /apps etc. keeps the dashboard mounted but flips it inactive; that's still an exit. - const prevIsActiveRef = useRef(isActive); - useEffect(() => { - if (prevIsActiveRef.current && !isActive) commitThumbnail(dashboardId); - prevIsActiveRef.current = isActive; - }, [isActive, dashboardId, commitThumbnail]); + }, [dashboardId]); return { captureNow }; } diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index 67c7feee..92fcdacc 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -29,8 +29,10 @@ 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 { truncateForTitle } from '@/shared/state/sessionDisplay'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import AgentChat from '../AgentChat/AgentChat'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -296,7 +298,7 @@ const ViewEditor: React.FC = ({ output }) => { const createdIdRef = useRef(null); const effectiveId = output?.id ?? createdId; - const [name, setName] = useState(output?.name ?? ''); + const [name, setName] = useState(output?.name || 'Untitled App'); const [description, setDescription] = useState(output?.description ?? ''); const initialFiles = useMemo>(() => { @@ -562,9 +564,58 @@ const ViewEditor: React.FC = ({ output }) => { const pollRef = useRef | null>(null); const lastPollRef = useRef(''); - const nameSetByMeta = useRef(false); + // Once true, meta.json syncs stop touching the field so a user rename isn't clobbered. + const nameSetByUserRef = useRef(false); + const descriptionSetByUserRef = useRef(false); + const [fileVersion, setFileVersion] = useState(0); + const nameTypewriterCancelRef = useRef<(() => void) | null>(null); + const descTypewriterCancelRef = useRef<(() => void) | null>(null); + + const driveTypewriter = useCallback(( + target: string, + setter: React.Dispatch>, + userTypedRef: React.MutableRefObject, + cancelRef: React.MutableRefObject<(() => void) | null>, + charDelayMs: number = 14, + ) => { + if (cancelRef.current) cancelRef.current(); + let cancelled = false; + let timerId: ReturnType | null = null; + const tick = () => { + if (cancelled) return; + setter((prev) => { + if (userTypedRef.current) { cancelled = true; return prev; } + if (prev === target) { cancelled = true; return prev; } + let commonLen = 0; + while (commonLen < prev.length && commonLen < target.length && prev[commonLen] === target[commonLen]) commonLen++; + const next = prev.length > commonLen + ? prev.substring(0, prev.length - 1) + : target.substring(0, prev.length + 1); + if (next !== target) timerId = setTimeout(tick, charDelayMs); + return next; + }); + }; + timerId = setTimeout(tick, charDelayMs); + cancelRef.current = () => { + cancelled = true; + if (timerId) clearTimeout(timerId); + }; + }, []); + + const driveNameTypewriter = useCallback((target: string) => { + driveTypewriter(target, setName, nameSetByUserRef, nameTypewriterCancelRef); + }, [driveTypewriter]); + const driveDescriptionTypewriter = useCallback((target: string) => { + driveTypewriter(target, setDescription, descriptionSetByUserRef, descTypewriterCancelRef); + }, [driveTypewriter]); + + useEffect(() => () => { + if (nameTypewriterCancelRef.current) nameTypewriterCancelRef.current(); + if (descTypewriterCancelRef.current) descTypewriterCancelRef.current(); + }, []); + const pollWorkspace = useCallback(async () => { if (!workspaceId) return; try { @@ -581,16 +632,24 @@ const ViewEditor: React.FC = ({ output }) => { } if (data.meta) { - if (data.meta.name && !nameSetByMeta.current) { - nameSetByMeta.current = true; - setName((prev) => prev || data.meta.name); + const eid = output?.id ?? createdIdRef.current; + if (data.meta.name && eid && !nameSetByUserRef.current) { + const row = store.getState().outputs.items[eid]; + if (row && row.name !== data.meta.name) { + dispatch(upsertOutput({ ...row, name: data.meta.name })); + driveNameTypewriter(data.meta.name); + } } - if (data.meta.description) { - setDescription((prev) => prev || data.meta.description); + if (data.meta.description && eid && !descriptionSetByUserRef.current) { + const row = store.getState().outputs.items[eid]; + if (row && row.description !== data.meta.description) { + dispatch(upsertOutput({ ...row, description: data.meta.description })); + driveDescriptionTypewriter(data.meta.description); + } } } } catch {} - }, [workspaceId]); + }, [workspaceId, output?.id, dispatch, driveNameTypewriter, driveDescriptionTypewriter]); useEffect(() => { if (!workspaceId) return; @@ -1118,8 +1177,8 @@ const ViewEditor: React.FC = ({ output }) => { > setName(e.target.value)} - placeholder="App name" + onChange={(e) => { nameSetByUserRef.current = true; setName(e.target.value); }} + placeholder="Untitled App" variant="standard" sx={{ flex: 1, @@ -1137,7 +1196,7 @@ const ViewEditor: React.FC = ({ output }) => { setDescription(e.target.value)} + onChange={(e) => { descriptionSetByUserRef.current = true; setDescription(e.target.value); }} placeholder="Description" 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..1ecc7db7 --- /dev/null +++ b/frontend/src/shared/state/sessionDisplay.ts @@ -0,0 +1,44 @@ +import type { AgentSession } from './agentsSlice'; + +export const SESSION_NAME_PLACEHOLDER = 'New chat'; + +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; +} + +export function normalizeSessionName(name: string | null | undefined): string { + if (!name || isLegacyAutoName(name)) return ''; + return name; +} + +const MAX_TITLE_CHARS = 30; +const MAX_TITLE_WORDS = 4; + +export function truncateForTitle(text: string | null | undefined): string { + const trimmed = (text || '').trim().replace(/\s+/g, ' '); + if (!trimmed) return ''; + const words = trimmed.split(' ').slice(0, MAX_TITLE_WORDS).join(' '); + if (words.length > MAX_TITLE_CHARS) return words.slice(0, MAX_TITLE_CHARS).trimEnd() + '…'; + if (words.length < trimmed.length) return words + '…'; + return words; +} + +export function displayChatTitle(session: AgentSession | null | undefined): string { + if (!session) return SESSION_NAME_PLACEHOLDER; + if (session.name && !isLegacyAutoName(session.name)) { + return session.name; + } + const firstUserMsg = session.messages?.find((m) => m.role === 'user'); + if (firstUserMsg && typeof firstUserMsg.content === 'string') { + const truncated = truncateForTitle(firstUserMsg.content); + if (truncated) return truncated; + } + return session.mode === 'view-builder' ? 'Untitled App' : SESSION_NAME_PLACEHOLDER; +} 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,