Files
PaperClipAI/ui/src/components/task-chat/TaskChatMarker.tsx
T
DottaandClaude Opus 5.5 bf10bc695a feat(task): show the agent's context window in task properties (#19273)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run through a harness (Codex app-server, or Claude through
ACPX), and the runner turns harness events into Paperclip events
> - Both harnesses report how full the model's context window is. Codex
sends `modelContextWindow` with its token usage. Claude sends `used` and
`size` in ACP `usage_update`
> - Paperclip dropped both values. The task view could not show how
close an agent is to compaction, and a compaction left no visible mark
in the task chat
> - This pull request carries the context-window occupancy through the
runner, stores it with the existing usage events, and shows it as a
"Context" row in the task Properties panel
> - It also shows a "Context compacted · 230k → 38k tokens" divider in
the task chat when the agent compacts its context
> - The benefit is that operators can see when a long task is near its
context limit and when the agent lost detail to a compaction

## Linked Issues or Issue Description

Refs #19252 (same pattern: a canonical harness event turned into
task-chat UI). Refs #19267 (status notices; it turns Claude's compaction
banner into a notice, and this PR reads that notice too).

**What existing behavior does this improve?**
The task view shows no context-window information, and a compaction is
invisible in the task chat.

**Subsystem affected**
Runner usage normalization (Rust and TypeScript transport), the PRP
usage schema, a read-only issue API, the task Properties panel and the
task-chat transcript.

**Current behavior**
Codex `modelContextWindow` and ACP `used`/`size` are dropped in the
runner. `context.compacted` always has null sizes, Claude compactions
emit no `context.compacted` event at all, and the live runner turn hides
every marker except "interrupted".

**Proposed behavior**
`usage.reported` carries an optional `context {usedTokens,
windowTokens}`. The task Properties panel shows a Context row ("10% of
258k") with a hover breakdown (tokens used, input and cached input,
output, cost, compactions). The transcript shows a compaction divider,
with sizes from the usage before and after the compaction.

**Reason and benefit**
Operators can see when a long task is close to its context limit, and
they can see where the agent's earlier context was summarized.

**Breaking changes**
None. The new schema field is optional, and the new endpoint is
read-only.

## What Changed

- PRP `usage.schema.json`: add an optional `context {usedTokens,
windowTokens}` object. Regenerate the schema bundle and the protocol
manifest.
- Rust `provider_events.rs`: add `codex_context_usage` (last request
`totalTokens` and `modelContextWindow`) and `acpx_context_usage` (ACP
`used`/`size`). ACPX `usage_update` now sets `context`.
- Rust `provider_backend.rs`: Codex `thread/tokenUsage/updated` sets
`context` on live (non-historical) usage.
- Rust `provider_events.rs`: emit `context.compacted` for the completed
`context_compaction` provider notice that #19267 produces from Claude's
compaction banner. Only this path marks a Claude compaction, so the
server counts each compaction once.
- `runnerd-codex-transport.ts`: `rehydrateRunnerdUsageNotification`
keeps a valid `context` on `tokenUsage`, so it reaches the stored
`item.completed` usage item.
- Server: new `GET /api/issues/:id/context-usage` (issue read access is
required) and `issueContextUsage` service. It reads only the assignee's
current session (the session of its newest usage report), and returns
that session's latest context reading, non-zero totals, cost, and the
count of compactions across all of the session's runs. Added to OpenAPI.
Shared type `IssueContextUsage`.
- DB: migration 0432 adds two partial indexes on `heartbeat_run_events`,
built concurrently. One indexes usage items by run and time. The other
indexes `context.compacted` events by company and session. `EXPLAIN`
shows that both lookups use them.
- UI: new `ContextUsageMeter` (bar, percentage, hover card) and
`IssueContextUsageRow` in the Execution section of Properties. The row
refreshes every 8 s while a run is active, and once more when the run
ends.
- UI transcript: `native-run-events.ts` maps `context.compacted` to a
`context` activity entry with pre/post sizes from neighbouring usage
items. `transcript-adapter.ts` turns it into a new `context_compacted`
marker variant. The live runner turn keeps that marker as its own
timeline row.
- Storybook: `task-context-window.stories.tsx` (Properties panel in a
task, all states, hover breakdowns, divider).

## Verification

