From b9b4756ab4805437003b55127c369dc18ce22b3b Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Mon, 22 Jun 2026 12:56:02 +1000 Subject: [PATCH] fix dashboard chat session titles --- tests/test_tui_gateway_server.py | 20 ++++ tui_gateway/server.py | 24 ++++- web/src/components/ChatSidebar.tsx | 163 +++++++++++++++-------------- web/src/lib/api.ts | 4 + web/src/lib/chat-title.test.ts | 35 +++++++ web/src/lib/chat-title.ts | 15 +++ web/src/pages/ChatPage.tsx | 60 ++++++++++- 7 files changed, 237 insertions(+), 84 deletions(-) create mode 100644 web/src/lib/chat-title.test.ts create mode 100644 web/src/lib/chat-title.ts diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 61c86d519..0c70557ce 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2127,8 +2127,10 @@ def test_session_title_clears_pending_after_persist(monkeypatch): return True db = _FakeDB() + emitted = [] server._sessions["sid"] = _session(pending_title="stale") monkeypatch.setattr(server, "_get_db", lambda: db) + monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args)) try: resp = server.handle_request( { @@ -2141,6 +2143,8 @@ def test_session_title_clears_pending_after_persist(monkeypatch): assert resp["result"]["pending"] is False assert resp["result"]["title"] == "fresh" assert server._sessions["sid"]["pending_title"] is None + assert emitted[-1][0:2] == ("session.info", "sid") + assert emitted[-1][2]["title"] == "fresh" finally: server._sessions.pop("sid", None) @@ -4461,6 +4465,22 @@ def test_session_info_includes_mcp_servers(monkeypatch): assert info["mcp_servers"] == fake_status +def test_session_info_includes_session_title(monkeypatch): + class _FakeDB: + def get_session_title(self, key): + assert key == "session-key" + return "Dashboard title" + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + + info = server._session_info( + types.SimpleNamespace(tools=[], model="test/model", provider="openai-codex"), + {"session_key": "session-key", "history": []}, + ) + + assert info["title"] == "Dashboard title" + + # --------------------------------------------------------------------------- # History-mutating commands must reject while session.running is True. # Without these guards, prompt.submit's post-run history write either diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7a63aec26..c024cc97d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2696,6 +2696,9 @@ def _session_info(agent, session: dict | None = None) -> dict: session = candidate break cwd = _session_cwd(session) + session_key = str( + (session or {}).get("session_key") or getattr(agent, "session_id", "") or "" + ) cfg_personality = ((_load_cfg().get("display") or {}).get("personality") or "") personality = (session or {}).get("personality", cfg_personality) reasoning_config = getattr(agent, "reasoning_config", None) @@ -2720,8 +2723,9 @@ def _session_info(agent, session: dict | None = None) -> dict: is_session_yolo_enabled, ) - session_key = (session or {}).get("session_key") - session_yolo = bool(is_session_yolo_enabled(session_key)) if session_key else False + session_yolo = ( + bool(is_session_yolo_enabled(session_key)) if session_key else False + ) yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off" except Exception: yolo = False @@ -2738,6 +2742,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "branch": _git_branch_for_cwd(cwd), "personality": str(personality or ""), "running": bool((session or {}).get("running")), + "title": _session_live_title(session or {}, session_key) if session_key else "", "desktop_contract": DESKTOP_BACKEND_CONTRACT, "version": "", "release_date": "", @@ -2802,6 +2807,16 @@ def _tool_ctx(name: str, args: dict) -> str: return "" +def _emit_session_info_for_session(sid: str, session: dict) -> None: + agent = session.get("agent") + if agent is None: + return + try: + _emit("session.info", sid, _session_info(agent, session)) + except Exception: + pass + + # Tool Args/Result text shipped to the TUI for the verbose trail line. The TUI # renders only a small persisted preview (ui-tui VERBOSE_TRAIL_MAX_CHARS), kept # all session and expanded by default — so shipping more than that is pure pipe @@ -5097,6 +5112,7 @@ def _(rid, params: dict) -> dict: session["pending_title"] = None except Exception: resolved_title = fallback + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok( rid, { @@ -5110,11 +5126,13 @@ def _(rid, params: dict) -> dict: try: if db.set_session_title(key, title): session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok(rid, {"pending": False, "title": title}) # rowcount == 0 can mean "same value" as well as "missing row". existing_row = db.get_session(key) if existing_row: session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok( rid, { @@ -5136,10 +5154,12 @@ def _(rid, params: dict) -> dict: with _session_db(session) as scoped_db: if scoped_db is not None and scoped_db.set_session_title(key, title): session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok(rid, {"pending": False, "title": title}) # Row creation didn't take (DB unavailable, or a concurrent writer) — # fall back to queuing so the post-turn apply block can still recover. session["pending_title"] = title + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok(rid, {"pending": True, "title": title}) except ValueError as e: return _err(rid, 4022, str(e)) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index c70f74d65..7bb71eb33 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -34,6 +34,7 @@ import { ReasoningPicker } from "@/components/ReasoningPicker"; import { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; +import { titleFromSessionInfoPayload } from "@/lib/chat-title"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; @@ -44,6 +45,7 @@ interface SessionInfo { model?: string; provider?: string; credential_warning?: string; + title?: string; } interface RpcEnvelope { @@ -78,6 +80,7 @@ interface ChatSidebarProps { profile?: string; className?: string; onDashboardNewSessionRequest?: () => void; + onSessionTitleChange?: (title: string | null) => void; /** * Render the tool-call activity card. Defaults to true. The dashboard Chat * tab sets this false so the right rail stays a thin model + session-list @@ -91,6 +94,7 @@ export function ChatSidebar({ profile, className, onDashboardNewSessionRequest, + onSessionTitleChange, showTools = true, }: ChatSidebarProps) { // `version` bumps on reconnect; gw is derived so we never call setState @@ -266,91 +270,96 @@ export function ChatSidebar({ }); ws.addEventListener("message", (ev) => { - let frame: RpcEnvelope; + let frame: RpcEnvelope; - try { - frame = JSON.parse(ev.data); - } catch { - return; - } - - if (frame.method !== "event" || !frame.params) { - return; - } - - const { type, payload } = frame.params; - - if (type === "dashboard.new_session_requested") { - onDashboardNewSessionRequest?.(); - } else if (type === "tool.start") { - const p = payload as - | { tool_id?: string; name?: string; context?: string } - | undefined; - const toolId = p?.tool_id; - - if (!toolId) { + try { + frame = JSON.parse(ev.data); + } catch { return; } - setTools((prev) => - [ - ...prev, - { - kind: "tool" as const, - id: `tool-${toolId}-${prev.length}`, - tool_id: toolId, - name: p?.name ?? "tool", - context: p?.context, - status: "running" as const, - startedAt: Date.now(), - }, - ].slice(-TOOL_LIMIT), - ); - } else if (type === "tool.progress") { - const p = payload as - | { name?: string; preview?: string } - | undefined; - - if (!p?.name || !p.preview) { + if (frame.method !== "event" || !frame.params) { return; } - setTools((prev) => - prev.map((t) => - t.status === "running" && t.name === p.name - ? { ...t, preview: p.preview } - : t, - ), - ); - } else if (type === "tool.complete") { - const p = payload as - | { - tool_id?: string; - summary?: string; - error?: string; - inline_diff?: string; - } - | undefined; + const { type, payload } = frame.params; - if (!p?.tool_id) { - return; + if (type === "session.info") { + const title = titleFromSessionInfoPayload(payload); + if (title !== undefined) { + onSessionTitleChange?.(title); + } + } else if (type === "dashboard.new_session_requested") { + onDashboardNewSessionRequest?.(); + } else if (type === "tool.start") { + const p = payload as + | { tool_id?: string; name?: string; context?: string } + | undefined; + const toolId = p?.tool_id; + + if (!toolId) { + return; + } + + setTools((prev) => + [ + ...prev, + { + kind: "tool" as const, + id: `tool-${toolId}-${prev.length}`, + tool_id: toolId, + name: p?.name ?? "tool", + context: p?.context, + status: "running" as const, + startedAt: Date.now(), + }, + ].slice(-TOOL_LIMIT), + ); + } else if (type === "tool.progress") { + const p = payload as + | { name?: string; preview?: string } + | undefined; + + if (!p?.name || !p.preview) { + return; + } + + setTools((prev) => + prev.map((t) => + t.status === "running" && t.name === p.name + ? { ...t, preview: p.preview } + : t, + ), + ); + } else if (type === "tool.complete") { + const p = payload as + | { + tool_id?: string; + summary?: string; + error?: string; + inline_diff?: string; + } + | undefined; + + if (!p?.tool_id) { + return; + } + + setTools((prev) => + prev.map((t) => + t.tool_id === p.tool_id + ? { + ...t, + status: p.error ? "error" : "done", + summary: p.summary, + error: p.error, + inline_diff: p.inline_diff, + completedAt: Date.now(), + } + : t, + ), + ); } - - setTools((prev) => - prev.map((t) => - t.tool_id === p.tool_id - ? { - ...t, - status: p.error ? "error" : "done", - summary: p.summary, - error: p.error, - inline_diff: p.inline_diff, - completedAt: Date.now(), - } - : t, - ), - ); - } }); })(); @@ -358,7 +367,7 @@ export function ChatSidebar({ unmounting = true; ws?.close(); }; - }, [channel, onDashboardNewSessionRequest, version]); + }, [channel, onDashboardNewSessionRequest, onSessionTitleChange, version]); // Seed the badge on mount and re-read it whenever the sockets are rebuilt // (a profile/channel switch bumps `version`). diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ba8989241..c154243bd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -360,6 +360,10 @@ export const api = { fetchJSON( appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile), ), + getSessionDetail: (id: string, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile), + ), getSessionLatestDescendant: (id: string) => fetchJSON( `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, diff --git a/web/src/lib/chat-title.test.ts b/web/src/lib/chat-title.test.ts new file mode 100644 index 000000000..b3fb1f51f --- /dev/null +++ b/web/src/lib/chat-title.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeSessionTitle, titleFromSessionInfoPayload } from "./chat-title"; + +describe("normalizeSessionTitle", () => { + it("trims non-empty session titles", () => { + expect(normalizeSessionTitle(" Rename the dashboard ")).toBe( + "Rename the dashboard", + ); + }); + + it("treats blank and non-string values as no title", () => { + expect(normalizeSessionTitle(" ")).toBeNull(); + expect(normalizeSessionTitle(null)).toBeNull(); + expect(normalizeSessionTitle(42)).toBeNull(); + }); +}); + +describe("titleFromSessionInfoPayload", () => { + it("returns undefined when the payload has no title field", () => { + expect(titleFromSessionInfoPayload({ model: "test/model" })).toBeUndefined(); + expect(titleFromSessionInfoPayload(null)).toBeUndefined(); + }); + + it("returns null when the title field is present but empty", () => { + expect(titleFromSessionInfoPayload({ title: "" })).toBeNull(); + expect(titleFromSessionInfoPayload({ title: " " })).toBeNull(); + }); + + it("returns the normalized title when present", () => { + expect(titleFromSessionInfoPayload({ title: " Live session title " })).toBe( + "Live session title", + ); + }); +}); diff --git a/web/src/lib/chat-title.ts b/web/src/lib/chat-title.ts new file mode 100644 index 000000000..c6cebebcf --- /dev/null +++ b/web/src/lib/chat-title.ts @@ -0,0 +1,15 @@ +export function normalizeSessionTitle(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const title = raw.trim(); + return title ? title : null; +} + +export function titleFromSessionInfoPayload( + payload: unknown, +): string | null | undefined { + if (!payload || typeof payload !== "object" || !("title" in payload)) { + return undefined; + } + + return normalizeSessionTitle((payload as { title?: unknown }).title); +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 2a135ed1a..0820ae82d 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -36,6 +36,7 @@ import { ChatSessionList } from "@/components/ChatSessionList"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; +import { normalizeSessionTitle } from "@/lib/chat-title"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; @@ -63,11 +64,14 @@ function buildWsUrl( // (subscriber). Generated once per mount so a tab refresh starts a fresh // channel — the previous PTY child terminates with the old WS, and its // channel auto-evicts when no subscribers remain. -function generateChannelId(): string { +function generateChannelId(scope?: string): string { + const prefix = scope ? "chat" : "chat-fresh"; if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); + return `${prefix}-${crypto.randomUUID()}`; } - return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; + return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString( + 36, + )}`; } // Colors for the terminal body. Matches the dashboard's dark teal canvas @@ -173,7 +177,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // tabs because the dep wouldn't change on tab switch. const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false); const mobilePanelOpen = isActive && mobilePanelOpenRaw; - const { setEnd } = usePageHeader(); + const { setEnd, setTitle } = usePageHeader(); + const [sessionTitleState, setSessionTitleState] = useState<{ + scope: string; + title: string | null; + }>({ scope: "", title: null }); const { t } = useI18n(); const closeMobilePanel = useCallback(() => setMobilePanelOpenRaw(false), []); const modelToolsLabel = useMemo( @@ -207,7 +215,47 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // management profile. Changing it remounts the terminal (key below / // effect dep) so the user explicitly starts a fresh scoped session. const { profile: scopedProfile } = useProfileScope(); - const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]); + const channel = useMemo( + () => generateChannelId(`${resumeParam ?? ""}\0${scopedProfile}`), + [resumeParam, scopedProfile], + ); + const titleScope = `${channel}\0${reconnectNonce}`; + const sessionTitle = + sessionTitleState.scope === titleScope ? sessionTitleState.title : null; + const handleSessionTitleChange = useCallback( + (title: string | null) => setSessionTitleState({ scope: titleScope, title }), + [titleScope], + ); + + useEffect(() => { + if (!isActive) { + setTitle(null); + return; + } + + setTitle(sessionTitle); + return () => setTitle(null); + }, [isActive, sessionTitle, setTitle]); + + useEffect(() => { + if (!resumeParam) return; + + let cancelled = false; + + api + .getSessionDetail(resumeParam, scopedProfile) + .then((session) => { + if (cancelled) return; + handleSessionTitleChange(normalizeSessionTitle(session.title)); + }) + .catch(() => { + // Best-effort: the PTY-side session.info stream can still supply it. + }); + + return () => { + cancelled = true; + }; + }, [resumeParam, scopedProfile, handleSessionTitleChange]); useEffect(() => { if (!resumeParam) return; @@ -896,6 +944,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { channel={channel} profile={scopedProfile} onDashboardNewSessionRequest={startFreshDashboardChat} + onSessionTitleChange={handleSessionTitleChange} showTools={false} /> @@ -995,6 +1044,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { channel={channel} profile={scopedProfile} onDashboardNewSessionRequest={startFreshDashboardChat} + onSessionTitleChange={handleSessionTitleChange} showTools={false} />