Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b26d747
[OPIK-6425] [FE] feat: add Explain bridge contract and explain store
awkoy Jun 16, 2026
6504577
[OPIK-6425] [FE] fix: address review + extract emitter hook
awkoy Jun 16, 2026
baa1ee3
[OPIK-6425] [FE] refactor: tidy explain store and bridge routing
awkoy Jun 16, 2026
142e9d9
[OPIK-6425] [FE] add ExplainButtonProps core type
awkoy Jun 17, 2026
6c49714
[OPIK-6425] [FE] add explain BI events
awkoy Jun 17, 2026
a1dd2c1
[OPIK-6425] [FE] add continueChat store action
awkoy Jun 17, 2026
52bf1b6
[OPIK-6425] [FE] add explain kind registry
awkoy Jun 17, 2026
1a556b2
[OPIK-6425] [FE] add ExplainPopover
awkoy Jun 17, 2026
20f60a9
[OPIK-6425] [FE] add ExplainButton
awkoy Jun 17, 2026
f33f811
[OPIK-6425] [FE] add ExplainButton plugin entry files
awkoy Jun 17, 2026
fa08ecd
[OPIK-6425] [FE] register ExplainButton plugin slot
awkoy Jun 17, 2026
94ea810
[OPIK-6425] [FE] wire Explain button into the Errors cell
awkoy Jun 17, 2026
822e7ea
[OPIK-6425] [FE] fix: report explain completion BI from the store; ho…
awkoy Jun 17, 2026
d738827
[OPIK-6425] [FE] add per-kind explain target builders
awkoy Jun 17, 2026
87ee613
[OPIK-6425] [FE] register cost + duration explain kinds
awkoy Jun 17, 2026
03dc836
[OPIK-6425] [FE] add generic ExplainableCell wrapper
awkoy Jun 17, 2026
0d02e8f
[OPIK-6425] [FE] wire Explain into Error, Duration and Cost cells
awkoy Jun 17, 2026
26f27be
[OPIK-6425] [FE] align Explain button & popover 1:1 with Figma + inte…
awkoy Jun 17, 2026
9484640
[OPIK-6425] [FE] finalize Explain UI + add test coverage
awkoy Jun 18, 2026
0e6ef91
[OPIK-6425] [FE] address review: route Explain accent colors through …
awkoy Jun 18, 2026
1810611
Merge remote-tracking branch 'origin/main' into awkoy/opik-6425-expla…
awkoy Jun 18, 2026
048c4ac
[OPIK-6425] [FE] make Explain thinking dot pulse like ollie-assist be…
awkoy Jun 18, 2026
26392fa
Merge remote-tracking branch 'origin/main' into awkoy/opik-6425-expla…
awkoy Jun 19, 2026
5d19089
[OPIK-6425] [FE] Extend Ollie Explain to Spans & Threads + stream rob…
awkoy Jun 19, 2026
b5ffe57
[OPIK-6425] [FE] Show Explain on N/A cost/duration + make popover modal
awkoy Jun 22, 2026
af5ff9f
Merge remote-tracking branch 'origin/main' into awkoy/opik-6425-expla…
awkoy Jun 22, 2026
84a53a8
[OPIK-6425] [FE] Address review: dedup thread targets + guard explain…
awkoy Jun 23, 2026
9f76a2f
[OPIK-6425] [FE] Address review: BI dedup, aria-busy, align popover s…
awkoy Jun 23, 2026
c2ef874
Merge remote-tracking branch 'origin/main' into awkoy/opik-6425-expla…
awkoy Jun 25, 2026
75368ad
[OPIK-6425] [FE] refactor(explain): share target builder across tabs,…
awkoy Jun 25, 2026
240b9b5
[OPIK-6425] [FE] refactor(explain): restructure store, extract watchd…
awkoy Jun 25, 2026
3191775
[OPIK-6425] [FE] refactor(explain): extract assistant bridge module +…
awkoy Jun 25, 2026
5fa7a29
[OPIK-6425] [FE] harden finiteOrNull: clamp negatives + coerce numeri…
awkoy Jun 25, 2026
9fdef87
[OPIK-6425] [FE] debug: verbose explain bridge/store tracing (temporary)
awkoy Jun 26, 2026
8c0d47e
[OPIK-6425] [FE] fix(explain): evict orphaned dead-realm explain subs…
awkoy Jun 26, 2026
c3904ec
[OPIK-6425] [FE] fix(explain): extend orphan eviction to chat:continu…
awkoy Jun 26, 2026
ecf9d22
[OPIK-6425] [FE] fix(explain): alignment-aware owl, popover below, sc…
awkoy Jun 26, 2026
758582c
[OPIK-6425] [FE] fix(explain): guard column meta access in withExplain
awkoy Jun 26, 2026
07e2d6b
[OPIK-6425] [FE] fix(explain): owl polish — inset, icon centering, po…
awkoy Jun 26, 2026
f79cb8f
[OPIK-6425] [FE] chore(explain): remove temporary debug logging
awkoy Jun 29, 2026
c2cd553
[OPIK-6425] [FE] fix(explain): drop stale route when re-explaining an…
awkoy Jun 29, 2026
6acb924
Merge remote-tracking branch 'origin/main' into awkoy/opik-6425-expla…
awkoy Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/opik-frontend/src/lib/analytics/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const OpikEvent = {
"opik_filter_dialog_closed_without_selection",
FILTERS_ACTIVE_COUNT: "opik_filters_active_count",
PINNED_FILTERS_COUNT: "opik_pinned_filters_count",
EXPLAIN_CLICKED: "opik_explain_clicked",
EXPLAIN_COMPLETED: "opik_explain_completed",
EXPLAIN_ERRORED: "opik_explain_errored",
EXPLAIN_CONTINUE_CLICKED: "opik_explain_continue_clicked",
EXPLAIN_RETRIED: "opik_explain_retried",
} as const;