- `cargo test -p paperclip-runner-core --lib provider_events` (new
tests: ACPX context, Codex context, banner compaction, notice
compaction)
- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/live/runnerd-codex-transport.test.ts`
- `cd ui && npx vitest run src/components/ContextUsageMeter.test.tsx
src/components/transcript src/components/task-chat
src/components/issue-properties`
- `cd server && npx vitest run
src/__tests__/issue-context-usage.test.ts` (embedded Postgres)
- `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`,
`pnpm --filter @paperclipai/paperclip-runner run check:protocol-types`
- Live check on a local worktree instance through the runnerd path:
- A Codex `gpt-5.5` task showed "10% of 258k" (27k of 258k, input 77k
with 49k cached, output 1.1k).
  - A Claude Sonnet 4.6 task showed "17% of 200k" (34k of 200k).
- Storybook: Tasks and conversations / … / Task chat / Context window.
- Not verified live: a real compaction. The divider is covered by unit
tests through the same projector path the task chat uses.

## Risks

- The schema field is optional, and older runners omit it. The UI
renders nothing until a harness reports a window.
- Migration 0432 builds two partial indexes concurrently on
`heartbeat_run_events`, which is a large table. The indexes cover only
usage items and compaction events, so they stay small. They add a little
write overhead to those events.
- Claude in-progress usage rows still record zero tokens with `requests:
1`. The final turn usage is correct. This PR does not change accounting.
- A "Compact now" action is out of scope. It needs a compaction-only run
that does not produce a run result. It will come in a follow-up.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, in-app browser for Storybook and
the live instance).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:54:27 -06:00

191 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useId, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, CircleDot, FoldVertical, OctagonX, Square, Flag, KeyRound } from "lucide-react";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import type { TaskChatMarkerItem } from "./task-chat-model";
import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
import { timeAgo } from "@/lib/timeAgo";
const VARIANT_ICON = {
session_start: CircleDot,
interrupted: OctagonX,
turn_boundary: Flag,
context_compacted: FoldVertical,
} as const;
/**
* Lifecycle divider — renders a state boundary (session start, interruption,
* turn boundary) as a recessed rule. Interruptions use a compact row without
* divider rules, including older items that did not set `collapsible`.
*/
export function TaskChatMarker({
item,
onTryAgain,
tryAgainPending = false,
}: {
item: TaskChatMarkerItem;
onTryAgain?: () => Promise<void> | void;
tryAgainPending?: boolean;
}) {
const [open, setOpen] = useState(false);
const detailsId = useId();
const streamlined = useStreamlinedTaskChatPresentation();
const Icon = item.tone === "neutral" ? Square : VARIANT_ICON[item.variant];
const interrupted = item.variant === "interrupted" && item.tone !== "neutral";
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
const handleTryAgain = () => {
void Promise.resolve()
.then(() => onTryAgain?.())
// The parent mutation owns visible error feedback.
.catch(() => undefined);
};
if (item.credentialAccess) {
const { agentName, credentialName, deniedUser, settingsHref } = item.credentialAccess;
return (
<div role="alert" data-testid="task-chat-credential-access-notice"
className="tc-enter-marker flex w-full items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm">
<KeyRound className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p className="font-semibold text-destructive">{agentName} couldn’t start</p>
<p className="break-words font-medium text-foreground">
{agentName} is configured to use {credentialName || "an AI credential"}, but {deniedUser === "you" ? "you don’t" : `${deniedUser} doesn’t`} have access to that credential.
</p>
<p className="text-muted-foreground">
Choose an AI connection {deniedUser === "you" ? "you can" : `${deniedUser} can`} use, or ask an administrator to update {agentName}’s connection. Then try again.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm"><Link to={settingsHref}>Change AI connection</Link></Button>
{item.runHref && <Button asChild variant="ghost" size="sm"><Link to={item.runHref}>View run</Link></Button>}
</div>
</div>
</div>
);
}
if (item.collapsible) {
return (
<div
className="tc-enter-marker flex flex-col items-start py-1 text-xs text-muted-foreground"
data-testid="task-chat-collapsible-marker"
>
<div className="flex max-w-(--pct-85) items-center gap-1.5">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5",
interrupted ? "text-destructive" : "text-muted-foreground",
"hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate font-medium">{item.label}</span>
{relative ? (
<span className="shrink-0 text-muted-foreground">
· {relative}
</span>
) : null}
<ChevronDown
className={cn(
"tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform",
open && "rotate-180",
)}
aria-hidden
/>
</button>
{onTryAgain && !open ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={handleTryAgain}
disabled={tryAgainPending}
data-testid="task-chat-run-failed-try-again"
>
{tryAgainPending ? "Trying again..." : "Try again"}
</Button>
) : null}
</div>
{open ? (
<div
id={detailsId}
data-testid="task-chat-collapsible-marker-details"
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
>
{item.detail ? (
<div className="px-3 py-2.5 text-foreground/90">
{item.detail}
</div>
) : null}
{item.runHref || item.planHref || onTryAgain ? (
<div className="flex items-center justify-end gap-2 border-t border-border/70 bg-background/50 px-3 py-2 dark:bg-background/30">
{item.planHref ? (
<Button asChild variant="ghost" size="xs">
<Link to={item.planHref}>View saved plan</Link>
</Button>
) : null}
{item.runHref ? (
<Button asChild variant="ghost" size="xs">
<Link to={item.runHref}>View run</Link>
</Button>
) : null}
{onTryAgain ? (
<Button
type="button"
size="xs"
onClick={handleTryAgain}
disabled={tryAgainPending}
data-testid="task-chat-run-failed-try-again"
>
{tryAgainPending ? "Trying again..." : "Try again"}
</Button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
);
}
return (
<div
className="tc-enter-marker flex items-center gap-2 py-1 text-xs text-muted-foreground"
role={streamlined && item.variant !== "interrupted" ? "separator" : undefined}
aria-label={streamlined && item.variant !== "interrupted" ? item.label : undefined}
>
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
<span
className={cn(
"flex min-w-0 flex-wrap items-center gap-1.5",
interrupted && "text-destructive",
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="font-medium">{item.label}</span>
{item.detail ? (
<span className="text-muted-foreground">· {item.detail}</span>
) : null}
{onTryAgain ? (
<Button
type="button"
variant="outline"
size="xs"
className="text-foreground"
onClick={handleTryAgain}
disabled={tryAgainPending}
data-testid="task-chat-run-failed-try-again"
>
{tryAgainPending ? "Trying again..." : "Try again"}
</Button>
) : null}
</span>
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
</div>
);
}