mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[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.
This commit is contained in:
@@ -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("```"):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>/`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,31 +58,33 @@ export function CrossFadeOnChange<T>({ 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<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) => {
|
||||
@@ -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)}</>;
|
||||
}
|
||||
|
||||
@@ -298,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>>(() => {
|
||||
@@ -548,14 +548,6 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
return state.agents.sessions[effectiveSessionId]?.status ?? null;
|
||||
});
|
||||
|
||||
// Aux-LLM-generated session title from the user's first prompt. Used as an
|
||||
// early signal for the App's display name so the sidebar doesn't sit at
|
||||
// "Untitled App" while the agent is still working toward its first meta.json write.
|
||||
const sessionName = useAppSelector((state) => {
|
||||
if (!effectiveSessionId) return '';
|
||||
return state.agents.sessions[effectiveSessionId]?.name ?? '';
|
||||
});
|
||||
|
||||
const isLaunched = !!effectiveSessionId && effectiveSessionId !== initialDraftId;
|
||||
const isAgentActive = agentStatus === 'running' || agentStatus === 'waiting_approval';
|
||||
|
||||
@@ -572,13 +564,58 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastPollRef = useRef<string>('');
|
||||
|
||||
// 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<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 {
|
||||
@@ -594,29 +631,25 @@ const ViewEditor: React.FC<Props> = ({ 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<Props> = ({ output }) => {
|
||||
};
|
||||
}, [workspaceId, pollWorkspace, isAgentActive]);
|
||||
|
||||
// Mirror the aux-LLM session title into the App's name as soon as the chat
|
||||
// gets one, so the sidebar reflects what the user just asked for instead of
|
||||
// sitting at "Untitled App" while the agent works. 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<typeof setTimeout> | 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<Props> = ({ output }) => {
|
||||
<TextField
|
||||
value={name}
|
||||
onChange={(e) => { 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<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={{
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user