Files
openclaw/ui/src/app/app-host.document-title.test.ts
T
Peter Steinberger 1e3192c0b4 refactor(ui): model gateway connection state as one closed phase union (#112909)
* refactor(ui): model gateway connection state as one closed phase union

ApplicationGatewaySnapshot carried connected + reconnecting as parallel
booleans whose illegal combination (both true) was representable, and
every consumer re-derived the same flag logic. The snapshot now carries
phase: "stopped" | "connecting" | "connected" | "reconnecting" |
"offline"; offlineStable stays as the store-owned 2s debounced
presentation boolean, and lastError/lastErrorCode are unchanged.

Mapping preserves shipped behavior exactly: never-connected terminal
closes return to "stopped" (login gate), established-connection drops
with retry are "reconnecting" (shell stays mounted), without retry
"offline". Component props stay boolean, derived once at the app-host
boundary; no compat aliases remain on the snapshot.

Closes #112741.

* fix(ui): migrate rebased-in test fixtures to the gateway phase union

* fix(ui): migrate post-rebase gateway snapshot readers to the phase union

* fix(ui): migrate document-title test harness snapshot to the phase union
2026-07-23 07:00:26 -07:00

175 lines
5.4 KiB
TypeScript

/* @vitest-environment jsdom */
import { describe, expect, it } from "vitest";
import type { AgentsListResult, GatewayAgentRow, GatewaySessionRow } from "../api/types.ts";
import type { RouteId } from "../app-routes.ts";
import "./app-host.ts";
import type { ApplicationContext } from "./context.ts";
type ShellDocumentTitleState = {
activeSessionKey: string;
outboxStoreRuntime: {
summarizeStoredChatOutboxes: () => { total: number };
} | null;
routeState: { routeId?: RouteId };
runtime?: { context: ApplicationContext };
syncDocumentTitle: () => void;
};
function roster(defaultId: string, agents: GatewayAgentRow[]): AgentsListResult {
return { defaultId, mainKey: "main", scope: "per-sender", agents };
}
describe("OpenClaw shell document title", () => {
function createShell(context?: ApplicationContext): ShellDocumentTitleState {
const shell = document.createElement(
"openclaw-app-shell",
) as unknown as ShellDocumentTitleState;
if (context) {
shell.runtime = { context };
}
return shell;
}
function createContext(options: {
connected?: boolean;
approvalCount?: number;
agentsList?: AgentsListResult | null;
assistantAgentId?: string;
sessions?: GatewaySessionRow[] | null;
}): ApplicationContext {
return {
gateway: {
snapshot: {
phase: (options.connected ?? true) ? "connected" : "reconnecting",
assistantAgentId: options.assistantAgentId ?? null,
},
connection: { gatewayUrl: "ws://gateway.test" },
},
agents: { state: { agentsList: options.agentsList ?? null } },
overlays: {
snapshot: { approvalQueue: Array.from({ length: options.approvalCount ?? 0 }) },
},
sessions: {
state: { result: options.sessions ? { sessions: options.sessions } : null },
},
} as unknown as ApplicationContext;
}
it("keeps the boot title before a route commits", () => {
const shell = createShell();
document.title = "OpenClaw Control";
shell.routeState = {};
shell.syncDocumentTitle();
expect(document.title).toBe("OpenClaw Control");
});
it("uses the route title for a connected route", () => {
const shell = createShell(createContext({ sessions: null }));
shell.routeState = { routeId: "usage" };
shell.syncDocumentTitle();
expect(document.title).toBe("Usage — OpenClaw");
});
it("uses the active session's derived title for a non-main chat", () => {
const session: GatewaySessionRow = {
key: "agent:main:dashboard:quarterly-launch",
kind: "direct",
updatedAt: 1,
derivedTitle: "Quarterly launch plan",
};
const shell = createShell(createContext({ sessions: [session] }));
shell.routeState = { routeId: "chat" };
shell.activeSessionKey = session.key;
shell.syncDocumentTitle();
expect(document.title).toBe("Quarterly launch plan — OpenClaw");
});
it("uses the agent name for an agent main chat", () => {
const shell = createShell(
createContext({ agentsList: roster("main", [{ id: "main", name: "Molty" }]) }),
);
shell.routeState = { routeId: "chat" };
shell.activeSessionKey = "agent:main:main";
shell.syncDocumentTitle();
expect(document.title).toBe("Molty — OpenClaw");
});
it("uses the selected agent name for a global-scope main chat", () => {
const shell = createShell(
createContext({
assistantAgentId: "molty",
agentsList: roster("main", [{ id: "molty", name: "Molty" }]),
}),
);
shell.routeState = { routeId: "chat" };
shell.activeSessionKey = "global";
shell.syncDocumentTitle();
expect(document.title).toBe("Molty — OpenClaw");
});
it("falls back to the session display name when the main agent is missing", () => {
const session: GatewaySessionRow = {
key: "agent:missing:main",
kind: "direct",
updatedAt: 1,
label: "Fallback thread",
};
const shell = createShell(
createContext({ sessions: [session], agentsList: roster("main", []) }),
);
shell.routeState = { routeId: "chat" };
shell.activeSessionKey = session.key;
shell.syncDocumentTitle();
expect(document.title).toBe("Fallback thread — OpenClaw");
});
it("prefixes the pending approval count", () => {
const shell = createShell(createContext({ approvalCount: 2 }));
shell.routeState = { routeId: "usage" };
shell.syncDocumentTitle();
expect(document.title).toBe("(2) Usage — OpenClaw");
});
it("shows offline instead of a stale approval count", () => {
const shell = createShell(createContext({ connected: false, approvalCount: 2 }));
shell.routeState = { routeId: "usage" };
shell.syncDocumentTitle();
expect(document.title).toBe("(Offline) Usage — OpenClaw");
});
it("includes stored chat outbox messages in the offline marker", () => {
const shell = createShell(createContext({ connected: false }));
shell.routeState = { routeId: "usage" };
shell.outboxStoreRuntime = {
summarizeStoredChatOutboxes: () => ({ total: 3 }),
};
shell.syncDocumentTitle();
expect(document.title).toBe("(Offline · 3 queued) Usage — OpenClaw");
});
it("uses the meaningful custodian label without a brand suffix", () => {
const shell = createShell(createContext({}));
shell.routeState = { routeId: "custodian" };
shell.syncDocumentTitle();
expect(document.title).toBe("Ask OpenClaw");
});
});