type OpikEventValues = (typeof OpikEvent)[keyof typeof OpikEvent];
Expand Down
7 changes: 7 additions & 0 deletions apps/opik-frontend/src/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@
--feature-experiment-management: #6c6ff7;
--feature-llm: #52aea4;
--color-ollie: #f46e41;
--color-ollie-amber: #f59e0b;
--color-ollie-live: #00d14c;
--shadow-ollie: 0px 4px 3px
color-mix(in srgb, var(--color-ollie) 10%, transparent),
0px 2px 2px color-mix(in srgb, var(--color-ollie) 10%, transparent);

/* Config link icon color */
--color-fuchsia: #db46ef;
Expand Down Expand Up @@ -348,6 +353,8 @@
--feature-experiment-management: #3b70f6;
--feature-llm: #20ac74;
--color-ollie: #f46e41;
--color-ollie-amber: #f59e0b;
--color-ollie-live: #00d14c;

/* Config link icon color - Dark Mode */
--color-fuchsia: #c850c0;
Expand Down
138 changes: 25 additions & 113 deletions apps/opik-frontend/src/plugins/comet/AssistantSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@ import React, {
} from "react";
import { useParams, useRouter } from "@tanstack/react-router";
import {
AssistantSidebarBridge,
AssistantSurfaceVariant,
BridgeContext,
BridgeSurface,
HostEventMap,
RunnerBridgeState,
SidebarEventMap,
} from "@/types/assistant-sidebar";
Expand All @@ -34,8 +32,15 @@ import {
isAssistantSidebarOpen,
setAssistantSidebarOpen,
} from "@/constants/assistantSidebar";

const BRIDGE_PROTOCOL_VERSION = 1;
import useExplainStore from "@/plugins/comet/explain/explainStore";
import {
createBridge,
createHostListeners,
emitHostEvent,
useLatestRef,
useRegisterExplainEmitter,
type HostListeners,
} from "@/plugins/comet/assistantBridge";

