refactor(test): migrate ChatView.test.ts to shared template-inspection seam

Reclassify ChatView.test.ts per the testing-guide skill: move content/text/
attribute/ordering assertions to new pure public seams on ChatView.ts
(chatSessionWarningRows, chatQueuedSectionShowsClearAction, chatGroupAnchorKey,
chatEventAnchorKey, chatGroupScrollMarkerId, chatMessageGroupClassName,
chatMessageGroupLabel) with the component render code delegating to them, and
route the genuine Clear-queue/dismiss/toggle event wiring through the shared
templateInspection.testSupport helpers with escape-hatch comments. Delete the
per-file TemplateResult-inspection cluster. Drop the now-consumed @public tag
from templateEventHandlerNearMarker.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 21:37:30 +02:00
parent 6ba215503f
commit 9a94f41a61
3 changed files with 207 additions and 239 deletions
+137 -219
View File
@@ -2,7 +2,19 @@ import type { TemplateResult } from "lit";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api"; import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api";
import type { ChatLine } from "./shared"; import type { ChatLine } from "./shared";
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView"; import {
ChatView,
chatEventAnchorKey,
chatGroupAnchorKey,
chatGroupScrollMarkerId,
chatMessageGroupClassName,
chatMessageGroupLabel,
chatMessageMetadataLabel,
chatQueuedMessageSections,
chatQueuedSectionShowsClearAction,
chatSessionWarningRows,
} from "./ChatView";
import { templateEventHandlerAfterMarker, templateEventHandlerNearMarker } from "../templateInspection.testSupport";
describe("chatQueuedMessageSections", () => { describe("chatQueuedMessageSections", () => {
it("labels client-side pending-start sends separately from server queued messages", () => { it("labels client-side pending-start sends separately from server queued messages", () => {
@@ -28,97 +40,87 @@ describe("chatQueuedMessageSections", () => {
}); });
}); });
describe("ChatView queued-message clear action", () => { describe("chatQueuedSectionShowsClearAction", () => {
// Direct handler extraction keeps this node-environment test focused on the // The show/hide decision for the server clear-queue button is content/layout,
// Clear queue template wiring without introducing a component-wide DOM shim. // so it lives in a pure exported seam instead of scraping rendered markup.
it("renders an accessible server-queue action and invokes its callback", () => { const serverSection = requireSection(chatQueuedMessageSections([], [{ kind: "steer", text: "server queued" }])[0]);
const clientSection = requireSection(chatQueuedMessageSections([{ kind: "followUp", text: "waiting" }], [])[0]);
it("shows the action for the server queue when clearing is supported and wired", () => {
expect(chatQueuedSectionShowsClearAction(serverSection, true, true)).toBe(true);
});
it("hides the action when the runtime does not support clearing", () => {
expect(chatQueuedSectionShowsClearAction(serverSection, false, true)).toBe(false);
});
it("hides the action when no clear handler is wired", () => {
expect(chatQueuedSectionShowsClearAction(serverSection, true, false)).toBe(false);
});
it("never shows the server action for the separate client pending-start queue", () => {
expect(chatQueuedSectionShowsClearAction(clientSection, true, true)).toBe(false);
});
});
describe("ChatView queued-message clear wiring", () => {
// Escape hatch: this case verifies the Clear queue button's Lit event wiring,
// whose only observable effect is invoking the injected callback. Vitest runs
// with no DOM environment here, so a shadow-DOM click harness would add
// disproportionate setup; handler extraction anchored to the user-facing
// "Clear queue" button text is proportionate.
it("invokes onClearServerQueue when the server-queue action is activated", () => {
const view = new ChatView(); const view = new ChatView();
const onClearServerQueue = vi.fn(); const onClearServerQueue = vi.fn();
view.status = queuedStatus([{ kind: "steer", text: "server queued" }]); view.status = queuedStatus([{ kind: "steer", text: "server queued" }]);
view.canClearServerQueue = true; view.canClearServerQueue = true;
view.onClearServerQueue = onClearServerQueue; view.onClearServerQueue = onClearServerQueue;
const rendered = renderQueuedMessages(view); templateEventHandlerNearMarker(renderQueuedMessages(view), "Clear queue")(new Event("click"));
const markup = templateStaticMarkup(rendered);
expect(markup).toContain('type="button"');
expect(markup).toContain('title="Clear queued messages without stopping active work"');
expect(markup).toContain(">Clear queue</button>");
templateEventHandler(rendered, "Clear queue")(new Event("click"));
expect(onClearServerQueue).toHaveBeenCalledOnce(); expect(onClearServerQueue).toHaveBeenCalledOnce();
}); });
it("hides the action when the selected runtime does not support clearing", () => {
const view = new ChatView();
view.status = queuedStatus([{ kind: "followUp", text: "server queued" }]);
view.canClearServerQueue = false;
view.onClearServerQueue = vi.fn();
expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue");
});
it("does not expose the server action for the separate client pending-start queue", () => {
const view = new ChatView();
view.status = queuedStatus([]);
view.clientQueuedMessages = [{ kind: "followUp", text: "waiting for session start" }];
view.canClearServerQueue = true;
view.onClearServerQueue = vi.fn();
expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue");
});
}); });
describe("ChatView session warnings banner", () => { describe("chatSessionWarningRows", () => {
it("renders one severity-tagged row per warning with optional path and source", () => { // Warning-row content (severity class, message, path, source, dismiss
const view = new ChatView(); // capability, ordering) is derived by a pure exported seam rather than scraped
view.status = warningStatus([ // from rendered `TemplateResult` markup, per the testing-guide rule that
// TemplateResult inspection is not for general content assertions.
it("derives one severity-tagged row per warning with optional path and source", () => {
const rows = chatSessionWarningRows(warningStatus([
{ severity: "error", message: "skill failed to load", source: "skill", path: "/skills/a.md" }, { severity: "error", message: "skill failed to load", source: "skill", path: "/skills/a.md" },
{ severity: "warning", message: "subscription auth is active" }, { severity: "warning", message: "subscription auth is active" },
{ severity: "info", message: "heads up", source: "runtime" }, { severity: "info", message: "heads up", source: "runtime" },
]));
expect(rows).toEqual([
{ severity: "error", severityClass: "session-warning error", message: "skill failed to load", source: "skill", path: "/skills/a.md", dismissId: undefined },
{ severity: "warning", severityClass: "session-warning warning", message: "subscription auth is active", source: undefined, path: undefined, dismissId: undefined },
{ severity: "info", severityClass: "session-warning info", message: "heads up", source: "runtime", path: undefined, dismissId: undefined },
]); ]);
const rendered = renderWarnings(view);
if (rendered === null) throw new Error("expected a warnings banner");
const markup = templateStaticMarkup(rendered);
const values = collectStringValues(rendered);
expect(markup).toContain('class="session-warnings"');
expect(markup).toContain('role="alert"');
expect(values).toContain("skill failed to load");
expect(values).toContain("/skills/a.md");
expect(values).toContain("subscription auth is active");
expect(values).toContain("heads up");
expect(values).toContain("skill");
const severityClasses = values.filter((value) => value.startsWith("session-warning "));
expect(severityClasses).toEqual(["session-warning error", "session-warning warning", "session-warning info"]);
}); });
it("renders nothing when there are no warnings", () => { it("exposes a dismiss id only for warnings carrying a dismiss capability", () => {
const view = new ChatView(); const rows = chatSessionWarningRows(warningStatus([
view.status = warningStatus([]);
expect(renderWarnings(view)).toBeNull();
});
it("renders nothing when status is unset", () => {
expect(renderWarnings(new ChatView())).toBeNull();
});
it("renders a dismiss control only for warnings carrying a dismiss capability", () => {
const view = new ChatView();
view.status = warningStatus([
{ severity: "error", message: "skill failed to load", source: "skill" }, { severity: "error", message: "skill failed to load", source: "skill" },
{ severity: "warning", message: "subscription auth is active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } }, { severity: "warning", message: "subscription auth is active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
]); ]));
const rendered = renderWarnings(view); expect(rows.map((row) => row.dismissId)).toEqual([undefined, "anthropicExtraUsage"]);
if (rendered === null) throw new Error("expected a warnings banner");
const markup = templateStaticMarkup(rendered);
// One dismiss button total: only the warning with a dismiss capability gets one.
expect(markup.match(/session-warning-dismiss/g)?.length).toBe(1);
}); });
// Direct handler extraction keeps this node-environment test focused on the it("derives no rows when there are no warnings or status is unset", () => {
// dismiss button wiring without a component-wide DOM shim. expect(chatSessionWarningRows(warningStatus([]))).toEqual([]);
expect(chatSessionWarningRows(undefined)).toEqual([]);
});
});
describe("ChatView session-warning dismiss wiring", () => {
// Escape hatch: this case verifies the dismiss button's Lit event wiring,
// whose observable effect is invoking onDismissWarning with the warning's
// dismiss id. No DOM environment is available, so handler extraction anchored
// to the stable `session-warning-dismiss` class marker is proportionate.
it("invokes onDismissWarning with the warning's dismiss id", () => { it("invokes onDismissWarning with the warning's dismiss id", () => {
const view = new ChatView(); const view = new ChatView();
const onDismissWarning = vi.fn(); const onDismissWarning = vi.fn();
@@ -129,10 +131,14 @@ describe("ChatView session warnings banner", () => {
const rendered = renderWarnings(view); const rendered = renderWarnings(view);
if (rendered === null) throw new Error("expected a warnings banner"); if (rendered === null) throw new Error("expected a warnings banner");
templateEventHandler(rendered, "session-warning-dismiss")(new Event("click")); templateEventHandlerAfterMarker(rendered, "session-warning-dismiss")(new Event("click"));
expect(onDismissWarning).toHaveBeenCalledExactlyOnceWith("anthropicExtraUsage"); expect(onDismissWarning).toHaveBeenCalledExactlyOnceWith("anthropicExtraUsage");
}); });
it("renders nothing when there are no warnings", () => {
expect(renderWarnings(withStatus(new ChatView(), warningStatus([])))).toBeNull();
});
}); });
describe("chatMessageMetadataLabel", () => { describe("chatMessageMetadataLabel", () => {
@@ -148,50 +154,39 @@ describe("chatMessageMetadataLabel", () => {
}); });
}); });
describe("ChatView technical-event groups", () => { describe("chat event-group content seams", () => {
// Group scroll-anchor keys, marker ids, class list, and disclosure label are
// content/structure derived from pure exported seams rather than scraped from
// rendered markup.
it("derives stable group and event scroll-anchor keys and marker ids", () => {
expect(chatGroupAnchorKey(40)).toBe("g:40");
expect(chatEventAnchorKey(40)).toBe("e:40");
expect(chatEventAnchorKey(41)).toBe("e:41");
expect(chatGroupScrollMarkerId(41)).toBe("g:41");
});
it("distinguishes the live tail group by class and disclosure label", () => {
expect(chatMessageGroupClassName(true)).toBe("msg event-group live");
expect(chatMessageGroupClassName(false)).toBe("msg event-group");
expect(chatMessageGroupLabel(true)).toBe("live events");
expect(chatMessageGroupLabel(false)).toBe("events");
});
});
describe("ChatView event-group disclosure wiring", () => {
const messages: ChatLine[] = [ const messages: ChatLine[] = [
{ role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] }, { role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] },
{ role: "tool", parts: [{ type: "toolExecution", toolName: "read", summary: "inspect a file", status: "success", resultText: "large result" }] }, { role: "tool", parts: [{ type: "toolExecution", toolName: "read", summary: "inspect a file", status: "success", resultText: "large result" }] },
]; ];
it("defers a closed body while retaining native disclosure and group scroll anchors", () => { it("defers a closed group body until it is opened", () => {
const view = new ChatView(); const view = new ChatView();
view.sessionId = "session-1"; view.sessionId = "session-1";
const bodyCalls = observeGroupBodyRenders(view); const bodyCalls = observeGroupBodyRenders(view);
const closed = renderMessageGroup(view, messages, 40, 41, false); renderMessageGroup(view, messages, 40, 41, false);
expect(bodyCalls).toEqual([]); expect(bodyCalls).toEqual([]);
expect(templateStaticMarkup(closed)).toContain("<details");
expect(templateStaticMarkup(closed)).toContain("<summary>");
expect(templateStaticMarkup(closed)).toContain('aria-hidden="true"');
expect(templateValuesAfterMarker(closed, "?open=")).toEqual([false]);
expect(templateValuesAfterMarker(closed, "data-scroll-anchor-id=")).toEqual(["g:40"]);
expect(templateValuesAfterMarker(closed, "data-marker-id=")).toEqual(["g:41"]);
});
// Direct handler extraction keeps this node-environment test focused on the
// native details toggle wiring without introducing a component-wide DOM shim.
it("renders an opened body with event anchors and removes it when closed again", () => {
const view = new ChatView();
view.sessionId = "session-1";
const bodyCalls = observeGroupBodyRenders(view);
const initiallyClosed = renderMessageGroup(view, messages, 40, 41, false);
dispatchDetailsToggle(templateEventHandler(initiallyClosed, "@toggle="), true);
const opened = renderMessageGroup(view, messages, 40, 41, false);
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
expect(templateValuesAfterMarker(opened, "?open=")).toEqual([true]);
expect(templateValuesAfterMarker(opened, "data-scroll-anchor-id=")).toEqual(["g:40", "e:40", "e:41"]);
bodyCalls.length = 0;
dispatchDetailsToggle(templateEventHandler(opened, "@toggle="), false);
const closedAgain = renderMessageGroup(view, messages, 40, 41, false);
expect(bodyCalls).toEqual([]);
expect(templateValuesAfterMarker(closedAgain, "?open=")).toEqual([false]);
expect(templateValuesAfterMarker(closedAgain, "data-scroll-anchor-id=")).toEqual(["g:40"]);
}); });
it("renders a live tail body by default", () => { it("renders a live tail body by default", () => {
@@ -199,12 +194,32 @@ describe("ChatView technical-event groups", () => {
view.sessionId = "session-1"; view.sessionId = "session-1";
const bodyCalls = observeGroupBodyRenders(view); const bodyCalls = observeGroupBodyRenders(view);
const live = renderMessageGroup(view, messages, 40, 41, true); renderMessageGroup(view, messages, 40, 41, true);
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]); expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
expect(templateValuesAfterMarker(live, "?open=")).toEqual([true]); });
expect(templateValues(live)).toContain("msg event-group live");
expect(templateValues(live)).toContain("live events"); // Escape hatch: this case verifies the native `<details>` `@toggle` wiring,
// whose observable effect is that a re-render renders (or defers) the group
// body. No DOM environment is available for a real disclosure interaction, so
// handler extraction anchored to the stable `@toggle=` attribute marker plus
// an injected details-toggle event is proportionate.
it("renders the body after a toggle-open and removes it when closed again", () => {
const view = new ChatView();
view.sessionId = "session-1";
const bodyCalls = observeGroupBodyRenders(view);
const initiallyClosed = renderMessageGroup(view, messages, 40, 41, false);
dispatchDetailsToggle(templateEventHandlerAfterMarker(initiallyClosed, "@toggle="), true);
renderMessageGroup(view, messages, 40, 41, false);
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
bodyCalls.length = 0;
dispatchDetailsToggle(templateEventHandlerAfterMarker(initiallyClosed, "@toggle="), false);
renderMessageGroup(view, messages, 40, 41, false);
expect(bodyCalls).toEqual([]);
}); });
}); });
@@ -216,6 +231,7 @@ interface GroupBodyRenderCall {
type RenderQueuedMessages = (this: ChatView) => TemplateResult; type RenderQueuedMessages = (this: ChatView) => TemplateResult;
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult; type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult; type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
type RenderWarnings = (this: ChatView) => TemplateResult | null;
type TemplateEventHandler = (event: Event) => void; type TemplateEventHandler = (event: Event) => void;
function renderQueuedMessages(view: ChatView): TemplateResult { function renderQueuedMessages(view: ChatView): TemplateResult {
@@ -230,6 +246,12 @@ function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: nu
return method.call(view, messages, startIndex, endIndex, defaultOpen); return method.call(view, messages, startIndex, endIndex, defaultOpen);
} }
function renderWarnings(view: ChatView): TemplateResult | null {
const method: unknown = Reflect.get(view, "renderWarnings");
if (!isRenderWarnings(method)) throw new Error("ChatView.renderWarnings is not callable");
return method.call(view);
}
function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] { function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
const method: unknown = Reflect.get(view, "renderMessageGroupBody"); const method: unknown = Reflect.get(view, "renderMessageGroupBody");
if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable"); if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable");
@@ -254,34 +276,7 @@ function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBo
return typeof value === "function"; return typeof value === "function";
} }
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { function isRenderWarnings(value: unknown): value is RenderWarnings {
let handler: TemplateEventHandler | undefined;
visit(template);
if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`);
return handler;
function visit(value: unknown): void {
if (handler !== undefined) return;
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
const strings = templateStrings(value);
const values = templateValues(value);
for (let index = 0; index < values.length; index += 1) {
const candidate = values[index];
const isNearMarker = strings[index]?.includes(marker) === true || strings[index + 1]?.includes(marker) === true;
if (isNearMarker && isTemplateEventHandler(candidate)) {
handler = candidate;
return;
}
visit(candidate);
}
}
}
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
return typeof value === "function"; return typeof value === "function";
} }
@@ -304,91 +299,14 @@ function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): vo
} }
} }
function templateStaticMarkup(template: TemplateResult): string { function requireSection(section: ReturnType<typeof chatQueuedMessageSections>[number] | undefined): ReturnType<typeof chatQueuedMessageSections>[number] {
const chunks: string[] = []; if (section === undefined) throw new Error("expected a queued-message section");
visit(template); return section;
return chunks.join("");
function visit(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
chunks.push(...templateStrings(value));
for (const child of templateValues(value)) visit(child);
}
} }
function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] { function withStatus(view: ChatView, status: SessionStatus): ChatView {
const matches: unknown[] = []; view.status = status;
visit(template); return view;
return matches;
function visit(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
const strings = templateStrings(value);
const values = templateValues(value);
for (let index = 0; index < values.length; index += 1) {
if (strings[index]?.includes(marker) === true) matches.push(values[index]);
visit(values[index]);
}
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function collectStringValues(template: TemplateResult): string[] {
const found: string[] = [];
visit(template);
return found;
function visit(value: unknown): void {
if (typeof value === "string") {
found.push(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
for (const child of templateValues(value)) visit(child);
}
}
type RenderWarnings = (this: ChatView) => TemplateResult | null;
function isRenderWarnings(value: unknown): value is RenderWarnings {
return typeof value === "function";
}
function renderWarnings(view: ChatView): TemplateResult | null {
const method: unknown = Reflect.get(view, "renderWarnings");
if (!isRenderWarnings(method)) throw new Error("ChatView.renderWarnings is not callable");
return method.call(view);
} }
function warningStatus(warnings: SessionWarning[]): SessionStatus { function warningStatus(warnings: SessionWarning[]): SessionStatus {
+70 -18
View File
@@ -62,6 +62,58 @@ export function chatMessageAnchorKey(index: number): string {
return `m:${String(index)}`; return `m:${String(index)}`;
} }
/** The stable scroll-anchor/render key for a collapsed event group starting at `startIndex`. */
export function chatGroupAnchorKey(startIndex: number): string {
return `g:${String(startIndex)}`;
}
/** The stable scroll-anchor key for an event inside a group at `index`. */
export function chatEventAnchorKey(index: number): string {
return `e:${String(index)}`;
}
/** The stable scroll-marker id emitted before an event group ending at `endIndex`. */
export function chatGroupScrollMarkerId(endIndex: number): string {
return `g:${String(endIndex)}`;
}
/** The CSS class list for an event-group `<details>`, distinguishing the live tail. */
export function chatMessageGroupClassName(defaultOpen: boolean): string {
return defaultOpen ? "msg event-group live" : "msg event-group";
}
/** The disclosure summary label for an event group, distinguishing the live tail. */
export function chatMessageGroupLabel(defaultOpen: boolean): string {
return defaultOpen ? "live events" : "events";
}
/** Whether a queued-message section shows the server clear-queue action. */
export function chatQueuedSectionShowsClearAction(section: QueuedMessageSection, canClearServerQueue: boolean, hasClearHandler: boolean): boolean {
return section.source === "server" && canClearServerQueue && hasClearHandler;
}
/** A rendered session-warning row derived from live status warnings. */
export interface ChatSessionWarningRow {
severity: SessionWarningSeverity;
severityClass: string;
message: string;
source?: string;
path?: string;
dismissId?: string;
}
/** Derive one severity-tagged warning row per live status warning, in order. */
export function chatSessionWarningRows(status: SessionStatus | undefined): ChatSessionWarningRow[] {
return (status?.warnings ?? []).map((warning) => ({
severity: warning.severity,
severityClass: `session-warning ${warning.severity}`,
message: warning.message,
...(warning.source === undefined ? {} : { source: warning.source }),
...(warning.path === undefined ? {} : { path: warning.path }),
...(warning.dismiss === undefined ? {} : { dismissId: warning.dismiss.id }),
}));
}
export function chatMessageMetadataLabel(message: ChatLine): string { export function chatMessageMetadataLabel(message: ChatLine): string {
const timestamp = message.meta?.timestamp; const timestamp = message.meta?.timestamp;
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp); const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
@@ -256,29 +308,29 @@ export class ChatView extends LitElement {
} }
private renderWarnings() { private renderWarnings() {
const warnings = this.status?.warnings ?? []; const rows = chatSessionWarningRows(this.status);
if (warnings.length === 0) return null; if (rows.length === 0) return null;
return html` return html`
<aside class="session-warnings" role="alert" aria-live="polite"> <aside class="session-warnings" role="alert" aria-live="polite">
${warnings.map((warning) => { ${rows.map((row) => {
const dismiss = warning.dismiss; const dismissId = row.dismissId;
return html` return html`
<div class=${`session-warning ${warning.severity}`}> <div class=${row.severityClass}>
<div class="session-warning-head"> <div class="session-warning-head">
<span class="session-warning-icon" aria-hidden="true">${warningSeverityIcon(warning.severity)}</span> <span class="session-warning-icon" aria-hidden="true">${warningSeverityIcon(row.severity)}</span>
${warning.source === undefined ? null : html`<span class="session-warning-source">${warning.source}</span>`} ${row.source === undefined ? null : html`<span class="session-warning-source">${row.source}</span>`}
</div> </div>
<div class="session-warning-body"> <div class="session-warning-body">
<p class="session-warning-message">${warning.message}</p> <p class="session-warning-message">${row.message}</p>
${warning.path === undefined ? null : html`<p class="session-warning-path">${warning.path}</p>`} ${row.path === undefined ? null : html`<p class="session-warning-path">${row.path}</p>`}
</div> </div>
${dismiss === undefined ? null : html` ${dismissId === undefined ? null : html`
<button <button
type="button" type="button"
class="session-warning-dismiss" class="session-warning-dismiss"
title="Don't show this warning again" title="Don't show this warning again"
aria-label="Dismiss warning" aria-label="Dismiss warning"
@click=${() => { this.onDismissWarning?.(dismiss.id); }} @click=${() => { this.onDismissWarning?.(dismissId); }}
>×</button> >×</button>
`} `}
</div> </div>
@@ -345,7 +397,7 @@ export class ChatView extends LitElement {
} }
private renderQueuedMessageList(section: QueuedMessageSection) { private renderQueuedMessageList(section: QueuedMessageSection) {
const canClear = section.source === "server" && this.canClearServerQueue && this.onClearServerQueue !== undefined; const canClear = chatQueuedSectionShowsClearAction(section, this.canClearServerQueue, this.onClearServerQueue !== undefined);
return html` return html`
<aside class="queued-messages" aria-live="polite"> <aside class="queued-messages" aria-live="polite">
<div class="queued-header"> <div class="queued-header">
@@ -472,9 +524,9 @@ export class ChatView extends LitElement {
const open = this.disclosures.isOpen(disclosureKey, defaultOpen); const open = this.disclosures.isOpen(disclosureKey, defaultOpen);
return html` return html`
${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))} ${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))}
<details class=${defaultOpen ? "msg event-group live" : "msg event-group"} data-index=${startIndex} data-scroll-anchor-id=${this.groupAnchorKey(startIndex)} ?open=${open} @toggle=${(event: Event) => { this.onGroupToggle(disclosureKey, event, defaultOpen); }}> <details class=${chatMessageGroupClassName(defaultOpen)} data-index=${startIndex} data-scroll-anchor-id=${this.groupAnchorKey(startIndex)} ?open=${open} @toggle=${(event: Event) => { this.onGroupToggle(disclosureKey, event, defaultOpen); }}>
<summary> <summary>
<b class="label">${defaultOpen ? "live events" : "events"}</b> <b class="label">${chatMessageGroupLabel(defaultOpen)}</b>
<span>${summarizeChatGroup(messages)}</span> <span>${summarizeChatGroup(messages)}</span>
</summary> </summary>
${open ? this.renderMessageGroupBody(messages, startIndex) : null} ${open ? this.renderMessageGroupBody(messages, startIndex) : null}
@@ -894,15 +946,15 @@ export class ChatView extends LitElement {
} }
private groupRenderKey(startIndex: number): string { private groupRenderKey(startIndex: number): string {
return `g:${String(startIndex)}`; return chatGroupAnchorKey(startIndex);
} }
private groupAnchorKey(startIndex: number): string { private groupAnchorKey(startIndex: number): string {
return `g:${String(startIndex)}`; return chatGroupAnchorKey(startIndex);
} }
private eventAnchorKey(index: number): string { private eventAnchorKey(index: number): string {
return `e:${String(index)}`; return chatEventAnchorKey(index);
} }
private messageScrollMarkerId(index: number): string { private messageScrollMarkerId(index: number): string {
@@ -910,7 +962,7 @@ export class ChatView extends LitElement {
} }
private groupScrollMarkerId(endIndex: number): string { private groupScrollMarkerId(endIndex: number): string {
return `g:${String(endIndex)}`; return chatGroupScrollMarkerId(endIndex);
} }
static override styles = chatStyles; static override styles = chatStyles;
@@ -201,8 +201,6 @@ export function templateValueAfterMarker(template: TemplateResult, marker: strin
* *
* Use for attribute-anchored wiring such as `@click=`, `@load=`, `@toggle=`, or * Use for attribute-anchored wiring such as `@click=`, `@load=`, `@toggle=`, or
* a marker in the text right after the handler (e.g. `>Clear queue</button>`). * a marker in the text right after the handler (e.g. `>Clear queue</button>`).
*
* @public
*/ */
export function templateEventHandlerNearMarker<E extends Event = Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> { export function templateEventHandlerNearMarker<E extends Event = Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateEventHandlerNearMarker<E>(template, marker); const handler = findOptionalTemplateEventHandlerNearMarker<E>(template, marker);