test: split chat helper coverage

This commit is contained in:
Peter Steinberger
2026-04-17 19:50:39 +01:00
parent 125b1e0e20
commit 7c862da6a1
6 changed files with 318 additions and 315 deletions

View File

@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import type { GatewaySessionRow } from "../types.ts";
import {
getContextNoticeViewModel,
renderContextNotice,
resetContextNoticeThemeCacheForTest,
} from "./context-notice.ts";
describe("context notice", () => {
afterEach(() => {
document.documentElement.style.removeProperty("--warn");
document.documentElement.style.removeProperty("--danger");
resetContextNoticeThemeCacheForTest();
});
it("renders only for fresh high current usage", () => {
const container = document.createElement("div");
document.documentElement.style.setProperty("--warn", "rgb(1, 2, 3)");
document.documentElement.style.setProperty("--danger", "tomato");
resetContextNoticeThemeCacheForTest();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 46_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
const session: GatewaySessionRow = {
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 190_000,
contextTokens: 200_000,
};
render(renderContextNotice(session, 200_000), container);
expect(container.textContent).toContain("95% context used");
expect(container.textContent).toContain("190k / 200k");
expect(container.textContent).not.toContain("757.3k / 200k");
const notice = container.querySelector<HTMLElement>(".context-notice");
expect(notice).not.toBeNull();
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb(");
expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN");
expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN");
const icon = container.querySelector<SVGElement>(".context-notice__icon");
expect(icon).not.toBeNull();
expect(icon?.tagName.toLowerCase()).toBe("svg");
expect(icon?.classList.contains("context-notice__icon")).toBe(true);
expect(icon?.getAttribute("width")).toBe("16");
expect(icon?.getAttribute("height")).toBe("16");
expect(icon?.querySelector("path")).not.toBeNull();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 500_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
});
});

View File

@@ -0,0 +1,125 @@
import { html, nothing } from "lit";
import type { GatewaySessionRow } from "../types.ts";
/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */
function parseHexRgb(hex: string): [number, number, number] | null {
const h = hex.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(h)) {
return null;
}
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
let cachedThemeNoticeColors: {
warnHex: string;
dangerHex: string;
warnRgb: [number, number, number];
dangerRgb: [number, number, number];
} | null = null;
function getThemeNoticeColors() {
if (cachedThemeNoticeColors) {
return cachedThemeNoticeColors;
}
const rootStyle = getComputedStyle(document.documentElement);
const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b";
const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444";
cachedThemeNoticeColors = {
warnHex,
dangerHex,
warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11],
dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68],
};
return cachedThemeNoticeColors;
}
export function resetContextNoticeThemeCacheForTest(): void {
cachedThemeNoticeColors = null;
}
export function getContextNoticeViewModel(
session: GatewaySessionRow | undefined,
defaultContextTokens: number | null,
): {
pct: number;
detail: string;
color: string;
bg: string;
} | null {
if (session?.totalTokensFresh === false) {
return null;
}
const used = session?.totalTokens ?? 0;
const limit = session?.contextTokens ?? defaultContextTokens ?? 0;
if (!used || !limit) {
return null;
}
const ratio = used / limit;
if (ratio < 0.85) {
return null;
}
const pct = Math.min(Math.round(ratio * 100), 100);
// Read theme semantic tokens so color tracks the active theme (Dash, dark, light ...).
const { warnRgb, dangerRgb } = getThemeNoticeColors();
const [wr, wg, wb] = warnRgb;
const [dr, dg, db] = dangerRgb;
const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1);
const r = Math.round(wr + (dr - wr) * t);
const g = Math.round(wg + (dg - wg) * t);
const b = Math.round(wb + (db - wb) * t);
const color = `rgb(${r}, ${g}, ${b})`;
const bgOpacity = 0.08 + 0.08 * t;
const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`;
return {
pct,
detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`,
color,
bg,
};
}
export function renderContextNotice(
session: GatewaySessionRow | undefined,
defaultContextTokens: number | null,
) {
const model = getContextNoticeViewModel(session, defaultContextTokens);
if (!model) {
return nothing;
}
return html`
<div
class="context-notice"
role="status"
style="--ctx-color:${model.color};--ctx-bg:${model.bg}"
>
<svg
class="context-notice__icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span>${model.pct}% context used</span>
<span class="context-notice__detail">${model.detail}</span>
</div>
`;
}
/** Format token count compactly (e.g. 128000 -> "128k"). */
function formatTokensCompact(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}

View File

