[aidan] bug/feat: sidebar reordering, title improvements, openai gen bug (#76)

* [aidan] ui: sidebar reorder

* [aidan] ux: sidebar naming bug fix

* [aidan] ui: title changing improvements

* [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.

* [aidan] tune: reduce gpt-5 aux token floor
This commit is contained in:
Aidan
2026-06-12 23:33:32 -07:00
committed by GitHub
parent d1e3c37b27
commit 08526b6149
18 changed files with 350 additions and 142 deletions
+40 -16
View File
@@ -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 <message> tags. Do not answer it.\n\n"
f"<message>\n{first_prompt}\n</message>"
f"<message>\n{labeled_prompt}\n</message>"
)
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"<request>\n{user_prompt[:2000]}\n</request>"
),
}],
)
) 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("```"):
+7
View File
@@ -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.
+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"
+8 -5
View File
@@ -340,14 +340,17 @@ async def generate_name(dashboard_id: str):
"<tasks>\n" + "\n".join(f"- {p}" for p in prompts) + "\n</tasks>"
)
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:
+15 -8
View File
@@ -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 `<Home>` 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
`<AppShell>`. 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/<name>/`.
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/<name>/`.
+16 -29
View File
@@ -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
+19 -14
View File
@@ -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',
}}
>
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.86rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{app.name}
</Typography>
<Typewriter value={app.name || 'Untitled App'} enabled={!!app.name && app.name !== 'Untitled App'}>
{(t) => (
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.86rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{t}
</Typography>
)}
</Typewriter>
</Box>
);
})}
@@ -55,6 +55,63 @@ export function CrossFadeOnChange<T>({ 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<ReturnType<typeof setTimeout> | 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()`. */
@@ -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,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<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>
<Typewriter
value={displayChatTitle(session)}
enabled={!!session.name && !isLegacyAutoName(session.name)}
>
{(t) => <Typography noWrap sx={{ color: c.text.primary, fontWeight: 600 }}>{t}</Typography>}
</Typewriter>
{!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,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<Props> = ({
borderRadius: 1,
}}
>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.name}
</Typography>
<Typewriter
value={displayChatTitle(session)}
enabled={!!session.name && !isLegacyAutoName(session.name)}
>
{(t) => (
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t}
</Typography>
)}
</Typewriter>
{/* Status speaks only when it needs the user; finished work sits quiet. */}
{session.status !== 'completed' && session.status !== 'stopped' && (
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
@@ -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]);
}
@@ -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<string | null>(savedSignature);
savedSignatureRef.current = savedSignature;
const pendingThumbnailRef = useRef<string | null>(null);
const pendingSignatureRef = useRef<string | null>(null);
// Baseline we compare against; seeded from the persisted signature, advanced on each commit.
const lastSavedSignatureRef = useRef<string | null>(savedSignature);
const captureTimerRef = useRef<ReturnType<typeof setTimeout> | 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 };
}
+71 -12
View File
@@ -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<Props> = ({ output }) => {
const createdIdRef = useRef<string | null>(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<Record<string, string>>(() => {
@@ -562,9 +564,58 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastPollRef = useRef<string>('');
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<React.SetStateAction<string>>,
userTypedRef: React.MutableRefObject<boolean>,
cancelRef: React.MutableRefObject<(() => void) | null>,
charDelayMs: number = 14,
) => {
if (cancelRef.current) cancelRef.current();
let cancelled = false;
let timerId: ReturnType<typeof setTimeout> | 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<Props> = ({ 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<Props> = ({ output }) => {
>
<TextField
value={name}
onChange={(e) => 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<Props> = ({ output }) => {
<TextField
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={(e) => { descriptionSetByUserRef.current = true; setDescription(e.target.value); }}
placeholder="Description"
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,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;
}
+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,