From c9f645c25b998dd0559f1551d060cd12b4f402b6 Mon Sep 17 00:00:00 2001 From: 0x-copilot-dev <0x-copilot-dev@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:25:37 +0530 Subject: [PATCH] feat(composer): show where the context window went, in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two read endpoints have shipped for a while and nothing rendered either one. `/context` answers "how full is the window"; `/context/occupancy` answers "who filled it". This adds the control that asks both, in the place the question actually gets asked — next to send. Three disclosure levels over one pill: the resting meter (a 22px gauge plus the server's headroom percent), a hover line (in · cached · free), and a 300px breakdown. The breakdown is grouped by LIFECYCLE and coloured by CLASS, and that is the whole design. `segment_class` is a structural taxonomy and answers "what kind of bytes are these"; `lifecycle` answers "what happens if I do nothing" — `resident` bytes are rent charged on every call and are fixed by trimming a surface, `per_result` bytes are a multiplier on tool-call count and are fixed by shrinking one note. Read by class, two connectors whose schemas cost 17% of the window every turn are an unremarkable "tools 19%". What the meter refuses to state is most of the work: - `headroom_pct` is rendered VERBATIM and the unit is named ("60% free"), so no `100 - x` happens on the client and a bare percent next to a filling gauge cannot be read backwards. The gauge draws consumed segments and the track behind it is that headroom — two independent server values that agree, rather than one derived from the other. - An unknown window (model absent from pricing) shows absolute tokens, an empty track, and NO percent anywhere. - Nothing measured renders no pill: no skeleton, no spinner, no zero. - The headline is `provider_input_tokens` (authoritative, matches billing); segments sum to our estimate and the signed `unattributed_delta` gets its own row instead of being absorbed. - `undeclared_tokens` surfaces in the popover only — it is a first-party contract defect, not something the user did. - `third_party` / `stable_prefix` / `counter_source: "proxy"` each get a marker, because "large but cached" and "large and re-billed every turn" are different findings, and a proxy count is the fail-open signature. NOT a stream projection, deliberately. `context_occupancy` is in the event vocabulary and `events.py` projects a payload and an activity_kind for it, but nothing emits it — the type is absent from `runtime_worker/streaming_executor.py` where its sibling `usage_recorded` is emitted. A selector over `session.events` would have been correct, tested, green and permanently blank. The cockpit reads the API once per turn boundary instead, through the existing Transport port, and hands the built view to whichever composer the host mounts — so the two hosts share the mapping rather than duplicating a fetch. Tokens are ALIASES over the existing `--surface-hue-*` ramp, not new colours: it is already a perceptually-even categorical set with a light-ground step-down. Cool rungs only — amber and ember carry pressure on this component, and a warm segment would read as an alarm when it means nothing but "which class". CB-8 drives it against the packaged app: it asks the running app what the occupancy ledger recorded, then checks the pixel agrees. That pairing is the point — every unit test here runs on fixtures and would pass over a stack that never writes a row. Co-Authored-By: Claude Opus 5 --- .../desktop/renderer/composer/RunComposer.tsx | 13 + apps/desktop/renderer/destinationBinders.tsx | 5 + .../frontend/src/features/run/RunComposer.tsx | 8 + apps/frontend/src/features/run/RunRoute.tsx | 4 + .../src/composer/AssistantComposer.tsx | 29 + .../src/composer/ContextPill.test.tsx | 362 +++++++++++++ .../chat-surface/src/composer/ContextPill.tsx | 380 +++++++++++++ .../chat-surface/src/composer/composer.css | 349 ++++++++++++ .../src/composer/contextPillView.test.ts | 503 ++++++++++++++++++ .../src/composer/contextPillView.ts | 454 ++++++++++++++++ .../src/composer/useConversationContext.ts | 92 ++++ .../src/destinations/run/RunDestination.tsx | 21 + packages/chat-surface/src/index.ts | 27 + packages/design-system/src/styles.css | 33 ++ tools/desktop-journeys/README.md | 28 +- .../desktop-journeys/composer_and_budgets.py | 152 ++++++ 16 files changed, 2446 insertions(+), 14 deletions(-) create mode 100644 packages/chat-surface/src/composer/ContextPill.test.tsx create mode 100644 packages/chat-surface/src/composer/ContextPill.tsx create mode 100644 packages/chat-surface/src/composer/contextPillView.test.ts create mode 100644 packages/chat-surface/src/composer/contextPillView.ts create mode 100644 packages/chat-surface/src/composer/useConversationContext.ts diff --git a/apps/desktop/renderer/composer/RunComposer.tsx b/apps/desktop/renderer/composer/RunComposer.tsx index 7365d9566..84f0e856c 100644 --- a/apps/desktop/renderer/composer/RunComposer.tsx +++ b/apps/desktop/renderer/composer/RunComposer.tsx @@ -13,6 +13,7 @@ import { parseTransportError, type CompleteAttachment, type ComposerConnectorsPort, + type ContextPillView, type ProviderKeysPort, type RunStartRequest, type StartRunError, @@ -112,6 +113,14 @@ export interface RunComposerProps { * frame. Host-bound (list + write); omitted ⇒ the zone is absent. */ readonly filing?: RunComposerFiling; + /** + * The context meter's view, built by the cockpit from the two `/context` + * reads and handed down the `renderComposer` ctx. `null` when nothing has + * been measured — the composer then renders no meter, which is the honest + * state. Mirrors the web binder's prop of the same name; the two hosts share + * the mapping, not the fetch. + */ + readonly context?: ContextPillView | null; } /** @@ -149,6 +158,7 @@ export function RunComposer(props: RunComposerProps): ReactElement { conversationId = null, conversationModel = null, filing, + context, } = props; // The last run-create failure, surfaced inline above the composer. The @@ -347,6 +357,9 @@ export function RunComposer(props: RunComposerProps): ReactElement { ( void; /** Newly authenticated connector to enable in the run-scoped Tools state. */ readonly autoActivateConnectorId?: string | null; + /** The context meter's view, built by the cockpit from the two `/context` + * reads. `null` when nothing has been measured — render no meter. */ + readonly context?: ContextPillView | null; } export interface RunComposerProps { @@ -172,6 +176,10 @@ export function RunComposer({ onOpenSkillsSettings={noop} onOpenMcpSettings={noop} toolsTrigger={toolsTrigger} + // Context meter. The cockpit reads both `/context` endpoints once and + // hands the built view down the ctx, so this binder and the desktop one + // cannot fetch it differently. `undefined` renders no meter. + context={ctx.context} models={models} selectedModel={selectedModel} onModelChange={onModelChange} diff --git a/apps/frontend/src/features/run/RunRoute.tsx b/apps/frontend/src/features/run/RunRoute.tsx index 9486dad0d..ecbea9182 100644 --- a/apps/frontend/src/features/run/RunRoute.tsx +++ b/apps/frontend/src/features/run/RunRoute.tsx @@ -39,6 +39,7 @@ import { RunDestination, buildRunCreateBody, useTransport, + type ContextPillView, type RunEmptyComposerCtx, type RunStartRequest, } from "@0x-copilot/chat-surface"; @@ -355,6 +356,9 @@ export function RunRoute({ // WC-P3 — cockpit-owned run state + cancel; RunComposer swaps send↔Stop. readonly running: boolean; readonly onCancel: () => void; + // The context meter, built ONCE by the cockpit from the two `/context` + // reads so both hosts draw the same number for the same conversation. + readonly context?: ContextPillView | null; }) => ( void; /** PR 8.0.1 — display name of the active model, surfaced in the * composer footer hint row. */ activeModelLabel?: string; @@ -340,6 +355,8 @@ export const AssistantComposer = forwardRef< // been migrated) but the composer no longer surfaces it — the model // name lives in only (Phase 9 dedup). activeModelLabel: _activeModelLabel, + context, + onOpenContextReport, models, selectedModel, onModelChange, @@ -691,6 +708,18 @@ export const AssistantComposer = forwardRef< {dictation.message} ) : null} + {/* Context meter, LEFT of the model pill — the model sets the + denominator, so reading rightward gives "60% free · of Opus 5's + window". It is also the one control here that answers a + question asked immediately before pressing send. + Absent (not disabled, not zeroed) until something is measured. */} + {context != null ? ( + + ) : null} {models && selectedModel !== undefined && onModelChange ? ( = {}): ContextPillView { + return { + headroomPct: 60, + pressure: "quiet", + modelLabel: "Claude Opus 5", + windowTokens: 200_000, + inputTokens: 79_240, + cachedTokens: 21_100, + freeTokens: 120_760, + slices: [ + { key: "tools:linear::", segmentClass: "tools", tone: 1, pct: 11.2 }, + { + key: "messages:tool_results::", + segmentClass: "messages", + tone: 1, + pct: 10.9, + }, + ], + groups: [ + { + lifecycle: "resident", + label: "Resident", + note: "every call", + rows: [ + { + key: "tools:linear::41 tools", + label: "linear", + detail: "41 tools", + segmentClass: "tools", + tone: 1, + tokens: 22_410, + pctOfWindow: 11.205, + thirdParty: true, + cacheable: true, + approximate: false, + }, + ], + }, + { + lifecycle: "per_result", + label: "Per result", + note: "× 24 results", + rows: [ + { + key: "messages:tool_results::", + label: "tool_results", + detail: null, + segmentClass: "messages", + tone: 1, + tokens: 21_830, + pctOfWindow: 10.915, + thirdParty: false, + cacheable: false, + approximate: true, + }, + ], + }, + ], + unattributedDelta: 1_240, + undeclaredTokens: 0, + compaction: null, + ...over, + }; +} + +function openBreakdown(): HTMLElement { + fireEvent.click(screen.getByTestId("context-pill")); + return screen.getByTestId("context-breakdown"); +} + +describe("ContextPill — the resting meter", () => { + it("states HEADROOM, with the word, not consumption", () => { + // "60%" alone next to a filling gauge is genuinely ambiguous about which + // direction it runs, and `headroom_pct` is the only percentage the server + // sanctions. Both problems are solved by naming the unit. + render(); + const pill = screen.getByTestId("context-pill"); + expect(pill).toHaveTextContent("60%"); + expect(pill).toHaveTextContent("free"); + expect(pill.textContent).not.toContain("40"); + }); + + it("puts input, cached and free on the hover line", () => { + // Cached earns its place: a cacheable prefix bills at roughly a tenth, so + // omitting it makes a large-but-cached surface look like a problem. + render(); + expect(screen.getByTestId("context-pill")).toHaveAttribute( + "data-tooltip", + "79,240 in · 21,100 cached · 120,760 free", + ); + }); + + it.each([ + ["quiet", "quiet"], + ["warm", "warm"], + ["critical", "critical"], + ] as const)("carries pressure %s onto the element", (pressure, expected) => { + render(); + expect(screen.getByTestId("context-pill")).toHaveAttribute( + "data-state", + expected, + ); + }); + + describe("unknown window", () => { + const unknown = view({ + headroomPct: null, + windowTokens: null, + freeTokens: null, + slices: [], + pressure: "quiet", + }); + + it("falls back to absolute tokens instead of inventing a percent", () => { + render(); + const pill = screen.getByTestId("context-pill"); + expect(pill).toHaveTextContent("79.2k"); + expect(pill).toHaveTextContent("in"); + expect(pill.textContent).not.toContain("%"); + }); + + it("omits free from the hover line rather than showing a blank", () => { + render(); + expect(screen.getByTestId("context-pill")).toHaveAttribute( + "data-tooltip", + "79,240 in · 21,100 cached", + ); + }); + + it("draws an EMPTY gauge — not a full one and not a zero one", () => { + const { container } = render(); + const gauge = container.querySelector(".atlas-ctx-gauge")!; + expect(gauge).toHaveAttribute("data-known", "false"); + expect(gauge.querySelectorAll("i")).toHaveLength(0); + }); + + it("announces the unknown window rather than an unstated percent", () => { + render(); + expect(screen.getByTestId("context-pill")).toHaveAccessibleName( + "Context: 79,240 tokens in, window size unknown. Open breakdown.", + ); + }); + }); +}); + +describe("ContextPill — the breakdown", () => { + it("groups by lifecycle and names each group's multiplier", () => { + render(); + const pop = openBreakdown(); + expect(pop).toHaveTextContent("Resident"); + expect(pop).toHaveTextContent("every call"); + expect(pop).toHaveTextContent("Per result"); + expect(pop).toHaveTextContent("× 24 results"); + }); + + it("marks a third-party, cacheable row and an approximate one differently", () => { + render(); + openBreakdown(); + const linear = screen.getByTestId("context-row-linear"); + expect(within(linear).getByTitle("Third-party")).toBeInTheDocument(); + expect(within(linear).getByTitle("Cached prefix")).toBeInTheDocument(); + expect(within(linear).queryByTitle("Estimated")).toBeNull(); + + // counter_source "proxy" — the ledger took a worse number over failing the + // run. Presenting it with the same confidence as a tokenized count is the + // thing this marker exists to prevent. + const results = screen.getByTestId("context-row-tool_results"); + expect(within(results).getByTitle("Estimated")).toBeInTheDocument(); + expect(within(results).queryByTitle("Cached prefix")).toBeNull(); + }); + + it("gives the provider delta its own row rather than absorbing it", () => { + // The headline is the provider's authoritative count; the segments sum to + // ours. The difference has to be visible or the rows appear not to add up. + render(); + const pop = openBreakdown(); + expect(pop).toHaveTextContent("provider overhead"); + expect(pop).toHaveTextContent("+1,240"); + }); + + it("renders a NEGATIVE delta signed, not as an absolute value", () => { + render(); + expect(openBreakdown()).toHaveTextContent("-820"); + }); + + it("omits the delta row entirely when it is zero", () => { + render(); + expect(openBreakdown()).not.toHaveTextContent("provider overhead"); + }); + + it("shows undeclared bytes in the popover and NEVER on the pill", () => { + // Undeclared is a first-party contract defect — our bug. It does not change + // what the user should send, so it must not colour the control they are + // about to press. + render(); + const pill = screen.getByTestId("context-pill"); + expect(pill).toHaveAttribute("data-state", "quiet"); + expect(pill.textContent).not.toContain("3,180"); + openBreakdown(); + expect(screen.getByTestId("context-undeclared")).toHaveTextContent( + "3,180 undeclared", + ); + }); + + it("hides the undeclared notice at the expected value of zero", () => { + render(); + openBreakdown(); + expect(screen.queryByTestId("context-undeclared")).toBeNull(); + }); + + it("reports a share under one percent as <1%, never as 0%", () => { + const v = view(); + render( + , + ); + openBreakdown(); + expect(screen.getByTestId("context-row-linear")).toHaveTextContent("<1%"); + }); + + it("leaves the share cell EMPTY when the window is unknown", () => { + const v = view(); + render( + , + ); + openBreakdown(); + const row = screen.getByTestId("context-row-linear"); + expect(row).toHaveTextContent("22,410"); + expect(row.textContent).not.toContain("%"); + }); + + it("renders no report link when the host supplied no navigation", () => { + // A dead link is worse than no link; navigation is host-owned. + render(); + openBreakdown(); + expect(screen.queryByTestId("context-open-report")).toBeNull(); + }); + + it("calls the host's report handler when it did", () => { + const onOpenReport = vi.fn(); + render(); + openBreakdown(); + fireEvent.click(screen.getByTestId("context-open-report")); + expect(onOpenReport).toHaveBeenCalledOnce(); + }); + + it("names the latest compaction in the footer", () => { + render( + , + ); + expect(openBreakdown()).toHaveTextContent("Compacted 128k → 34k"); + }); +}); + +// jsdom performs no layout, so a green DOM assertion says nothing about whether +// this control pushes send onto a second row in the ~300px Run rail. These +// assert the CSS CONTRACT that decides who gives way, against the real +// stylesheet — the same technique TcWriteGateRow.test.tsx uses for the header's +// clipping order, and for the same reason. +describe("ContextPill — the shrink contract", () => { + const here = + typeof import.meta.dirname === "string" + ? import.meta.dirname + : dirname(fileURLToPath(import.meta.url)); + + let sheet: HTMLStyleElement | null = null; + + function renderWithRealCss(): void { + sheet = document.createElement("style"); + sheet.textContent = readFileSync(resolve(here, "composer.css"), "utf-8"); + document.head.appendChild(sheet); + render(); + } + + afterEach(() => { + sheet?.remove(); + sheet = null; + }); + + it("never gives way — the model pill is the row's sole ellipsis target", () => { + // `.aui-composer-action-wrapper__right` is `flex: 0 1 auto` so exactly ONE + // item can shrink, and `.atlas-model-pill__name` was built for it. A meter + // that shrank would either ellipsize its own digits (making the number a + // lie) or compete for the role and push send onto a second line. + renderWithRealCss(); + const root = document.querySelector(".atlas-ctx-pill__root")!; + // jsdom reports the longhand expansion of `flex: none`. + expect(getComputedStyle(root).flex).toBe("0 0 auto"); + }); + + it("holds the gauge at a fixed width so only the label could ever give", () => { + renderWithRealCss(); + const gauge = document.querySelector(".atlas-ctx-gauge")!; + const style = getComputedStyle(gauge); + expect(style.flex).toBe("0 0 auto"); + expect(style.width).toBe("22px"); + }); + + it("lets a row's LABEL ellipsize while its figures stay whole", () => { + // A segment label is an arbitrary-length MCP server slug; the figures are + // the reason the row exists. Cutting the number instead of the name is the + // failure this pins. + renderWithRealCss(); + fireEvent.click(screen.getByTestId("context-pill")); + const row = screen.getByTestId("context-row-linear"); + const label = row.querySelector(".atlas-ctx-row__label")!; + const tokens = row.querySelector(".atlas-ctx-row__tok")!; + + expect(getComputedStyle(label).textOverflow).toBe("ellipsis"); + expect(getComputedStyle(label).minWidth).toBe("0px"); + expect(getComputedStyle(tokens).flex).toBe("0 0 auto"); + }); + + it("pins the popover to the model popover's 300px", () => { + // They anchor to adjacent controls in the same row; a mismatch reads as a + // mistake. `Menu` writes `min-width: px` inline, which no + // class can beat — the trigger is far narrower, so the floor never lifts. + renderWithRealCss(); + fireEvent.click(screen.getByTestId("context-pill")); + const pop = document.querySelector(".atlas-ctx-pop")!; + expect(getComputedStyle(pop).width).toBe("300px"); + }); +}); diff --git a/packages/chat-surface/src/composer/ContextPill.tsx b/packages/chat-surface/src/composer/ContextPill.tsx new file mode 100644 index 000000000..78183e4b5 --- /dev/null +++ b/packages/chat-surface/src/composer/ContextPill.tsx @@ -0,0 +1,380 @@ +// ContextPill — the composer's context meter, and the breakdown behind it. +// +// Three disclosure levels over one control: +// +// L0 the pill itself: a 22px gauge + the server's headroom percent. Quiet at +// rest, warm below 40% headroom, ember below 15%. +// L1 hover: input · cached · free, so the common question needs no click. +// L2 click: the 300px breakdown, grouped by LIFECYCLE and coloured by CLASS. +// +// Why lifecycle owns the grouping: `segment_class` is a structural taxonomy +// (system / tools / messages / response_format) and answers "what kind of bytes +// are these". `lifecycle` answers "what happens if I do nothing" — `resident` +// bytes are rent charged on every call and are fixed by trimming a surface, +// `per_result` bytes are a multiplier on tool-call count and are fixed by +// shrinking one note. Read by class, two connectors whose schemas cost 17% of +// the window every single turn are an unremarkable "tools 19%". +// +// PRESENTATIONAL. It fetches nothing and owns no state but `open`; the host +// binder supplies a {@link ContextPillView} built by `contextPillView.ts`. + +import { Menu } from "@0x-copilot/design-system"; +import { useRef, useState, type ReactElement } from "react"; + +import { Icon } from "../icons/Icon"; +import type { + ContextBarSlice, + ContextLifecycleGroup, + ContextPillView, + ContextSegmentRow, +} from "./contextPillView"; + +/** Hue per `ContextSegmentClass`, resolved from the design-system's context + * ramp. `null` is the unattributed provider delta, which is not a class. */ +const CLASS_VAR: Record = { + system: "var(--color-ctx-system)", + tools: "var(--color-ctx-tools)", + messages: "var(--color-ctx-messages)", + response_format: "var(--color-ctx-response-format)", +}; +const UNATTRIBUTED_VAR = "var(--color-ctx-unattributed)"; + +export interface ContextPillProps { + /** The meter's data. Build it with `buildContextPillView`; a `null` view + * means nothing has been measured, and the host should render no pill at + * all rather than passing a zeroed one. */ + readonly view: ContextPillView; + readonly disabled?: boolean; + /** + * Open the full report (`/context`'s by_call + by_subagent + the per-turn + * occupancy series). Host-owned navigation — omitted renders no link, never + * a dead one. + */ + readonly onOpenReport?: () => void; +} + +export function ContextPill({ + view, + disabled, + onOpenReport, +}: ContextPillProps): ReactElement { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + + const known = view.headroomPct !== null; + // Unknown window (`context_window_tokens: null` — the model is absent from + // the pricing catalogue) has no percent to state, so the pill falls back to + // the one figure that is still true: how many tokens went in. + const primary = known + ? `${String(view.headroomPct)}%` + : formatCompact(view.inputTokens); + const unit = known ? "free" : "in"; + + return ( +
+ + + setOpen(false)} + anchorRef={buttonRef} + side="up" + align="right" + className="ui-pop atlas-ctx-pop" + > + + +
+ ); +} + +/** + * The meter. Consumed slices are drawn; the remaining track IS the headroom the + * number names — so the bar and the percent are two independent server values + * that agree, and no `100 - headroom` arithmetic happens on the client. + * + * With an unknown window there are no shares to draw, so the gauge degrades to + * an empty track rather than a full or a zero one: both would be a claim. + * + * `aria-hidden` because the button's own `aria-label` already states the + * numbers — a screen reader should hear one meter, not a bar and a label. + */ +function ContextGauge({ + slices, + known, +}: { + readonly slices: readonly ContextBarSlice[]; + readonly known: boolean; +}): ReactElement { + return ( + + ); +} + +function ContextBreakdown({ + view, + onOpenReport, +}: { + readonly view: ContextPillView; + readonly onOpenReport?: () => void; +}): ReactElement { + return ( +
+
+ Context + + {view.modelLabel} + {view.windowTokens !== null + ? ` · ${formatCompact(view.windowTokens)}` + : ""} + +
+ +
+ +

+ {formatFull(view.inputTokens)} in + · + {formatFull(view.cachedTokens)} cached + {view.freeTokens !== null ? ( + <> + · + {formatFull(view.freeTokens)} free + + ) : null} +

+
+ + {/* Expected 0. Above it, a first-party contract defect — measured bytes + that no declaration covers. It lives here and never on the pill: it is + our bug, and it does not change what the user should send. */} + {view.undeclaredTokens > 0 ? ( +

+ + + {formatFull(view.undeclaredTokens)} undeclared — measured + bytes matching no declaration. + +

+ ) : null} + + {view.groups.map((group) => ( + + ))} + + {view.unattributedDelta !== 0 ? ( +

+

+ ) : null} + + {view.compaction !== null || onOpenReport !== undefined ? ( +
+ + {view.compaction !== null + ? `Compacted ${formatCompact(view.compaction.before)} → ${formatCompact(view.compaction.after)}` + : ""} + + {onOpenReport !== undefined ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +function ContextGroup({ + group, +}: { + readonly group: ContextLifecycleGroup; +}): ReactElement { + return ( + <> +

+ {group.label} + {group.note !== null ? {group.note} : null} +

+ {group.rows.map((row) => ( + + ))} + + ); +} + +function ContextRow({ + row, +}: { + readonly row: ContextSegmentRow; +}): ReactElement { + return ( +

+

+ ); +} + +function sliceColor(slice: ContextBarSlice): string { + if (slice.segmentClass === null) return UNATTRIBUTED_VAR; + return CLASS_VAR[slice.segmentClass] ?? UNATTRIBUTED_VAR; +} + +function pillLabel(view: ContextPillView, known: boolean): string { + const head = known + ? `Context: ${String(view.headroomPct)}% of the window free` + : `Context: ${formatFull(view.inputTokens)} tokens in, window size unknown`; + return `${head}. Open breakdown.`; +} + +/** The one-line hover readout. Cached is on it deliberately: a cached prefix + * bills at roughly a tenth, so omitting it makes a large-but-cached surface + * look like a problem it is not. */ +function tooltipLine(view: ContextPillView): string { + const parts = [ + `${formatFull(view.inputTokens)} in`, + `${formatFull(view.cachedTokens)} cached`, + ]; + if (view.freeTokens !== null) { + parts.push(`${formatFull(view.freeTokens)} free`); + } + return parts.join(" · "); +} + +/** Grouped thousands, locale-independent so a test asserts one string and a + * user in any locale reads the same figure as the docs. */ +function formatFull(value: number): string { + const sign = value < 0 ? "-" : ""; + const digits = Math.abs(Math.round(value)).toString(); + let out = ""; + for (let i = 0; i < digits.length; i += 1) { + if (i > 0 && (digits.length - i) % 3 === 0) out += ","; + out += digits[i]; + } + return `${sign}${out}`; +} + +/** Pill/header form: 200000 -> "200k", 79240 -> "79.2k". */ +function formatCompact(value: number): string { + const abs = Math.abs(value); + if (abs < 1000) return formatFull(value); + const thousands = value / 1000; + // Drop the decimal once it stops carrying information at this size. + const text = + Math.abs(thousands) >= 100 + ? Math.round(thousands).toString() + : (Math.round(thousands * 10) / 10).toString(); + return `${text}k`; +} + +function signed(value: number): string { + return value > 0 ? `+${formatFull(value)}` : formatFull(value); +} + +/** `null` share = unknown window, so the cell is empty rather than "0%". + * Anything that rounds to zero but is not zero reads "<1%". */ +function formatShare(pct: number | null): string { + if (pct === null) return ""; + const rounded = Math.round(pct); + if (rounded === 0) return pct > 0 ? "<1%" : "0%"; + return `${String(rounded)}%`; +} diff --git a/packages/chat-surface/src/composer/composer.css b/packages/chat-surface/src/composer/composer.css index c15e4f955..dd580c843 100644 --- a/packages/chat-surface/src/composer/composer.css +++ b/packages/chat-surface/src/composer/composer.css @@ -1671,3 +1671,352 @@ font-size: var(--font-size-mono-10); color: var(--color-text-subtle); } + +/* ========================================================================== + * CONTEXT PILL — the composer's context meter (ContextPill.tsx). + * + * The trigger is the shared `.ui-cpill` recipe, so only what this pill adds is + * declared here: the gauge, the two type roles, and the pressure states. That + * keeps it a SIBLING of the model pill rather than a new species in a row that + * already speaks one language. + * + * SHRINK CONTRACT. `flex: none`, deliberately. `.aui-composer-action-wrapper__right` + * is `flex: 0 1 auto` so that ONE item can give when the ~300px Run rail runs + * out, and that item is the model pill (`.atlas-model-pill__name` ellipsizes — + * it was built for it). A meter that shrank would either ellipsize its own + * digits, which makes the number a lie, or compete with the pill for the role + * and push send onto a second row. + * ========================================================================== */ +.atlas-ctx-pill__root { + flex: none; + min-width: 0; + position: relative; +} + +.atlas-ctx-pill { + font-variant-numeric: tabular-nums; + gap: 5px; +} + +/* Rest state is deliberately colourless: at healthy headroom this is ambient + * information, and the row already has a send button competing for the eye. */ +.atlas-ctx-pill__num { + color: var(--color-text-muted); +} + +.atlas-ctx-pill__unit { + color: var(--color-text-subtle); +} + +.atlas-ctx-pill:hover:not([disabled]) .atlas-ctx-pill__num, +.atlas-ctx-pill[data-open] .atlas-ctx-pill__num { + color: var(--color-text); +} + +/* PRESSURE — the only colour escalation, and the reason the segment ramp is + * cool-only: a warm segment would be indistinguishable from an alarm. + * + * Warm is ink alone. Critical adds the fill + hairline, which is the first + * point at which the meter is asking for an action rather than reporting. */ +.atlas-ctx-pill[data-state="warm"] .atlas-ctx-pill__num { + color: var(--color-warning); +} + +.atlas-ctx-pill[data-state="warm"] .atlas-ctx-pill__unit { + color: color-mix(in srgb, var(--color-warning) 62%, transparent); +} + +.atlas-ctx-pill[data-state="critical"] { + background: color-mix(in srgb, var(--color-danger) 11%, transparent); + border-color: color-mix(in srgb, var(--color-danger) 26%, transparent); +} + +.atlas-ctx-pill[data-state="critical"] .atlas-ctx-pill__num { + color: var(--color-danger); +} + +.atlas-ctx-pill[data-state="critical"] .atlas-ctx-pill__unit { + color: color-mix(in srgb, var(--color-danger) 62%, transparent); +} + +/* The 22px meter. Same object as the popover's `.atlas-ctx-bar`, at the size + * where only magnitude survives — four classes across 22px at 40% fill is under + * 9px of ink, so the composition is drawn once, in the popover, where it can + * actually be read. */ +.atlas-ctx-gauge { + display: flex; + width: 22px; + height: 4px; + flex: none; + border-radius: 2px; + overflow: hidden; + background: var(--color-border-stronger); +} + +.atlas-ctx-gauge > i { + display: block; + height: 100%; +} + +/* Unknown window: no denominator, so no share to draw. An EMPTY track, never a + * full or a zero one — both would state something the data does not support. */ +.atlas-ctx-gauge[data-known="false"] { + opacity: 0.5; +} + +/* ── the breakdown popover ──────────────────────────────────────────────── + * `.ui-pop` owns the frame; this pins the design's 300px (the `Menu` primitive + * writes `min-width: px` INLINE, which no class can beat — the + * trigger is far narrower, so that floor never lifts the frame) and styles the + * contents. Same width as `.atlas-model-pill__menu`, because they anchor to + * adjacent controls in the same row and a mismatch reads as a mistake. */ +.atlas-ctx-pop { + width: 300px; + min-width: 0; + padding: 0; +} + +.atlas-ctx-pop__body { + display: flex; + flex-direction: column; + font-variant-numeric: tabular-nums; + padding-bottom: 2px; +} + +.atlas-ctx-pop__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-sm); + padding: 11px 12px 9px; + border-bottom: 1px solid var(--color-border); +} + +.atlas-ctx-pop__title { + font-family: var(--font-mono); + font-size: var(--font-size-mono-9-5); + letter-spacing: var(--tracking-mono-caps); + text-transform: uppercase; + color: var(--color-text-muted); +} + +.atlas-ctx-pop__model { + font-family: var(--font-mono); + font-size: var(--font-size-mono-10); + color: var(--color-text-subtle); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atlas-ctx-pop__summary { + padding: 12px 12px 11px; +} + +.atlas-ctx-bar { + display: flex; + gap: 1px; + height: 8px; + border-radius: 3px; + overflow: hidden; + background: var(--color-border-stronger); +} + +.atlas-ctx-bar > i { + display: block; + height: 100%; +} + +.atlas-ctx-pop__figs { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 5px; + margin: 9px 0 0; + font-family: var(--font-mono); + font-size: var(--font-size-mono-10); + color: var(--color-text-subtle); +} + +.atlas-ctx-pop__figs b { + color: var(--color-text); + font-weight: var(--font-weight-regular); +} + +.atlas-ctx-pop__sep { + color: var(--color-border-stronger); +} + +.atlas-ctx-pop__defect { + display: flex; + align-items: flex-start; + gap: 7px; + margin: 10px 12px 0; + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--color-danger) 26%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-danger) 10%, transparent); + font-family: var(--font-mono); + font-size: var(--font-size-mono-9-5); + line-height: var(--line-height-snug); + color: var(--color-danger); +} + +.atlas-ctx-pop__defect b { + font-weight: var(--font-weight-regular); +} + +/* Lifecycle heading. The rule after it is what makes the groups read as bands + * rather than as a flat list with bold rows. */ +.atlas-ctx-grp { + display: flex; + align-items: center; + gap: var(--space-sm); + margin: 0; + padding: 12px 12px 5px; + font-family: var(--font-mono); + font-size: var(--font-size-mono-9-5); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + color: var(--color-text-subtle); +} + +.atlas-ctx-grp::after { + content: ""; + order: 2; + flex: 1; + height: 1px; + background: var(--color-border); +} + +/* The multiplier ("every call", "× 24 results") — the half of the heading that + * makes the group actionable, so it is set apart from the label but not hidden + * behind a disclosure. */ +.atlas-ctx-grp em { + order: 1; + font-style: normal; + text-transform: none; + letter-spacing: var(--tracking-caption); + color: var(--color-border-stronger); +} + +.atlas-ctx-row { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + padding: 4px 12px; + font-family: var(--font-mono); + font-size: var(--font-size-mono-10); + color: var(--color-text-muted); +} + +.atlas-ctx-row__sw { + width: 7px; + height: 7px; + flex: none; + border-radius: 2px; +} + +/* The ONLY shrinkable cell in the row. A segment label is an arbitrary-length + * MCP server slug, and the figures are the reason the row exists — so the name + * ellipsizes and the numbers never do. Same shape of rule as the write gate's, + * and for the same reason: an over-wide row must give somewhere it can afford. */ +.atlas-ctx-row__label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text); +} + +.atlas-ctx-row__label s { + text-decoration: none; + color: var(--color-text-subtle); +} + +.atlas-ctx-row__tok { + flex: none; + width: 46px; + text-align: right; + color: var(--color-text); +} + +.atlas-ctx-row__pct { + flex: none; + width: 32px; + text-align: right; + color: var(--color-text-subtle); +} + +/* The signed provider delta. Its own row rather than absorbed into a total, so + * the segments visibly do not have to sum to the headline the provider bills. */ +.atlas-ctx-row--delta .atlas-ctx-row__tok { + color: var(--color-text-subtle); +} + +.atlas-ctx-mk { + flex: none; + padding: 0 3px; + border: 1px solid var(--color-border-strong); + border-radius: 3px; + font-size: var(--font-size-mono-8-5); + line-height: 1.5; + color: var(--color-text-subtle); +} + +.atlas-ctx-mk--3p { + border-color: color-mix(in srgb, var(--color-ctx-system) 40%, transparent); + color: var(--color-ctx-system); +} + +.atlas-ctx-mk--cache { + border-color: color-mix(in srgb, var(--color-ctx-messages) 35%, transparent); + color: var(--color-ctx-messages); +} + +.atlas-ctx-mk--prox { + border-color: color-mix(in srgb, var(--color-warning) 40%, transparent); + color: var(--color-warning); +} + +.atlas-ctx-pop__foot { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-top: var(--space-sm); + padding: 9px 12px 10px; + border-top: 1px solid var(--color-border); +} + +.atlas-ctx-pop__note { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--font-size-mono-9-5); + color: var(--color-text-subtle); +} + +.atlas-ctx-pop__link { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + flex: none; + height: 22px; + padding: 0 8px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface-elevated); + color: var(--color-text); + font-family: var(--font-mono); + font-size: var(--font-size-mono-9-5); + cursor: pointer; +} + +.atlas-ctx-pop__link:hover { + border-color: var(--color-border-stronger); +} diff --git a/packages/chat-surface/src/composer/contextPillView.test.ts b/packages/chat-surface/src/composer/contextPillView.test.ts new file mode 100644 index 000000000..187e31295 --- /dev/null +++ b/packages/chat-surface/src/composer/contextPillView.test.ts @@ -0,0 +1,503 @@ +import type { + ConversationContextOccupancyResponse, + ConversationContextResponse, + ContextOccupancySegment, + ContextOccupancySnapshot, +} from "@0x-copilot/api-types"; +import { describe, expect, it } from "vitest"; + +import { buildContextPillView } from "./contextPillView"; + +// One consistent scenario throughout: Opus 5, a 200k window, Linear + GitHub +// connected, 24 tool results. The finding it exists to surface is that the two +// connectors' schemas are 33,610 RESIDENT tokens — rent charged on every call +// whether or not the model touches them. +function segment( + over: Partial = {}, +): ContextOccupancySegment { + return { + segment_class: "tools", + label: "tools:linear", + lifecycle: "resident", + third_party: true, + detail: "41 tools", + byte_count: 89_640, + estimated_tokens: 22_410, + item_count: 41, + cache_eligibility: "stable_prefix", + counter_source: "tokenizer", + ...over, + }; +} + +const SEGMENTS: ContextOccupancySegment[] = [ + segment(), + segment({ + label: "tools:github", + detail: "26 tools", + estimated_tokens: 11_200, + item_count: 26, + }), + segment({ + segment_class: "messages", + label: "messages:tool_results", + lifecycle: "per_result", + third_party: false, + detail: null, + estimated_tokens: 21_830, + item_count: 24, + cache_eligibility: "never", + counter_source: "proxy", + }), + segment({ + segment_class: "messages", + label: "messages:transcript", + lifecycle: "per_turn", + third_party: false, + detail: "18 msgs", + estimated_tokens: 12_300, + item_count: 18, + cache_eligibility: "never", + }), + segment({ + segment_class: "system", + label: "system:instructions", + lifecycle: "resident", + third_party: false, + detail: null, + estimated_tokens: 4_100, + item_count: 1, + }), + segment({ + label: "tools:core", + detail: "9 tools", + third_party: false, + estimated_tokens: 3_860, + item_count: 9, + }), + segment({ + segment_class: "system", + label: "system:skills", + lifecycle: "resident", + third_party: false, + detail: "6 loaded", + estimated_tokens: 2_300, + item_count: 6, + }), +]; + +function snapshot( + over: Partial = {}, +): ContextOccupancySnapshot { + return { + schema_version: 1, + model_call_id: "call_1", + attempt_ordinal: 1, + assembly_record_id: null, + graph_scope: "root", + provider: "anthropic", + model_family: "claude-opus-5", + measured_at: "2026-08-14T10:00:00Z", + context_window_tokens: 200_000, + estimated_input_tokens: 78_000, + provider_input_tokens: 79_240, + cached_input_tokens: 21_100, + cache_creation_input_tokens: 0, + undeclared_tokens: 0, + unattributed_delta: 1_240, + free_tokens: 120_760, + segments: SEGMENTS, + unreadable_segment_count: 0, + ...over, + }; +} + +function occupancy( + over: Partial = {}, +): ConversationContextOccupancyResponse { + return { + conversation_id: "conv_1", + run_id: "run_1", + snapshot: snapshot(over), + }; +} + +function context( + over: Partial = {}, +): ConversationContextResponse { + return { + model: { + provider: "anthropic", + name: "Claude Opus 5", + context_window_tokens: 200_000, + }, + current: { + last_run_id: "run_1", + input_tokens: 79_240, + output_tokens: 2_100, + cached_input_tokens: 21_100, + available_tokens: 120_760, + headroom_pct: 60, + ...over, + }, + breakdown: { by_call: [], by_subagent: [], compression_events: [] }, + }; +} + +describe("buildContextPillView", () => { + it("passes headroom_pct through verbatim, never re-deriving it", () => { + // The server's number is deliberately inconsistent with the token figures + // here. The contract says render it verbatim, so a view that "corrects" it + // from available/window would silently disagree with every other surface. + const view = buildContextPillView({ + context: context({ headroom_pct: 37 }), + occupancy: occupancy(), + }); + expect(view?.headroomPct).toBe(37); + }); + + it("takes the PROVIDER token count as the headline, not our estimate", () => { + // provider_input_tokens is authoritative and matches billing; the segments + // sum to our estimate, and the difference is reported rather than hidden. + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + expect(view?.inputTokens).toBe(79_240); + expect(view?.unattributedDelta).toBe(1_240); + const summed = view!.groups + .flatMap((group) => group.rows) + .reduce((total, row) => total + row.tokens, 0); + expect(summed).toBe(78_000); + }); + + it("falls back to our estimate when the provider reported nothing", () => { + const view = buildContextPillView({ + context: null, + occupancy: occupancy({ provider_input_tokens: null }), + }); + expect(view?.inputTokens).toBe(78_000); + }); + + describe("pressure bands", () => { + it.each([ + [100, "quiet"], + [40, "quiet"], + [39, "warm"], + [15, "warm"], + [14, "critical"], + [0, "critical"], + ])("headroom %i%% reads as %s", (headroom, expected) => { + const view = buildContextPillView({ + context: context({ headroom_pct: headroom }), + occupancy: occupancy(), + }); + expect(view?.pressure).toBe(expected); + }); + + it("is quiet — NOT critical — when the window is unknown", () => { + // A model absent from the pricing catalogue has no headroom to report. + // Escalating on missing data would put an ember meter in front of every + // such user for a condition that is not theirs and not urgent. + const view = buildContextPillView({ + context: context({ headroom_pct: null, available_tokens: null }), + occupancy: occupancy({ + context_window_tokens: null, + free_tokens: null, + }), + }); + expect(view?.pressure).toBe("quiet"); + expect(view?.windowTokens).toBeNull(); + }); + }); + + it("reports no percentages at all when the window size is unknown", () => { + // Every share is a fraction of a denominator we do not have. Inventing one + // is the failure mode this guards. + const view = buildContextPillView({ + context: context({ headroom_pct: null }), + occupancy: occupancy({ context_window_tokens: null }), + }); + expect(view?.slices).toEqual([]); + const shares = view!.groups + .flatMap((group) => group.rows) + .map((row) => row.pctOfWindow); + expect(shares.length).toBeGreaterThan(0); + expect(shares.every((share) => share === null)).toBe(true); + }); + + describe("malformed bodies", () => { + // A meter is not worth crashing a composer over. A 200 carrying a partial + // or error-shaped body yields a TRUTHY response object whose nested members + // never arrived, and reading through them threw — which took out the whole + // composer, send button included. The failure mode has to be a missing + // meter, never a missing send button. + it.each([ + ["an empty object", {}], + ["a body with no model", { current: {}, breakdown: {} }], + ["a body with no current", { model: {}, breakdown: {} }], + ["a body with no breakdown", { model: {}, current: {} }], + ["an error-shaped body", { detail: "not found" }], + ])("survives %s from /context", (_label, body) => { + expect(() => + buildContextPillView({ + context: body as unknown as ConversationContextResponse, + occupancy: occupancy(), + }), + ).not.toThrow(); + }); + + it("survives a snapshot whose segments are not an array", () => { + const view = buildContextPillView({ + context: context(), + occupancy: { + conversation_id: "c", + run_id: "r", + snapshot: { + ...snapshot(), + segments: undefined as unknown as ContextOccupancySegment[], + }, + }, + }); + expect(view?.groups).toEqual([]); + // The totals are STORED, not summed from segments — so the headline + // survives a decomposition the client could not read. + expect(view?.inputTokens).toBe(79_240); + }); + }); + + it("returns null when nothing has been measured", () => { + // Not-measured, unknown-run and other-tenant are deliberately + // indistinguishable at the API, so they are one state here too: the meter + // renders nothing. A zeroed meter would be a claim. + expect(buildContextPillView({ context: null, occupancy: null })).toBeNull(); + expect( + buildContextPillView({ + context: null, + occupancy: { conversation_id: "c", run_id: null, snapshot: null }, + }), + ).toBeNull(); + }); + + describe("grouping", () => { + it("groups by lifecycle, resident first", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + expect(view?.groups.map((group) => group.lifecycle)).toEqual([ + "resident", + "per_result", + "per_turn", + ]); + }); + + it("surfaces the resident connector rent as the top two rows", () => { + // The whole point of grouping by lifecycle rather than by class: read by + // class this is an unremarkable "tools 19%". + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + const resident = view!.groups[0]!; + expect(resident.rows.slice(0, 2).map((row) => row.label)).toEqual([ + "linear", + "github", + ]); + expect(resident.rows[0]!.tokens + resident.rows[1]!.tokens).toBe(33_610); + expect(resident.rows.every((row) => row.tokens > 0)).toBe(true); + }); + + it("notes the multiplier that makes each group actionable", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + expect(view?.groups[0]!.note).toBe("every call"); + expect(view?.groups[1]!.note).toBe("× 24 results"); + expect(view?.groups[2]!.note).toBeNull(); + }); + + it("drops zero-token segments rather than listing them", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy({ + segments: [ + ...SEGMENTS, + segment({ + segment_class: "response_format", + label: "response_format:none", + estimated_tokens: 0, + item_count: 0, + }), + ], + }), + }); + const labels = view!.groups.flatMap((group) => + group.rows.map((row) => row.label), + ); + expect(labels).not.toContain("none"); + }); + }); + + describe("segment markers", () => { + it("carries third-party, cacheable and approximate through", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + const rows = view!.groups.flatMap((group) => group.rows); + const linear = rows.find((row) => row.label === "linear")!; + expect(linear).toMatchObject({ + thirdParty: true, + cacheable: true, + approximate: false, + }); + // counter_source "proxy" is the fail-open signature — the ledger chose a + // worse number over failing the run, and the row must say so. + const results = rows.find((row) => row.label === "tool_results")!; + expect(results.approximate).toBe(true); + expect(results.cacheable).toBe(false); + }); + + it("steps tone within a class so siblings stay distinguishable", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + const rows = view!.groups.flatMap((group) => group.rows); + const tools = rows.filter((row) => row.segmentClass === "tools"); + expect(tools.map((row) => row.tone)).toEqual([1, 0.72, 0.5]); + }); + + it("strips the owner namespace for display but keys on the full label", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + const rows = view!.groups.flatMap((group) => group.rows); + expect(rows.map((row) => row.label)).toContain("instructions"); + expect(rows.map((row) => row.key)).toContain("system:instructions::"); + }); + + it("keys two rows of the same label apart by their detail", () => { + // `label` alone is not unique — one declaration can contribute several + // rows separated only by `detail`. A colliding key silently drops one of + // them from the popover AND misfiles its lifecycle group. + const view = buildContextPillView({ + context: context(), + occupancy: occupancy({ + segments: [ + segment({ detail: "issues", estimated_tokens: 9_000 }), + segment({ detail: "projects", estimated_tokens: 7_000 }), + ], + }), + }); + const rows = view!.groups.flatMap((group) => group.rows); + expect(rows).toHaveLength(2); + expect(new Set(rows.map((row) => row.key)).size).toBe(2); + }); + + it("names the UNDECLARED sentinel rather than rendering an empty cell", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy({ + segments: [ + segment({ + label: "UNDECLARED", + detail: null, + estimated_tokens: 3_180, + third_party: false, + }), + ], + undeclared_tokens: 3_180, + }), + }); + expect(view!.groups[0]!.rows[0]!.label).toBe("undeclared"); + expect(view?.undeclaredTokens).toBe(3_180); + }); + }); + + describe("the bar", () => { + it("draws consumed slices only, leaving the track as the headroom", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + const total = view!.slices.reduce((sum, slice) => sum + slice.pct, 0); + // 79,240 of 200,000 — the remaining ~60% is the track the number names, + // which is why nothing here computes 100 - headroom. + expect(total).toBeCloseTo(39.62, 1); + }); + + it("groups slices by class so each class reads as ONE run", () => { + // Sorted by size alone the classes interleave (tools · messages · + // messages · tools · system …), which draws seven stripes and states + // none of the four totals. Row correspondence is by colour, not + // position, so regrouping the bar costs nothing. + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + const classes = view!.slices + .map((slice) => slice.segmentClass) + .filter((c): c is NonNullable => c !== null); + const runs = classes.filter((c, i) => c !== classes[i - 1]); + expect(runs).toEqual(["tools", "messages", "system"]); + expect(new Set(runs).size).toBe(runs.length); + }); + + it("gives the positive provider delta a classless slice", () => { + const view = buildContextPillView({ + context: context(), + occupancy: occupancy(), + }); + const delta = view!.slices.find((slice) => slice.segmentClass === null); + expect(delta?.pct).toBeCloseTo(0.62, 2); + }); + + it("draws no slice for a NEGATIVE delta but still reports it", () => { + // Negative means we over-counted. A bar cannot draw negative width; the + // figure still has to survive to the row. + const view = buildContextPillView({ + context: context(), + occupancy: occupancy({ + provider_input_tokens: 77_180, + unattributed_delta: -820, + }), + }); + expect(view!.slices.some((slice) => slice.segmentClass === null)).toBe( + false, + ); + expect(view?.unattributedDelta).toBe(-820); + }); + }); + + it("reports the most recent compaction by timestamp, not by array order", () => { + const base = context(); + const view = buildContextPillView({ + context: { + ...base, + breakdown: { + ...base.breakdown, + compression_events: [ + { + before: 128_000, + after: 34_000, + strategy: "summarize", + at: "2026-08-14T09:00:00Z", + }, + { + before: 90_000, + after: 21_000, + strategy: "summarize", + at: "2026-08-14T08:00:00Z", + }, + ], + }, + }, + occupancy: occupancy(), + }); + expect(view?.compaction).toEqual({ before: 128_000, after: 34_000 }); + }); +}); diff --git a/packages/chat-surface/src/composer/contextPillView.ts b/packages/chat-surface/src/composer/contextPillView.ts new file mode 100644 index 000000000..2be3c0b4c --- /dev/null +++ b/packages/chat-surface/src/composer/contextPillView.ts @@ -0,0 +1,454 @@ +// contextPillView — the pure mapping from the two context read APIs onto what +// the composer's context meter draws. +// +// Lives beside the component (like `modelCatalog.ts`) rather than in +// `projections/`, but obeys the same rule: it is the SHARED half of the host +// binders. Web and desktop cannot share their fetch, so if this mapping were +// written twice the two hosts would drift — which is exactly how the model +// catalog opened the same user on two different models. +// +// TWO ENDPOINTS, TWO QUESTIONS, AND THEY ARE NOT INTERCHANGEABLE: +// +// GET /v1/agent/conversations/{id}/context -> how full is the window +// GET /v1/agent/conversations/{id}/context/occupancy -> who filled it +// +// The first owns `headroom_pct`, which the server computes and this module +// passes through UNTOUCHED (see `ContextPillView.headroomPct`). The second owns +// the decomposition. Neither is derivable from the other. +// +// NOT A STREAM PROJECTION. `context_occupancy` exists in the event vocabulary +// (`RuntimeApiEventType.CONTEXT_OCCUPANCY`) and `events.py` projects a payload +// and an activity_kind for it, but NOTHING emits it — the type is absent from +// `runtime_worker/streaming_executor.py`, where its sibling `usage_recorded` is +// emitted. A selector over `session.events` would therefore be correct, tested, +// green, and permanently empty. Read the API; revisit if §7's emitter lands. + +import type { + ConversationContextOccupancyResponse, + ConversationContextResponse, + ContextCacheEligibility, + ContextLifecycle, + ContextOccupancySegment, + ContextOccupancySnapshot, + ContextSegmentClass, +} from "@0x-copilot/api-types"; + +/** The reserved label for measured bytes matching no declaration. + * Mirrors `UNDECLARED_CONTEXT_LABEL` (`context_origin.py`). */ +const UNDECLARED_LABEL = "UNDECLARED"; + +/** List key for the provider-overhead slice. Carries the `::` joiner that + * {@link segmentKey} uses so it can never collide with a real segment, whose + * left half is always a non-empty `owner:name`. */ +const UNATTRIBUTED_SLICE_KEY = "::unattributed"; + +/** Headroom at or above this reads as unremarkable — no colour, no urgency. */ +const WARM_BELOW_PCT = 40; +/** Headroom below this is the "compact before you send" band. */ +const CRITICAL_BELOW_PCT = 15; + +/** + * Opacity steps applied to a class hue so sibling segments of the SAME class + * stay distinguishable in the bar without spending a second hue on them. + * Beyond the fourth, everything shares the floor — a fifth `tools:` server is + * already below the resolution of a 4px meter. + */ +const TONE_STEPS: readonly number[] = [1, 0.72, 0.5, 0.38]; +const TONE_FLOOR = 0.38; + +/** Render order. Resident first: it is the group whose bytes recur on every + * call, so it is both the largest lever and the least obvious one. */ +const LIFECYCLE_ORDER: readonly ContextLifecycle[] = [ + "resident", + "per_result", + "per_turn", + "on_demand", +]; + +const LIFECYCLE_LABEL: Record = { + resident: "Resident", + per_result: "Per result", + per_turn: "Per turn", + on_demand: "On demand", +}; + +/** How much pressure the meter is under. Derived ONLY from the server's + * `headroom_pct`; an unknown window has no pressure to report. */ +export type ContextPressure = "quiet" | "warm" | "critical"; + +export interface ContextSegmentRow { + /** Stable list key — the wire label plus its detail, never re-ordered into. */ + readonly key: string; + /** Display label: the owner namespace is stripped (`tools:linear` -> `linear`) + * because the group heading already says which lifecycle owns the row and the + * swatch already says which class. */ + readonly label: string; + readonly detail: string | null; + readonly segmentClass: ContextSegmentClass; + /** Multiplier on the class hue — see {@link TONE_STEPS}. */ + readonly tone: number; + readonly tokens: number; + /** Share of the window, or `null` when the window size is unknown. */ + readonly pctOfWindow: number | null; + /** Bytes the user did not author and can remove by disconnecting a server. */ + readonly thirdParty: boolean; + /** `stable_prefix` — bills at roughly a tenth of a fresh send. */ + readonly cacheable: boolean; + /** `counter_source: "proxy"` — the fail-open signature. The figure is an + * estimate the ledger fell back to, and the row says so rather than + * presenting a worse number with the same confidence as a tokenized one. */ + readonly approximate: boolean; +} + +export interface ContextLifecycleGroup { + readonly lifecycle: ContextLifecycle; + readonly label: string; + /** The multiplier that makes the group actionable ("every call", "× 24 + * results"), or `null` when the group has no meaningful one. */ + readonly note: string | null; + readonly rows: readonly ContextSegmentRow[]; +} + +export interface ContextBarSlice { + readonly key: string; + /** `null` marks the unattributed provider delta, which is not a class. */ + readonly segmentClass: ContextSegmentClass | null; + readonly tone: number; + /** Share of the window, 0–100. */ + readonly pct: number; +} + +export interface ContextPillView { + /** + * The server's integer percent of window REMAINING, passed through verbatim. + * + * Never re-derived from `available_tokens / context_window_tokens` — the + * contract on `ContextCurrentSlice` forbids it, and the meter renders + * headroom ("60% free") rather than consumption precisely so that no + * `100 - x` arithmetic has to happen on the client either. + * + * `null` when the model is absent from the pricing catalogue. + */ + readonly headroomPct: number | null; + readonly pressure: ContextPressure; + readonly modelLabel: string; + /** `null` = unknown denominator. Every percentage in this view is then null + * too, and the meter falls back to absolute tokens. */ + readonly windowTokens: number | null; + /** Headline input count. The PROVIDER's number when it reported one — it is + * authoritative and matches billing — falling back to our estimate. */ + readonly inputTokens: number; + readonly cachedTokens: number; + readonly freeTokens: number | null; + readonly slices: readonly ContextBarSlice[]; + readonly groups: readonly ContextLifecycleGroup[]; + /** `provider_input_tokens - estimated_input_tokens`, SIGNED. Rendered as its + * own row rather than absorbed, so the segments visibly do not have to sum + * to the headline. Negative means we over-counted. */ + readonly unattributedDelta: number; + /** Expected 0. Above it, a first-party contract defect — surfaced inside the + * popover only, never on the meter: it is our bug, and it does not change + * what the user should do next. */ + readonly undeclaredTokens: number; + /** Most recent compaction, when the conversation has one. */ + readonly compaction: { + readonly before: number; + readonly after: number; + } | null; +} + +export interface BuildContextPillViewInput { + /** `GET /v1/agent/conversations/{id}/context`. */ + readonly context: ConversationContextResponse | null; + /** `GET /v1/agent/conversations/{id}/context/occupancy`. */ + readonly occupancy: ConversationContextOccupancyResponse | null; +} + +/** + * Build the meter's view model, or `null` when there is nothing honest to show. + * + * `null` covers every not-yet-measured case — a fresh conversation, a run that + * predates the ledger, another tenant's id — deliberately without + * distinguishing them, exactly as the endpoint does. The meter then renders + * NOTHING: no skeleton, no spinner, no zero. A zeroed meter is a claim. + */ +export function buildContextPillView({ + context, + occupancy, +}: BuildContextPillViewInput): ContextPillView | null { + const snapshot = occupancy?.snapshot ?? null; + if (snapshot === null && context === null) return null; + + // EVERY read below optional-chains through the nested objects, not just + // through the response itself. A 200 carrying a partial or error-shaped body + // gives a truthy `context` whose `model` / `current` never arrived, and a + // meter is not worth crashing a composer over: the failure mode has to be a + // missing meter, never a missing send button. + // + // The window denominator. The snapshot's is preferred — it is the window the + // model call actually ran against, including on an alternate-route retry to a + // differently sized deployment. + // + // Branch on the SNAPSHOT's presence, not on `??` over its field. Both sources + // use `null` to mean "this model is not in the pricing catalogue", so `??` + // would read that deliberate unknown as "absent" and quietly substitute the + // other endpoint's number — reporting a confident percentage against a + // denominator the measured call never had. + const windowTokens = + snapshot !== null + ? snapshot.context_window_tokens + : (context?.model?.context_window_tokens ?? null); + + const headroomPct = context?.current?.headroom_pct ?? null; + + const inputTokens = + snapshot?.provider_input_tokens ?? + snapshot?.estimated_input_tokens ?? + context?.current?.input_tokens ?? + 0; + + // Nothing measured and nothing counted: there is no meter to draw. + if (snapshot === null && inputTokens === 0 && headroomPct === null) { + return null; + } + + const cachedTokens = + snapshot?.cached_input_tokens ?? context?.current?.cached_input_tokens ?? 0; + // Same deliberate-null rule as `windowTokens` above: an unknown window has no + // remainder, and borrowing the other endpoint's would name a figure this + // scope cannot support. + const freeTokens = + snapshot !== null + ? snapshot.free_tokens + : (context?.current?.available_tokens ?? null); + + const segments = Array.isArray(snapshot?.segments) ? snapshot.segments : []; + const rows = buildRows(segments, windowTokens); + + return { + headroomPct, + pressure: pressureFor(headroomPct), + modelLabel: modelLabelFor(context, snapshot), + windowTokens, + inputTokens, + cachedTokens, + freeTokens, + slices: buildSlices(rows, snapshot, windowTokens), + groups: buildGroups(rows, segments), + unattributedDelta: snapshot?.unattributed_delta ?? 0, + undeclaredTokens: snapshot?.undeclared_tokens ?? 0, + compaction: latestCompaction(context), + }; +} + +/** + * Pressure bands. + * + * An unknown window is `quiet`, not `critical`. Escalating on missing data + * would put an ember meter in front of every user whose model is simply absent + * from the pricing catalogue. + */ +function pressureFor(headroomPct: number | null): ContextPressure { + if (headroomPct === null) return "quiet"; + if (headroomPct < CRITICAL_BELOW_PCT) return "critical"; + if (headroomPct < WARM_BELOW_PCT) return "warm"; + return "quiet"; +} + +function modelLabelFor( + context: ConversationContextResponse | null, + snapshot: ContextOccupancySnapshot | null, +): string { + const name = context?.model?.name ?? snapshot?.model_family ?? ""; + return name.trim() === "" ? "Model" : name; +} + +/** Rows for every segment carrying tokens, tone-stepped within their class. + * + * Zero-token segments are dropped: a declaration that measured to nothing is + * a true fact about the request and a useless row in a 300px frame. */ +function buildRows( + segments: readonly ContextOccupancySegment[], + windowTokens: number | null, +): ContextSegmentRow[] { + const scored = segments + .filter((segment) => segment.estimated_tokens > 0) + .sort((a, b) => b.estimated_tokens - a.estimated_tokens); + + const seenPerClass = new Map(); + + return scored.map((segment) => { + const rank = seenPerClass.get(segment.segment_class) ?? 0; + seenPerClass.set(segment.segment_class, rank + 1); + return { + key: segmentKey(segment), + label: displayLabel(segment.label), + detail: segment.detail, + segmentClass: segment.segment_class, + tone: TONE_STEPS[rank] ?? TONE_FLOOR, + tokens: segment.estimated_tokens, + pctOfWindow: shareOfWindow(segment.estimated_tokens, windowTokens), + thirdParty: segment.third_party, + cacheable: isCacheable(segment.cache_eligibility), + approximate: segment.counter_source === "proxy", + }; + }); +} + +/** + * Identity of one segment within a snapshot. + * + * `label` alone is not unique — the same declaration can contribute several + * rows distinguished only by `detail` (a tool name, a message ordinal range). + * `::` is the joiner because neither half may contain it: a label is + * `owner:name` with a single colon, and a detail is a bounded sub-identity. + */ +function segmentKey(segment: ContextOccupancySegment): string { + return `${segment.label}::${segment.detail ?? ""}`; +} + +/** + * Strip the owner namespace for display. + * + * The label is an OPAQUE grouping key per the contract — never switch on its + * value. Splitting on the first `:` is presentation, and it degrades to the + * whole string when there is no namespace, so an unrecognised shape still + * renders its own name rather than an empty cell. + */ +function displayLabel(label: string): string { + if (label === UNDECLARED_LABEL) return "undeclared"; + const separator = label.indexOf(":"); + if (separator < 0) return label; + const tail = label.slice(separator + 1).trim(); + return tail === "" ? label : tail; +} + +function isCacheable(eligibility: ContextCacheEligibility | null): boolean { + return eligibility === "stable_prefix"; +} + +function shareOfWindow( + tokens: number, + windowTokens: number | null, +): number | null { + if (windowTokens === null || windowTokens <= 0) return null; + return (tokens / windowTokens) * 100; +} + +/** Class order along the bar. Fixed rather than data-derived, so the same + * conversation does not reshuffle its bar between turns as sizes change. */ +const CLASS_BAR_ORDER: readonly ContextSegmentClass[] = [ + "tools", + "messages", + "system", + "response_format", +]; + +/** + * The stacked bar: one slice per row, plus the unattributed delta. + * + * Slices are the CONSUMED portion only — the track behind them is the headroom + * the number names, so the bar and the percent are two independent server + * values that agree rather than one derived from the other. + * + * GROUPED BY CLASS, unlike the rows below it. The rows answer "what should I + * do about this", so they group by lifecycle; the bar answers "what is this + * window made of", and a class whose slices are scattered along it cannot be + * read as a quantity at all. Sorting by token size alone interleaved them + * (tools · messages · messages · tools · system …), which drew seven stripes + * and communicated none of the four totals. Correspondence with the rows is + * carried by COLOUR, not position, so regrouping costs nothing. + * + * A negative `unattributed_delta` (we over-counted) contributes no slice: a + * bar cannot draw negative width, and the signed figure is still reported in + * full on its own row. + */ +function buildSlices( + rows: readonly ContextSegmentRow[], + snapshot: ContextOccupancySnapshot | null, + windowTokens: number | null, +): ContextBarSlice[] { + const slices: ContextBarSlice[] = []; + for (const segmentClass of CLASS_BAR_ORDER) { + for (const row of rows) { + if (row.segmentClass !== segmentClass) continue; + if (row.pctOfWindow === null || row.pctOfWindow <= 0) continue; + slices.push({ + key: row.key, + segmentClass: row.segmentClass, + tone: row.tone, + pct: row.pctOfWindow, + }); + } + } + const delta = snapshot?.unattributed_delta ?? 0; + const deltaPct = delta > 0 ? shareOfWindow(delta, windowTokens) : null; + if (deltaPct !== null && deltaPct > 0) { + slices.push({ + key: UNATTRIBUTED_SLICE_KEY, + segmentClass: null, + tone: 1, + pct: deltaPct, + }); + } + return slices; +} + +/** Group rows by lifecycle, in {@link LIFECYCLE_ORDER}, dropping empties. */ +function buildGroups( + rows: readonly ContextSegmentRow[], + segments: readonly ContextOccupancySegment[], +): ContextLifecycleGroup[] { + const lifecycleByKey = new Map(); + for (const segment of segments) { + lifecycleByKey.set(segmentKey(segment), segment.lifecycle); + } + + const groups: ContextLifecycleGroup[] = []; + for (const lifecycle of LIFECYCLE_ORDER) { + const owned = rows.filter( + (row) => lifecycleByKey.get(row.key) === lifecycle, + ); + if (owned.length === 0) continue; + groups.push({ + lifecycle, + label: LIFECYCLE_LABEL[lifecycle], + note: groupNote(lifecycle, segments), + rows: owned, + }); + } + return groups; +} + +/** + * The multiplier that turns a number into an action. + * + * `resident` bytes recur on every call, so the note is the recurrence itself. + * `per_result` bytes scale with how many results came back, so the note is the + * count — that is what makes "shrink the per-result note" the obvious fix + * rather than "delete the tool". + */ +function groupNote( + lifecycle: ContextLifecycle, + segments: readonly ContextOccupancySegment[], +): string | null { + if (lifecycle === "resident") return "every call"; + if (lifecycle !== "per_result") return null; + const items = segments + .filter((segment) => segment.lifecycle === "per_result") + .reduce((max, segment) => Math.max(max, segment.item_count), 0); + return items > 0 ? `× ${items} results` : null; +} + +/** The most recent compaction, by `at`. Sorting rather than trusting order: + * the contract does not promise one. */ +function latestCompaction( + context: ConversationContextResponse | null, +): { readonly before: number; readonly after: number } | null { + const events = context?.breakdown?.compression_events ?? []; + if (events.length === 0) return null; + const latest = [...events].sort((a, b) => a.at.localeCompare(b.at)).at(-1); + return latest === undefined + ? null + : { before: latest.before, after: latest.after }; +} diff --git a/packages/chat-surface/src/composer/useConversationContext.ts b/packages/chat-surface/src/composer/useConversationContext.ts new file mode 100644 index 000000000..0e0924d21 --- /dev/null +++ b/packages/chat-surface/src/composer/useConversationContext.ts @@ -0,0 +1,92 @@ +// useConversationContext — the ONE fetch behind the composer's context meter. +// +// It lives in this package rather than in each host binder for the same reason +// `contextPillView.ts` does: `apps/* -> apps/*` is a hard boundary, so a fetch +// written per host is a fetch that drifts per host. Both hosts already provide +// a `Transport` port, and the cockpit already fetches through it +// (`useRunSession`), so there is nothing substrate-specific left to own. +// +// WHY POLLING IS NOT NEEDED, AND WHY THE REFETCH KEY IS THE RUN STATUS. +// Occupancy is measured per model call, but the question the meter answers — +// "can I send this, or do I compact first?" — is asked BETWEEN turns. Refetching +// when the bound run reaches a terminal state gives the meter its answer exactly +// when the user is next in a position to act on it, at one request per turn. +// A `context_occupancy` run event exists in the vocabulary and would make this +// live mid-run, but nothing emits it today (see `contextPillView.ts`). + +import { useEffect, useState } from "react"; + +import type { + ConversationContextOccupancyResponse, + ConversationContextResponse, +} from "@0x-copilot/api-types"; + +import { useTransport } from "../providers/TransportProvider"; +import { buildContextPillView, type ContextPillView } from "./contextPillView"; + +export interface UseConversationContextInput { + /** `null` disables the hook — a cockpit with no conversation has no window. */ + readonly conversationId: string | null; + /** + * Refetch trigger. Pass the bound run's status (or any value that changes + * once per turn); the meter re-reads whenever it changes, so a turn that just + * added 20k of tool results is reflected before the next send. + */ + readonly refetchKey?: string | null; +} + +export interface UseConversationContextResult { + /** `null` until something is measured — pass it straight through, and let the + * composer render no meter rather than a zeroed one. */ + readonly view: ContextPillView | null; +} + +export function useConversationContext({ + conversationId, + refetchKey = null, +}: UseConversationContextInput): UseConversationContextResult { + const transport = useTransport(); + const [view, setView] = useState(null); + + useEffect(() => { + if (conversationId === null) { + setView(null); + return; + } + let cancelled = false; + + // Both endpoints are read-only sub-resources of the same conversation and + // neither is required: `/context` alone still gives a headroom percent, and + // `/context/occupancy` alone still gives a decomposition. So they settle + // INDEPENDENTLY — one 404 (a conversation with no runs, a model absent from + // pricing) must not blank a meter the other could still draw. + const contextRequest = transport + .request({ + method: "GET", + path: `/v1/agent/conversations/${conversationId}/context`, + }) + .catch(() => null); + + const occupancyRequest = transport + .request({ + method: "GET", + path: `/v1/agent/conversations/${conversationId}/context/occupancy`, + }) + .catch(() => null); + + void Promise.all([contextRequest, occupancyRequest]).then( + ([context, occupancy]) => { + if (cancelled) return; + setView(buildContextPillView({ context, occupancy })); + }, + ); + + return () => { + cancelled = true; + }; + // `refetchKey` is a trigger, not a value this effect reads — it is in the + // deps precisely so a turn boundary re-runs the fetch. + }, [transport, conversationId, refetchKey]); + + return { view }; +} diff --git a/packages/chat-surface/src/destinations/run/RunDestination.tsx b/packages/chat-surface/src/destinations/run/RunDestination.tsx index d7589074e..83ceb4767 100644 --- a/packages/chat-surface/src/destinations/run/RunDestination.tsx +++ b/packages/chat-surface/src/destinations/run/RunDestination.tsx @@ -260,6 +260,8 @@ import { import { useRunSources } from "./useRunSources"; import { useRunTranscript } from "./useRunTranscript"; import { useRunSession } from "./useRunSession"; +import { useConversationContext } from "../../composer/useConversationContext"; +import type { ContextPillView } from "../../composer/contextPillView"; const EMPTY_DECISIONS: ReadonlyMap = new Map(); const EMPTY_CLOSED_URIS: ReadonlySet = new Set(); @@ -1202,6 +1204,14 @@ export interface RunDestinationProps { * same seam, so both composers seed their pill from the same server truth. */ readonly conversationModel?: string | null; + /** + * The context meter's view model, or `null` when nothing has been measured + * (a fresh conversation, a run predating the ledger). The cockpit reads + * both `/context` endpoints through the `Transport` port ONCE and hands the + * result to whichever composer the host mounts, so the two hosts cannot + * fetch it differently — or, worse, one of them not at all. + */ + readonly context?: ContextPillView | null; }) => ReactElement | null; /** * WC-P5a (AD-6/AD-7): host launcher for the mid-run `mcp_auth` Connect card. @@ -2025,6 +2035,15 @@ export function RunDestination(props: RunDestinationProps): ReactElement { // new run supersedes it. const todos = useConversationTodos(conversationId, runTodos); + // The composer's context meter. Keyed on run STATUS, so it re-reads once per + // turn boundary rather than per event: occupancy is measured per model call, + // but "can I send this, or do I compact first?" is a question asked between + // turns, and that is when a fresh number can still change what the user does. + const contextMeter = useConversationContext({ + conversationId: conversationId === "new" ? null : conversationId, + refetchKey: session.runStatus, + }); + // WC-P6a (AD-11): the run-scoped citation registries, projected off the SAME // `session.events` (FR-3.3 — no second subscription/projector). Feeds the // `CitationsProvider` mounted around the single TcChat so the host chip @@ -4146,6 +4165,7 @@ export function RunDestination(props: RunDestinationProps): ReactElement { onCancel: handleCancel, autoActivateConnectorId: connectedConnectorServerId, conversationModel: session.conversationModel, + context: contextMeter.view, }), [ renderComposer, @@ -4154,6 +4174,7 @@ export function RunDestination(props: RunDestinationProps): ReactElement { handleCancel, connectedConnectorServerId, session.conversationModel, + contextMeter.view, ], ); diff --git a/packages/chat-surface/src/index.ts b/packages/chat-surface/src/index.ts index 4aaac525d..36b3eedef 100644 --- a/packages/chat-surface/src/index.ts +++ b/packages/chat-surface/src/index.ts @@ -2229,3 +2229,30 @@ export { type ConnectorWriteBackResult, } from "./surfaces"; // === end Editable surface Phase 2 === + +// === Context meter (composer) === +// The composer's "how full is the window, and who filled it" control, plus the +// pure mapping that builds its view model from the two read APIs +// (`/context` for `headroom_pct`, `/context/occupancy` for the decomposition). +// +// The MAPPING is exported, not just the component, because it is the shared +// half of two host binders that cannot import each other. Web and desktop each +// own their fetch; both must fold the result through `buildContextPillView` or +// the same conversation reads differently depending on which host opened it — +// the exact failure the model catalog shipped. +// +// NOT a stream projection: `context_occupancy` is in the event vocabulary but +// has no emitter (absent from `runtime_worker/streaming_executor.py`), so a +// selector over `session.events` would be permanently empty. See the file +// header in `contextPillView.ts`. +export { ContextPill, type ContextPillProps } from "./composer/ContextPill"; +export { + buildContextPillView, + type BuildContextPillViewInput, + type ContextBarSlice, + type ContextLifecycleGroup, + type ContextPillView, + type ContextPressure, + type ContextSegmentRow, +} from "./composer/contextPillView"; +// === end Context meter (composer) === diff --git a/packages/design-system/src/styles.css b/packages/design-system/src/styles.css index 551217f24..6885de272 100644 --- a/packages/design-system/src/styles.css +++ b/packages/design-system/src/styles.css @@ -387,6 +387,39 @@ --surface-hue-amber: oklch(0.62 0.11 78); } +/* ========================================================================== + * CONTEXT SEGMENT CLASSES — the closed `ContextSegmentClass` taxonomy from + * `@0x-copilot/api-types`, given one hue each so the composer's context meter + * and its breakdown popover encode "which class" in colour. + * + * ALIASES over the surface-hue ramp above, not new colours. That ramp is + * already a perceptually-even categorical set at fixed L/C with a light-ground + * step-down, which is exactly what a second categorical encoding needs; the + * names here exist so a consumer says what it MEANS (`--color-ctx-tools`) + * rather than borrowing a surface's identity hue by its hue name. + * + * Two constraints decide the picks, and both are load-bearing: + * + * 1. COOL ONLY. Amber and ember carry context PRESSURE on this component + * (the meter's ink at <40% / <15% headroom). A warm segment would read as + * an alarm when it means nothing but "which class", so the two warm rungs + * are deliberately unused here. + * 2. WIDELY SEPARATED. The swatch is 7px and the meter's fill is ~4px tall. + * violet 300° → sky 232° → jade 158° are ~70° apart and stay distinct at + * that size; the adjacent indigo 258° would collide with sky. + * + * `response_format` takes the neutral rather than a fourth hue: it is normally + * absent or negligible, and spending a distinguishable hue on it would cost + * the three that carry the finding. `unattributed` is the signed provider + * delta — not a class at all, so it reads as an absence of colour. */ +:root { + --color-ctx-system: var(--surface-hue-violet); + --color-ctx-tools: var(--surface-hue-sky); + --color-ctx-messages: var(--surface-hue-jade); + --color-ctx-response-format: var(--color-text-subtle); + --color-ctx-unattributed: var(--color-border-stronger); +} + /* Named source classes. A consumer sets ONE attribute — data-surface-hue — * and every downstream rule (dot, halo, value bar, numeric header) reads * --surface-src. That keeps name → hue resolution in exactly one place, so a diff --git a/tools/desktop-journeys/README.md b/tools/desktop-journeys/README.md index c694596c1..9ca7e9e2d 100644 --- a/tools/desktop-journeys/README.md +++ b/tools/desktop-journeys/README.md @@ -51,20 +51,20 @@ isolated, individually reported as `passed` / `failed` / `blocked` / `skipped`, and the next one runs whatever the last one did. Read the phase table the run prints; the exit code is only its aggregate. -| Journey | What it covers | -| --------------------------- | ------------------------------------------------------------------ | -| `first_run.py` | sign-in, BYOK, model preselect, where the first message lands | -| `transcript_rendering.py` | rich cards, Focus, thinking, interleaving, timeline, citations | -| `composer_and_budgets.py` | the Tools pill, todo checklist, tool-call limits, run health | -| `shell_and_projects.py` | the fixed window frame, and filing a chat into a project | -| `workspace_consent.py` | the folder bar, the ungranted ask, approvals (DEFAULT lane) | -| `workspace_bypass.py` | the execution-mode pill and what a grant buys (default + enforce) | -| `artifacts_and_surfaces.py` | Studio artifacts, the canvas, identity colour, the inference floor | -| `mcp_connected.py` | everything needing a REAL connected MCP server (reuse profile) | -| `installed_payload.py` | the shipped npm artifact and the G3-G10 release matrix | -| `virtuals_provider.py` | Virtuals as a native provider — **needs no key** (see below) | -| `virtuals_thinking.py` | a gateway model's reasoning survives to the transcript (Kimi K3) | -| `focus_panel_default.py` | Focus opens with the Run-details column folded, and still opens | +| Journey | What it covers | +| --------------------------- | --------------------------------------------------------------------------- | +| `first_run.py` | sign-in, BYOK, model preselect, where the first message lands | +| `transcript_rendering.py` | rich cards, Focus, thinking, interleaving, timeline, citations | +| `composer_and_budgets.py` | the Tools pill, todo checklist, tool-call limits, run health, context meter | +| `shell_and_projects.py` | the fixed window frame, and filing a chat into a project | +| `workspace_consent.py` | the folder bar, the ungranted ask, approvals (DEFAULT lane) | +| `workspace_bypass.py` | the execution-mode pill and what a grant buys (default + enforce) | +| `artifacts_and_surfaces.py` | Studio artifacts, the canvas, identity colour, the inference floor | +| `mcp_connected.py` | everything needing a REAL connected MCP server (reuse profile) | +| `installed_payload.py` | the shipped npm artifact and the G3-G10 release matrix | +| `virtuals_provider.py` | Virtuals as a native provider — **needs no key** (see below) | +| `virtuals_thinking.py` | a gateway model's reasoning survives to the transcript (Kimi K3) | +| `focus_panel_default.py` | Focus opens with the Run-details column folded, and still opens | Two `installed-payload` journeys are still standalone and unfolded: `generative-workflows/g1_markdown_lifecycle.py` and `g2_csv_lifecycle.py`. See diff --git a/tools/desktop-journeys/composer_and_budgets.py b/tools/desktop-journeys/composer_and_budgets.py index 30b551989..f4e7d3a55 100644 --- a/tools/desktop-journeys/composer_and_budgets.py +++ b/tools/desktop-journeys/composer_and_budgets.py @@ -877,6 +877,153 @@ def cb7_settings_tool_limit_governs_the_runtime(s: DriverSession) -> None: tb_run_prompt_and_check(s, LIMIT) +# -------------------------------------------------------------------------- +# CB-8 — the composer's context meter. +# +# READ-ONLY, and last: it sends nothing and changes nothing, so it consumes +# whatever run the phases above left bound rather than paying for another. +# +# The class of failure it exists to catch is specific. The meter is fed by two +# READ endpoints, and the unit tests behind it run on fixtures — so every one of +# them passes whether or not the packaged stack ever writes an occupancy row. +# `context_occupancy` is in the event vocabulary but has NO emitter (it is +# absent from `runtime_worker/streaming_executor.py` where `usage_recorded` is +# emitted), which is exactly how a meter ships correct, tested, green and +# permanently blank. This phase asks the running app the same question the +# composer asks, then checks the pixel agrees with the answer. +# -------------------------------------------------------------------------- + +CONTEXT_PILL = "[data-testid=context-pill]" +CONTEXT_POPOVER = "[data-testid=context-breakdown]" + + +def cb8_context_meter_reflects_the_ledger(s: DriverSession) -> None: + """The meter renders what the occupancy ledger actually recorded.""" + + # CB-7 ended in Settings. Shared boot: a stale route makes every selector + # below miss, and the failure reads as "the meter is gone". + s.open_destination("Run") + assert s.wait_for("[data-testid=tc-chat]", 30), ( + "the cockpit never re-opened a bound run" + ) + + conversation_id = s.evaluate( + "(() => (location.hash.match(/conversations?\\/([^/?#]+)/) || [])[1] || null)()" + ) + assert conversation_id, ( + "could not read a conversation id off the route; the phase cannot ask " + "the server what it should be seeing" + ) + + # What the server says. This is the premise — without it the pill is + # CORRECT to render nothing, and asserting its presence would be the bug. + occupancy = s.transport( + "GET", f"/v1/agent/conversations/{conversation_id}/context/occupancy" + ) + snapshot = (occupancy or {}).get("snapshot") + if not snapshot: + raise AssertionError( + "the occupancy ledger recorded NOTHING for a conversation that has " + "just run tools. The composer meter is fed by this endpoint, so it " + "will be blank in the packaged app no matter how green the unit " + f"tests are. Response was {occupancy!r}" + ) + + segments = snapshot.get("segments") or [] + assert segments, ( + "a snapshot exists but decomposes into no segments — the meter would " + "draw an empty bar under a real token count" + ) + assert snapshot.get("graph_scope") == "root", ( + "the conversation endpoint must answer with the ROOT window; a " + f"subagent's window has a different denominator. Got {snapshot!r}" + ) + + # The pill itself. + assert s.wait_for(CONTEXT_PILL, 20), ( + "the occupancy ledger has a root snapshot but the composer renders no " + "context meter — the read reached the server and not the pixel" + ) + s.shot("cb8-context-pill") + + pill_text = ( + s.evaluate( + f"(() => (document.querySelector('{CONTEXT_PILL}')||{{}}).textContent || '')()" + ) + or "" + ).strip() + + window_tokens = snapshot.get("context_window_tokens") + if window_tokens: + # `headroom_pct` is the server's, rendered verbatim, and the unit is + # part of the reading: a bare percent next to a filling gauge does not + # say which direction it runs. + assert "free" in pill_text, ( + f"the meter must name its unit; rendered {pill_text!r}" + ) + assert "%" in pill_text, ( + f"a known window must show the server's headroom percent; got {pill_text!r}" + ) + else: + # Model absent from pricing: absolute tokens, and NO invented percent. + assert "%" not in pill_text, ( + "the window size is unknown, so any percent here is against a " + f"denominator that does not exist; rendered {pill_text!r}" + ) + + # The breakdown — the part that makes a number actionable. + s.click(CONTEXT_PILL) + assert s.wait_for(CONTEXT_POPOVER, 10), "the meter did not open its breakdown" + s.shot("cb8-context-breakdown") + + body = ( + s.evaluate( + f"(() => (document.querySelector('{CONTEXT_POPOVER}')||{{}}).textContent || '')()" + ) + or "" + ).strip() + + # Grouped by LIFECYCLE, not by class. Every real request has resident bytes + # (the system prompt at minimum), so this group is always expected. + assert "Resident" in body, ( + "the breakdown is not grouped by lifecycle — 'resident' is the group " + "that names bytes recurring on EVERY call, which is the whole finding. " + f"Rendered: {body!r}" + ) + assert "every call" in body, ( + f"the resident group lost its multiplier note; rendered {body!r}" + ) + + rows = ( + s.evaluate( + """ + (() => [...document.querySelectorAll('[data-testid^=context-row-]')] + .map(r => r.textContent.trim()))() + """ + ) + or [] + ) + assert rows, "the breakdown opened with no segment rows at all" + assert any(ch.isdigit() for ch in " ".join(rows)), ( + f"no segment row carried a token figure; rows were {rows!r}" + ) + + # `undeclared_tokens` is EXPECTED 0 — anything above it is a first-party + # contract defect, not drift. Surfacing it here means the journey reports + # our own bug rather than passing over it. + undeclared = snapshot.get("undeclared_tokens") or 0 + assert undeclared == 0, ( + f"the ledger measured {undeclared} undeclared tokens — bytes matching " + "no declaration. That is a first-party contract defect in the occupancy " + "declarations, not a UI problem" + ) + + log( + f"context meter: {pill_text!r} over {len(segments)} segments, " + f"{len(rows)} rows drawn" + ) + + def main() -> int: plan = JourneyPlan("composer-and-budgets") plan.boot( @@ -919,6 +1066,11 @@ def main() -> int: "the Settings tool-call limit persists and governs the runtime", cb7_settings_tool_limit_governs_the_runtime, ), + ( + "CB-8", + "the composer's context meter reflects the occupancy ledger", + cb8_context_meter_reflects_the_ledger, + ), ], ) return plan.finish()