Skip to content

feat(web): categorized context usage display, budget surfacing, and lifecycle inspector [stack 1/2] - #1118

Open
ChrAlpha wants to merge 31 commits into
felinics:mainfrom
ChrAlpha:feat/context-usage-viz
Open

feat(web): categorized context usage display, budget surfacing, and lifecycle inspector [stack 1/2]#1118
ChrAlpha wants to merge 31 commits into
felinics:mainfrom
ChrAlpha:feat/context-usage-viz

Conversation

@ChrAlpha

@ChrAlpha ChrAlpha commented Aug 31, 2026

Copy link
Copy Markdown
Member

Summary

Bottom of a two-PR stack. This PR surfaces the fragment-first context orchestration data (#1085/#1099) in the web UI and makes the lifecycle store it reads from safe for long-horizon sessions; #1157 (Trajectory pane) stacks on it, replaces the Phase C inspector dialog with a DeepSeek-Harness-style trajectory, and adds the per-request traces and stored context texts. Review this PR on its own; nothing here depends on #1157.

  • Phase A — categorized Context Usage panel. Revives the shelved composer context ring + session-info popover as a Claude-Code-style categorized display: the 17 ContextfragKinds + tool-def buckets map into 8 fixed-order categories (context-categories.ts), rendered as a segmented bar + legend (context-usage-breakdown.vue) with an estimate-basis header (~total / window (%)). Provider-reported input stays a separate fact row, and the provider-basis single bar remains the fallback for sessions without lifecycle data. The ring shows for native and ACP sessions, stays off for direct runtimes, and refreshes when a turn finishes (one invalidation owner, set-diffed across panes, plus one follow-up refetch once the lifecycle row has landed). It is keyboard reachable: Enter opens the popover with focus inside, the label speaks the usage percentage, Escape returns focus to the ring.
  • Phase B — coherent denominator and compaction mark. GET /bots/:id/sessions/:sid/status returns the persisted budget_plan (window, output reserve, tool-def cost, estimator) and compaction (enabled, auto_tokens). The mark derives from the persisted plan window only, only for native runtimes, and only when a summarizer resolves through the trigger's candidate chain; the plan is omitted when the pane's model_id resolves to a different provider model than the newest snapshot. The UI uses budget_plan.window as denominator, draws the output reserve band and a single auto-compact threshold tick against the window (the trigger's measured quantity differs by turn path, so the tick states the threshold and makes no claim about which segment reaches it). Compact Now and the /compact slash command follow the same availability.
  • Phase C — Context Lifecycle Inspector. A dialog over /context-lifecycle: newest-first collapsible turns (first expanded, the reader's expansions survive refreshes), status tone, composition bar, selection counts incl. trimmed, drop reasons with token cost, trust breakdown, window/stable-prefix/cache metrics, mutations, per-step drops, a prompt-diff tag per turn (first turn / system / tools / system+tools / history only), page-scoped aggregates that render only observed values, and has_more/legacy notices with a load-up-to-200 action. The inspector chunk loads on first open and its query unmounts with the dialog.

Long-horizon storage boundary

selection_decisions grows with the whole history on every turn, so a 1000-turn session made the list endpoint return 42 MB and /status detoast a ~1 MB JSONB per poll. This PR moves that cost out of every hot path:

  • context_lifecycles.snapshot is now a bounded summary (about 3 KB per row); the per-fragment audit lives in a new selection_decisions column. Every write statement splits the incoming snapshot at the storage boundary (snapshot - 'selection_decisions' / -> 'selection_decisions') and uses COALESCE so a summary-only rewrite can never erase an existing audit. Writes return identity columns only, and reconciliation read-backs no longer project the audit.
  • SelectionTrace gains trimmed and drop_reason_tokens, rolled up when the snapshot is built, so no reader needs the per-fragment audit. Assistant-message metadata copies persist the summary only.
  • Migration 0146 adds the column, then splits and back-fills existing rows per team (it opens and restores the teams policy like 0120; it holds an exclusive lock on teams for its transaction, so run it with the server stopped as the compose stack does).
  • /status reads one summary row, the list projects summaries, the per-run detail endpoint is gone, lifecycle queries never persist to localStorage, and a storage quota error pauses cache persistence for the page instead of retrying on every change.

Measured on a 1000-turn seeded session on the dev stack: /status 20 ms → 7 ms; /context-lifecycle?limit=50 42 MB / 463 ms → 297 KB / 12 ms (Postgres 1,271 shared buffers → 210); inspector open 0.7 to 4.5 s with long tasks → about 100 ms with none.

Trade-offs kept

  • The audit column still grows with history on disk; retention (TTL or last-K runs per session) is a separate decision and is one UPDATE on that column.
  • Runs whose run-table row was never written recover from the summary-only metadata copy and therefore carry no per-fragment audit.
  • Pages beyond 200 turns need cursor pagination, not included here; steps/mutations per row are step-bound, not history-bound.
  • After a manual Compact Now the display reflects the last completed turn until the next turn produces a snapshot.

Since opening

Test plan

  • Unit (Go): fragment rollup and Summary(), handler composition, plan applicability, compaction mark derivation, legacy status fallback, compaction threshold policy.
  • Unit (web): category mapping, view adapters incl. prompt diff, breakdown geometry, inspector turns, turn-end invalidation, cache persistence breaker, session context view.
  • Postgres integration (go test -tags integration): query round-trip with the split, summary-only rewrites keep the audit, 0146 down/up round-trip on a pre-split row incl. the token rollup, migration as a non-superuser database-owner role.
  • Live e2e (Playwright against the dev stack with a real provider turn): popover numbers reconcile with /status exactly, no selection_decisions on the wire, the inspector opens without per-turn detail requests, keyboard path ring → popover → inspector → back, turn end refetches once plus one follow-up.

QA

  1. mise run dev, open a session with at least one assistant turn.
  2. Hover the ring left of the model pill, or focus it and press Enter: categorized bar + legend, ~ totals, provider input row, output-reserve band and free space when the window is known.
  3. Click Context Inspector: per-turn composition, selection and drop reasons, trust, metrics, prompt-diff tags; the dialog stays open while the popover closes; Escape returns focus to the ring.
  4. Sessions without lifecycle data fall back to the single-bar display and an empty inspector.

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.

# Conflicts:
#	apps/web/src/pages/home/components/chat-pane.vue
#	packages/sdk/src/index.ts
…ector

The list now arrives without selection_decisions, so the dialog fetches one
turn on expand and caches it; drop reasons come from that detail while
trimmed fragments are counted apart from dropped ones. Rows carry a
prompt-diff tag against the older turn, the header only prints observed
cache totals, and pages beyond 50 turns say so with a load-older action.
…e compaction UI by runtime

The trigger measures history alone, so both marks start where the
conversation segment does; the blocking level gets its own tick. Plan-derived
bands drop out under a pane model override, Compact Now only shows where the
status reports compaction at all, and provider input stays visible next to
the estimate.
…er-run query to the team

The synchronous backstop only runs on the history path, which no reader can
observe per session, so reporting it would label a level that may never
fire. The legacy by-run lookup now carries the same team predicate as its
run-table sibling.
…den inspector detail loading

The trigger's measured quantity differs by turn path (provider input on the
pipeline path, history estimate otherwise), so the mark is drawn against the
window with no claim about which segment reaches it, and the blocking tick is
gone. Per-turn detail only loads while the dialog is open, resets on close so a
session switch cannot fetch a foreign run, surfaces its failure, keeps the
list on screen while older turns load, treats legacy history as older turns,
and the /compact slash command follows the same availability as Compact Now.
The pane always sends its selected model as model_id, so applicability has to
be decided where the model is resolved: the newest plan only describes the
next turn when it was made for the same provider model.
…pages per session

placeholderData carried the previous session's turns into a freshly opened
inspector long enough to request a foreign run; the list now starts clean and
the page size resets whenever the session changes.
…ummary

Every reader only ever needed the trimmed count and the tokens lost per drop
reason, so the snapshot now carries those as fixed-size fields computed when
the turn is built. Summary() strips the per-fragment audit, the one part that
grows with the conversation, and the assistant-message metadata copies persist
that summary instead of the full audit.
…eads stay bounded

The audit that grows with conversation length moves to selection_decisions,
split at the storage boundary by every write statement; the snapshot column is
now a bounded summary and 0146 splits and rolls up existing rows per team.
Writes return identity columns only, the session status reads one summary row,
and the per-run detail endpoint is gone: nothing in the read path scales with
history any more.
Drop reasons and trimmed counts come from the rolled-up trace, so the lazy
per-turn detail path is gone. The lifecycle query lives inside the dialog
content and expires soon after it closes, requests carry the abort signal,
a finished turn also refreshes an open inspector, and a storage quota error
pauses cache persistence for the page instead of retrying every second.
The per-team loop reads public.teams under its forced policy, which raises
without a bound team; open the policy for the migration and restore it, as
0120 does. Also pins the two audit-preserving rewrite paths and the legacy
status fallback with tests, and recognizes Firefox's quota error code.
…the compaction mark honestly

Read-backs during lifecycle reconciliation no longer project the per-fragment
audit; a dedicated query serves it. The auto-compact mark is only reported
when a summarizer resolves through the trigger's candidate chain, the
threshold helper is exported instead of aliased, the list description no
longer points at the removed per-run endpoint, and the migration notes its
lock and skips trimmed-only rows on re-run.
…spector honest across refreshes

Enter on the ring opens the popover with focus inside while hover still opens
it silently, the label speaks the usage percentage, and closing the inspector
returns focus to the ring. Turn-end invalidation diffs the set of streaming
sessions and refetches once more after the lifecycle row has landed; Compact
gates on whichever token basis the session reports; expanded turns survive a
refetch; the clamped mark stays visible; the inspector chunk loads on first
open; a pane without a session shows its empty state; immediate cache saves
honour the quota breaker; prompt diffs distinguish system-and-tools changes.
…-review

# Conflicts:
#	apps/web/src/pages/home/components/chat-pane.vue
…-review

# Conflicts:
#	apps/web/src/store/chat-list.ts
#	packages/sdk/src/index.ts
@ChrAlpha ChrAlpha changed the title feat(web): categorized context usage display, budget surfacing, and lifecycle inspector feat(web): categorized context usage display, budget surfacing, and lifecycle inspector [stack 1/2] Sep 5, 2026
@ChrAlpha
ChrAlpha marked this pull request as ready for review September 5, 2026 06:53
@ChrAlpha
ChrAlpha requested review from a team as code owners September 5, 2026 06:53
…window

The plan applicability check compared provider model names only, so a
pane override to a model of the same name on another provider kept the
previous provider's plan and the UI budgeted a 32K model against a 128K
window. A plan never exceeds the window of the model it was made for, so
a plan wider than the resolved model's window is treated as another
model's and dropped.
ChrAlpha added a commit to ChrAlpha/Memoh that referenced this pull request Sep 5, 2026
Picks up the budget plan window check (felinics#1118 review) so the stacked base stays current.
ChrAlpha added a commit to ChrAlpha/Memoh that referenced this pull request Sep 5, 2026
The per-run audit and the injected texts were fetched under new query
keys that the cache persistence did not exclude, so workspace files and
hook output landed on disk and the audit rejoined the synchronous
whole-cache serialization that felinics#1118 had moved it out of. The three
keys are excluded like the lifecycle page.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant