From ba3f7cb5c93cc0ed69f568daeb1f2cce7bbce825 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 12 Jun 2026 23:15:03 -0700 Subject: [PATCH] [aidan] ui/ux: aux title gen streams for cx/ route + agent writes meta.json first - All aux LLM calls (chat title, turn label, group meta, dashboard name) now stream instead of using messages.create; 9router's cx/ subscription response translator drops content for GPT-5-family models on the non-streaming path but works per-event. - aux_max_tokens_for floors GPT-5 budget at 4K so reasoning still leaves headroom for a label; non-reasoning models get the base 100. - App Builder skill: write meta.json FIRST (step 1 of Quick start) so the app's name surfaces in the sidebar + ViewEditor header on the agent's first tool call instead of waiting until end-of-turn. - Session-end sync_output_from_meta_json takes a fallback_name (= session.name) so an agent that never writes meta.json still leaves the app with the aux-LLM chat title rather than "Untitled App". - Title display: truncateForTitle caps at 4 words / 30 chars; displayChatTitle picks the right Phase 1 placeholder by session.mode; normalizeSessionName strips legacy Agent-XXXX names at slice intake. - Typewriter component (char-by-char delete-then-type) drives the chat header, dashboard card title, sidebar Apps entry, and ViewEditor TextField + description field; honors useReducedMotion. --- backend/apps/agents/agent_manager.py | 56 +++++--- backend/apps/agents/core/aux_llm.py | 7 + backend/apps/dashboards/dashboards.py | 13 +- backend/apps/outputs/app_builder_skill.md | 23 +-- backend/apps/outputs/outputs.py | 45 +++--- .../src/app/components/feedback/Animated.tsx | 26 ++-- frontend/src/app/pages/Views/ViewEditor.tsx | 132 +++++++++--------- frontend/src/shared/state/sessionDisplay.ts | 15 +- 8 files changed, 164 insertions(+), 153 deletions(-) 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..bd5910dd 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 4K so a label can land. + if isinstance(model, str) and "gpt-5" in model.lower(): + return max(base, 4096) + 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/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/feedback/Animated.tsx b/frontend/src/app/components/feedback/Animated.tsx index 67050dab..831721d7 100644 --- a/frontend/src/app/components/feedback/Animated.tsx +++ b/frontend/src/app/components/feedback/Animated.tsx @@ -58,31 +58,33 @@ export function CrossFadeOnChange({ value, children, durationMs }: CrossFadeP interface TypewriterProps { value: string; children: (current: string) => React.ReactNode; - /** Per-char delay during delete + type phases. Default 14ms. Total swap ~ (deleteCount + typeCount) * delay. */ charDelayMs?: number; - /** Set false to snap-render the value (e.g. while no real title exists yet). */ enabled?: boolean; + snapOnFirstTransition?: boolean; } -/** - * Char-by-char delete-then-type swap. When `value` changes, the displayed string - * deletes down to the longest common prefix with the new value, then types the rest in. - * Respects useReducedMotion. Use for title swaps where the user should see the chars - * scrub through, not a cross-fade. - */ -export function Typewriter({ value, children, charDelayMs = 14, enabled = true }: TypewriterProps) { +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) => { @@ -93,8 +95,8 @@ export function Typewriter({ value, children, charDelayMs = 14, enabled = true } commonLen++; } const next = prev.length > commonLen - ? prev.substring(0, prev.length - 1) // delete one char from end - : target.substring(0, prev.length + 1); // type next char from target + ? prev.substring(0, prev.length - 1) + : target.substring(0, prev.length + 1); if (next !== target) { timerRef.current = setTimeout(tick, charDelayMs); } @@ -105,7 +107,7 @@ export function Typewriter({ value, children, charDelayMs = 14, enabled = true } return () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }; - }, [value, enabled, reduced, charDelayMs]); + }, [value, enabled, reduced, charDelayMs, snapOnFirstTransition, displayed]); return <>{children(displayed)}; } diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index 46a85827..92fcdacc 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -298,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>(() => { @@ -548,14 +548,6 @@ 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'; @@ -572,13 +564,58 @@ const ViewEditor: React.FC = ({ output }) => { const pollRef = useRef | null>(null); const lastPollRef = useRef(''); - // 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. + // 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 { @@ -594,29 +631,25 @@ const ViewEditor: React.FC = ({ output }) => { setFileVersion(v => v + 1); } - if (data.meta && !nameSetByUserRef.current) { + if (data.meta) { 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.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(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 })); - } + 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, output?.id, dispatch]); + }, [workspaceId, output?.id, dispatch, driveNameTypewriter, driveDescriptionTypewriter]); useEffect(() => { if (!workspaceId) return; @@ -647,45 +680,6 @@ 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. Truncate to the title-cap - // (same rule as the chat header) so the right-panel input and sidebar never - // exceed their width. Typewriter-animate the React `name` state so the input - // field char-by-char swaps from "Untitled App" to the new name; the sidebar - // animates independently off its own Typewriter wrapper. - 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; - const target = truncateForTitle(sessionName) || sessionName; - if (row.name === target) return; - if (row.name !== '' && row.name !== 'Untitled App') return; - dispatch(upsertOutput({ ...row, name: target })); - - let cancelled = false; - let timerId: ReturnType | null = null; - const tick = () => { - if (cancelled) return; - setName((prev) => { - if (nameSetByUserRef.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, 14); - return next; - }); - }; - timerId = setTimeout(tick, 14); - return () => { cancelled = true; if (timerId) clearTimeout(timerId); }; - }, [sessionName, output?.id, dispatch]); - const prevAgentActive = useRef(false); useEffect(() => { if (prevAgentActive.current && !isAgentActive && workspaceId) { @@ -1184,7 +1178,7 @@ const ViewEditor: React.FC = ({ output }) => { { nameSetByUserRef.current = true; setName(e.target.value); }} - placeholder="App name" + placeholder="Untitled App" variant="standard" sx={{ flex: 1, @@ -1202,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/sessionDisplay.ts b/frontend/src/shared/state/sessionDisplay.ts index 153b3d8a..1ecc7db7 100644 --- a/frontend/src/shared/state/sessionDisplay.ts +++ b/frontend/src/shared/state/sessionDisplay.ts @@ -1,11 +1,7 @@ import type { AgentSession } from './agentsSlice'; -// Placeholder shown for surfaces that only have a name string (search palette, -// dynamic island, notifications, history). Smarter surfaces use displayChatTitle. 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 { @@ -17,16 +13,11 @@ export function displaySessionName(name: string | null | undefined): string { 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; } -// First 4 words OR 30 chars, whichever shorter; ellipsis if cut. Tight enough to fit -// every render surface (sidebar columns, dashboard cards) without CSS overflow. -// Applied to both Phase 2 (first-message fallback) and Phase 3 (aux-LLM title), so even -// a misbehaving aux-LLM response can't blow the cap. const MAX_TITLE_CHARS = 30; const MAX_TITLE_WORDS = 4; @@ -39,14 +30,10 @@ export function truncateForTitle(text: string | null | undefined): string { return words; } -// Phase-aware chat title: -// Phase 3: aux-LLM title (session.name) when present. -// Phase 2: first user message (truncated) when sent but no aux title yet. -// Phase 1: context placeholder (App Builder -> "Untitled App", else "New chat"). export function displayChatTitle(session: AgentSession | null | undefined): string { if (!session) return SESSION_NAME_PLACEHOLDER; if (session.name && !isLegacyAutoName(session.name)) { - return truncateForTitle(session.name) || session.name; + return session.name; } const firstUserMsg = session.messages?.find((m) => m.role === 'user'); if (firstUserMsg && typeof firstUserMsg.content === 'string') {