Skip to content

feat(desktop,hub): agent pointing — ref-chips + ui_highlight (D6) - #490

Merged
physercoe merged 3 commits into
j8-d5-remote-desktop-uifrom
j8-d6-agent-pointing
Jul 31, 2026
Merged

feat(desktop,hub): agent pointing — ref-chips + ui_highlight (D6)#490
physercoe merged 3 commits into
j8-d5-remote-desktop-uifrom
j8-d6-agent-pointing

Conversation

@physercoe

Copy link
Copy Markdown
Owner

D6 of docs/plans/desktop-ui-context-and-pointing.md (§3.4b) — the last wedge; the plan is code-complete. Stacked on #489 (D5)#488 (D4)#487 (D3).

The missing half of the symmetry (ADR-062 D-5): the user has been able to point at the agent since D2; now the agent can point back. Both primitives are non-actuating — nothing focuses, scrolls, clicks or types, and the user's click stays the only actuator.

The thing the plan left implicit

A UIRef an agent can type. The ADR defines the JSON shape, but a reply is prose — so the wire form is the URI spelling of the same fields, ui://replay?dataset_id=ds_1&cursor=1234, on the scheme already minted for ui://focus. One grammar (state/uiRef.ts), two spellings: ui_highlight accepts either, so a transcript chip and a tool call point at the same thing by construction.

Ref-chips

linkifyUiRefs rewrites bare tokens into markdown links so the transcript's existing link renderer paints them. A string pre-pass rather than a markdown plugin, so it is provable without a renderer — including the one thing it must never do: touch code. An agent explaining a URI inside a fence is showing it, not pointing with it; fenced blocks and inline spans pass through untouched.

