diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts new file mode 100644 index 00000000000..8e5ccff7780 --- /dev/null +++ b/ui/src/ui/chat/context-notice.test.ts @@ -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(".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(".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(); + }); +}); diff --git a/ui/src/ui/chat/context-notice.ts b/ui/src/ui/chat/context-notice.ts new file mode 100644 index 00000000000..79fd05bba5f --- /dev/null +++ b/ui/src/ui/chat/context-notice.ts @@ -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` +
+ + + + + + ${model.pct}% context used + ${model.detail} +
+ `; +} + +/** 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); +} diff --git a/ui/src/ui/chat/side-result-render.test.ts b/ui/src/ui/chat/side-result-render.test.ts new file mode 100644 index 00000000000..0764035f298 --- /dev/null +++ b/ui/src/ui/chat/side-result-render.test.ts @@ -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(".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(); + }); +}); diff --git a/ui/src/ui/chat/side-result-render.ts b/ui/src/ui/chat/side-result-render.ts new file mode 100644 index 00000000000..de4ca6f5085 --- /dev/null +++ b/ui/src/ui/chat/side-result-render.ts @@ -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` +
+
+
+ BTW + Not saved to chat history +
+ +
+
${sideResult.question}
+
+ ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} +
+
+ `; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index d7acc580fc0..92812ccf21f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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(".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["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(".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(".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( diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 8a1e18698dd..b55e9066454 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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` -
-
-
- BTW - Not saved to chat history -
- -
-
${sideResult.question}
-
- ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} -
-
- `; -} - -/** - * 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` -
- - - - - - ${model.pct}% context used - ${model.detail} -
- `; -} - -/** 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)}`; }