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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user