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()