@@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderSideResult } from "./side-result-render.ts";
describe("side result render", () => {
it("renders, dismisses, and styles BTW side results outside transcript history", () => {
const container = document.createElement("div");
const onDismissSideResult = vi.fn();
render(
renderSideResult(
{
kind: "btw",
runId: "btw-run-1",
sessionKey: "main",
question: "what changed?",
text: "The web UI now renders **BTW** separately.",
isError: false,
ts: 2,
},
onDismissSideResult,
),
container,
);
expect(container.querySelector(".chat-side-result")).not.toBeNull();
expect(container.textContent).toContain("BTW");
expect(container.textContent).toContain("what changed?");
expect(container.textContent).toContain("Not saved to chat history");
expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1);
const button = container.querySelector<HTMLButtonElement>(".chat-side-result__dismiss");
expect(button).not.toBeNull();
button?.click();
expect(onDismissSideResult).toHaveBeenCalledTimes(1);
render(
renderSideResult({
kind: "btw",
runId: "btw-run-3",
sessionKey: "main",
question: "what failed?",
text: "The side question could not be answered.",
isError: true,
ts: 4,
}),
container,
);
expect(container.querySelector(".chat-side-result--error")).not.toBeNull();
});
});

View File

@@ -0,0 +1,43 @@
import { html, nothing, type TemplateResult } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { ChatSideResult } from "./side-result.ts";
export function renderSideResult(
sideResult: ChatSideResult | null | undefined,
onDismiss?: () => void,
): TemplateResult | typeof nothing {
if (!sideResult) {
return nothing;
}
return html`
<section
class=${`chat-side-result ${sideResult.isError ? "chat-side-result--error" : ""}`}
role="status"
aria-live="polite"
aria-label="BTW side result"
>
<div class="chat-side-result__header">
<div class="chat-side-result__label-row">
<span class="chat-side-result__label">BTW</span>
<span class="chat-side-result__meta">Not saved to chat history</span>
</div>
<button
class="btn chat-side-result__dismiss"
type="button"
aria-label="Dismiss BTW result"
title="Dismiss"
@click=${() => onDismiss?.()}
>
${icons.x}
</button>
</div>
<div class="chat-side-result__question">${sideResult.question}</div>
<div class="chat-side-result__body" dir=${detectTextDirection(sideResult.text)}>
${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))}
</div>
</section>
`;
}

View File

