feat(web): show unmeasured state for zero-usage context window (#2392)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+106
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user