From d9d7ed6699cba432fff6b161157df1dccc1616cc Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Sun, 3 May 2026 13:51:22 +0900 Subject: [PATCH] feat(web): show message metadata (invoke time, duration, model) on click (#555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): show message metadata (invoke time, duration, model) on click * fix(cli): preserve model field on assistant messages forwarded to hub `RawMessageSchema` validates the `message` object in Claude Code session JSONL lines before the cli forwards each message to the hub. Zod's default parse mode strips fields that the schema does not declare, so the `message.model` value (e.g. `claude-sonnet-4-6`) was silently removed before the message reached the hub. The web normalizer reads `data.message.model` to label assistant blocks, so without this field every assistant message fell back to a generic "AI Model" label — defeating the per-message model attribution this PR adds. Add `model` to `RawMessageSchema` so it survives parse and reaches the hub intact. * fix(web): drop dead model shorthand in result envelope normalize The `result/success` branch in `normalizeAgentRecord` referenced a `model` identifier that was never declared in the function scope, breaking `bun typecheck`. The reducer that consumes the resulting `turn-duration` event does not look at `model` on the event itself, so the shorthand was dead code. Remove it to restore typecheck. * refactor(web): simplify turn-duration matcher with findLastIndex Replace the imperative reverse-scan loops in the `turn-duration` reducer branch with `findLastIndex`. The previous fallback also had an awkward double-loop that mutated the matched block in place; using an index plus a single immutable update keeps the block reference clean and makes the match priority (id-prefix > tool-call id > last assistant-like) explicit. Behaviour is unchanged — existing reducer tests cover both the messageId match and the fallback paths. * fix(web): preserve per-message model across mid-session model switches The metadata footer fell back to `Session.model` from chat context when a message did not carry its own `model`. That session value mutates when the user switches models mid-session, so older messages were relabeled with the latest model — including Codex/local assistant paths (`AGENT_MESSAGE_PAYLOAD_TYPE`) that don't populate `msg.model`. Drop the mutable-context fallback: pass `messageModel ?? null` to `MessageMetadata` and let it omit the model line when no per-message value is available. This is correct behaviour for messages whose producer didn't record a model, and avoids ever attributing a message to a model that didn't generate it. Also remove the now-unused `useHappyChatContext` import in this file. Add reducer invariants to lock in the data flow: - `preserves per-message model across mid-session model switches` - `leaves model undefined when message lacks per-message model` * fix(web): keep tool-block reference identity when applying turn-duration `ensureToolBlock` stores the same `ToolCallBlock` instance in both `toolBlocksById` and `blocks`. The earlier refactor cloned the matched block via `blocks[foundIndex] = { ...b, durationMs }`, which left the map pointing at the stale original. A subsequent permission/result mutation through `ensureToolBlock` would then update the stale map object while the rendered `blocks` entry never sees the completion or result, causing tool cards to miss state transitions. Mutate the matched block in place instead — same in-place pattern the reducer used before — and gate the assignment on the kinds that carry a `durationMs` field so TypeScript narrows correctly. Add an invariant test that fires a `turn-duration` event at a tool-call block and asserts the rendered block and `toolBlocksById.get(...)` remain the same object reference. * fix(web): do not render service_tier as the model id `MessageMetadata` previously fell back to `usage.service_tier` as the "model" when no per-message `model` was available, so messages without their own model id could surface labels like `Model: standard_only` — service_tier is tier metadata, not a model. Render the model line only when a real `model` is present; if a non-`standard` `service_tier` is the only signal, surface it as a separate `Tier: ` label so it is not mistaken for the model. The standard tier is the implicit default and is never rendered alone. Extract the label-building logic into `buildMessageMetadataLabels` so it can be unit-tested without a DOM. Add tests covering: model present, model missing with non-standard tier, default standard tier, model with non-standard tier appended, and the empty-input case. * fix(web): metadata toggle ignores clicks on nested interactive controls The bubble-level click handler that opens the metadata footer wraps interactive descendants — tool-card buttons, retry buttons, dialog triggers (Radix `role="button"`), and the Markdown code-copy button. Clicking any of those flips the metadata footer as a side effect, even when the descendant is the actual target of the user's intent. Extract the closest-ancestor check into a small `metadataToggle` helper and route both `AssistantMessage` and `UserMessage` click paths through it. The toggle bails out when the click target sits inside any `button`, `a`, `input`, `textarea`, `select`, or `[role="button"]` ancestor; plain message-body text still toggles as before. Add unit tests covering: button target, nested span inside a button, `role="button"` Radix-style trigger, anchor/input/textarea/select form controls, plain message-body text (no toggle), and a non-HTMLElement target. * fix(cli): preserve messageId on system/turn_duration record `web/src/chat/normalizeAgent.ts` matches each `turn-duration` event to the assistant block carrying the same `data.messageId`. Claude code emits that field on the `system/turn_duration` record, but `RawJSONLinesSchema`'s system branch did not declare `messageId`, so Zod stripped it before the cli forwarded the record to the hub. The matcher then fell back to "the last visible block", which is wrong for interleaved/tool-heavy turns and silently attaches the duration to the wrong assistant block. Add `messageId: z.string().optional()` to the system schema so the id survives parse and reaches the web reducer. Tests cover the preserved case, the legacy case without `messageId`, and the previously-fixed `message.model` case so Zod strip regressions on adjacent fields stay locked in. * fix(web): metadata toggle accepts SVG event targets `isClickOnNestedControl` only walked up via `closest` when the click target was an `HTMLElement`. The copy / retry / Markdown code-copy buttons render SVG icons, so clicking the icon makes the event target an `SVGElement` (not an `HTMLElement`) — the guard returned false and the bubble-level click flipped the metadata footer anyway. Widen the type check to `Element`, which is the common super-class of both `HTMLElement` and `SVGElement` and also exposes `closest`. Plain text targets and non-Element targets still behave as before. Add a regression test that mounts an icon-only button (``) and asserts both the `` and `` targets walk up to the enclosing button. * refactor(cli): rely on Zod passthrough for jsonl envelopes `RawMessageSchema` and the `system` branch of `RawJSONLinesSchema` were declared with Zod's default `strip` mode, so any field the cli did not explicitly enumerate was silently dropped before the hub forwarded the record. The metadata pipeline lost `message.model` and `system/turn_duration.messageId` exactly that way, and each gap took a separate fix. Switch both schemas to `.passthrough()` so undeclared fields survive parse and reach the web reducer verbatim. Future SDK additions no longer require another schema patch. Add tests asserting that unknown keys on assistant messages and unknown keys on system records (alongside the existing `messageId` case) are preserved end-to-end through the schema. * refactor(web): clean up dead metadata propagation surface Several knobs were added to thread metadata through the chat tree but ended up unused or redundant; consolidate them so the data flow has a single canonical path. - Drop the unreachable `data.type === 'result' && data.subtype === 'success'` branch in `normalizeAgentRecord`. Claude's `result` records are consumed by `claudeRemote` as session-completion signals and never forwarded to the hub; the cli `RawJSONLinesSchema` discriminator does not include `result`, so these records are rejected before they reach `normalizeAgentRecord` either way. - Stop threading `invokedAt` through the inner `normalizeAssistantOutput` / `normalizeUserOutput` / `normalizeAgentRecord` calls. Every caller in `normalizeDecryptedMessage` already overwrites it via the outer spread, so the inner copies were dead writes. Set `invokedAt` only at the outer boundary. - Remove the `model?: string | null` field from `HappyChatContextValue` and the `model` prop on `HappyThread` / `SessionChat`. Its only consumer (`AssistantMessage` mutable-fallback) was removed when the per-message model attribution fix landed; the prop has no readers now. - Match the existing `as Partial | undefined` cast pattern in `AssistantMessage` and `UserMessage` instead of the non-`Partial` cast that pretended every field was present even when `custom` is undefined. - Rename `AgentEvent.turn-duration.messageId` to `targetMessageId` so a reader does not confuse the duration's target with the surrounding envelope id; the wire field on Claude's `system/turn_duration` record stays `messageId` (vendor name) and is mapped at the normalize boundary. No behaviour change. All existing tests pass. * fix(web): turn-duration matcher and cli-output merge precedence Two reducer-level metadata-correctness bugs surfaced during a hostile self-review. 1. Turn-duration matcher silently dropped the duration when `targetMessageId` resolved to a non-duration-bearing block. The existing pipeline did `findLastIndex(b => b.id === targetId || ...)` first; if that hit an `agent-event` or `user-text` block (id-prefix collision), the kind guard at the assignment site failed and the duration was never attached. The fallback search ran only when the first pass returned -1, not when the kind check rejected the match. Fold the kind filter into every search predicate via a typed `isDurationTarget` helper so the priority `target-bearing match > tool-call id > last duration-bearing block` is exhaustive. 2. `mergeCliOutputBlocks` had asymmetric metadata precedence between the command-name block (`prev`) and the stdout follow-up (`block`): `invokedAt` and `model` preferred prev, but `durationMs` and `usage` preferred block. Only the command-name block carries first-class metadata; the stdout follow-up is a synthetic split. Use prev as the primary source uniformly and fall back to block only when prev is missing the field. Tests cover the fallback path on the matcher and both precedence scenarios on the merger. * fix(web): preserve tool-call invokedAt across tool-result update `ensureToolBlock` is called twice for the same tool: first with the seed from the assistant's tool-use block, then with the seed from the matching tool-result message. The second call's `seed.invokedAt` came from the tool-result message and was unconditionally overwriting the tool-call's original invokedAt. The rendered "Invoke" timestamp on a tool card therefore showed when the result was processed, contradicting the column header. Guard the assignment so the timestamp survives the second call — `existing.invokedAt ??= seed.invokedAt` semantics — while still letting the first call set the value when the tool is created. `durationMs`, `usage`, and `model` continue to overwrite because their values come from the result message's usage block and are intentionally newer. Add a regression test that fires a tool-use followed by a tool-result with a later invokedAt and asserts the tool block keeps the original. * fix(web): metadata footer UX, accessibility, and label hardening Bundle the remaining UI surface fixes for the metadata footer. - Make the bubble interactive only when there is metadata to disclose. Without the guard, every non-Claude session bubble (Codex / Cursor / Gemini, none of which populate `model`/`usage`/`durationMs` in the web layer) showed a pointer cursor and reacted to clicks even though `MessageMetadata` rendered nothing — false-positive interactivity. - Add keyboard support: when the bubble is interactive it now exposes `role="button"`, `tabIndex=0`, `aria-expanded`, and an `Enter`/`Space` key handler so screen readers and keyboard-only users can disclose the footer the same way mouse users do. - Fix nullish-vs-falsy bugs in the label builder: a 0 ms turn or a 0 unix-epoch invokedAt no longer hides their lines. Use explicit `!= null` / `>= 0` checks. - Rename the token total to "billable tokens" so the explicit exclusion of cache I/O is signalled in the label rather than implied by the number alone. - Tag the queued/sending status spans with `role="status"` (and an accessible label) so they are announced by AT and so the metadata toggle's `closest('button, ..., [role="status"]')` filter does not accidentally fire when a user clicks a status icon. - Add the native `` element and `[role="status"]` to the toggle's nested-control selector. Tool cards already render their expandable bodies as `
` — clicking the summary now expands the disclosure without also flipping the metadata footer. Tests cover: native `` target, `role="status"` target, the billable label, durationMs=0 surfaced, invokedAt=0 surfaced, invokedAt=null/undefined hidden. * fix(web): expose cli-output metadata via dedicated toggle button CliOutputBlock renders the entire card as a Dialog trigger
+
+ {showMetadata && ( + + )} {copyText && (
+
+ )} + {showMetadata && invokedAt != null && ( + + )}
) @@ -68,29 +103,42 @@ export function HappyUserMessage() { return ( -
-
- {hasText && } - {hasAttachments && } -
- {(hasText || status) && ( -
- {hasText && ( - - )} - {status && } +
+
+
+ {hasText && } + {hasAttachments && }
+ {(hasText || status) && ( +
+ {hasText && ( + + )} + {status && } +
+ )} +
+ {showMetadata && invokedAt != null && ( + )}
diff --git a/web/src/components/AssistantChat/messages/metadataToggle.test.ts b/web/src/components/AssistantChat/messages/metadataToggle.test.ts new file mode 100644 index 00000000..7862fe63 --- /dev/null +++ b/web/src/components/AssistantChat/messages/metadataToggle.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { KeyboardEvent, MouseEvent } from 'react' +import { isNestedInteractiveEvent } from './metadataToggle' + +function makeMouseEvent(target: HTMLElement, currentTarget?: HTMLElement): MouseEvent { + return { target, currentTarget } as unknown as MouseEvent +} + +function makeKeyboardEvent(target: HTMLElement, currentTarget?: HTMLElement): KeyboardEvent { + return { target, currentTarget } as unknown as KeyboardEvent +} + +describe('isNestedInteractiveEvent', () => { + it('returns true when the click target is itself a button', () => { + const button = document.createElement('button') + expect(isNestedInteractiveEvent(makeMouseEvent(button))).toBe(true) + }) + + it('returns true when the click target is nested inside a button (e.g. icon)', () => { + const button = document.createElement('button') + const icon = document.createElement('span') + button.appendChild(icon) + expect(isNestedInteractiveEvent(makeMouseEvent(icon))).toBe(true) + }) + + it('returns true for role="button" elements (Radix triggers, Markdown copy button)', () => { + const div = document.createElement('div') + div.setAttribute('role', 'button') + const inner = document.createElement('span') + div.appendChild(inner) + expect(isNestedInteractiveEvent(makeMouseEvent(inner))).toBe(true) + }) + + it('returns true for anchors and form controls', () => { + const a = document.createElement('a') + const input = document.createElement('input') + const textarea = document.createElement('textarea') + const select = document.createElement('select') + expect(isNestedInteractiveEvent(makeMouseEvent(a))).toBe(true) + expect(isNestedInteractiveEvent(makeMouseEvent(input))).toBe(true) + expect(isNestedInteractiveEvent(makeMouseEvent(textarea))).toBe(true) + expect(isNestedInteractiveEvent(makeMouseEvent(select))).toBe(true) + }) + + it('returns false for plain message body text', () => { + const root = document.createElement('div') + const paragraph = document.createElement('p') + paragraph.textContent = 'Hello' + root.appendChild(paragraph) + expect(isNestedInteractiveEvent(makeMouseEvent(paragraph))).toBe(false) + }) + + it('returns false when target is not an Element', () => { + expect(isNestedInteractiveEvent({ target: null } as unknown as MouseEvent)).toBe(false) + }) + + it('returns true when the click target is an SVG icon inside a button', () => { + // Icon-only controls (copy, retry, code-copy) render an / + // child of the