Skip to content

feat(desktop): show context usage beside the model controls - #4576

Merged
M4n5ter merged 1 commit into
apache:mainfrom
Joob1n:feat/context-usage-indicator
Sep 3, 2026
Merged

feat(desktop): show context usage beside the model controls#4576
M4n5ter merged 1 commit into
apache:mainfrom
Joob1n:feat/context-usage-indicator

Conversation

@Joob1n

@Joob1n Joob1n commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Follows #4653, which is merged; this branch is a single commit on main.

A read-only context-usage indicator in the composer's model controls, to the right of the thinking-level control. It shows the latest request as the provider counted it — input plus output tokens of the last accepted request, read from the session's newest token_usage record (lastRequestAnchor, the shape #4653 introduces) — and never a local estimate.

  • With a window from either source — the user's declaration first, otherwise the window the model reports — a percentage, with used / window on hover. Over 100% is shown as such, not clamped: a declared window is a target, not a limit. The distinction between the two sources matters for the compaction threshold, which only a declaration arms; it does not matter for reading a number off the screen, and the reported window is already what the rest of the app displays.
  • With no window at all: the absolute token count, and a tooltip saying that none is declared and none is reported.
  • Without usage: a dash, with a tooltip saying the provider reported none.

buildChatModelChoices now carries the reported window (contextWindow) and the declared window (declaredContextWindow) as separate fields, resolved by core's single owner of the declaration rule, so the two are never confused downstream.

The usage it shows is route-validated at the owner. token_usage anchors now record the model and connection that produced them, and selectLatestRequestUsage refuses anything it cannot pair with the request the user is about to make: a loaded range that is not the session tail, a usage row with no anchor (which is what manual /compact writes, and which the runtime's own reader also scans past), an anchor from another route, and an anchor written before anchors carried their route. The composer receives one number, not the transcript slice. Adding two keys to a record decoded against a closed allowlist moves the compatibility epoch to 107.

Refs #4559

Verification

npm --workspace @maka/{core,ui} run build, tsc --noEmit for core and ui, npm --workspace @maka/desktop run typecheck, npm run check:renderer-architecture -- --base origin/main, npm run check:app-shell-hooks, npm run astryx:surface-inventory, npm run lint, npm run format:check — clean. llm-connections.test.js 13/13. Not run locally: the desktop main suite (chat-composer-region-draft-handoff.test.ts gains the new required messages prop) and Desktop e2e — relying on CI.

Review focus

  • app-shell.tsx carries two small unrelated rewrites (newChatProviderType, the activeSessionForView annotation) that pay for the new messages prop under the renderer ratchet: the file's import declarations and specifiers go down by one each, so the debt counts do not increase. Say if you would rather see the ratchet entry adjusted instead.
  • chat-composer-region.tsx scans the message list backwards on each render to find the newest token_usage. It is a legacy-ratchet file, so a useMemo would count as a new hook call; the scan stops at the first match, which is normally the last message.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@Joob1n

Joob1n commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

The test failure here is the Desktop e2e race tracked in #4573 (transcript-scroll:316 and quote-selection:22, both element(s) not found after a send); nothing in this PR touches those paths, and the same two tests are failing on unrelated PRs today. Will rebase once #4577 lands.

@Joob1n
Joob1n force-pushed the feat/context-usage-indicator branch 2 times, most recently from bb6730c to fd09f45 Compare September 3, 2026 05:07
@Joob1n
Joob1n force-pushed the feat/context-usage-indicator branch 6 times, most recently from 4768bc1 to 5287d9e Compare September 3, 2026 08:59
@Joob1n
Joob1n force-pushed the feat/context-usage-indicator branch from 5287d9e to e091fce Compare September 3, 2026 13:29
@Joob1n
Joob1n marked this pull request as ready for review September 3, 2026 13:29
@github-actions github-actions Bot added effort/M Under 500 readable lines and removed effort/XL Over 1000 readable lines labels Sep 3, 2026
@Joob1n
Joob1n force-pushed the feat/context-usage-indicator branch from e091fce to 3452f8b Compare September 3, 2026 14:02
@Joob1n

Joob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Changed after looking at it running: the indicator now shows a percentage whenever a window is available from either source — the user's declaration first, otherwise the window the model reports — with used / window on hover. Only when neither exists does it fall back to the absolute count.

The earlier behaviour reserved the percentage for a declared window, which left the common case (nothing declared) showing a bare token count that means little on its own. The declaration-only rule belongs to the compaction threshold, which a reported window must never arm; it does not belong to a read-only display, and resolveSelectedModelContextWindow already backs the rest of the app's context display.

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

Blocking: the indicator is reading request state from the rendered transcript slice, not from the session tail

ChatComposerRegion finds the last token_usage in messages and combines its anchor with the currently selected model's context window. Those values are not guaranteed to describe the same request:

  • Manual /compact appends an anchorless synthetic token_usage row. The runtime selector deliberately skips that row and keeps the last real request anchor, but this selector stops on it and shows .
  • Switching the model or connection immediately changes activeModelChoice, while the transcript still contains the previous route's anchor. The UI can therefore divide tokens produced by model A's tokenizer by model B's window (for example, 100k / 16k = 625%). The runtime already treats these anchors as route-specific and rejects them across (connectionId, model) changes.
  • messages is only the currently loaded transcript range. Restoring or browsing an older range can make this control report historical usage as the session's latest usage.

These are normal supported states, so the displayed number can look precise while being unrelated to the active request. This needs one source-of-truth fix rather than separate UI guards: derive a route-validated latest-request usage projection from the session tail (reusing the invariant enforced by persistedRequestAnchor) and pass that small projection into the composer. If the active route has no valid provider usage, show .

Please add behavior coverage for at least model/connection switching, an anchorless /compact row, and a loaded range with newer transcript data.

Simplification audit: once this is fixed at the owner, the broad messages prop and the duplicate structural LastRequestAnchor shape can be removed from ChatComposerRegion. I did not find another decision-relevant simplification issue in this patch.

Verification: the focused core test passed (13/13), renderer architecture checks passed (71/71), the AppShell hook check passed, and git diff --check passed. GitHub CI was still running when this review was posted.

中文

阻塞问题:这里拿到的是“当前画面里最后一条 usage”,不是“这个 session 最近一次有效请求的 usage”

ChatComposerRegion 先从 messages 里倒着找 token_usage,再拿当前选中模型的窗口去算比例。这两个数据不一定属于同一次请求:

  • 手动 /compact 会追加一条没有 anchor 的合成 token_usage。runtime 现有逻辑会跳过它,继续使用上一次真实请求;这里却会停在这条记录上,直接显示
  • 切换模型或连接后,模型窗口马上变了,但 transcript 里还是旧模型的 anchor。于是界面可能拿 A 模型 tokenizer 算出的 token 数,除以 B 模型的窗口,甚至显示出 625% 这种看似准确、实际没意义的数字。runtime 已经明确把 anchor 限定在同一个 (connectionId, model) 上,这里绕开了这个约束。
  • messages 只是当前加载出来的 transcript 区间。用户翻到历史区间时,这个控件会把历史 usage 当成 session 的最新 usage。

这些都不是罕见边界,而是正常操作路径。不要在组件里分别打补丁;应该由掌握 session 尾部和当前路由的那一层,产出一份已经校验过的“最近请求 usage”,再把这个小对象传给 composer。当前模型/连接没有有效 provider usage 时就显示

测试至少要覆盖:切换模型/连接、遇到没有 anchor 的 /compact usage,以及当前只加载了历史区间但后面还有新消息。

简化审计的结论也指向同一个修复:状态归属放对以后,ChatComposerRegion 不需要接收整个 messages,也不需要自己再定义一份 LastRequestAnchor 结构。除此之外,这个补丁里没有值得单独拦截的复杂度问题。

本地验证:core 定向测试 13/13、renderer architecture 71/71、AppShell hook 检查和 git diff --check 都通过。发评论时 GitHub CI 仍在运行。

@Joob1n
Joob1n force-pushed the feat/context-usage-indicator branch from 3452f8b to c2cbbd4 Compare September 3, 2026 14:46
A read-only indicator in the composer's model controls shows the latest
request as the provider counted it: input plus output tokens of the last
accepted request, read from the session's newest token_usage record. With
a user-declared Maka window it shows the percentage (over 100% is shown as
such, never clamped); without one it shows the absolute count and names the
model's reported window in a tooltip; without usage it shows a dash and
says the provider reported none. Chat model choices carry the reported and
the declared window separately so the two are never confused (apache#4559).

Refs apache#4559

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1n force-pushed the feat/context-usage-indicator branch from c2cbbd4 to c112553 Compare September 3, 2026 14:48
@Joob1n

Joob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

All three failure modes are real, and I agree they are one problem rather than three: the control was reading request state from the rendered slice. Fixed on c1125534d, at the owner, and the fix reaches one level further down than the review asked.

The record now carries the route. The reason the renderer could not do this correctly is that lastRequestAnchor held only counts. The runtime already refuses an anchor across a (connectionId, model) change, but it does so by consulting run headers, which the renderer does not have. So the anchor now records the model and connection that produced it. A token count is a number in one model's tokenizer against one connection; carrying that on the record lets every reader apply the invariant persistedRequestAnchor enforces, instead of reconstructing it. Two keys on a record decoded against a closed allowlist is an incompatibility, so the epoch moves to 107; the guard did not demand it (it watches the protocol file), and the CHANGELOG states the downgrade consequence.

The projection is resolved at the owner. selectLatestRequestUsage runs in AppShell, which knows the transcript range and the active route, and the composer receives a single number. It refuses rather than approximates, in exactly the cases you listed:

  • the loaded range is not the session tail (hasNewer), so a newer request may exist that this range cannot see;
  • the newest usage row has no anchor — what manual /compact writes — so the scan continues past it, matching the runtime's reader instead of blanking the indicator after every manual compaction;
  • the anchor names another model or another connection;
  • the anchor names no route at all, because it was written before this change.

ChatComposerRegion no longer takes messages, and the duplicated LastRequestAnchor shape is gone, as your simplification audit predicted.

Coverage is apps/desktop/src/main/__tests__/latest-request-usage.test.ts, 8 cases: the happy path, the anchorless /compact row, a foreign model, a foreign connection, a routeless legacy anchor, a non-tail loaded range, no active route yet, and a non-positive input count. The core schema test also pins that an anchor carrying its route decodes.

One note on the ratchet, since it shaped the shape: a new renderer module would have counted as closure growth, so the selector lives in chat-composer-region.tsx and AppShell imports it from a module it already imported. The call site cost tokens, so it takes the range and session objects rather than optional-chained fields, and one redundant as DesktopSessionSummary | undefined cast is gone. app-shell.tsx ends below its recorded debt (15,687 against a base of 15,692).

@Joob1n

Joob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

CI is green on c1125534d.

Flagging a scope change for the re-review: this is no longer a renderer-only patch. Fixing the three failure modes at the owner required the record to carry what the owner needs, so token_usage anchors now record the model and connection that produced them, and the compatibility epoch moves to 107. The earlier approval predates that, so this wants a fresh pass under "contains a protocol change" rather than a follow-up glance: 15 files now, across core, runtime, runtime-host and the renderer.

The renderer half is the smaller half. The parts worth the attention are usage-record-schema.ts (two optional keys on a closed allowlist, and what that means for downgrade), the anchor write site in ai-sdk-backend.ts, and selectLatestRequestUsage with its eight cases.

@M4n5ter
M4n5ter merged commit b9748a7 into apache:main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants