Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 26 additions & 1 deletion plugins/example-plugin/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ instance Studio renders — same behavior, same styles, no second copy in the
bundle. See `src/SharedUiPage.tsx`.

```ts
import { StudioDataView, useStudioDataViewState } from '@nemo/common';
import { AssistantChat, StudioDataView, useStudioDataViewState } from '@nemo/common';
```

- **Bare specifier only.** A deep `@nemo/common/src/...` import is not
Expand All @@ -78,6 +78,11 @@ import { StudioDataView, useStudioDataViewState } from '@nemo/common';
export is already a tsc error, so `pnpm typecheck` covers that half.
- **`plugin.ts` is the API.** Need something Studio has but the barrel doesn't
export? Add it there — additions are cheap, removals are breaking.
- **`AssistantChat` is shared.** A plugin can point it at an authenticated,
OpenAI-compatible `baseURL`; Studio supplies its current access token and
chat runtime. Use `messageContentProps.markdownLinkComponent` when a plugin
owns trusted, in-app citation targets. The plugin should still own the panel,
prompts, endpoint, and citation behavior specific to its feature.
- **Types come from source**, via `paths` in `tsconfig.json`; `@nemo/common` is
unpublished, so there is nothing to install. `src/env.d.ts` declares the `*.css`
side-effect imports those sources carry.
Expand Down Expand Up @@ -122,6 +127,26 @@ import { StudioDataView, useStudioDataViewState } from '@nemo/common';
`src/index.ts` must export `Root` (a `ComponentType<PluginRootProps>`) and
`navItems(workspaceId) => PluginNavGroup[]`. See `src/Root.tsx` and `src/Nav.tsx`.

A plugin may also export `traceViews` to add native modes beside Studio's Tree
and List trace views. Each definition provides a kebab-case `id`, a `label`, a
`View` component, and an optional compact `Activity` component. Studio renders
both components inside its existing providers and passes `{ host, trace }`,
where `trace` contains the selected trace's `id` and `sessionId`. Keep all
feature-specific API calls, polling, generation state, and presentation inside
the plugin bundle; Studio owns only discovery, mode selection, host injection,
and crash containment.

```ts
export const traceViews = [
{
id: 'semantic-map',
label: 'Semantic map',
View: SemanticMap,
Activity: SemanticMapProgress,
},
];
```

## Externals & versions

