- {formatNumber(used)} of {formatNumber(size)} tokens
+ {usageTokenLabel}
+ {pendingUsage && (
+
+ {t("task:contextWindowUsagePendingExplain")}
+
+ )}
diff --git a/apps/web/e2e/tests/chat/context-window-source-helpers.ts b/apps/web/e2e/tests/chat/context-window-source-helpers.ts
index f23ce5fa81..4a14c4744e 100644
--- a/apps/web/e2e/tests/chat/context-window-source-helpers.ts
+++ b/apps/web/e2e/tests/chat/context-window-source-helpers.ts
@@ -8,26 +8,28 @@ type ContextWindowStore = Window & {
getState: () => {
taskSessions: { items: Record };
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;
+
export async function seedContextWindowTask(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
+ seed: ContextWindowSeed = {},
): Promise {
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 {
diff --git a/apps/web/e2e/tests/chat/context-window-source.spec.ts b/apps/web/e2e/tests/chat/context-window-source.spec.ts
index 65766334a4..51dd444726 100644
--- a/apps/web/e2e/tests/chat/context-window-source.spec.ts
+++ b/apps/web/e2e/tests/chat/context-window-source.spec.ts
@@ -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,
diff --git a/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts b/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts
index 593f2ea634..3fa638d766 100644
--- a/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts
@@ -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,
diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json
index a5908257e1..3f691253fa 100644
--- a/apps/web/src/locales/en/task.json
+++ b/apps/web/src/locales/en/task.json
@@ -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",
diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json
index 574d2031c0..6739975121 100644
--- a/apps/web/src/locales/pseudo/task.json
+++ b/apps/web/src/locales/pseudo/task.json
@@ -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": "Ćōńţŕōĺ Ď",
diff --git a/docs/plans/context-window-unmeasured-state/plan.md b/docs/plans/context-window-unmeasured-state/plan.md
new file mode 100644
index 0000000000..ab76eaaf08
--- /dev/null
+++ b/docs/plans/context-window-unmeasured-state/plan.md
@@ -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.
+
diff --git a/docs/plans/context-window-unmeasured-state/task-01-render-unmeasured-context-state.md b/docs/plans/context-window-unmeasured-state/task-01-render-unmeasured-context-state.md
new file mode 100644
index 0000000000..dc8f3f31b4
--- /dev/null
+++ b/docs/plans/context-window-unmeasured-state/task-01-render-unmeasured-context-state.md
@@ -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.
+
diff --git a/docs/plans/context-window-unmeasured-state/task-02-context-window-unmeasured-e2e.md b/docs/plans/context-window-unmeasured-state/task-02-context-window-unmeasured-e2e.md
new file mode 100644
index 0000000000..38c6f5a91e
--- /dev/null
+++ b/docs/plans/context-window-unmeasured-state/task-02-context-window-unmeasured-e2e.md
@@ -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.
+
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index ec4daa3296..c28c9aa96c 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -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 |
diff --git a/docs/specs/ui/context-window-unmeasured-state.md b/docs/specs/ui/context-window-unmeasured-state.md
new file mode 100644
index 0000000000..ff241d7d8a
--- /dev/null
+++ b/docs/specs/ui/context-window-unmeasured-state.md
@@ -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 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.
+