diff --git a/apps/web/components/task/chat/token-usage-display.test.tsx b/apps/web/components/task/chat/token-usage-display.test.tsx index b0e4bf156a..7981ef7e47 100644 --- a/apps/web/components/task/chat/token-usage-display.test.tsx +++ b/apps/web/components/task/chat/token-usage-display.test.tsx @@ -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 }) => ( -
+
{children}
), @@ -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(); + + 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(); }); diff --git a/apps/web/components/task/chat/token-usage-display.tsx b/apps/web/components/task/chat/token-usage-display.tsx index 31a7ce8f6e..6a74a7870f 100644 --- a/apps/web/components/task/chat/token-usage-display.tsx +++ b/apps/web/components/task/chat/token-usage-display.tsx @@ -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({
- {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. +