Focus dispatch is honest about its depth. Switching surface always works; entity focus works where a store already exposes a setter (Replay's dataset/episode, an Inspect tab already open on that path) and stops at the surface where none does. A ref this build cannot focus still renders — a reference is worth reading — just not as a control. It runs only from a click handler; there is deliberately no path from an agent message to it.

ui_highlight raises no card — and the hub had to be told

D-5 puts its consent in the sharing toggle plus the policy bit, so the hub class list gained a third class, annotate, between read and action: routed immediately, but named distinctly so nobody later reads "routes without a card" as "is a read".

Its safety lives where the plan's risk section put it:

which surfaces the highlight column of ui_policy.ts
how often 6 per agent per minute
how long our TTL (8s default, 30s max), never the caller's
whose voice always attributed — an unattributed glow is the fake-UI failure mode
what's recorded audited like an action, every call

The overlay sits below the modal tier: an agent must not be able to cover the Attention dock or a consent dialog with its own marker.

Refusals deliberately do not consume rate budget — the limit exists to stop a loop, not to punish an agent that mistyped a ref.

Verification

  • 308 electron node --test pass (D6 adds 9), 386 frontend state/ssh pass (D6 adds 6), go test ./... green (D6 adds 2)
  • tsc --noEmit clean both sides · all 12 repo lints clean · lint-desktop-tokens.sh at baseline 65 with the new .agent-highlight* / .md-uiref rules present (no phantom classes)
  • Not run: anything needing a screen — the overlay, the chip click, the marker's expiry

Stack complete: D3 (#487) → D4 (#488) → D5 (#489) → D6.

🤖 Generated with Claude Code

The missing half of the symmetry (ADR-062 D-5): the user has been able
to point at the agent since D2; now the agent can point back. Two
primitives, both NON-ACTUATING — nothing here focuses, scrolls, clicks
or types, and the user's click stays the only actuator.

The wedge needed one thing the plan left implicit: a written form for a
UIRef an AGENT CAN TYPE. The ADR defines the JSON shape, but a reply is
prose, so the wire form is the URI spelling of the same fields —
ui://replay?dataset_id=ds_1&cursor=1234 — on the scheme already minted
for ui://focus. One grammar (state/uiRef.ts), two spellings:
ui_highlight accepts either, so a transcript chip and a tool call point
at the same thing by construction.

- REF-CHIPS. linkifyUiRefs rewrites bare tokens into markdown links so
  the transcript's existing link renderer paints them as chips. A string
  pre-pass rather than a markdown plugin, because it must be provable
  without a renderer — including the one thing it must never do, which
  is touch code: an agent explaining a URI inside a fence is showing it,
  not pointing with it. Fenced blocks and inline spans pass through.
- FOCUS DISPATCH, honest about its depth. Switching surface always
  works; entity focus works where a store already exposes a setter
  (Replay's dataset/episode, an Inspect tab already open on that path)
  and stops at the surface where none does. A ref this build cannot
  focus still renders — a reference is worth reading — just not as a
  control. It runs ONLY from a click handler; there is deliberately no
  path from an agent message to it.
- ui_highlight RAISES NO CARD, and the hub had to be told. D-5 puts its
  consent in the sharing toggle plus the policy bit, so the hub class
  list gained a third class (`annotate`) between read and action:
  routed immediately, but named distinctly so nobody later reads "routes
  without a card" as "is a read". Its safety lives where the plan's risk
  section put it — the `highlight` column, a per-agent rate limit
  (6/minute), a TTL that is ours not the caller's, always-present
  attribution, and an audit entry per call.
- The overlay sits BELOW the modal tier. An agent must not be able to
  cover the Attention dock or a consent dialog with its own marker.

Refusals do not consume rate budget: the limit exists to stop a loop,
not to punish an agent that mistyped a ref.

Tests: 9 electron (policy bit, attribution, TTL clamp, note clip, rate
window, refusal ordering) + 6 frontend (grammar round-trip, both
spellings, the code-fence rule) + 2 Go (highlight routes cardless; the
class lists are exhaustive and disjoint). 308 electron pass, 386
frontend pass, go test ./... green, 12 lints clean. Everything needing a
screen is unexercised here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@physercoe
physercoe force-pushed the j8-d5-remote-desktop-ui branch from 92dabaa to b9802e3 Compare July 31, 2026 03:22
@physercoe
physercoe force-pushed the j8-d6-agent-pointing branch from 6158024 to a84a948 Compare July 31, 2026 03:22
@agentfleets

Copy link
Copy Markdown
Collaborator

Review of D6 (verified against the rebased head a84a9483; both new suites pass locally: electron uihighlight 9/9, frontend uiRef 6/6). The decision core, hub class routing, rate limiting, and TTL enforcement are solid. But the headline feature is miswired, and there's an agent-triggerable crash:

BLOCKER — ref-chips never fire on agent replies

desktop/src/ui/EventCard.tsx:410 — the feed kind for agent prose is text:

case 'text': {
  const body = str(p, 'text') ?? '';
  if (!isFoldable(body)) return <Markdown text={body} />;   // no uiRefs
  return (<ClampText><Markdown text={body} /></ClampText>); // no uiRefs
}

uiRefs is passed only to InputTextBody (input.text — the user's own input echo, line 373) and the default: fallback for unknown kinds (line 531). So an agent writing ui://replay?dataset_id=ds_1 in a normal reply gets no chip — the plan's core sentence ("an agent-emitted UIRef in a reply renders in the transcript as a clickable chip") is unmet on its primary path. One-prop fix on both Markdown instances in case 'text'. A component test asserting a text-kind agent message renders chips would have caught this.

MAJOR — parseUiRefUri crashes on malformed percent escapes in agent prose

desktop/src/state/uiRef.ts:62-63decodeURIComponent is unguarded and the token regex admits a lone % in the query. Verified live against this head:

linkifyUiRefs('see ui://read?file=100% done') → URIError: URI malformed
linkifyUiRefs('x ui://read?a=%zz y')          → URIError: URI malformed

The throw happens during Markdown render, so one agent message containing ui://x?a=% crashes the transcript surface — and since the message persists in the feed, the surface re-crashes on every re-mount (persistent transcript DoS from agent-controlled text). Reachable via linkifyUiRefs on any uiRefs-enabled card, and independently via the unconditional ui:// branch in the link renderer (Markdown.tsx:259) for a hand-authored [x](ui://read?a=%) link in any markdown surface. Also violates the file's own contract ("a token we cannot parse stays prose"). Fix: try/catch both decodes (or a safeDecode helper), treat failures as junk params.

MAJOR — the highlight is a corner toast, not the "glow over a surface" §3.4b specifies

§3.4b promises an ephemeral, attributed glow over a highlight: allow surface — or over an AX element of a webtab guest (the D4 machinery in reverse). The shipped overlay is a fixed bottom-right card stack that only names the surface; refs carrying positional payloads (D4's {guest, rect}) are parsed into params and never used for placement. A highlight over "the failing pane" looks identical to one over the whole app. If this is a deliberate v1 descope, the "D6 as shipped" amendment should record it — it currently claims the overlay renderer without noting the positional half is absent.

Minor

  • "Never covers the Attention dock" is false — the modal tier is protected (--z-overlay: 40 < --z-menu: 200), but the attention dock is an in-flow right-side region (z-auto), so the fixed bottom-right layer (pointer-events: auto per card) can paint over and click-intercept the bottom strip of the dock — where desktop_action approval cards render — for up to 30s. Trivial fixes: offset the layer by dock width when open, or raise the dock region above --z-overlay.
  • Audit ring stores the raw, unclipped note — the entry is built from raw args before runTool slices to 140 chars (contrast: browser_eval expressions are clipped at 200 in redactBridgeArgs). Same for arbitrary-depth ref objects.
  • Local loop attribution — the static mcp.json path (kimi web/CLI, the plan's first-priority channel) sends no x-tp-agent-id, so every local caller renders "an agent points here" and shares one rate bucket. Worth at least a doc note.
  • Nits: highlight.points string-replace with agent-influenced by (use a function replacement); agentHighlight.ts clear() is dead code; asHighlightOrder trusts IPC ttl_ms/note (main enforces both — defense-in-depth gap); via populated but never consumed; focusUiRef's 'surface' tier gives no user feedback.

Test gaps

No test for uiRefFocus.ts — plan §5 explicitly lists "ref-chip parse + dispatch"; no regression test for the parser crash; agentHighlight.ts narrowing/MAX_LIVE untested; no e2e for chip-click→focus or overlay render/expiry (plan is candid about the owed Playwright pass).

Ubuntu and others added 2 commits July 31, 2026 04:24
…ger crashes the transcript

Review fixes for #490 (D6):

- BLOCKER: uiRefs now rides both Markdown instances of the 'text' feed
  kind — agent prose is the primary carrier of ref-chips, and it was the
  one kind that never got them (only the user's input echo and the
  unknown-kind fallback did).
- MAJOR: parseUiRefUri guarded decodeURIComponent (safeDecode) — a lone
  % or %zz in agent prose ('100% done') threw during Markdown render and
  crashed the transcript surface on every re-mount (persistent,
  agent-controlled). A pair that fails to decode is junk; the ref
  survives. Regression tests pin both linkify and the direct link path.
- MAJOR (descope recorded): the corner marker vs §3.4b's positional glow
  is now stated as a deliberate v1 descope in the plan amendment, with
  the positional half owed to a follow-up wedge — plus the local-loop
  coarse-attribution caveat.
- 'Never covers the Attention dock' is now structurally true: the dock
  region stacks one step above the fixed highlight layer (still below
  menus/modals), so a marker card can't paint over or click-intercept
  the dock's bottom strip where desktop_action cards render.
- Audit ring: ui_highlight's note is clipped and its ref
  stringified+capped in redactBridgeArgs — no raw agent-authored
  structures retained for 50 entries.
- Hardening/nits: renderer re-clamps ttl/note at the IPC boundary
  (asHighlightOrder); sharing-off clears live markers (clear() is no
  longer dead); '{agent}' substitution uses a function replacement so
  $& in a handle renders literally; dead 'via' dropped from
  UiHighlightRequest (the ring records the leg); a chip click that stops
  at the surface tier reports it in a toast (en+zh).
- Tests: uiRefFocus.test.ts (the plan §5 'ref-chip parse + dispatch'
  gap; stores proven pure — .ts-extension imports fixed in
  uiRefFocus/inspect), parser-crash regressions. EventCard's prop
  pass-through and the overlay render remain owed to the Playwright
  pass, as the plan records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@physercoe

Copy link
Copy Markdown
Owner Author

Second review pass (agent): blocker + crash confirmed live and fixed on the branch — commit 2c62cc1c (merges the D5 fix underneath).

BLOCKER (fixed): uiRefs now rides both Markdown instances in the text case — agent prose gets chips. (A component test would need a DOM; recorded as owed to the Playwright pass along with the overlay render.)

MAJOR — parser crash (fixed): safeDecode guards both decodeURIComponent calls; a pair that fails to decode is junk and the ref survives. Regression tests pin linkifyUiRefs('…100% done'), %zz, and the direct parseUiRefUri path (which also covers the Markdown.tsx link-renderer branch).

MAJOR — positional glow: recorded in the plan's D6-as-shipped amendment as a deliberate v1 descope — the shipped marker names the surface + attribution + user-actuated "Go there"; positional payloads parse but don't place; the glow-over-region/AX-element half is owed to a follow-up wedge. Also recorded: the local-loop coarse-attribution caveat (static mcp.json sends no x-tp-agent-id).

Minors, all fixed:

  • Dock cover: .region.dock now stacks one step above the highlight layer (still below menus/modals) — "never covers the Attention dock" is structurally true, including its bottom strip where desktop_action cards render.
  • Audit ring: ui_highlight's note is clipped (140) and its ref stringified+capped (200) in redactBridgeArgs — no raw agent-authored structures retained.
  • Nits: {agent} substitution uses a function replacement ($& in a handle renders literally); asHighlightOrder re-clamps ttl (≤30s) + clips note at the IPC boundary; clear() is no longer dead — turning sharing OFF clears live markers; dead via dropped from UiHighlightRequest (the ring records the leg); a chip click that stops at the surface tier reports it in a toast (en+zh).
  • Test gaps: uiRefFocus.test.ts added (plan §5 "ref-chip parse + dispatch" — 6 tests; the three stores proved pure, .ts-extension imports fixed in uiRefFocus/inspect), parser-crash regressions. agentHighlight narrowing remains node-untestable (store imports the bridge) — its main-side twin decideHighlight is covered.

Verified: electron 322/322, frontend state/ssh 423/423, tsc clean both sides, go suite green, lint-docs/arb/hardcoded-strings clean.

🤖 Generated with Claude Code

@physercoe
physercoe merged commit 42e096c into j8-d5-remote-desktop-ui Jul 31, 2026
8 checks passed
physercoe pushed a commit that referenced this pull request Jul 31, 2026
…490)

The stacked PRs squash-merged into their base branches (the GitHub
stack cascade), so main received only D3 (#487); this merge brings the
cascaded j8-d5-remote-desktop-ui head — element-resolved pointing (D4),
hub-relayed desktop UI context (D5), and agent pointing (D6), each
including its review-fix commit — onto main. Conflicts (main's D3-squash
regions vs the branch's D5/D6-extended versions of the same code)
resolved by taking the branch side, which is a strict superset; the
plan's status block updated to all-shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@physercoe
physercoe deleted the j8-d6-agent-pointing branch July 31, 2026 05:04
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.

2 participants