The `external` list in `vite.config.ts` **must match the keys of** Studio's
Expand Down
23 changes: 23 additions & 0 deletions web/packages/common/src/components/AssistantChat/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,29 @@ describe('AssistantChat', () => {
interactionTimeoutMs
);

it('allows a caller to render trusted Markdown links in messages', async () => {
mocks.createChatCompletion.mockResolvedValueOnce(
createCompletion('[Trace source](#zoomer-node=summary-1)')
);
renderAssistantChat(
<AssistantChat
model="test-model"
workspace="default"
messageContentProps={{
markdownLinkComponent: ({ href, children }) => <a href={href}>{children}</a>,
}}
/>
);

await userEvent.type(screen.getByRole('textbox', { name: /Task prompt/i }), 'Show evidence');
await userEvent.click(screen.getByRole('button', { name: /Submit/i }));

expect(await screen.findByRole('link', { name: 'Trace source' })).toHaveAttribute(
'href',
'#zoomer-node=summary-1'
);
});

it('renders base64 images returned by an image model stream', async () => {
const imageUrl = 'data:image/png;base64,iVBORw0KGgo=';
const stream = {
Expand Down
2 changes: 2 additions & 0 deletions web/packages/common/src/components/AssistantChat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const AssistantChat: FC<AssistantChatProps> = ({
stopCount,
slotComposerStart,
emptyState,
messageContentProps,
composerOverride,
enableImageAttachments = true,
}) => {
Expand Down Expand Up @@ -76,6 +77,7 @@ export const AssistantChat: FC<AssistantChatProps> = ({
composerMode={composerMode}
slotComposerStart={slotComposerStart}
emptyState={emptyState}
messageContentProps={messageContentProps}
composerOverride={composerOverride}
enableImageAttachments={imageAttachmentsEnabled}
/>
Expand Down
2 changes: 2 additions & 0 deletions web/packages/common/src/components/AssistantChat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export interface AssistantChatProps {
slotHeading?: string;
slotSubheading?: string;
};
/** Overrides used when rendering Markdown inside chat messages. */
messageContentProps?: AssistantChatMessageContentProps;
composerOverride?: ReactNode;
/**
* @default true
Expand Down
5 changes: 5 additions & 0 deletions web/packages/common/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
// Removals are breaking. Explicit exports, not `export *`.

export { AccessibleTitle } from '@nemo/common/src/components/AccessibleTitle';
export { AssistantChat } from '@nemo/common/src/components/AssistantChat';
export type {
AssistantChatMessageContentProps,
AssistantChatProps,
} from '@nemo/common/src/components/AssistantChat/types';
export { AccordionSection } from '@nemo/common/src/components/AccordionSection';
export { ConfirmationModal } from '@nemo/common/src/components/ConfirmationModal';
export { DeleteConfirmationModal } from '@nemo/common/src/components/DeleteConfirmationModal';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { useSessionTrajectories } from '@studio/components/IntakeDetail/useSessionTrajectories';
import { Loading } from '@studio/components/Layouts/Loading';
import { NotFound } from '@studio/components/Layouts/NotFound';
import { usePluginTraceViews } from '@studio/plugins/PluginTraceViewContext';
import {
type BreadcrumbsItemProps,
useBreadcrumbs,
Expand Down Expand Up @@ -50,6 +51,7 @@ export const SessionDetailView: FC<SessionDetailViewProps> = ({
const traceId = searchParams.get(QUERY_PARAMETERS.traceId) || undefined;
const linkedSpanId = searchParams.get(QUERY_PARAMETERS.spanId) || undefined;
const [viewMode, setViewMode] = useState<TraceViewMode>('tree');
const pluginViews = usePluginTraceViews();
const defaultGetSessionHref = useCallback(
(targetSessionId: string) => getIntakeSessionRoute(workspace, targetSessionId),
[workspace]
Expand Down Expand Up @@ -101,6 +103,15 @@ export const SessionDetailView: FC<SessionDetailViewProps> = ({
);
}, [linkedSpanId, setSearchParams, traceId]);

useEffect(() => {
if (
viewMode.startsWith('plugin:') &&
(!traceId || !pluginViews.some((view) => view.mode === viewMode))
) {
setViewMode('tree');
}
}, [pluginViews, traceId, viewMode]);

const handleSelectSession = useCallback(() => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
Expand Down Expand Up @@ -196,6 +207,7 @@ export const SessionDetailView: FC<SessionDetailViewProps> = ({
sessionErrored={session.status === 'error'}
viewMode={viewMode}
onViewModeChange={setViewMode}
pluginViews={pluginViews}
/>
)}
</TraceDetailView>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import {
type TraceViewMode,
TraceViewToolbar,
} from '@studio/components/IntakeDetail/TraceViewToolbar';
import type { ResolvedPluginTraceView } from '@studio/plugins/PluginTraceViewContext';
import { PluginTraceViewRenderer } from '@studio/plugins/PluginTraceViews';
import type { PluginTrace } from '@studio/plugins/types';
import { QUERY_PARAMETERS } from '@studio/routes/constants';
import {
buildSpanHierarchyRows,
Expand All @@ -52,6 +55,7 @@ interface TraceSpanAccordionsProps {
sessionErrored: boolean;
viewMode: TraceViewMode;
onViewModeChange: (viewMode: TraceViewMode) => void;
pluginViews: ResolvedPluginTraceView[];
}

export interface SessionExplorerData {
Expand All @@ -73,6 +77,7 @@ export const TraceSpanAccordions: FC<TraceSpanAccordionsProps> = ({
sessionErrored,
viewMode,
onViewModeChange,
pluginViews,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const linkedSpanId = searchParams.get(QUERY_PARAMETERS.spanId) || null;
Expand All @@ -93,6 +98,11 @@ export const TraceSpanAccordions: FC<TraceSpanAccordionsProps> = ({
const spans =
trajectories.find(({ trace: sessionTrace }) => sessionTrace.id === trace.id)?.spans ??
EMPTY_SPANS;
const pluginTrace = useMemo<PluginTrace>(
() => ({ id: trace.id, sessionId: trace.session_id }),
[trace.id, trace.session_id]
);
const selectedPluginView = pluginViews.find((view) => view.mode === viewMode);

const spanRows = useMemo(() => buildSpanHierarchyRows(spans), [spans]);
const resolvedSessionDurationMs = useMemo(
Expand Down Expand Up @@ -293,6 +303,8 @@ export const TraceSpanAccordions: FC<TraceSpanAccordionsProps> = ({
onViewModeChange={handleViewModeChange}
onCollapseAll={spanRows.length > 0 ? collapseAll : undefined}
onExpandAll={spanRows.length > 0 ? expandAll : undefined}
pluginViews={pluginViews}
trace={pluginTrace}
/>

{showSpanLimitMessage && (
Expand All @@ -302,7 +314,9 @@ export const TraceSpanAccordions: FC<TraceSpanAccordionsProps> = ({
</Text>
)}

{viewMode === 'tree' ? (
{selectedPluginView ? (
<PluginTraceViewRenderer view={selectedPluginView} trace={pluginTrace} />
) : viewMode === 'tree' ? (
<SpanTreeView
trajectories={trajectories}
activeTraceId={trace.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { TraceViewToolbar } from '@studio/components/IntakeDetail/TraceViewToolbar';
import type { ResolvedPluginTraceView } from '@studio/plugins/PluginTraceViewContext';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const View = () => null;

const zoomerView: ResolvedPluginTraceView = {
pluginName: 'zoomer',
id: 'semantic-map',
label: 'Zoomer',
mode: 'plugin:zoomer:semantic-map',
View,
};

describe('TraceViewToolbar', () => {
it('selects plugin-contributed modes beside Tree and List', async () => {
const onViewModeChange = vi.fn();
const user = userEvent.setup();
render(
<TraceViewToolbar
viewMode="tree"
onViewModeChange={onViewModeChange}
pluginViews={[zoomerView]}
/>
);

expect(screen.getByText('Tree')).toBeInTheDocument();
expect(screen.getByText('List')).toBeInTheDocument();
await user.click(screen.getByText('Zoomer'));

expect(onViewModeChange).toHaveBeenCalledWith('plugin:zoomer:semantic-map');
});

it('hides built-in expand and collapse actions in a plugin mode', () => {
render(
<TraceViewToolbar
viewMode="plugin:zoomer:semantic-map"
onViewModeChange={vi.fn()}
onCollapseAll={vi.fn()}
onExpandAll={vi.fn()}
pluginViews={[zoomerView]}
/>
);

expect(screen.queryByRole('button', { name: 'Collapse all' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Expand all' })).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
// SPDX-License-Identifier: Apache-2.0

import { Button, Flex, SegmentedControl } from '@nvidia/foundations-react-core';
import type { ResolvedPluginTraceView } from '@studio/plugins/PluginTraceViewContext';
import { PluginTraceViewActivity } from '@studio/plugins/PluginTraceViews';
import type { PluginTrace, PluginTraceViewMode } from '@studio/plugins/types';
import { ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
import type { FC } from 'react';

export type TraceViewMode = 'tree' | 'list';
export type TraceViewMode = 'tree' | 'list' | PluginTraceViewMode;

interface TraceViewToolbarProps {
viewMode: TraceViewMode;
onViewModeChange: (viewMode: TraceViewMode) => void;
onCollapseAll?: () => void;
onExpandAll?: () => void;
pluginViews?: ResolvedPluginTraceView[];
trace?: PluginTrace;
}

/** Shared Tree/List toolbar for session and trace-selected detail bodies. */
Expand All @@ -20,6 +25,8 @@ export const TraceViewToolbar: FC<TraceViewToolbarProps> = ({
onViewModeChange,
onCollapseAll,
onExpandAll,
pluginViews = [],
trace,
}) => (
<Flex align="center" justify="between" gap="density-lg" className="min-w-0">
<SegmentedControl
Expand All @@ -29,31 +36,39 @@ export const TraceViewToolbar: FC<TraceViewToolbarProps> = ({
items={[
{ value: 'tree', children: 'Tree' },
{ value: 'list', children: 'List' },
...pluginViews.map((view) => ({ value: view.mode, children: view.label })),
]}
/>
{onCollapseAll && onExpandAll ? (
<Flex align="center" gap="density-xs">
<Button
kind="tertiary"
size="tiny"
type="button"
aria-label="Collapse all"
title="Collapse all"
onClick={onCollapseAll}
>
<ChevronsDownUp size={14} aria-hidden />
</Button>
<Button
kind="tertiary"
size="tiny"
type="button"
aria-label="Expand all"
title="Expand all"
onClick={onExpandAll}
>
<ChevronsUpDown size={14} aria-hidden />
</Button>
</Flex>
) : null}
<Flex align="center" justify="end" gap="density-sm" className="min-w-0">
{trace
? pluginViews.map((view) => (
<PluginTraceViewActivity key={view.mode} view={view} trace={trace} />
))
: null}
{(viewMode === 'tree' || viewMode === 'list') && onCollapseAll && onExpandAll ? (
<Flex align="center" gap="density-xs">
<Button
kind="tertiary"
size="tiny"
type="button"
aria-label="Collapse all"
title="Collapse all"
onClick={onCollapseAll}
>
<ChevronsDownUp size={14} aria-hidden />
</Button>
<Button
kind="tertiary"
size="tiny"
type="button"
aria-label="Expand all"
title="Expand all"
onClick={onExpandAll}
>
<ChevronsUpDown size={14} aria-hidden />
</Button>
</Flex>
) : null}
</Flex>
</Flex>
);
3 changes: 3 additions & 0 deletions web/packages/studio/src/plugins/PluginErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface PluginErrorBoundaryProps {
// Changing this resets the boundary.
pluginName: string;
children: ReactNode;
/** Optional embedded-surface fallback. The default is the full plugin error panel. */
fallback?: ReactNode;
}

interface PluginErrorBoundaryState {
Expand Down Expand Up @@ -46,6 +48,7 @@ export class PluginErrorBoundary extends Component<
render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback !== undefined) return this.props.fallback;

return (
<Stack className="h-full" padding="4" gap="3">
Expand Down
Loading