feat: live document title — chat tab shows session name + activity (writing…/tool name/working…), overview shows busy/active counts; 268 tests, gates ≥95%

This commit is contained in:
Raphael Westphal
2026-08-20 11:19:21 +02:00
parent ecee633479
commit 9d369f410e
5 changed files with 100 additions and 0 deletions
+26
View File
@@ -1692,3 +1692,29 @@ describe("ChatView ⋯ menu and timestamps", () => {
expect(container.querySelectorAll(".ts")).toHaveLength(1); 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"));
});
});
+17
View File
@@ -8,6 +8,7 @@ import type {
SetModelBody, SetModelBody,
} from "./protocol"; } from "./protocol";
import { EventType, Route } from "./protocol"; import { EventType, Route } from "./protocol";
import { useTitle } from "./title";
import { ApiError, errMessage, fetchJson } from "./api"; import { ApiError, errMessage, fetchJson } from "./api";
import { import {
deriveChat, deriveChat,
@@ -210,6 +211,22 @@ export default function ChatView({ store, pushToast }: Props) {
// session must never look busy (B2) // session must never look busy (B2)
const busy: boolean = chat.busy && session?.online !== false; 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( const minSeq: number = useMemo(
() => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))), () => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))),
[events], [events],
+12
View File
@@ -512,3 +512,15 @@ describe("busy indicator on cards", () => {
expect(pips).toHaveLength(1); 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");
});
});
+7
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import type { SessionListItem, StatsTotals } from "./protocol"; import type { SessionListItem, StatsTotals } from "./protocol";
import { Route } from "./protocol"; import { Route } from "./protocol";
import { fetchJson } from "./api"; import { fetchJson } from "./api";
import { useTitle } from "./title";
import { classNames, formatTokens, relativeTime } from "./store"; import { classNames, formatTokens, relativeTime } from "./store";
interface Props { interface Props {
@@ -143,6 +144,12 @@ export default function SessionsView({
const active = sessions.filter((s) => s.online).sort(byActivity); const active = sessions.filter((s) => s.online).sort(byActivity);
const archived = 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<void> => { const remove = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
+38
View File
@@ -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);
}