interface AssistantSidebarLoaderProps {
error: string | null;
Expand Down Expand Up @@ -101,115 +106,6 @@ const AssistantSidebarLoader: React.FC<AssistantSidebarLoaderProps> = ({

const stopPropagation = (e: Event) => e.stopPropagation();

/** Keeps a ref whose `.current` always points to the latest value. */
function useLatestRef<T>(value: T): React.MutableRefObject<T> {
const ref = useRef(value);
ref.current = value;
return ref;
}

type HostListeners = {
[K in keyof HostEventMap]: Set<(data: HostEventMap[K]) => void>;
};

function createHostListeners(): HostListeners {
return {
"context:changed": new Set(),
"visibility:changed": new Set(),
"runner:state-changed": new Set(),
"conversation:start": new Set(),
};
}

interface BridgeRefs {
navigate: React.MutableRefObject<
(path: string, search?: Record<string, unknown>) => void
>;
onWidthChange: React.MutableRefObject<(width: number) => void>;
onNotification: React.MutableRefObject<
(data: SidebarEventMap["notification"]) => void
>;
onRequestVisibility: React.MutableRefObject<(open: boolean) => void>;
onRequestPair: React.MutableRefObject<
(data: SidebarEventMap["runner:request-pair"]) => void
>;
context: React.MutableRefObject<BridgeContext>;
listeners: React.MutableRefObject<HostListeners>;
lastRunnerState: React.MutableRefObject<RunnerBridgeState | null>;
}

const createBridge = (refs: BridgeRefs): AssistantSidebarBridge => ({
version: BRIDGE_PROTOCOL_VERSION,
getContext: () => refs.context.current,
subscribe: (event, callback) => {
const set = refs.listeners.current[event as keyof HostEventMap] as
| Set<typeof callback>
| undefined;
if (!set) return () => {};
set.add(callback);

// Replay latest runner state to late subscribers (e.g. Ollie iframe loaded after FE)
if (event === "runner:state-changed" && refs.lastRunnerState.current) {
(callback as (data: RunnerBridgeState) => void)(
refs.lastRunnerState.current,
);
}

return () => {
set.delete(callback);
};
},
emit: (event, data) => {
switch (event) {
case "navigate": {
const { path, search } = data as SidebarEventMap["navigate"];
refs.navigate.current(path, search);
break;
}
case "sidebar:resized":
refs.onWidthChange.current(
(data as SidebarEventMap["sidebar:resized"]).width,
);
break;
case "notification":
refs.onNotification.current(data as SidebarEventMap["notification"]);
break;
case "sidebar:request-open":
refs.onRequestVisibility.current(true);
break;
case "sidebar:request-close":
refs.onRequestVisibility.current(false);
break;
case "runner:request-pair":
refs.onRequestPair.current(
data as SidebarEventMap["runner:request-pair"],
);
break;
default:
if (IS_ASSISTANT_DEV) {
console.warn(
`[AssistantBridge] Unhandled sidebar event: "${event}"`,
data,
);
}
}
},
startConversation: (message: string) => {
emitHostEvent(refs.listeners, "conversation:start", { message });
},
});

/** Emit a host event to all subscribed sidebar listeners. */
function emitHostEvent<E extends keyof HostEventMap>(
listenersRef: React.MutableRefObject<HostListeners>,
event: E,
data: HostEventMap[E],
) {
for (const listener of listenersRef.current[event]) {
(listener as (d: HostEventMap[E]) => void)(data);
}
}

function useBridgeContext(
assistantBackendUrl: string,
surface: BridgeSurface,
Expand Down Expand Up @@ -394,6 +290,22 @@ const AssistantSidebar: React.FC<AssistantSidebarProps> = ({
};
}, [meta]);

useRegisterExplainEmitter(listenersRef);

// Mirror pod readiness into the explain store so the (per-row) Explain
// buttons gate on one shared value rather than each calling the backend hook.
// No unmount reset here on purpose: a surface switch (sidebar↔page) mounts the
// new instance — which sets ready=true — BEFORE the old one's cleanup runs (the
// same ordering that forces the ownership guards on window.opikBridge/clearEmit
// above). An unconditional setReady(false) on unmount would clobber the live
// instance and, since its effect dep already settled, never restore it — every
// Explain button would silently vanish until reload. Teardown is instead owned
// by the ownership-guarded clearEmit, which resets ready only for the instance
// that still owns the bridge.
useEffect(() => {
useExplainStore.getState().setReady(isBackendReady);
}, [isBackendReady]);

// Emit context changes to sidebar listeners
useEffect(() => {
emitHostEvent(listenersRef, "context:changed", context);
Expand Down
1 change: 1 addition & 0 deletions apps/opik-frontend/src/plugins/comet/ExplainButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./explain/ExplainButton";
155 changes: 155 additions & 0 deletions apps/opik-frontend/src/plugins/comet/assistantBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { useEffect, useRef, type MutableRefObject } from "react";
import {
AssistantSidebarBridge,
BRIDGE_PROTOCOL_VERSION,
BridgeContext,
HostEventMap,
RunnerBridgeState,
SidebarEventMap,
} from "@/types/assistant-sidebar";
import { IS_ASSISTANT_DEV } from "@/plugins/comet/constants/assistant";
import useExplainStore, {
ConsoleEmit,
handleConsoleEvent,
} from "@/plugins/comet/explain/explainStore";

/**
* Host side of the assistant bridge: the in-page channel the Ollie console
* (iframe) talks to via `window.opikBridge`. `createBridge` builds the bridge
* object the console consumes — host→shell events fan out to subscribers, and
* shell→host events are dispatched to host handlers (the explain relay is owned
* by the explain store via `handleConsoleEvent`). The owning `AssistantSidebar`
* component supplies the live refs and wires these into React effects.
*/

/** Keeps a ref whose `.current` always points to the latest value. */
export function useLatestRef<T>(value: T): MutableRefObject<T> {
const ref = useRef(value);
ref.current = value;
return ref;
}

/** A subscriber set per host→shell event the console can listen to. */
export type HostListeners = {
[K in keyof HostEventMap]: Set<(data: HostEventMap[K]) => void>;
};

export function createHostListeners(): HostListeners {
return {
"context:changed": new Set(),
"visibility:changed": new Set(),
"runner:state-changed": new Set(),
"conversation:start": new Set(),
"explain:run": new Set(),
"explain:cancel": new Set(),
"chat:continue": new Set(),
};
}

/** The live values `createBridge` reads through (kept current by the component). */
export interface BridgeRefs {
navigate: MutableRefObject<
(path: string, search?: Record<string, unknown>) => void
>;
onWidthChange: MutableRefObject<(width: number) => void>;
onNotification: MutableRefObject<
(data: SidebarEventMap["notification"]) => void
>;
onRequestVisibility: MutableRefObject<(open: boolean) => void>;
onRequestPair: MutableRefObject<
(data: SidebarEventMap["runner:request-pair"]) => void
>;
context: MutableRefObject<BridgeContext>;
listeners: MutableRefObject<HostListeners>;
lastRunnerState: MutableRefObject<RunnerBridgeState | null>;
}

export const createBridge = (refs: BridgeRefs): AssistantSidebarBridge => ({
version: BRIDGE_PROTOCOL_VERSION,
getContext: () => refs.context.current,
subscribe: (event, callback) => {
const set = refs.listeners.current[event as keyof HostEventMap] as
| Set<typeof callback>
| undefined;
if (!set) return () => {};
set.add(callback);

// Replay latest runner state to late subscribers (e.g. Ollie iframe loaded after FE)
if (event === "runner:state-changed" && refs.lastRunnerState.current) {
(callback as (data: RunnerBridgeState) => void)(
refs.lastRunnerState.current,
);
}

return () => {
set.delete(callback);
};
},
emit: (event, data) => {
switch (event) {
case "navigate": {
const { path, search } = data as SidebarEventMap["navigate"];
refs.navigate.current(path, search);
break;
}
case "sidebar:resized":
refs.onWidthChange.current(
(data as SidebarEventMap["sidebar:resized"]).width,
);
break;
case "notification":
refs.onNotification.current(data as SidebarEventMap["notification"]);
break;
case "sidebar:request-open":
refs.onRequestVisibility.current(true);
break;
case "sidebar:request-close":
refs.onRequestVisibility.current(false);
break;
case "runner:request-pair":
refs.onRequestPair.current(
data as SidebarEventMap["runner:request-pair"],
);
break;
default:
// The explain relay (shell → host) is owned by the explain module; the
// bridge just forwards anything it doesn't handle itself.
if (!handleConsoleEvent(event, data) && IS_ASSISTANT_DEV) {
console.warn(
`[AssistantBridge] Unhandled sidebar event: "${event}"`,
data,
);
}
}
},
startConversation: (message: string) => {
emitHostEvent(refs.listeners, "conversation:start", { message });
},
});

/** Emit a host event to all subscribed sidebar listeners. */
export function emitHostEvent<E extends keyof HostEventMap>(
listenersRef: MutableRefObject<HostListeners>,
event: E,
data: HostEventMap[E],
) {
for (const listener of listenersRef.current[event]) {
(listener as (d: HostEventMap[E]) => void)(data);
}
}

/**
* Registers this sidebar as the explain bridge's host→shell emitter for its
* lifetime, tearing it down (ownership-guarded) on unmount so the
* sidebar↔OlliePage instance switch can't drop a freshly mounted instance.
*/
export function useRegisterExplainEmitter(
listenersRef: MutableRefObject<HostListeners>,
) {
useEffect(() => {
const emit: ConsoleEmit = (event, data) =>
emitHostEvent(listenersRef, event, data);
useExplainStore.getState().setEmit(emit);
return () => useExplainStore.getState().clearEmit(emit);
}, [listenersRef]);
}
Loading
Loading