@@ -6,7 +6,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts";
import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts";
import { normalizeMessage } from "../chat/message-normalizer.ts";
import type { SessionsListResult } from "../types.ts";
import { getContextNoticeViewModel, renderChat, type ChatProps } from "./chat.ts";
import { renderChat, type ChatProps } from "./chat.ts";
function createSessions(): SessionsListResult {
return {
@@ -81,156 +81,6 @@ function clearDeleteConfirmSkip() {
}
describe("chat view", () => {
it("renders, dismisses, and styles BTW side results outside transcript history", () => {
const container = document.createElement("div");
const onDismissSideResult = vi.fn();
render(
renderChat(
createProps({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "Saved transcript message" }],
timestamp: 1,
},
],
sideResult: {
kind: "btw",
runId: "btw-run-1",
sessionKey: "main",
question: "what changed?",
text: "The web UI now renders **BTW** separately.",
isError: false,
ts: 2,
},
onDismissSideResult,
}),
),
container,
);
expect(container.querySelector(".chat-side-result")).not.toBeNull();
expect(container.textContent).toContain("BTW");
expect(container.textContent).toContain("what changed?");
expect(container.textContent).toContain("Not saved to chat history");
expect(container.textContent).toContain("Saved transcript message");
expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1);
const button = container.querySelector<HTMLButtonElement>(".chat-side-result__dismiss");
expect(button).not.toBeNull();
button?.click();
expect(onDismissSideResult).toHaveBeenCalledTimes(1);
render(
renderChat(
createProps({
sideResult: {
kind: "btw",
runId: "btw-run-3",
sessionKey: "main",
question: "what failed?",
text: "The side question could not be answered.",
isError: true,
ts: 4,
},
}),
),
container,
);
expect(container.querySelector(".chat-side-result--error")).not.toBeNull();
});
it("renders the context notice only for fresh high current usage", () => {
const container = document.createElement("div");
document.documentElement.style.setProperty("--warn", "rgb(1, 2, 3)");
document.documentElement.style.setProperty("--danger", "tomato");
const renderWithSession = (session: NonNullable<ChatProps["sessions"]>["sessions"][number]) =>
render(
renderChat(
createProps({
sessions: {
ts: 0,
path: "",
count: 1,
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: 200_000 },
sessions: [session],
},
}),
),
container,
);
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 46_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
renderWithSession({
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 190_000,
contextTokens: 200_000,
});
expect(container.textContent).toContain("95% context used");
expect(container.textContent).toContain("190k / 200k");
expect(container.textContent).not.toContain("757.3k / 200k");
const notice = container.querySelector<HTMLElement>(".context-notice");
expect(notice).not.toBeNull();
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb(");
expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN");
expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN");
const icon = container.querySelector<SVGElement>(".context-notice__icon");
expect(icon).not.toBeNull();
expect(icon?.tagName.toLowerCase()).toBe("svg");
expect(icon?.classList.contains("context-notice__icon")).toBe(true);
expect(icon?.getAttribute("width")).toBe("16");
expect(icon?.getAttribute("height")).toBe("16");
expect(icon?.querySelector("path")).not.toBeNull();
document.documentElement.style.removeProperty("--warn");
document.documentElement.style.removeProperty("--danger");
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 500_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
});
it("uses the assistant avatar URL or bundled logo fallbacks", () => {
const container = document.createElement("div");
render(

View File

@@ -1,12 +1,12 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts";
import {
CHAT_ATTACHMENT_ACCEPT,
isSupportedChatAttachmentMimeType,
} from "../chat/attachment-support.ts";
import { renderContextNotice } from "../chat/context-notice.ts";
import { DeletedMessages } from "../chat/deleted-messages.ts";
import { exportChatMarkdown } from "../chat/export.ts";
import {
@@ -25,6 +25,7 @@ import { PinnedMessages } from "../chat/pinned-messages.ts";
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
import { messageMatchesSearchQuery } from "../chat/search-match.ts";
import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts";
import { renderSideResult } from "../chat/side-result-render.ts";
import type { ChatSideResult } from "../chat/side-result.ts";
import {
CATEGORY_LABELS,
@@ -38,10 +39,9 @@ import { isSttSupported, startStt, stopStt } from "../chat/speech.ts";
import { buildSidebarContent, extractToolCards, extractToolPreview } from "../chat/tool-cards.ts";
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import type { SessionsListResult } from "../types.ts";
import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
@@ -456,167 +456,6 @@ function renderFallbackIndicator(status: FallbackStatus | null | undefined) {
`;
}
function renderSideResult(
sideResult: ChatSideResult | null | undefined,
onDismiss?: () => void,
): TemplateResult | typeof nothing {
if (!sideResult) {
return nothing;
}
return html`
<section
class=${`chat-side-result ${sideResult.isError ? "chat-side-result--error" : ""}`}
role="status"
aria-live="polite"
aria-label="BTW side result"
>
<div class="chat-side-result__header">
<div class="chat-side-result__label-row">
<span class="chat-side-result__label">BTW</span>
<span class="chat-side-result__meta">Not saved to chat history</span>
</div>
<button
class="btn chat-side-result__dismiss"
type="button"
aria-label="Dismiss BTW result"
title="Dismiss"
@click=${() => onDismiss?.()}
>
${icons.x}
</button>
</div>
<div class="chat-side-result__question">${sideResult.question}</div>
<div class="chat-side-result__body" dir=${detectTextDirection(sideResult.text)}>
${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))}
</div>
</section>
`;
}
/**
* Compact notice when context usage reaches 85%+.
* Progressively shifts from amber (85%) to red (90%+).
*/
/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */
function parseHexRgb(hex: string): [number, number, number] | null {
const h = hex.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(h)) {
return null;
}
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
let cachedThemeNoticeColors: {
warnHex: string;
dangerHex: string;
warnRgb: [number, number, number];
dangerRgb: [number, number, number];
} | null = null;
function getThemeNoticeColors() {
if (cachedThemeNoticeColors) {
return cachedThemeNoticeColors;
}
const rootStyle = getComputedStyle(document.documentElement);
const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b";
const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444";
cachedThemeNoticeColors = {
warnHex,
dangerHex,
warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11],
dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68],
};
return cachedThemeNoticeColors;
}
export function getContextNoticeViewModel(
session: GatewaySessionRow | undefined,
defaultContextTokens: number | null,
): {
pct: number;
detail: string;
color: string;
bg: string;
} | null {
if (session?.totalTokensFresh === false) {
return null;
}
const used = session?.totalTokens ?? 0;
const limit = session?.contextTokens ?? defaultContextTokens ?? 0;
if (!used || !limit) {
return null;
}
const ratio = used / limit;
if (ratio < 0.85) {
return null;
}
const pct = Math.min(Math.round(ratio * 100), 100);
// Read theme semantic tokens so color tracks the active theme (Dash, dark, light …)
const { warnRgb, dangerRgb } = getThemeNoticeColors();
const [wr, wg, wb] = warnRgb;
const [dr, dg, db] = dangerRgb;
// Blend from --warn at 85% usage to --danger at 95%+ usage
const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1);
const r = Math.round(wr + (dr - wr) * t);
const g = Math.round(wg + (dg - wg) * t);
const b = Math.round(wb + (db - wb) * t);
const color = `rgb(${r}, ${g}, ${b})`;
const bgOpacity = 0.08 + 0.08 * t;
const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`;
return {
pct,
detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`,
color,
bg,
};
}
function renderContextNotice(
session: GatewaySessionRow | undefined,
defaultContextTokens: number | null,
) {
const model = getContextNoticeViewModel(session, defaultContextTokens);
if (!model) {
return nothing;
}
return html`
<div
class="context-notice"
role="status"
style="--ctx-color:${model.color};--ctx-bg:${model.bg}"
>
<svg
class="context-notice__icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span>${model.pct}% context used</span>
<span class="context-notice__detail">${model.detail}</span>
</div>
`;
}
/** Format token count compactly (e.g. 128000 → "128k"). */
function formatTokensCompact(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}