feat(web): show unmeasured state for zero-usage context window (#2392)

This commit is contained in:
Carlos Florêncio
2026-08-07 21:28:22 +01:00
committed by GitHub
parent 9e503c5e6b
commit b08d28f513
12 changed files with 585 additions and 31 deletions
@@ -3,6 +3,8 @@ import { cleanup, fireEvent, render } from "@testing-library/react";
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
import { isContextWindowReliable, TokenUsageDisplay } from "./token-usage-display";
const TOOLTIP_ROOT_TESTID = "tooltip-root";
vi.mock("@/hooks/domains/session/use-session-context-window", () => ({
useSessionContextWindow: vi.fn(),
}));
@@ -10,7 +12,7 @@ vi.mock("@/hooks/domains/session/use-session-context-window", () => ({
vi.mock("@kandev/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children, open }: { children: React.ReactNode; open?: boolean }) => (
<div data-testid="tooltip-root" data-open={open}>
<div data-testid={TOOLTIP_ROOT_TESTID} data-open={open}>
{children}
</div>
),
@@ -73,12 +75,38 @@ describe("TokenUsageDisplay", () => {
fireEvent.click(getByRole("button", { name: "Context window: 28% used" }));
expect(getByTestId("tooltip-root").getAttribute("data-open")).toBe("true");
expect(getByTestId(TOOLTIP_ROOT_TESTID).getAttribute("data-open")).toBe("true");
const compactionRow = getByTestId("context-window-compactions-row");
expect(compactionRow.textContent).toContain("Compactions");
expect(compactionRow.textContent).toContain("0");
});
it("renders an unmeasured pending state when usage is zero", () => {
vi.mocked(useSessionContextWindow).mockReturnValue({
size: 200_000,
used: 0,
remaining: 200_000,
efficiency: 0,
compactionCount: 2,
source: "acp",
});
const { getByRole, getByTestId } = render(<TokenUsageDisplay sessionId="sess-1" />);
const trigger = getByRole("button", { name: "Context window: usage not measured" });
fireEvent.click(trigger);
expect(getByTestId(TOOLTIP_ROOT_TESTID).getAttribute("data-open")).toBe("true");
const usage = getByTestId("context-window-usage");
expect(usage.textContent).toContain("—%");
expect(usage.textContent).toContain("— of 200.0K tokens");
expect(usage.textContent).toContain("Usage data appears after the first completed turn.");
expect(usage.textContent).not.toContain("0%");
expect(usage.textContent).not.toContain("0 of");
expect(usage.textContent).toContain("ACP");
expect(usage.textContent).toContain("Compactions");
});
it("closes a tapped tooltip when Escape is pressed", () => {
vi.mocked(useSessionContextWindow).mockReturnValue({
size: 200_000,
@@ -93,7 +121,7 @@ describe("TokenUsageDisplay", () => {
fireEvent.click(getByRole("button", { name: "Context window: 28% used" }));
fireEvent.keyDown(document, { key: "Escape" });
expect(getByTestId("tooltip-root").getAttribute("data-open")).toBe("false");
expect(getByTestId(TOOLTIP_ROOT_TESTID).getAttribute("data-open")).toBe("false");
});
});
@@ -120,7 +148,7 @@ describe("TokenUsageDisplay context source", () => {
expect(tokenCount.closest('[data-testid="context-window-token-row"]')).toBe(
source.closest('[data-testid="context-window-token-row"]'),
);
expect(getAllByTestId("tooltip-root")).toHaveLength(1);
expect(getAllByTestId(TOOLTIP_ROOT_TESTID)).toHaveLength(1);
expect(getByLabelText("About context window source")).toBeDefined();
expect(getByText(/ACP is the active session's effective window/i)).toBeDefined();
});
@@ -254,6 +254,17 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
if (!isContextWindowReliable(size, used)) return null;
const usagePercent = (used / size) * 100;
const pendingUsage = used === 0;
const usagePercentLabel = pendingUsage
? t("task:contextWindowUsagePendingPercent")
: `${usagePercent.toFixed(0)}%`;
const usageTokenLabel = pendingUsage
? t("task:contextWindowUsagePendingTokens", { size: formatNumber(size) })
: t("task:contextWindowUsedTokens", {
used: formatNumber(used),
size: formatNumber(size),
});
return (
// The UI wrapper defaults this to true; the source control must remain reachable inside.
@@ -263,7 +274,11 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
<button
ref={tooltip.triggerRef}
type="button"
aria-label={t("task:contextWindowUsed", { percent: usagePercent.toFixed(0) })}
aria-label={
pendingUsage
? t("task:contextWindowNotMeasured")
: t("task:contextWindowUsed", { percent: usagePercent.toFixed(0) })
}
aria-expanded={tooltip.open}
onClick={tooltip.onTriggerClick}
className={cn(
@@ -282,7 +297,7 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
{t("task:contextWindow")}
</span>
<span className="text-base font-semibold tabular-nums text-foreground">
{usagePercent.toFixed(0)}%
{usagePercentLabel}
</span>
</div>
<div
@@ -301,10 +316,18 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
data-testid="context-window-token-row"
>
<span className="text-[11px] tabular-nums text-muted-foreground">
{formatNumber(used)} of {formatNumber(size)} tokens
{usageTokenLabel}
</span>
<ContextWindowSource source={source} />
</div>
{pendingUsage && (
<p
className="text-[11px] text-muted-foreground"
data-testid="context-window-usage-pending"
>
{t("task:contextWindowUsagePendingExplain")}
</p>
)}
<ContextCompactionCount count={compactionCount} />
</div>
</div>
@@ -8,26 +8,28 @@ type ContextWindowStore = Window & {
getState: () => {
taskSessions: { items: Record<string, { state?: string }> };
clearContextWindow: (sessionId: string) => void;
setContextWindow: (
sessionId: string,
contextWindow: {
size: number;
used: number;
remaining: number;
efficiency: number;
compactionCount: number;
source: "acp" | "api";
},
) => void;
setContextWindow: (sessionId: string, contextWindow: ContextWindowFixture) => void;
};
setState: (partial: { clearContextWindow: (sessionId: string) => void }) => void;
};
};
type ContextWindowFixture = {
size: number;
used: number;
remaining: number;
efficiency: number;
compactionCount: number;
source: "acp" | "api";
};
type ContextWindowSeed = Partial<ContextWindowFixture>;
export async function seedContextWindowTask(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
seed: ContextWindowSeed = {},
): Promise<void> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
@@ -95,19 +97,31 @@ export async function seedContextWindowTask(
});
}, task.session_id!);
await testPage.evaluate((sessionId) => {
const store = (window as ContextWindowStore).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge is unavailable");
store.getState().setContextWindow(sessionId, {
size: 258_400,
used: 54_100,
remaining: 204_300,
efficiency: 21,
compactionCount: 2,
source: "acp",
});
}, task.session_id!);
await expect(testPage.getByRole("button", { name: "Context window: 21% used" })).toBeVisible();
const contextWindowFixture: ContextWindowFixture = {
size: 258_400,
used: 54_100,
remaining: 204_300,
efficiency: 21,
compactionCount: 2,
source: "acp",
...seed,
};
await testPage.evaluate(
({ sessionId, fixture }) => {
const store = (window as ContextWindowStore).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge is unavailable");
store.getState().setContextWindow(sessionId, fixture);
},
{ sessionId: task.session_id!, fixture: contextWindowFixture },
);
const triggerName =
contextWindowFixture.used === 0
? "Context window: usage not measured"
: `Context window: ${((contextWindowFixture.used / contextWindowFixture.size) * 100).toFixed(
0,
)}% used`;
await expect(testPage.getByRole("button", { name: triggerName })).toBeVisible();
}
export async function expectCompactionCount(contextTooltip: Locator): Promise<void> {
@@ -5,6 +5,43 @@ import {
seedContextWindowTask,
} from "./context-window-source-helpers";
test("context window announces unmeasured usage before the first sample", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
await seedContextWindowTask(testPage, apiClient, seedData, {
used: 0,
remaining: 258_400,
efficiency: 0,
});
const contextTrigger = testPage.getByRole("button", {
name: "Context window: usage not measured",
});
await contextTrigger.hover();
const contextTooltip = testPage
.locator('[data-slot="tooltip-content"]:not([data-state="closed"])')
.filter({ has: testPage.getByTestId("context-window-usage") });
// Radix renders a visually-hidden accessibility copy of the content with the
// same testid; scope to the visible instance like the sibling positive test.
const contextUsage = contextTooltip.getByTestId("context-window-usage").first();
await expect(contextUsage).toBeVisible();
await expect(contextUsage.getByText("—%", { exact: true })).toBeVisible();
await expect(contextUsage.getByText("— of 258.4K tokens", { exact: true })).toBeVisible();
await expect(
contextUsage.getByText(/Usage data appears after the first completed turn/),
).toBeVisible();
await expect(contextUsage.getByText("0%", { exact: true })).toHaveCount(0);
await expect(contextUsage.getByText(/0 of/)).toHaveCount(0);
await expectSourceRightOfTokenCount(contextTooltip);
await expectCompactionCount(contextTooltip);
await prCapture.screenshot("context-window-unmeasured", {
caption: "Context window pending state with em-dash usage values",
});
});
test("context source help stays open when hovered", async ({
testPage,
apiClient,
@@ -5,6 +5,45 @@ import {
seedContextWindowTask,
} from "./context-window-source-helpers";
test("pending context usage is reachable by touch without overflow", async ({
testPage,
apiClient,
seedData,
}) => {
await seedContextWindowTask(testPage, apiClient, seedData, {
used: 0,
remaining: 258_400,
efficiency: 0,
});
const contextTrigger = testPage.getByRole("button", {
name: "Context window: usage not measured",
});
await contextTrigger.tap();
const contextTooltip = testPage
.locator('[data-slot="tooltip-content"]:not([data-state="closed"])')
.filter({ has: testPage.getByTestId("context-window-usage") });
// Radix renders a visually-hidden accessibility copy of the content with the
// same testid; scope to the visible instance like the sibling positive test.
const contextUsage = contextTooltip.getByTestId("context-window-usage").first();
await expect(contextUsage).toBeVisible();
await expect(contextUsage.getByText("—%", { exact: true })).toBeVisible();
await expect(contextUsage.getByText("— of 258.4K tokens", { exact: true })).toBeVisible();
await expect(
contextUsage.getByText(/Usage data appears after the first completed turn/),
).toBeVisible();
await expect(contextUsage.getByText("0%", { exact: true })).toHaveCount(0);
await expect(contextUsage.getByText(/0 of/)).toHaveCount(0);
await expectSourceRightOfTokenCount(contextTooltip);
await expectCompactionCount(contextTooltip);
const hasHorizontalOverflow = await testPage.evaluate(() => {
const root = document.scrollingElement ?? document.documentElement;
return root.scrollWidth > root.clientWidth + 1;
});
expect(hasHorizontalOverflow).toBe(false);
});
test("context source help is reachable by touch without overflow", async ({
testPage,
apiClient,
+5
View File
@@ -234,7 +234,12 @@
"context": "Context",
"contextFieldLabel": "context",
"contextWindow": "Context window",
"contextWindowNotMeasured": "Context window: usage not measured",
"contextWindowUsagePendingExplain": "Usage data appears after the first completed turn.",
"contextWindowUsagePendingPercent": "—%",
"contextWindowUsagePendingTokens": "— of {{size}} tokens",
"contextWindowUsed": "Context window: {{percent}}% used",
"contextWindowUsedTokens": "{{used}} of {{size}} tokens",
"control": "Control",
"controlC": "Control C",
"controlD": "Control D",
+5
View File
@@ -234,7 +234,12 @@
"context": "Ćōńţēxţ",
"contextFieldLabel": "ćōńţēxţ",
"contextWindow": "Ćōńţēxţ ŵĩńďōŵ",
"contextWindowNotMeasured": "Ćōńţēxţ ŵĩńďōŵ: ũśàĝē ńōţ ḿēàśũŕēď",
"contextWindowUsagePendingExplain": "Ũśàĝē ďàţà àƥƥēàŕś àƒţēŕ ţĥē ƒĩŕśţ ćōḿƥĺēţēď ţũŕń.",
"contextWindowUsagePendingPercent": "—%",
"contextWindowUsagePendingTokens": "— ōƒ {{size}} ţōķēńś",
"contextWindowUsed": "Ćōńţēxţ ŵĩńďōŵ: {{percent}}% ũśēď",
"contextWindowUsedTokens": "{{used}} ōƒ {{size}} ţōķēńś",
"control": "Ćōńţŕōĺ",
"controlC": "Ćōńţŕōĺ Ć",
"controlD": "Ćōńţŕōĺ Ď",
@@ -0,0 +1,132 @@
---
spec: docs/specs/ui/context-window-unmeasured-state.md
created: 2026-08-07
status: completed
---
# Implementation Plan: Context Window Unmeasured State
## Overview
Make the context-window hover honest before the first positive ACP usage sample arrives. The
existing session-runtime entry and ACP/WebSocket pipeline remain the source of truth; the frontend
will render used === 0 as an unmeasured state rather than a measured 0%. A focused component
test plus the existing desktop and mobile context-window E2E surfaces will prove the pending state
and the unchanged measured state.
## Backend/API
No backend, ACP adapter, WebSocket, persistence, or store-schema changes are planned. The
existing ContextWindowEntry contract already carries the window size, current usage, source, and
compaction count. This plan deliberately treats a reliable used === 0 entry as "no positive
sample has been observed for display" and confines that interpretation to the context hover.
## Frontend
### Pending context-window presentation
- Update apps/web/components/task/chat/token-usage-display.tsx so the existing reliable-data
branch distinguishes used === 0 from positive usage without changing the existing used > size
guard.
- Keep the ring trigger visible and empty while pending, but use a translated pending accessible
label instead of interpolating 0%.
- In the tooltip, render translated "—%" and "— of {{size}} tokens" values plus a short pending
status sentence. Keep the source and compaction rows in their current positions.
- Leave the positive, exact-full, and impossible-report branches behaviorally unchanged.
- Extend apps/web/components/task/chat/token-usage-display.test.tsx with a pending-state case that
opens the tooltip, asserts the accessible label and pending copy, and proves numeric 0% and
0 of are absent. Retain the existing positive, source, compaction, and dismissal coverage.
### Translation catalog
- Add the pending accessible label, percentage/token placeholders, and pending explanation to
apps/web/src/locales/en/task.json.
- Regenerate or update apps/web/src/locales/pseudo/task.json through the repository's existing
i18n workflow; do not hardcode new visible strings in the component.
- Run both the i18n key check and ratchet because the changed component and catalogs are user-facing
frontend files.
### Mobile design contract
- **Desktop outcome:** the current context ring and tooltip gain only a clear pre-sample content
state; the measured state keeps its current compact layout.
- **Mobile entry point and exemplar:** the same context ring remains the entry point and remains
tap-pinnable. apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts is the nearest
shipped mobile exemplar.
- **Hierarchy and presentation:** the ring, pending percentage, token row, source, and compaction
row remain in the existing tooltip. No drawer, route, breakpoint-specific copy branch, new
scroll owner, or persistent mobile preference is introduced.
- **Shared behavior:** desktop and mobile consume the same ContextWindowEntry, translation keys,
pending predicate, and tooltip dismissal behavior.
- **Mobile proof:** the mobile E2E scenario taps the ring, asserts the pending content inside the
pinned tooltip, and checks that the document has no horizontal overflow.
## Tests
- **What:** the pending branch shows an unmeasured state for used === 0, while positive, exact
full, and impossible reports retain existing behavior.
- **File:** apps/web/components/task/chat/token-usage-display.test.tsx
- **How:** render the component with the existing mocked session hook, open the pinnable
tooltip, assert translated accessible/content values, and exercise the existing regression
cases.
- **What:** the new translation keys are present in both catalogs and comply with i18n rules.
- **Files:** apps/web/src/locales/en/task.json, apps/web/src/locales/pseudo/task.json
- **How:** run the focused web i18n check and ratchet.
- **Integration boundary:** no backend integration test is needed because no transport or
persistence behavior changes. The managed desktop and mobile browser tests below exercise the
rendered store-to-tooltip path.
## E2E Tests
- **Scenario:** **GIVEN** a seeded session whose reliable context window has used: 0, **WHEN**
a desktop user opens the context control, **THEN** the trigger announces pending usage and the
tooltip shows em-dash usage values, the known window size, the source, and no numeric 0%/0 of
usage.
- **Files:** apps/web/e2e/tests/chat/context-window-source-helpers.ts,
apps/web/e2e/tests/chat/context-window-source.spec.ts
- **What to verify:** pending content and the existing positive-sample scenario.
- **Scenario:** **GIVEN** the same pending session on the mobile-chrome project, **WHEN** the user
taps the context ring, **THEN** the pending content remains visible in the tap-pinned tooltip
and the page has no horizontal overflow.
- **File:** apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts
- **What to verify:** touch entry, pending content, and document containment.
## Verification Results
Implemented on `feature/investigate-deepseek-882` (2026-08-07):
- Focused Vitest `components/task/chat/token-usage-display.test.tsx` — 12 tests passed (11 retained
+ 1 new pending-state case).
- `pnpm --filter @kandev/web run typecheck` — passed.
- `pnpm --filter @kandev/web run i18n:check` — keys OK, pseudo in sync; `i18n:ratchet` — clean.
- Managed desktop E2E `pnpm e2e:run tests/chat/context-window-source.spec.ts` — 2 passed (pending +
positive-sample regression).
- Managed mobile-chrome E2E `pnpm e2e:run --project mobile-chrome
tests/chat/mobile-context-window-source.spec.ts` — 2 passed (pending + positive-sample touch
scenarios, overflow check green).
## Implementation Waves And Parallel Candidates
Execution is sequential in the primary conversation because the browser scenarios depend on the
component's pending-state contract and translation keys.
- [x] [Task 01: Render the unmeasured context state](task-01-render-unmeasured-context-state.md) —
done
- [x] [Task 02: Cover pending state on desktop and mobile](task-02-context-window-unmeasured-e2e.md) —
done
## Open Questions
None. The design intentionally uses the existing reliable used === 0 state and does not expand
the ACP/session metadata contract.
## Risks and boundaries
- A provider could theoretically report a legitimate zero-token sample. The chosen product
behavior still prioritizes avoiding a false "measured zero" claim; the first positive sample
transitions the display to numeric usage.
- The pending state must not accidentally suppress source or compaction information, or alter the
parent tooltip's touch/keyboard dismissal behavior.
- The new em-dash and pending copy must be translated so pseudo-locale rendering remains a valid
completeness check.
@@ -0,0 +1,106 @@
---
id: "01-render-unmeasured-context-state"
title: "Render the unmeasured context state"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/context-window-unmeasured-state.md"
---
# Task 01: Render the unmeasured context state
## Intent
Update the existing context-window hover to distinguish a reliable window with used === 0 from
a measured non-zero usage sample, using translated pending copy and preserving the current
interaction and measured-data behavior.
## Acceptance
- A reliable zero-usage entry keeps the context trigger visible, announces usage not measured,
renders em-dash usage values, and never presents numeric 0% or 0 of ... usage.
- The pending tooltip retains the current source and compaction rows plus the existing pinnable
hover, keyboard, touch, outside-dismissal, and Escape behavior.
- A positive or exactly-full sample still renders the current numeric display, and an impossible
used > size report remains hidden.
- All new visible and accessible copy is translated in the English and pseudo task catalogs.
## TDD sequence
1. Add the pending component test and run the focused Vitest command to establish the expected RED
failure against the current numeric-zero rendering.
2. Add the English/pseudo translation keys and implement the smallest conditional rendering
change in TokenUsageDisplay.
3. Rerun the focused test, typecheck, i18n check, and i18n ratchet; keep existing source,
compaction, and tooltip-dismissal cases green.
## Files likely touched
- apps/web/components/task/chat/token-usage-display.tsx
- apps/web/components/task/chat/token-usage-display.test.tsx
- apps/web/src/locales/en/task.json
- apps/web/src/locales/pseudo/task.json
## Dependencies
None.
## Parallelism
sequential. The component test, translation keys, and pending render branch define the contract
consumed by the browser coverage task.
## Verification
- cd apps && rtk pnpm --filter @kandev/web test -- --run components/task/chat/token-usage-display.test.tsx
- cd apps/web && rtk pnpm run typecheck
- cd apps && rtk pnpm --filter @kandev/web run i18n:check
- cd apps && rtk pnpm --filter @kandev/web run i18n:ratchet
## Inputs
- Spec What, pending-state scenarios, and Out of scope.
- Existing reliability guard, pinnable tooltip, source row, and compaction row in
apps/web/components/task/chat/token-usage-display.tsx.
- Frontend i18n and mobile rules in apps/web/AGENTS.md.
## Output contract
Report the RED assertion, exact files changed, translation keys, focused test count, typecheck and
i18n results, mobile-contract confirmation, remaining risks, and synchronized task/plan/spec
status.
## Results
Implemented. The RED assertion was `getByRole("button", { name: "Context window: usage not measured" })`
failing against the then-current "Context window: 0% used" trigger.
Files changed:
- `apps/web/components/task/chat/token-usage-display.tsx` — reliable `used === 0` entries now render a
pending state: translated accessible label on the trigger, em-dash "—%" and "— of {{size}} tokens"
values, a translated pending explanation row, with the ring, source row, compaction row, and
pinnable tooltip behavior unchanged. Positive, exact-full, and impossible-report branches are
behaviorally identical.
- `apps/web/components/task/chat/token-usage-display.test.tsx` — new pending-state case asserting the
accessible label, em-dash values, pending copy, retained source/compaction rows, and the absence of
numeric 0%/0-of text; existing positive, source, compaction, and dismissal cases retained.
- `apps/web/src/locales/en/task.json` — keys `contextWindowNotMeasured`,
`contextWindowUsagePendingPercent`, `contextWindowUsagePendingTokens`,
`contextWindowUsagePendingExplain`.
- `apps/web/src/locales/pseudo/task.json` — regenerated via `pnpm run i18n:pseudo`.
Verification:
- Focused Vitest (`components/task/chat/token-usage-display.test.tsx`) — 12 tests passed.
- `pnpm --filter @kandev/web run typecheck` — passed.
- `pnpm --filter @kandev/web run i18n:check` — keys OK, pseudo in sync.
- `pnpm --filter @kandev/web run i18n:ratchet` — new-code ratchet clean, guard allowlist intact.
- ESLint on the two changed component files — zero warnings.
Mobile contract: no new drawer, route, breakpoint branch, or scroll owner; the ring remains the
tap-pinnable entry point, verified in task 02's mobile E2E. Remaining risk: a provider reporting a
legitimate zero-token sample displays the pending state until the first positive sample, which is
the intended product trade-off recorded in the plan.
@@ -0,0 +1,97 @@
---
id: "02-context-window-unmeasured-e2e"
title: "Cover pending state on desktop and mobile"
status: done
wave: 2
depends_on: ["01-render-unmeasured-context-state"]
plan: "plan.md"
spec: "../../specs/ui/context-window-unmeasured-state.md"
---
# Task 02: Cover pending state on desktop and mobile
## Intent
Extend the existing context-window source fixtures so browser tests prove the unmeasured state
through the same store-to-tooltip path on desktop and mobile, while retaining the positive-sample
regression scenario.
## Acceptance
- The desktop context-window E2E case seeds used: 0, opens the control, and verifies the pending
accessible label, em-dash values, known size/source, and absence of numeric zero usage.
- The mobile-chrome case reaches the same pending content through a tap-pinned tooltip and proves
there is no document-level horizontal overflow.
- Existing positive-sample source/compaction assertions continue to pass, proving the measured
state was not regressed.
## TDD sequence
1. Parameterize the shared context-window seed for a zero-usage fixture and add desktop/mobile
pending assertions; run the focused E2E specs against the current UI to observe RED.
2. After Task 01 is present, run the managed production-build desktop and mobile projects.
3. Tighten selectors to the translated accessible/content contract and inspect any failure
artifacts without changing the mobile composition.
## Files likely touched
- apps/web/e2e/tests/chat/context-window-source-helpers.ts
- apps/web/e2e/tests/chat/context-window-source.spec.ts
- apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts
## Dependencies
Task 01.
## Parallelism
sequential. The browser tests depend on the completed pending UI and translation contract.
## Verification
- cd apps && rtk pnpm install --frozen-lockfile
- cd apps/web && rtk pnpm e2e:run tests/chat/context-window-source.spec.ts
- cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/chat/mobile-context-window-source.spec.ts
## Inputs
- Spec pending desktop/mobile scenarios and Out of scope.
- Plan Mobile design contract and E2E Tests.
- Existing seed and interaction helpers in
apps/web/e2e/tests/chat/context-window-source-helpers.ts.
- Mobile E2E conventions in .agents/skills/e2e/SKILL.md.
## Output contract
Report the exact managed E2E commands, browser project and test counts, pending/positive
assertions, overflow result, artifacts or teardown issues, changed files, remaining risks, and
synchronized task/plan/spec status.
## Results
Implemented.
Files changed:
- `apps/web/e2e/tests/chat/context-window-source-helpers.ts` — `seedContextWindowTask` now accepts an
optional `ContextWindowSeed` partial merged over the default fixture, and derives the expected
trigger accessible name (pending label for `used: 0`, else the rounded percentage).
- `apps/web/e2e/tests/chat/context-window-source.spec.ts` — new pending-state scenario: seeds
`used: 0`, opens the control, asserts the pending accessible label, "—%" and "— of 258.4K tokens",
the pending explanation, retained source/compaction rows, and the absence of numeric 0%/0-of usage.
- `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts` — same pending content asserted
through a tap-pinned tooltip on the mobile-chrome project, plus the document-level no-horizontal-
overflow check.
Managed E2E commands (docker mode, `ghcr.io/kdlbs/kandev-ci:runtime-latest`):
- `pnpm e2e:run tests/chat/context-window-source.spec.ts` — 2 passed (pending + positive-sample
regression).
- `pnpm e2e:run --project mobile-chrome tests/chat/mobile-context-window-source.spec.ts` — 2 passed
(pending touch + positive-sample touch regression, both with the overflow check).
The existing positive-sample source/compaction assertions still pass, proving the measured state was
not regressed. No artifacts or teardown issues; first run revealed and fixed a
`page.evaluate` argument-serialization bug in the helper (fixture now travels as a single object
argument). Remaining risk: none beyond the recorded zero-token-sample display trade-off.
+1
View File
@@ -151,6 +151,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [empty-turn-notice](ui/empty-turn-notice.md) | shipped |
| [acp-shell-command-output](ui/acp-shell-command-output.md) | shipped |
| [acp-model-configuration-summary](ui/acp-model-configuration-summary.md) | shipped |
| [context-window-unmeasured-state](ui/context-window-unmeasured-state.md) | building |
| [review-file-status](ui/review-file-status.md) | building |
| [submodule-review](ui/submodule-review.md) | shipped |
| [review-markdown-preview](ui/review-markdown-preview.md) | draft |
@@ -0,0 +1,67 @@
---
status: building
created: 2026-08-07
owner: kandev
---
# Context Window Unmeasured State
## Why
The ACP adapter can provide the context-window size before the first positive usage sample is
available. While a turn is still in flight, the task chat can therefore show a context window
with 0% and 0 of 1.0M tokens, even though the agent is already consuming context and the next
completed usage update will replace those values. The numeric zero reads as a measured result
rather than an unmeasured state.
## What
- When a reliable context-window size is available and used is zero, keep the existing context
ring available but render an explicit pending state:
- the trigger has the translated accessible label "Context window: usage not measured";
- the ring remains empty;
- the tooltip shows "—%" and "— of <window size> tokens", never "0%" or "0 of ...";
- the tooltip includes translated copy explaining that usage data appears after the first
completed turn.
- Preserve the existing source label, compaction row, context-window size formatting, and
pinnable tooltip behavior while usage is pending.
- When a positive usage sample arrives, return to the existing percentage and token-count
presentation without changing its formatting or thresholds.
- Continue hiding impossible reports where size <= 0 or used > size; an exactly full window
remains valid.
- Route all new visible and accessible copy through the task translation namespace and keep the
English and pseudo-locale catalogs synchronized.
- Keep the ACP, WebSocket, persistence, and session-runtime contracts unchanged. This is a
presentation rule for the existing used === 0 state, not a new provider event or sample
field.
## Scenarios
- **GIVEN** a session has a reliable ACP window size and no positive usage sample yet, **WHEN** a
desktop user opens the context control, **THEN** the trigger and tooltip identify usage as not
measured, show the em-dash values, retain the ACP source, and do not show a numeric zero for
usage.
- **GIVEN** the same pending state on a phone viewport, **WHEN** the user taps the context ring,
**THEN** the same pending content is reachable in the existing tap-pinned hover without a new
drawer, route, or horizontal page overflow.
- **GIVEN** a pending context window, **WHEN** a positive ACP usage sample arrives, **THEN** the
control updates to the current numeric percentage and token count.
- **GIVEN** a pending API-derived window, **WHEN** the user opens the control, **THEN** the
pending presentation is identical except for the existing API source label.
- **GIVEN** a reliable non-zero sample, **WHEN** the control renders, **THEN** the current numeric
percentage, progress bar, source, compaction count, and tooltip interactions remain unchanged.
- **GIVEN** an exact-full or impossible report, **WHEN** the control renders, **THEN** the existing
full-window behavior or hidden behavior is preserved.
## Out of scope
- Changing ACP usage reporting, provider semantics, or the context-window size chosen by the
adapter.
- Adding a persisted has_usage_sample field, changing WebSocket metadata, or changing session
hydration.
- Changing the normal non-zero percentage/token formatting, color thresholds, source explanation,
or compaction-count semantics.
- Adding polling, a spinner, a progress estimate, or a new context-window surface.
- Changing the existing tooltip's desktop hover, keyboard, touch-pinning, outside-dismissal, or
Escape behavior.