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);
|
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,
|
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],
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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