mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 04:07:44 +02:00
[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:
@@ -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 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
|
||||
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { displaySessionName } from '../state/sessionDisplay';
|
||||
import { getAuthToken } from '../config';
|
||||
import { notifyAgentCompletion } from '../notifications';
|
||||
|
||||
@@ -480,7 +481,7 @@ class WebSocketManager {
|
||||
.find((m: any) => m.role === 'assistant' && typeof m.content === 'string');
|
||||
notifyAgentCompletion({
|
||||
sessionId: session_id,
|
||||
sessionName: sess.name || 'Agent',
|
||||
sessionName: displaySessionName(sess.name),
|
||||
dashboardId: sess.dashboard_id,
|
||||
status: data.status as 'completed' | 'error',
|
||||
bodyExcerpt: lastAssistant ? String(lastAssistant.content) : undefined,
|
||||
|
||||
Reference in New Issue
Block a user