diff --git a/plugins/example-plugin/web/AGENTS.md b/plugins/example-plugin/web/AGENTS.md index 24c75a6078..902e1c6e12 100644 --- a/plugins/example-plugin/web/AGENTS.md +++ b/plugins/example-plugin/web/AGENTS.md @@ -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 @@ -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. @@ -122,6 +127,26 @@ import { StudioDataView, useStudioDataViewState } from '@nemo/common'; `src/index.ts` must export `Root` (a `ComponentType`) 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 diff --git a/web/packages/common/src/components/AssistantChat/index.test.tsx b/web/packages/common/src/components/AssistantChat/index.test.tsx index e85d96ff58..831dcef6da 100644 --- a/web/packages/common/src/components/AssistantChat/index.test.tsx +++ b/web/packages/common/src/components/AssistantChat/index.test.tsx @@ -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( + {children}, + }} + /> + ); + + 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 = { diff --git a/web/packages/common/src/components/AssistantChat/index.tsx b/web/packages/common/src/components/AssistantChat/index.tsx index e1c61fb311..54ccf45918 100644 --- a/web/packages/common/src/components/AssistantChat/index.tsx +++ b/web/packages/common/src/components/AssistantChat/index.tsx @@ -35,6 +35,7 @@ export const AssistantChat: FC = ({ stopCount, slotComposerStart, emptyState, + messageContentProps, composerOverride, enableImageAttachments = true, }) => { @@ -76,6 +77,7 @@ export const AssistantChat: FC = ({ composerMode={composerMode} slotComposerStart={slotComposerStart} emptyState={emptyState} + messageContentProps={messageContentProps} composerOverride={composerOverride} enableImageAttachments={imageAttachmentsEnabled} /> diff --git a/web/packages/common/src/components/AssistantChat/types.ts b/web/packages/common/src/components/AssistantChat/types.ts index b76e3c6def..ed205a223f 100644 --- a/web/packages/common/src/components/AssistantChat/types.ts +++ b/web/packages/common/src/components/AssistantChat/types.ts @@ -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 diff --git a/web/packages/common/src/plugin.ts b/web/packages/common/src/plugin.ts index c0a2a4c864..15896cd7ba 100644 --- a/web/packages/common/src/plugin.ts +++ b/web/packages/common/src/plugin.ts @@ -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'; diff --git a/web/packages/studio/src/components/IntakeDetail/SessionDetailView.tsx b/web/packages/studio/src/components/IntakeDetail/SessionDetailView.tsx index a1ed620cff..e25a4234ea 100644 --- a/web/packages/studio/src/components/IntakeDetail/SessionDetailView.tsx +++ b/web/packages/studio/src/components/IntakeDetail/SessionDetailView.tsx @@ -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, @@ -50,6 +51,7 @@ export const SessionDetailView: FC = ({ const traceId = searchParams.get(QUERY_PARAMETERS.traceId) || undefined; const linkedSpanId = searchParams.get(QUERY_PARAMETERS.spanId) || undefined; const [viewMode, setViewMode] = useState('tree'); + const pluginViews = usePluginTraceViews(); const defaultGetSessionHref = useCallback( (targetSessionId: string) => getIntakeSessionRoute(workspace, targetSessionId), [workspace] @@ -101,6 +103,15 @@ export const SessionDetailView: FC = ({ ); }, [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); @@ -196,6 +207,7 @@ export const SessionDetailView: FC = ({ sessionErrored={session.status === 'error'} viewMode={viewMode} onViewModeChange={setViewMode} + pluginViews={pluginViews} /> )} diff --git a/web/packages/studio/src/components/IntakeDetail/TraceSpanAccordions.tsx b/web/packages/studio/src/components/IntakeDetail/TraceSpanAccordions.tsx index 12a8ff5c2d..38b5320af2 100644 --- a/web/packages/studio/src/components/IntakeDetail/TraceSpanAccordions.tsx +++ b/web/packages/studio/src/components/IntakeDetail/TraceSpanAccordions.tsx @@ -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, @@ -52,6 +55,7 @@ interface TraceSpanAccordionsProps { sessionErrored: boolean; viewMode: TraceViewMode; onViewModeChange: (viewMode: TraceViewMode) => void; + pluginViews: ResolvedPluginTraceView[]; } export interface SessionExplorerData { @@ -73,6 +77,7 @@ export const TraceSpanAccordions: FC = ({ sessionErrored, viewMode, onViewModeChange, + pluginViews, }) => { const [searchParams, setSearchParams] = useSearchParams(); const linkedSpanId = searchParams.get(QUERY_PARAMETERS.spanId) || null; @@ -93,6 +98,11 @@ export const TraceSpanAccordions: FC = ({ const spans = trajectories.find(({ trace: sessionTrace }) => sessionTrace.id === trace.id)?.spans ?? EMPTY_SPANS; + const pluginTrace = useMemo( + () => ({ 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( @@ -293,6 +303,8 @@ export const TraceSpanAccordions: FC = ({ onViewModeChange={handleViewModeChange} onCollapseAll={spanRows.length > 0 ? collapseAll : undefined} onExpandAll={spanRows.length > 0 ? expandAll : undefined} + pluginViews={pluginViews} + trace={pluginTrace} /> {showSpanLimitMessage && ( @@ -302,7 +314,9 @@ export const TraceSpanAccordions: FC = ({ )} - {viewMode === 'tree' ? ( + {selectedPluginView ? ( + + ) : viewMode === 'tree' ? ( 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( + + ); + + 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( + + ); + + expect(screen.queryByRole('button', { name: 'Collapse all' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand all' })).not.toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/components/IntakeDetail/TraceViewToolbar.tsx b/web/packages/studio/src/components/IntakeDetail/TraceViewToolbar.tsx index 5a14cd9bf9..e61fab0361 100644 --- a/web/packages/studio/src/components/IntakeDetail/TraceViewToolbar.tsx +++ b/web/packages/studio/src/components/IntakeDetail/TraceViewToolbar.tsx @@ -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. */ @@ -20,6 +25,8 @@ export const TraceViewToolbar: FC = ({ onViewModeChange, onCollapseAll, onExpandAll, + pluginViews = [], + trace, }) => ( = ({ items={[ { value: 'tree', children: 'Tree' }, { value: 'list', children: 'List' }, + ...pluginViews.map((view) => ({ value: view.mode, children: view.label })), ]} /> - {onCollapseAll && onExpandAll ? ( - - - - - ) : null} + + {trace + ? pluginViews.map((view) => ( + + )) + : null} + {(viewMode === 'tree' || viewMode === 'list') && onCollapseAll && onExpandAll ? ( + + + + + ) : null} + ); diff --git a/web/packages/studio/src/plugins/PluginErrorBoundary.tsx b/web/packages/studio/src/plugins/PluginErrorBoundary.tsx index 18b60752d5..392daa6cb7 100644 --- a/web/packages/studio/src/plugins/PluginErrorBoundary.tsx +++ b/web/packages/studio/src/plugins/PluginErrorBoundary.tsx @@ -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 { @@ -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 ( diff --git a/web/packages/studio/src/plugins/PluginRenderer.tsx b/web/packages/studio/src/plugins/PluginRenderer.tsx index 10f888b511..7cfbf40c75 100644 --- a/web/packages/studio/src/plugins/PluginRenderer.tsx +++ b/web/packages/studio/src/plugins/PluginRenderer.tsx @@ -1,32 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { logger } from '@nemo/common/src/utils/logger'; -import * as platformSdk from '@nemo/sdk/generated/platform/api'; -import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { usePlugins, usePluginsLoaded } from '@studio/plugins/PluginContext'; import { PluginErrorBoundary } from '@studio/plugins/PluginErrorBoundary'; -import type { - PluginBreadcrumb, - PluginHost, - PluginSdk, - PluginTelemetry, -} from '@studio/plugins/types'; +import { usePluginHost } from '@studio/plugins/usePluginHost'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react'; -import { useAuth } from 'react-oidc-context'; -import { useNavigate, useParams } from 'react-router'; - -// Module-scope for stable identity; plugins run these on Studio's axios + cache. -const STUDIO_SDK: PluginSdk = { platform: platformSdk }; - -const makeTelemetry = (name: string): PluginTelemetry => ({ - info: (message, cause) => logger.info(`[plugin:${name}] ${message}`, cause), - warn: (message, cause) => logger.warn(`[plugin:${name}] ${message}`, cause), - error: (message, cause) => logger.error(`[plugin:${name}] ${message}`, cause), - event: (event, attributes) => logger.info(`[plugin:${name}] event:${event}`, attributes), -}); +import { useEffect, type ReactElement } from 'react'; +import { useParams } from 'react-router'; // Renders the active plugin's `Root` as a normal child (not a detached // `createRoot`) so it shares Studio's Router, QueryClient, and theme. @@ -34,43 +14,15 @@ export const PluginRenderer = (): ReactElement => { const { pluginName } = useParams<{ pluginName: string }>(); const plugins = usePlugins(); const isLoaded = usePluginsLoaded(); - const workspace = useWorkspaceFromPath(); - const { user } = useAuth(); - const navigate = useNavigate(); - const toast = useToast(); + const host = usePluginHost(pluginName ?? 'unknown'); const plugin = plugins.find((p) => p.name === pluginName); - const accessToken = user?.access_token ?? ''; - // Keep the latest token in a ref so getAccessToken has a stable identity but - // still returns the current token after OIDC silent renew. - const accessTokenRef = useRef(accessToken); - accessTokenRef.current = accessToken; - const getAccessToken = useCallback(() => accessTokenRef.current, []); - const { setBreadcrumbs } = useBreadcrumbs(); - const setPluginBreadcrumbs = useCallback( - (trail: PluginBreadcrumb[]) => - setBreadcrumbs(trail.map(({ label, href }) => ({ slotLabel: label, href }))), - [setBreadcrumbs] - ); // Studio owns the cleanup so a plugin can't leave a stale trail behind. Keyed // on pluginName too: the router reuses this component across plugins, and the // outgoing plugin's trail would otherwise persist until the next one sets its own. useEffect(() => () => setBreadcrumbs([]), [setBreadcrumbs, pluginName]); - const host = useMemo( - () => ({ - workspaceId: workspace, - auth: { accessToken, getAccessToken }, - sdk: STUDIO_SDK, - navigation: { navigate: (to) => navigate(to), back: () => navigate(-1) }, - notifications: { notify: (message, type = 'info', options) => toast[type](message, options) }, - telemetry: makeTelemetry(pluginName ?? 'unknown'), - breadcrumbs: { set: setPluginBreadcrumbs }, - }), - [workspace, accessToken, getAccessToken, navigate, toast, pluginName, setPluginBreadcrumbs] - ); - if (!isLoaded) { return (
Loading plugin…
diff --git a/web/packages/studio/src/plugins/PluginTraceViewContext.ts b/web/packages/studio/src/plugins/PluginTraceViewContext.ts new file mode 100644 index 0000000000..693512575f --- /dev/null +++ b/web/packages/studio/src/plugins/PluginTraceViewContext.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { usePlugins } from '@studio/plugins/PluginContext'; +import type { PluginTraceViewDefinition, PluginTraceViewMode } from '@studio/plugins/types'; +import { useMemo } from 'react'; + +export interface ResolvedPluginTraceView extends PluginTraceViewDefinition { + pluginName: string; + mode: PluginTraceViewMode; +} + +export const usePluginTraceViews = (): ResolvedPluginTraceView[] => { + const plugins = usePlugins(); + return useMemo( + () => + plugins.flatMap((plugin) => + (plugin.traceViews ?? []).map((view) => ({ + ...view, + pluginName: plugin.name, + mode: `plugin:${plugin.name}:${view.id}` as const, + })) + ), + [plugins] + ); +}; diff --git a/web/packages/studio/src/plugins/PluginTraceViews.test.tsx b/web/packages/studio/src/plugins/PluginTraceViews.test.tsx new file mode 100644 index 0000000000..89c91ac9d2 --- /dev/null +++ b/web/packages/studio/src/plugins/PluginTraceViews.test.tsx @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { usePlugins } from '@studio/plugins/PluginContext'; +import { usePluginTraceViews } from '@studio/plugins/PluginTraceViewContext'; +import { PluginTraceViewActivity, PluginTraceViewRenderer } from '@studio/plugins/PluginTraceViews'; +import type { LoadedPlugin, PluginHost, PluginTraceViewProps } from '@studio/plugins/types'; +import { usePluginHost } from '@studio/plugins/usePluginHost'; +import { render, renderHook, screen } from '@testing-library/react'; + +vi.mock('@studio/plugins/PluginContext', () => ({ usePlugins: vi.fn() })); +vi.mock('@studio/plugins/usePluginHost', () => ({ usePluginHost: vi.fn() })); + +const HOST = { + workspaceId: 'default', + auth: { accessToken: 'token', getAccessToken: () => 'token' }, + sdk: { platform: {} }, + navigation: { navigate: vi.fn(), back: vi.fn() }, + notifications: { notify: vi.fn() }, + telemetry: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), event: vi.fn() }, + breadcrumbs: { set: vi.fn() }, +} as unknown as PluginHost; + +const Root = () => null; +const navItems = () => []; + +const TraceView = ({ host, trace }: PluginTraceViewProps) => ( +
+ view:{host.workspaceId}:{trace.sessionId}:{trace.id} +
+); + +const TraceActivity = ({ trace }: PluginTraceViewProps) =>
activity:{trace.id}
; + +const plugin: LoadedPlugin = { + name: 'zoomer', + Root, + navItems, + traceViews: [ + { + id: 'semantic-map', + label: 'Zoomer', + View: TraceView, + Activity: TraceActivity, + }, + ], +}; + +beforeEach(() => { + vi.mocked(usePlugins).mockReturnValue([plugin]); + vi.mocked(usePluginHost).mockReturnValue(HOST); +}); + +describe('plugin trace views', () => { + it('resolves plugin-scoped modes from loaded bundle exports', () => { + const { result } = renderHook(usePluginTraceViews); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + pluginName: 'zoomer', + id: 'semantic-map', + label: 'Zoomer', + mode: 'plugin:zoomer:semantic-map', + }); + }); + + it('renders the selected view and compact activity with the shared host', () => { + const { result } = renderHook(usePluginTraceViews); + const view = result.current[0]; + const trace = { id: 'trace-1', sessionId: 'session-1' }; + + render( + <> + + + + ); + + expect(screen.getByText('view:default:session-1:trace-1')).toBeInTheDocument(); + expect(screen.getByText('activity:trace-1')).toBeInTheDocument(); + expect(usePluginHost).toHaveBeenCalledWith('zoomer'); + }); +}); diff --git a/web/packages/studio/src/plugins/PluginTraceViews.tsx b/web/packages/studio/src/plugins/PluginTraceViews.tsx new file mode 100644 index 0000000000..f8e6bbc106 --- /dev/null +++ b/web/packages/studio/src/plugins/PluginTraceViews.tsx @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PluginErrorBoundary } from '@studio/plugins/PluginErrorBoundary'; +import type { ResolvedPluginTraceView } from '@studio/plugins/PluginTraceViewContext'; +import type { PluginTrace } from '@studio/plugins/types'; +import { usePluginHost } from '@studio/plugins/usePluginHost'; +import { type FC, type ReactElement } from 'react'; + +interface PluginTraceViewRendererProps { + view: ResolvedPluginTraceView; + trace: PluginTrace; +} + +interface PluginTraceViewActivityContentProps extends PluginTraceViewRendererProps { + Activity: NonNullable; +} + +export const PluginTraceViewRenderer: FC = ({ view, trace }) => { + const host = usePluginHost(view.pluginName); + const { View } = view; + return ( + + + + ); +}; + +const PluginTraceViewActivityContent: FC = ({ + view, + trace, + Activity, +}) => { + const host = usePluginHost(view.pluginName); + return ( + + + + ); +}; + +export const PluginTraceViewActivity = ({ + view, + trace, +}: PluginTraceViewRendererProps): ReactElement | null => + view.Activity ? ( + + ) : null; diff --git a/web/packages/studio/src/plugins/types.ts b/web/packages/studio/src/plugins/types.ts index c3e6f07cf9..2e1f071363 100644 --- a/web/packages/studio/src/plugins/types.ts +++ b/web/packages/studio/src/plugins/types.ts @@ -72,6 +72,31 @@ export interface PluginRootProps { host: PluginHost; } +/** Stable trace identity exposed to plugin-contributed trace views. */ +export interface PluginTrace { + id: string; + sessionId: string; +} + +export interface PluginTraceViewProps { + host: PluginHost; + trace: PluginTrace; +} + +/** A native view that a plugin contributes to Studio's trace viewer. */ +export interface PluginTraceViewDefinition { + /** Kebab-case identifier unique within the plugin. */ + id: string; + label: string; + description?: string; + /** Main view rendered when the user selects this trace mode. */ + View: ComponentType; + /** Optional compact status rendered in the trace toolbar for background work. */ + Activity?: ComponentType; +} + +export type PluginTraceViewMode = `plugin:${string}:${string}`; + /** API manifest returned by `GET /apis/plugins`. */ export interface PluginManifest { name: string; @@ -114,12 +139,15 @@ export interface LoadedPlugin { Root: ComponentType; /** Return nav items scoped to the given workspace. */ navItems: (workspaceId: string) => PluginNavGroup[]; + /** Optional native views contributed to Studio's trace viewer. */ + traceViews?: readonly PluginTraceViewDefinition[]; } /** The exports a loaded plugin bundle module must expose. */ export interface PluginModule { Root: LoadedPlugin['Root']; navItems: LoadedPlugin['navItems']; + traceViews?: LoadedPlugin['traceViews']; } /** Result of fetching the manifest and loading each plugin's bundle. */ diff --git a/web/packages/studio/src/plugins/usePluginHost.ts b/web/packages/studio/src/plugins/usePluginHost.ts new file mode 100644 index 0000000000..45f9a75c69 --- /dev/null +++ b/web/packages/studio/src/plugins/usePluginHost.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { logger } from '@nemo/common/src/utils/logger'; +import * as platformSdk from '@nemo/sdk/generated/platform/api'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import type { + PluginBreadcrumb, + PluginHost, + PluginSdk, + PluginTelemetry, +} from '@studio/plugins/types'; +import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; +import { useCallback, useMemo, useRef } from 'react'; +import { useAuth } from 'react-oidc-context'; +import { useNavigate } from 'react-router'; + +// Module-scope for stable identity; plugins run these on Studio's axios + cache. +const STUDIO_SDK: PluginSdk = { platform: platformSdk }; + +const makeTelemetry = (name: string): PluginTelemetry => ({ + info: (message, cause) => logger.info(`[plugin:${name}] ${message}`, cause), + warn: (message, cause) => logger.warn(`[plugin:${name}] ${message}`, cause), + error: (message, cause) => logger.error(`[plugin:${name}] ${message}`, cause), + event: (event, attributes) => logger.info(`[plugin:${name}] event:${event}`, attributes), +}); + +/** Build the host handle for both plugin pages and embedded plugin surfaces. */ +export const usePluginHost = (pluginName: string): PluginHost => { + const workspace = useWorkspaceFromPath(); + const { user } = useAuth(); + const navigate = useNavigate(); + const toast = useToast(); + const { setBreadcrumbs } = useBreadcrumbs(); + const accessToken = user?.access_token ?? ''; + const accessTokenRef = useRef(accessToken); + accessTokenRef.current = accessToken; + const getAccessToken = useCallback(() => accessTokenRef.current, []); + const setPluginBreadcrumbs = useCallback( + (trail: PluginBreadcrumb[]) => + setBreadcrumbs(trail.map(({ label, href }) => ({ slotLabel: label, href }))), + [setBreadcrumbs] + ); + + return useMemo( + () => ({ + workspaceId: workspace, + auth: { accessToken, getAccessToken }, + sdk: STUDIO_SDK, + navigation: { navigate: (to) => navigate(to), back: () => navigate(-1) }, + notifications: { notify: (message, type = 'info', options) => toast[type](message, options) }, + telemetry: makeTelemetry(pluginName), + breadcrumbs: { set: setPluginBreadcrumbs }, + }), + [workspace, accessToken, getAccessToken, navigate, toast, pluginName, setPluginBreadcrumbs] + ); +}; diff --git a/web/packages/studio/src/plugins/utils.test.ts b/web/packages/studio/src/plugins/utils.test.ts new file mode 100644 index 0000000000..6852e6355a --- /dev/null +++ b/web/packages/studio/src/plugins/utils.test.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isPluginModule, isPluginTraceViewDefinition } from '@studio/plugins/utils'; + +const Root = () => null; +const View = () => null; +const Activity = () => null; +const navItems = () => []; + +describe('plugin module validation', () => { + it('accepts a plugin with a native trace view and optional activity renderer', () => { + expect( + isPluginModule({ + Root, + navItems, + traceViews: [{ id: 'semantic-map', label: 'Semantic map', View, Activity }], + }) + ).toBe(true); + }); + + it('keeps trace views optional for page-only plugins', () => { + expect(isPluginModule({ Root, navItems })).toBe(true); + }); + + it('rejects malformed and duplicate trace view contributions', () => { + expect(isPluginTraceViewDefinition({ id: '../escape', label: 'Escape', View })).toBe(false); + expect(isPluginTraceViewDefinition({ id: 'map', label: ' ', View })).toBe(false); + expect(isPluginTraceViewDefinition({ id: 'map', label: 'Map', View: 'not-a-component' })).toBe( + false + ); + expect( + isPluginModule({ + Root, + navItems, + traceViews: [ + { id: 'map', label: 'Map', View }, + { id: 'map', label: 'Duplicate', View }, + ], + }) + ).toBe(false); + }); +}); diff --git a/web/packages/studio/src/plugins/utils.ts b/web/packages/studio/src/plugins/utils.ts index 0d5c1463bd..1721311735 100644 --- a/web/packages/studio/src/plugins/utils.ts +++ b/web/packages/studio/src/plugins/utils.ts @@ -10,8 +10,11 @@ import type { PluginManifest, PluginModule, PluginQueryData, + PluginTraceViewDefinition, } from '@studio/plugins/types'; +const PLUGIN_EXTENSION_ID = /^[a-z][a-z0-9-]*$/; + export function isValidPluginManifest(obj: unknown): obj is PluginManifest { if (typeof obj !== 'object' || obj === null) return false; const o = obj as Record; @@ -21,7 +24,30 @@ export function isValidPluginManifest(obj: unknown): obj is PluginManifest { export function isPluginModule(mod: unknown): mod is PluginModule { if (typeof mod !== 'object' || mod === null) return false; const m = mod as Record; - return typeof m.Root === 'function' && typeof m.navItems === 'function'; + if (typeof m.Root !== 'function' || typeof m.navItems !== 'function') return false; + if (m.traceViews === undefined) return true; + if (!Array.isArray(m.traceViews)) return false; + + const ids = new Set(); + return m.traceViews.every((view) => { + if (!isPluginTraceViewDefinition(view) || ids.has(view.id)) return false; + ids.add(view.id); + return true; + }); +} + +export function isPluginTraceViewDefinition(value: unknown): value is PluginTraceViewDefinition { + if (typeof value !== 'object' || value === null) return false; + const view = value as Record; + return ( + typeof view.id === 'string' && + PLUGIN_EXTENSION_ID.test(view.id) && + typeof view.label === 'string' && + view.label.trim().length > 0 && + (view.description === undefined || typeof view.description === 'string') && + typeof view.View === 'function' && + (view.Activity === undefined || typeof view.Activity === 'function') + ); } export async function loadPlugin( @@ -45,7 +71,12 @@ export async function loadPlugin( logger.warn(`[plugins] Plugin "${manifest.name}" missing required exports (Root, navItems)`); return null; } - return { name: manifest.name, Root: module.Root, navItems: module.navItems }; + return { + name: manifest.name, + Root: module.Root, + navItems: module.navItems, + traceViews: module.traceViews, + }; } catch (err) { logger.warn(`[plugins] Failed to load plugin "${manifest.name}":`, err); return null;