[OPIK-6425] [FE] feat: Explain (Ollie) on trace cells — bridge, store, button & popover#7132
Conversation
Host-side foundation for the Ollie "Explain" feature (no UI yet). - Bridge contract: 7 explain/chat/console events + ExplainTarget in assistant-sidebar.ts; bump BRIDGE_PROTOCOL_VERSION 1->2; add the three new host events to createHostListeners() and route the four shell->host events into the explain store. Must stay byte-identical with ollie-console. - Plugin-scoped explain store (src/plugins/comet/explain): caches results per cell (kind:entityId) so reopening a popover shows the cached answer or its still-streaming text; an explainId->cell route map multiplexes parallel streams over the single bridge; in-flight cap of 4; capabilities from console:ready; ownership-guarded host->shell emit registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // Max concurrent in-flight explains; cached (done) results don't count. | ||
| const MAX_IN_FLIGHT = 4; | ||
|
|
||
| const keyOf = (t: ExplainTarget) => `${t.kind}:${t.entityId}`; |
There was a problem hiding this comment.
keyOf() drops projectId, so explain state from different projects collides in the same kind:entityId bucket; should we scope the key/route by projectId or clear project-scoped explain state on switch?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/plugins/comet/explain/explainStore.ts around lines 26-26, the
`keyOf(t: ExplainTarget)` function builds `${t.kind}:${t.entityId}` but drops
`projectId`. Refactor `keyOf` (and anything that depends on it, like `entries`,
`routes`, `useExplainEntry`, `explain`, and `retry`) to include `projectId` in the
composite key so cached answers and streaming routes cannot collide across projects.
Additionally, if the UI does not automatically reset explain state on
`context.projectId` changes, add/trigger a store reset/clear when the active project
changes (or otherwise scope the in-flight counting to the current project) so stale
loading entries from a previous project don’t consume the shared `MAX_IN_FLIGHT`
budget.
There was a problem hiding this comment.
Commit 6504577 addressed this comment by scoping keyOf() to projectId (${t.projectId}:${t.kind}:${t.entityId}), which prevents explain-state and stream-route collisions across projects. The dependent entry lookup and retry routing now inherit that project-scoped key as well.
| onDone: ({ explainId }) => | ||
| set((s) => { | ||
| const key = s.routes[explainId]; | ||
| const entry = s.entries[key]; | ||
| if (!entry) return s; | ||
| return { entries: { ...s.entries, [key]: { ...entry, phase: "done" } } }; | ||
| }), |
There was a problem hiding this comment.
routes never drops finished explainIds, so the map grows unbounded as explain keeps creating fresh UUIDs. Should we mirror retry and delete routes[explainId] when the stream completes or errors?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/plugins/comet/explain/explainStore.ts around lines 112-131, the
`onDone` and `onError` handlers update `entries` but never remove `routes[explainId]`.
Refactor these handlers so that when a stream finishes successfully or with an error,
they also delete the corresponding `explainId` entry from `routes` inside the same `set`
call, mirroring the cleanup done in `retry`. Be careful to handle cases where
`entries[key]` is missing (unknown/cleared explain) but still remove `routes[explainId]`
so the `routes` map cannot grow unbounded over time.
There was a problem hiding this comment.
Commit 6504577 addressed this comment by deleting routes[explainId] in both onDone and onError, matching the cleanup already used in retry. It also keeps the route removal even when the cached entry is missing, preventing the routes map from growing unbounded.
- Scope explain cache/route key by projectId (no cross-project collision). - Drop routes[explainId] on done/error so the route map can't grow unbounded. - Extract the emit-registration effect into a named useRegisterExplainEmitter hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- DRY the three stream handlers behind pure patchEntry/removeEntry helpers. - Move shell->host explain event routing into the explain module (handleConsoleEvent) so the sidebar bridge stays generic and owns no explain-specific cases/casts. - Rename keyOf -> cellKey for intent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ist readiness gate Review follow-ups: - Fire EXPLAIN_COMPLETED/ERRORED (+TTFT) from the store once per stream, not the popover — streams outlive popovers in the cache model, so UI-side reporting double-counted on reopen and missed completions while closed. - Hoist pod readiness into the store (`ready`, synced by the sidebar) so each per-row button reads one selector instead of calling useAssistantBackend. - Only offer "Continue" when the kind has a configured question. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ractions - Button: round Ollie-owl IconButton (gradient/shadow per Figma), pinned top-right/centered, grows RIGHT to "Explain" on hover, stays expanded while the popover is open; reveals on cell-content hover. - Popover: Ollie header + divider, green-dot "Thinking..." (pulsing), "Continue conversation ->" underlined link, Ubuntu Mono. - Streaming: fake typing caret (caret-blink animation). - Click no longer opens the trace panel (propagation stopped on the button). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| "group/explain absolute left-[calc(100%-26px)] top-1/2 z-10 flex -translate-y-1/2 items-center rounded-full border border-[#F46E41] p-1 text-white opacity-0 shadow-[0px_4px_3px_rgba(244,110,65,0.1),0px_2px_2px_rgba(244,110,65,0.1)] transition-all", | ||
| // Collapsed icon button (Figma 351:31623): -45deg gradient, p-1. | ||
| "bg-[linear-gradient(-45deg,#F59E0B_0%,#F46E41_100%)]", | ||
| // Expanded pill (Figma 351:31659): gradient rotates to -18deg and | ||
| // right padding grows to pr-1.5. Resting state keeps symmetric p-1 | ||
| // so the icon-only button stays a circle. | ||
| "group-hover/explain:bg-[linear-gradient(-18deg,#F59E0B_0%,#F46E41_100%)] group-hover/explain:pr-1.5", | ||
| // Revealed on cell-content hover... | ||
| "pointer-events-none group-hover/cell:pointer-events-auto group-hover/cell:opacity-100", | ||
| // ...or kept visible + expanded while the popover is open. | ||
| open && | ||
| "pointer-events-auto bg-[linear-gradient(-18deg,#F59E0B_0%,#F46E41_100%)] pr-1.5 opacity-100", |
There was a problem hiding this comment.
The ExplainButton accent classes (border-[#F46E41], gradient backgrounds, and shadow-[...rgba(244,110,65,0.1)]) hard-code colors, so they bypass theme tokens and won't adapt in light/dark or future theme changes — should we swap them for theme-aware values?
Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/plugins/comet/explain/ExplainButton.tsx around lines 58-70 (the
`className` passed to the Explain button in `ExplainButton`), remove the hard-coded
accent hex/RGBA values (`#F46E41`, `#F59E0B`, and the rgba(...) shadows/gradients).
Refactor by replacing them with theme-aware design tokens (e.g., existing Tailwind theme
colors or CSS variables for the accent and accent-shadow), and update the
gradient/shadow utilities to reference those tokens so the styling adapts to light/dark
and other theme changes. After refactoring, verify the popover trigger still uses the
same sizing/behavior while visually matching the previous accent in the default theme.
There was a problem hiding this comment.
Commit 0e6ef91 addressed this comment by replacing the hard-coded hex/RGBA values in ExplainButton with CSS variables (--color-ollie, --color-ollie-amber, --shadow-ollie). It also defines those tokens in main.scss, so the button styling now comes from theme-aware values instead of inline constants.
| <div className="flex items-center gap-1.5 px-1.5 pb-1"> | ||
| {/* The owl's eye-circles are bottom-heavy in the 13x13 viewBox, so | ||
| items-center leaves extra space above it. Lift it ~1px to optically | ||
| center against the label. (Tune against Figma.) */} | ||
| <OllieOwl className="relative -top-px size-4 shrink-0 text-[#F46E41]" /> | ||
| <span className="leading-4 text-foreground">Ollie</span> | ||
| </div> | ||
| <div className="my-1 h-px w-full bg-border" /> | ||
|
|
||
| <div className="px-2 pt-0.5"> | ||
| {/* Live region stays mounted for the popover's lifetime so streamed | ||
| updates are announced — a region inserted only once content arrives |
There was a problem hiding this comment.
The popover header icon and loading dot use hard-coded text-[#F46E41] and bg-[#00D14C], so they stay the same in dark mode instead of following the design system — should we swap them for the :root/.dark CSS variables and utility classes from main.css, as .agents/skills/opik-frontend/ui-components.md requires?
Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/plugins/comet/explain/ExplainPopover.tsx around lines 40-56 (the
header icon <OllieOwl ...> and the loading dot <span className="... bg-[#00D14C]">),
replace the hard-coded hex colors text-[#F46E41] and bg-[#00D14C] with theme-aware
classes/variables from the design system. Update main.css to define matching CSS
variables under :root and .dark (or reuse existing ones if already present), then switch
the component to reference those variables via utility classes (so dark mode
automatically changes). Make sure the visual intent (owl icon orange and
“Thinking...” dot green) remains unchanged aside from theme adaptation.
There was a problem hiding this comment.
Commit 0e6ef91 addressed this comment by replacing the hard-coded hex colors in ExplainPopover.tsx with CSS-variable-based Tailwind classes (text-[var(--color-ollie)] and bg-[var(--color-ollie-live)]). It also adds those variables in main.scss under both :root and .dark, so the icon and loading dot now follow the design system in dark mode.
Polish the Explain pill and popover to match Figma: resting owl circle vs. expanded -18deg gradient pill, shared <Button variant="tableLink"> for Retry/Continue (border-b underline, no default Button padding), 12px markdown-rendered output, and owl optical centering. Add Retry + mid-stream cancel handling, an aria-live region for streamed text, and unit tests for the button, popover, store, useCanExplain and assistant-sidebar types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f5ad504 to
9484640
Compare
| if (inFlight >= MAX_IN_FLIGHT) { | ||
| // At capacity: surface a clear, retryable error rather than leave the | ||
| // popover stuck on "Thinking…" forever. No stream is dispatched. | ||
| const entry = makeEntry(target, { | ||
| phase: "error", |
There was a problem hiding this comment.
The explain store's inFlight >= MAX_IN_FLIGHT guard rejects the 4th concurrent request while MAX_IN_FLIGHT is still 3, so Too many explanations in progress fires one request early — should we bump the cap to 4, or, if 3 is intended according to the PR description, align the spec and tests?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/plugins/comet/explain/explainStore.ts around lines 30 and 197-206
inside the `explain: (target) => boolean` logic, the in-flight throttling guard
currently rejects when `inFlight >= MAX_IN_FLIGHT`, which makes the 4th concurrent
request fail if MAX_IN_FLIGHT is still 3. Update MAX_IN_FLIGHT (and any related PR/test
expectations) to match the intended concurrency spec (either set MAX_IN_FLIGHT to 4, or
if 3 is correct, adjust the PR description and any tests/assertions that assume 4). Also
ensure the AT_CAPACITY_MESSAGE triggers exactly when the cap is exceeded (one request
after the allowed max).
There was a problem hiding this comment.
Working as intended — 3 is the spec (PR description: "In-flight concurrency cap of 3"). inFlight >= MAX_IN_FLIGHT with MAX_IN_FLIGHT = 3 allows exactly 3 concurrent streams (when 3 are already in flight, the next is rejected), so the cap is 3, not 2 — the 4th request is correctly the one that's throttled. The test caps in-flight streams at MAX_IN_FLIGHT (3) asserts this directly: 3 loading entries, the 4th explain() returns false and surfaces the retryable "Too many" message. No change needed.
There was a problem hiding this comment.
Thanks for the clarification — you're right that the current check enforces a cap of exactly 3 concurrent streams. I'll save this to memory once the PR is merged.
…design tokens Replace hard-coded Ollie hex/rgba in ExplainButton/ExplainPopover with design-system CSS variables: reuse the existing --color-ollie (#f46e41) for the orange accent/border/gradient, and add --color-ollie-amber, --color-ollie-live and --shadow-ollie to main.scss (:root + .dark) for the gradient amber stop, the streaming dot, and the brand shadow. No visual change. (baz-reviewer: hard-coded theme colors) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…acon Replace the near-imperceptible ollie-breathe (scale 1.04 / opacity .95) on the 8px 'Thinking…' dot with a beacon-pulse: an outward box-shadow ring in currentColor + opacity fade (1→.7), 1.4s ease-out infinite — matching ollie-assist's status dot. The dot sets text-[--color-ollie-live] so the ring inherits the green, and uses motion-safe: for reduced-motion (same pattern as OwlArt). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ustness
Extend the contextual Explain feature beyond the Traces table:
- Widen ExplainKind with span.{error,duration,cost} and thread.{duration,cost};
AI_EXPLAIN_REGISTRY stays a total Record so a missing config is a compile error.
- Make the Traces/Spans builders entity-aware via shared factories: the Spans
view emits span.* targets (entityId = span id; the backend resolves the parent
trace), the Traces view emits trace.*. Wrapping moved into a per-view useMemo.
- Wire Explain onto the Threads table (duration + cost; threads have no error).
- Extract the generic withExplain HOC to a shared LogsPage/explain module so the
Traces/Spans and Threads tabs share one wrapper.
Stream robustness (host-only):
- Watchdog for a stalled stream (no chunk yet): ~10s -> "waking" state, ~30s ->
retryable timeout error + explain:cancel. A live pod streams/pings well before
then, so this only catches a request that never reached a live pod.
- Fail in-flight cells when the pod/bridge goes away (setReady(false)/clearEmit)
so open popovers can't hang on "Thinking…/waking".
- Optional structured explain:error.code mapped to contextual copy (falls back
to the raw message); host-generated timeout/pod-loss set their own codes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| if (!row.project_id || !isPositive(row.duration)) return null; | ||
| const payload: Record<string, unknown> = { duration: row.duration }; | ||
| if (isFinite(row.number_of_messages)) { | ||
| payload.number_of_messages = row.number_of_messages; | ||
| } |
There was a problem hiding this comment.
buildThreadDurationTarget repeats buildThreadCostTarget's guard/payload flow, so future payload changes have to be synced in two places and the duration/cost targets can drift — should we factor a shared helper that takes the positive field and payload builder and keeps the number_of_messages branch in one place?
Want Baz to fix this for you? Activate Fixer
There was a problem hiding this comment.
Commit 84a53a8 addressed this comment by replacing the two separate thread target builders with a shared threadTargetBuilder factory. That centralizes the common number_of_messages handling and payload/scaffold logic so duration and cost stay in sync.
There was a problem hiding this comment.
Done in 84a53a8 — factored a shared threadTargetBuilder(kind, basePayload) that owns the project_id guard and the number_of_messages branch, with buildThreadDurationTarget/buildThreadCostTarget now just supplying their kind + value field. Mirrors the errorTargetBuilder/durationTargetBuilder/costTargetBuilder factory pattern in the Traces/Spans explainTargets.ts, so the two can't drift.
- Drop the value>0 guard on duration/cost target builders so the Explain button also appears for N/A / zero cells (error stays gated on error_info, since an empty error cell has nothing to explain). - Make the Explain Popover modal so the dismissing outside-click is swallowed instead of passing through to the table row and opening the sidebar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… readiness teardown - Factor buildThreadDuration/CostTarget through a shared threadTargetBuilder so the guard + number_of_messages branch live in one place (mirrors the Traces/Spans builders). Addresses Baz review comment. - Drop the unconditional setReady(false) cleanup in AssistantSidebar: on a surface switch (sidebar<->page) the new instance mounts and sets ready=true before the old one's cleanup runs, so the unguarded reset clobbered the live instance and never restored it, silently hiding every Explain button until reload. Teardown is now owned solely by the ownership-guarded clearEmit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| const payload = base(row); | ||
| if (isFinite(row.number_of_messages)) { | ||
| payload.number_of_messages = row.number_of_messages; | ||
| } | ||
| return { |
There was a problem hiding this comment.
threadTargetBuilder repeats the same project_id guard/payload scaffolding used by the TracesSpansTab/explainTargets.ts builders, so changes to the guard, number_of_messages, or ExplainTarget shape can drift between the thread and trace/span branches — should we factor a shared createExplainTargetBuilder(kind, payloadFn) helper?
Want Baz to fix this for you? Activate Fixer
There was a problem hiding this comment.
Commit 3191775 addressed this comment by extracting a shared createExplainTargetBuilder helper and using it from both the Threads and Traces/Spans explain target builders. That removes the duplicated project_id guard and shared payload envelope so the builders stay in sync.
…hadow - ExplainButton: fire EXPLAIN_CLICKED only on a fresh dispatch — reopening a popover on a cached/in-flight cell reuses the entry and no longer recounts, so the CLICKED→COMPLETED funnel isn't inflated. Locked in with a test. - ExplainButton: drop the hard-coded rgba PopoverContent shadow and inherit the shared shadow-md, matching all 44 other PopoverContent call sites (the popover was the only one overriding it; modal + onCloseAutoFocus already match FilterChipPopover/PromptLibraryMenu). - ExplainPopover: add aria-busy on the live region so screen readers announce the settled answer once instead of re-reading every streamed token; collapse the duplicated Thinking/waking blocks into one with a derived label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… null-safe payloads Factor a single createExplainTargetBuilder<TRow>(kind, buildPayload) shared by the Traces/Spans and Threads explainTargets so the project_id guard + target envelope can't drift between tabs. buildPayload returns null to veto explicitly. Add finiteOrNull so N/A duration/cost serialize as null (not dropped undefined), keeping "why is there no cost/duration?" explainable. Lives under LogsPage/ (not the comet plugin) so OSS table code never imports the plugin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…og, recover on retry-after-error
Rewrite explainStore into clear layers: pure entry transitions (startEntry /
withChunk / ...), pure cache ops over { entries, routes } (no dropRoute/keepRoute
flag soup), then a thin store. Extract the waking/timeout timers into
streamWatchdog.ts. Public API + behavior unchanged.
Fix the sleeping-pod bug: when the console retries a transient 503 and streams
chunks under the SAME explainId AFTER its explain:error, the host used to retire
the route on error and drop the recovery, leaving the popover stuck on "Couldn't
load". explain:chunk is now authoritative ("stream is alive") and resurrects an
errored cell; the route survives a console error (retired only on done / pod
loss / timeout). A done after an error with no chunks keeps the error rather than
blanking it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… button style helpers Move the bridge machinery (createBridge / createHostListeners / emitHostEvent / useRegisterExplainEmitter / useLatestRef + HostListeners/BridgeRefs types) out of AssistantSidebar into assistantBridge.ts, leaving the component focused on rendering. Lift ExplainButton's inline className blobs into named, Figma-annotated owlTriggerClass/owlLabelClass helpers. Pure code moves — no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| export const finiteOrNull = (value: unknown): number | null => | ||
| isFinite(value) ? (value as number) : null; |
There was a problem hiding this comment.
Null explain payloads may be rejected
finiteOrNull now converts missing duration/cost values to explicit null, so the explain builders send { duration: null } / { total_estimated_cost: null } over the bridge instead of omitting the key, which will fail if the assistant backend validator still expects numeric-or-absent fields — should we confirm null is accepted there or keep the omitted-key shape for N/A rows?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/v2/pages/LogsPage/createExplainTargetBuilder.ts around lines
16-17, in the `finiteOrNull` helper, you’re converting all non-finite inputs
(including missing cost/duration) into explicit `null`, which forces `{ duration: null
}` / `{ total_estimated_cost: null }` to be sent. Please verify the backend
explain-payload schema/validator for the affected `ExplainKind`s to confirm whether
these fields explicitly allow `null`; if they do not, change the N/A behavior to
preserve the old “omit the key” payload shape (so JSON.stringify drops the field) by
returning `undefined` for those cases from `finiteOrNull` (or by doing a per-field
conditional in the payload builder). Then add/update a unit test that asserts the exact
payload shape generated for an N/A/missing duration/cost row matches what the backend
accepts.
…c strings
Review follow-up. finiteOrNull is the single coercion point for explain metric
payloads, so it must only emit a backend-valid `number | null` (backend models
are `float | None` with `ge=0`):
- negatives now → null. lodash isFinite is true for negatives, so a corrupt
negative duration/cost (e.g. clock-skew) used to pass through and 422 against
the backend's `ge=0` — the same broken Explain button the fix removed.
- numeric strings now coerced. A non-conforming API value ("5") collapsed to
null ("not recorded") for a row that has a real value; coercing preserves the
pre-fix behaviour where the backend parsed it.
Direct unit test added pinning the helper's contract (0/positive pass, negatives
/ NaN / Infinity / absent → null, numeric strings coerced).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds host-side `[EXPLAIN]` logging at the bridge chokepoint and the store so a test-env capture pins down where the spurious `explain:error` originates: whether it arrives INBOUND from the console runner or is host-generated (watchdog/timeout/pod-loss), plus the live explain:run subscriber count (>=2 => duplicate runner). Gated by EXPLAIN_DEBUG; remove with the helper file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aadereiko
left a comment
There was a problem hiding this comment.
The PR looks good! Thank you for covering a lot of functionality with tests, this is useful. The only thing that I noticed is that the comments are too verbose sometimes, but it's up to you whether you want to fix
Great job and nice feature!
…cribers
Root cause of the spurious "Couldn't load the explanation." flash (proven by
the test-env [EXPLAIN] capture): a pod readiness flap toggles isBackendReady,
which remounts the Ollie <iframe> into a fresh JS realm. The old iframe's
document is destroyed WITHOUT running the console's React cleanup, so its
explain:run/explain:cancel subscription closures stay parked in the host
listener Set (the host AssistantSidebar stays mounted and never recreates it).
The host then fans every explain:run out to BOTH the live runner AND the
orphaned dead-realm closure; the orphan fails fast against its torn-down fetch
context and emits explain:error, which flashes before the live runner's chunks
recover the cell ("error → 1s → answer").
This is a CROSS-realm duplicate, so the console-side single-runner guard
(separate module state per realm) could not catch it. Fix it at the host
bridge: explain:run/explain:cancel are single-subscriber events, so a new
subscription evicts any pre-existing (orphaned) one before adding itself.
Scoped to those two events only — context/visibility/runner-state replays are
untouched. Adds a regression test for the invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| import { describe, expect, it, vi } from "vitest"; | ||
| import type { MutableRefObject } from "react"; |
There was a problem hiding this comment.
React import grouped after vitest
This file puts vitest before import type { MutableRefObject } from "react";, so it breaks the repo's react → external → internal import order, including type-only React imports — can we reorder it to match .agents/skills/opik-frontend/code-quality.md?
Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/plugins/comet/assistantBridge.test.ts around lines 1-10, the
import ordering violates the frontend convention: the `vitest` imports are placed before
the type-only React import. Reorder the top-level imports so they follow `react`
(including `import type { MutableRefObject } from "react"`) first, then external
libraries (`vitest`), and finally internal imports from `./assistantBridge`. Keep the
import specifiers the same; only change the ordering to match the repo’s code-quality
rules.
…e + conversation:start The prior fix scoped the single-subscriber eviction to explain:run/cancel, but the test-env capture showed conversation:start and chat:continue also hit set.size=2 after an iframe remount (pod readiness flap). chat:continue is the same class of bug: the orphaned dead-realm ChatSidebar still receives it, runs seedSession/resumeSession against its torn-down context, fails, and emits a "Couldn't continue the conversation." notification toast — while the live realm created the session correctly (idempotency_key reopens the same one), so the chat appears with the right context AND a toast fires. Generalize the eviction to a SINGLE_SUBSCRIBER_EVENTS set (explain:run/explain:cancel/chat:continue/conversation:start). context:changed / runner:state-changed / visibility:changed stay excluded — they legitimately have >1 subscriber per console and runner-state relies on the replay. Extends the regression test to all four events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oped colors, link hover Address review feedback on the Explain popover/owl: - Owl button now anchors on the side opposite the cell value and grows away from it (left-aligned cells: right edge, grows left; right-aligned cells: left edge, grows right) so it never covers the value. - Explanation popover opens beneath the owl (side="bottom") instead of to its right, hugging the owl's anchored edge. - Alignment is resolved through a single shared resolver (resolveHorizontalAlignment) driven by column meta — the owl never hardcodes column types. Adds an optional, declarative meta.horizontalAlignment override (mirrors verticalAlignment) so future cells can set alignment without code changes; CellWrapper now honors it too. - Bold + inline-code text in the popover use the body color (scoped [&_…] overrides on the MarkdownPreview), leaving other .comet-markdown surfaces untouched. - "Continue conversation" link regains a hover state (stops overriding the tableLink hover; border tracks text via hover:border-primary) and gets mt-3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolveHorizontalAlignment read `context.column.columnDef.meta`, which throws on a minimal/synthetic CellContext that omits `column` (as the withExplain unit test fixture does). Use optional chaining — the resolver already defaults a missing meta to `start`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pover size/padding Follow-up review tweaks after repositioning the owl: - Inset the owl 6px from the cell edge (was 2px) so it isn't cramped against the column divider on the side it anchors to. - Lift the owl icon ~1px so its (low-sitting) eye-circles optically center against the "Explain" label. - Widen the explanation popover (w-72 -> w-80). - Bump padding below the link button (8px -> 12px). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the verbose [EXPLAIN] tracing added for the test-env investigation: deletes explainDebug.ts and strips every explainLog/shortId call from the bridge and store. The actual fixes stay — the single-subscriber orphan eviction in the bridge and its regression test are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… errored cell Review-blocker: onError keeps the errored stream's explainId→cell route alive (retire=false) so a same-id console retry can recover the cell. But reopening the popover calls explain() — not retry() — which started a fresh stream under a NEW explainId via putCell without dropping the old route, leaving two explainIds aliased to one cell. A late chunk/done/error from the abandoned stream then corrupted, truncated, or wrongly-errored the live answer the user was reading (and the orphaned route leaked). explain() now drops the errored cell first, mirroring retry()'s dropCell, so only the live stream routes in. Also harden errorMessage(): guard the ERROR_COPY[code] lookup with an own-property check so a free-form code like "constructor" can't resolve an inherited Object.prototype Function and crash the popover when rendered. Adds regression tests for both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…in-bridge # Conflicts: # apps/opik-frontend/src/lib/analytics/tracking.ts # apps/opik-frontend/tailwind.config.ts
Details
Adds the Ollie "Explain" feature (OPIK-6425) on the frontend, end to end: the host-side bridge contract, the plugin-scoped explain store, and the user-facing Explain button + popover wired into trace-table cells. Frontend-only; no backend, migrations, or
v1changes. Everything explain-specific lives inside the comet plugin, so OSS builds get nothing.Bridge contract
Adds the 7 explain/chat/console events (
explain:run/cancel,chat:continue,console:ready,explain:chunk/done/error) +ExplainTargettoassistant-sidebar.ts, bumpsBRIDGE_PROTOCOL_VERSION1→2, registers the new host events increateHostListeners(), and routes the shell→host events into the explain store. Bridge machinery is extracted intoplugins/comet/assistantBridge.ts(host listeners,createBridge,emitHostEvent, emitter registration) and must stay in lockstep withollie-console/src/bridge.ts(separate repo / PR).Explain store (
plugins/comet/explain/explainStore.ts)projectId:kind:entityId) — reopening a popover shows the cached answer (or its still-streaming text), not a refetch; entries persist across close.explainId → cellroute map fans incomingexplain:chunkdeltas back to the right cell, so N concurrent explains = N entries + N routes over the single bridge. In-flight concurrency cap of 3; oldest settled cells evicted pastMAX_CACHED.capabilities+ bridge version captured fromconsole:ready; ownership-guarded host→shell emit registration.continueChat(hands the partial answer to the sidebar chat and cancels the cell stream), andcancel.Stream resilience & bridge robustness
streamWatchdog.ts): awakinghint at 10s then a retryabletimeoutat 30s, so a request that never reaches a live pod can't hang on "Thinking…". A streamed chunk retires the watchdog.explainIdretry can stream chunks in and resurrect the cell; a terminal error→done stays an error rather than blanking.<iframe>into a fresh JS realm without running the old realm's React cleanup, leaving its host→shell subscription parked in the listener set. The host then double-handled each event — the dead-realm handler failing fast and surfacing a spurious "Couldn't load…" / "Couldn't continue…" — sosubscribe()now evicts the orphan for the single-subscriber events (explain:run/explain:cancel/chat:continue/conversation:start) before wiring the fresh one. Multi-subscriber events (context:changed/runner:state-changed/visibility:changed) are deliberately excluded.UI
ExplainButton— owl pill pinned to the cell's right edge, fail-closed when the pod isn't ready / lacks theexplaincapability / the kind is unknown. Resting circular icon button expands to the Figma pill on hover/open (gradient rotates −45°→−18°, "Explain" label slides in). Opening dispatches the stream and only counts a click that actually surfaces an explanation; closing mid-stream cancels the unseen (paid) generation.ExplainPopover— streamed output via the sharedMarkdownPreview,Thinking…/waking/ error / empty states, Retry and Continue conversation actions, and anaria-liveregion so streamed text is announced. Aligned 1:1 with Figma (nodes351:17617/351:31659/351:31623).ExplainableCell+ a sharedcreateExplainTargetBuilderfactory build per-kind, null-safe payloads, wiring Explain into the Error, Duration and Cost cells across the Traces/Spans and Threads tabs from one place.BI
EXPLAIN_CLICKED,EXPLAIN_COMPLETED,EXPLAIN_ERRORED,EXPLAIN_CONTINUE_CLICKED,EXPLAIN_RETRIED.Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
Testing
Commands (from
apps/opik-frontend):npm run typecheck— clean (incl. after merging latestmain)npx eslinton the changed files — 0 problemsnpx vitest run(explain + bridge suites) — 58 tests passing acrossExplainButton,ExplainPopover,explainStore,useCanExplain,registry, andassistantBridge(the single-subscriber eviction invariant + the route-lifecycle / recovery / error-code-hardening regressions).Runtime: button + popover verified in the running app and iterated against Figma (hover → expand → popover, streamed markdown, Retry, Continue conversation); the orphan-eviction fix was confirmed against the test environment's pod-flap repro.
Documentation
N/A — internal host bridge + plugin UI only; no user-facing docs change.
🤖 Generated with Claude Code