From 9d369f410e4b12b8b066a789832e06827aa77ac8 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Thu, 20 Aug 2026 11:19:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20live=20document=20title=20=E2=80=94=20c?= =?UTF-8?q?hat=20tab=20shows=20session=20name=20+=20activity=20(writing?= =?UTF-8?q?=E2=80=A6/tool=20name/working=E2=80=A6),=20overview=20shows=20b?= =?UTF-8?q?usy/active=20counts;=20268=20tests,=20gates=20=E2=89=A595%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/ChatView.test.tsx | 26 ++++++++++++++++++++++++ web/src/ChatView.tsx | 17 ++++++++++++++++ web/src/SessionsView.test.tsx | 12 +++++++++++ web/src/SessionsView.tsx | 7 +++++++ web/src/title.ts | 38 +++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+) create mode 100644 web/src/title.ts diff --git a/web/src/ChatView.test.tsx b/web/src/ChatView.test.tsx index d2e89ac..4daac5b 100644 --- a/web/src/ChatView.test.tsx +++ b/web/src/ChatView.test.tsx @@ -1692,3 +1692,29 @@ describe("ChatView ⋯ menu and timestamps", () => { expect(container.querySelectorAll(".ts")).toHaveLength(1); }); }); + +describe("document title", () => { + it("chat view claims title with session label and activity", async () => { + mockFetchJson((url) => { + if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents(); + if (url.endsWith("/stats")) return { turns: 0, inputTokens: 0, outputTokens: 0, totalCost: 0, sessionsCount: 0, onlineCount: 0 }; + return []; + }); + renderChat(makeStore()); + await waitFor(() => expect(document.title).toContain("worker")); + expect(document.title).toContain("lvmh"); + }); + + it("busy + running tool shows the tool name in the title", async () => { + mockFetchJson((url) => { + if (url.startsWith("http://srv/api/sessions/s1/events")) { + seq = 0; + return [...historyEvents(), ev("agent_start"), ev("tool_execution_start", { toolCallId: "tc9", toolName: "bash" })]; + } + if (url.endsWith("/stats")) return { turns: 0, inputTokens: 0, outputTokens: 0, totalCost: 0, sessionsCount: 0, onlineCount: 0 }; + return []; + }); + renderChat(makeStore()); + await waitFor(() => expect(document.title).toContain("bash")); + }); +}); diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx index 7bf6ff4..40c085e 100644 --- a/web/src/ChatView.tsx +++ b/web/src/ChatView.tsx @@ -8,6 +8,7 @@ import type { SetModelBody, } from "./protocol"; import { EventType, Route } from "./protocol"; +import { useTitle } from "./title"; import { ApiError, errMessage, fetchJson } from "./api"; import { deriveChat, @@ -210,6 +211,22 @@ export default function ChatView({ store, pushToast }: Props) { // session must never look busy (B2) const busy: boolean = chat.busy && session?.online !== false; + // Live tab title: session name + what the agent is doing right now. + const label: string = session?.name ?? session?.repo ?? sessionId; + const streaming: boolean = chat.messages.some((m) => m.streaming); + const toolNow: string | undefined = [...chat.tools.values()] + .filter((t) => t.running) + .map((t) => t.name)[0]; + const titleClaim: string | null = + !busy + ? label + : streaming + ? `${label} · writing…` + : toolNow !== undefined + ? `${label} · ${toolNow}` + : `${label} · working…`; + useTitle(titleClaim); + const minSeq: number = useMemo( () => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))), [events], diff --git a/web/src/SessionsView.test.tsx b/web/src/SessionsView.test.tsx index a03b605..15cad68 100644 --- a/web/src/SessionsView.test.tsx +++ b/web/src/SessionsView.test.tsx @@ -512,3 +512,15 @@ describe("busy indicator on cards", () => { expect(pips).toHaveLength(1); }); }); + +describe("sessions title", () => { + it("overview shows active/working count in the tab title", () => { + renderView({ + sessions: [ + session({ id: "a", name: "x", busy: true }), + session({ id: "b", name: "y" }), + ], + }); + expect(document.title).toContain("1 working"); + }); +}); diff --git a/web/src/SessionsView.tsx b/web/src/SessionsView.tsx index 0576844..80492c4 100644 --- a/web/src/SessionsView.tsx +++ b/web/src/SessionsView.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import type { SessionListItem, StatsTotals } from "./protocol"; import { Route } from "./protocol"; import { fetchJson } from "./api"; +import { useTitle } from "./title"; import { classNames, formatTokens, relativeTime } from "./store"; interface Props { @@ -143,6 +144,12 @@ export default function SessionsView({ const active = sessions.filter((s) => s.online).sort(byActivity); const archived = sessions.filter((s) => !s.online).sort(byActivity); + // Tab title on the overview: activity summary (busy sessions first). + const busyCount: number = active.filter((s) => s.busy).length; + const titleClaim: string | null = + active.length === 0 ? null : busyCount > 0 ? `${busyCount} working` : `${active.length} active`; + useTitle(titleClaim); + const remove = async (e: MouseEvent, s: SessionListItem): Promise => { e.preventDefault(); e.stopPropagation(); diff --git a/web/src/title.ts b/web/src/title.ts new file mode 100644 index 0000000..a3f5c4f --- /dev/null +++ b/web/src/title.ts @@ -0,0 +1,38 @@ +import { useEffect } from "react"; + +// Live document-title state: the deepest mounted view pushes its strip; +// when it unmounts the title falls back to the previous claimant. +let base = "lvmh"; +let current: string | null = null; +const listeners = new Set<() => void>(); + +function emit(): void { + document.title = current === null ? base : `${current} — ${base}`; + for (const l of listeners) l(); +} + +/** Set the app-wide base name (kept when no view claims a title). */ +export function setBaseTitle(name: string): void { + base = name; + emit(); +} + +/** Claim the document title while the calling view is mounted. */ +export function useTitle(claim: string | null): void { + useEffect(() => { + current = claim; + emit(); + return () => { + if (current === claim) { + current = null; + emit(); + } + }; + }, [claim]); +} + +/** Force-refresh consumers (used after setBaseTitle). */ +export function subscribeTitle(fn: () => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +}