mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:30:44 +00:00
test: split chat helper coverage
This commit is contained in:
92
ui/src/ui/chat/context-notice.test.ts
Normal file
92
ui/src/ui/chat/context-notice.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
125
ui/src/ui/chat/context-notice.ts
Normal file
125
ui/src/ui/chat/context-notice.ts
Normal 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);
|
||||
}
|
||||
54
ui/src/ui/chat/side-result-render.test.ts
Normal file
54
ui/src/ui/chat/side-result-render.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
43
ui/src/ui/chat/side-result-render.ts
Normal file
43
ui/src/ui/chat/side-result-render.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user