Skip to content
277 changes: 260 additions & 17 deletions services/studio/src/nmp/studio/coding_agents.py

Large diffs are not rendered by default.

646 changes: 646 additions & 0 deletions services/studio/src/nmp/studio/studio_links.py

Large diffs are not rendered by default.

590 changes: 578 additions & 12 deletions services/studio/tests/unit/test_coding_agents.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
type TextMessagePartComponent,
type ToolCallMessagePartComponent,
} from '@assistant-ui/react';
import { ChatEmptyState } from '@nemo/common/src/components/Chat/ChatEmptyState';
import { MessageContent } from '@nemo/common/src/components/Chat/MessageContent';
import {
MessageContent,
type MessageContentProps,
} from '@nemo/common/src/components/Chat/MessageContent';
import {
Banner,
Button,
Expand All @@ -29,6 +31,8 @@ export interface AssistantChatThreadAttributes {
ThreadViewport?: ComponentProps<typeof ThreadPrimitive.Viewport>;
}

type AssistantChatMessageContentProps = Pick<MessageContentProps, 'markdownLinkComponent'>;

interface AssistantChatThreadProps {
disabled?: boolean;
placeholder: string;
Expand All @@ -45,21 +49,20 @@ interface AssistantChatThreadProps {
toolCallPartComponent?: ToolCallMessagePartComponent;
viewportClassName?: string;
composerOverride?: ReactNode;
messageContentProps?: AssistantChatMessageContentProps;
}

const AssistantChatTextPart: TextMessagePartComponent = ({ text }) => (
<MessageContent content={text} />
);

const AssistantChatMessageContent = ({
messageContentProps,
toolCallPartComponent,
}: {
messageContentProps?: AssistantChatMessageContentProps;
toolCallPartComponent?: ToolCallMessagePartComponent;
}) => (
<>
<MessagePrimitive.Parts
components={{
Text: AssistantChatTextPart,
Text: ({ text }) => <MessageContent content={text} {...messageContentProps} />,
tools: { Fallback: toolCallPartComponent },
}}
/>
Expand Down Expand Up @@ -100,10 +103,12 @@ const CopyAction = () => (

const AssistantMessage = ({
hideAssistantMessageActions,
messageContentProps,
showRunningIndicator = true,
toolCallPartComponent,
}: {
hideAssistantMessageActions?: boolean;
messageContentProps?: AssistantChatMessageContentProps;
showRunningIndicator?: boolean;
toolCallPartComponent?: ToolCallMessagePartComponent;
}) => (
Expand All @@ -112,7 +117,10 @@ const AssistantMessage = ({
data-testspeaker="assistant"
className="group/message self-stretch whitespace-normal"
>
<AssistantChatMessageContent toolCallPartComponent={toolCallPartComponent} />
<AssistantChatMessageContent
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
{showRunningIndicator ? (
<MessagePrimitive.If last>
<ThreadPrimitive.If running>
Expand Down Expand Up @@ -150,8 +158,10 @@ const AssistantMessage = ({
);

const UserMessage = ({
messageContentProps,
toolCallPartComponent,
}: {
messageContentProps?: AssistantChatMessageContentProps;
toolCallPartComponent?: ToolCallMessagePartComponent;
}) => (
<MessagePrimitive.Root
Expand All @@ -160,7 +170,10 @@ const UserMessage = ({
className="group/message flex w-full flex-col items-end gap-density-xs whitespace-normal"
>
<div className="max-w-[80%] rounded-xl rounded-br-none bg-surface-overlay px-3 py-2">
<AssistantChatMessageContent toolCallPartComponent={toolCallPartComponent} />
<AssistantChatMessageContent
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
</div>
<div className="flex h-8 shrink-0 items-center">
<ActionBarPrimitive.Root
Expand Down Expand Up @@ -294,22 +307,29 @@ export const AssistantChatThread = ({
toolCallPartComponent,
viewportClassName,
composerOverride,
messageContentProps,
}: AssistantChatThreadProps) => {
const { className: threadViewportClassName, ...threadViewportAttributes } =
attributes?.ThreadViewport ?? {};
const AssistantMessageWithToolCallPart = useCallback(
() => (
<AssistantMessage
hideAssistantMessageActions={hideAssistantMessageActions}
messageContentProps={messageContentProps}
showRunningIndicator={showRunningIndicator}
toolCallPartComponent={toolCallPartComponent}
/>
),
[hideAssistantMessageActions, showRunningIndicator, toolCallPartComponent]
[hideAssistantMessageActions, messageContentProps, showRunningIndicator, toolCallPartComponent]
);
const UserMessageWithToolCallPart = useCallback(
() => <UserMessage toolCallPartComponent={toolCallPartComponent} />,
[toolCallPartComponent]
() => (
<UserMessage
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
),
[messageContentProps, toolCallPartComponent]
);
const messageComponents = useMemo(
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { MessageContent } from '@nemo/common/src/components/Chat/MessageContent';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Components } from 'react-markdown';

const expectNoDirectParagraphChild = (listItem: HTMLElement): void => {
// The list marker alignment depends on the first paragraph being unwrapped inside list items.
Expand Down Expand Up @@ -129,6 +130,29 @@ describe('MessageContent', () => {
expect(screen.getByText('pnpm test')).not.toHaveClass('font-mono');
});

it('keeps markdown links inert by default', () => {
render(<MessageContent content="Open [Agents](/workspaces/default/agents)." />);

expect(screen.queryByRole('link', { name: 'Agents' })).not.toBeInTheDocument();
expect(screen.getByText('Agents')).toBeInTheDocument();
});

it('uses an opt-in markdown link renderer', () => {
const LinkComponent: Components['a'] = ({ href, children }) => <a href={href}>{children}</a>;

render(
<MessageContent
content="Open [Agents](/workspaces/default/agents)."
markdownLinkComponent={LinkComponent}
/>
);

expect(screen.getByRole('link', { name: 'Agents' })).toHaveAttribute(
'href',
'/workspaces/default/agents'
);
});

it('renders markdown tables with sortable data-view table components', async () => {
render(
<MessageContent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface MarkdownTableOptions {

export interface MessageContentProps {
content?: string | null;
markdownLinkComponent?: Components['a'];
markdownTableOptions?: MarkdownTableOptions;
renderAsMarkdown?: boolean;
}
Expand Down Expand Up @@ -475,8 +476,9 @@ const messageMarkdownComponents: Components = {
</blockquote>
),
img: ({ src, alt }) => <img src={src} alt={alt ?? ''} className="max-w-full" />,
// We don't want links embedded in markdown responses to be clickable.
a: ({ ...props }) => <span>{props.children}</span>,
// Most chat surfaces keep model-supplied links inert. Consumers that know how
// to handle links can provide their own renderer.
a: ({ children }) => <span>{children}</span>,
code: ({ children }) => <code className={INLINE_CODE_CLASS}>{children}</code>,
table: ({ children }) => <MarkdownDataViewTable>{children}</MarkdownDataViewTable>,
};
Expand All @@ -487,18 +489,20 @@ const messageMarkdownComponents: Components = {
*/
export const MessageContent: FC<PropsWithChildren<MessageContentProps>> = ({
content,
markdownLinkComponent,
markdownTableOptions,
renderAsMarkdown = true,
}) => {
const snippets = useMemo(() => splitMessageWithLabels(content), [content]);
const markdownComponents = useMemo<Components>(
() => ({
...messageMarkdownComponents,
a: markdownLinkComponent ?? messageMarkdownComponents.a,
table: ({ children }) => (
<MarkdownDataViewTable options={markdownTableOptions}>{children}</MarkdownDataViewTable>
),
}),
[markdownTableOptions]
[markdownLinkComponent, markdownTableOptions]
);

return snippets.map((descriptor) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,19 @@ describe('DashboardLandingRoute', () => {
'[&&]:focus-visible:ring-0'
);
expect(composer).not.toHaveClass('[&&]:focus-visible:outline-accent');
expect(screen.getByRole('button', { name: /Explore repo/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Draft a change/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Review recent work/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create an agent/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Start an evaluation/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Set up a secret/ })).toBeInTheDocument();
});

it('lets prompt suggestions populate the landing composer', async () => {
const user = userEvent.setup();
renderRoute();

await user.click(await screen.findByRole('button', { name: /Explore repo/ }));
await user.click(await screen.findByRole('button', { name: /Create an agent/ }));

expect(screen.getByRole('textbox', { name: 'Message Claude' })).toHaveValue(
'Give me a concise map of this repo and the main places I should know about.'
'Create a new agent in this workspace and deploy it once it is ready.'
);
});

Expand Down
20 changes: 10 additions & 10 deletions web/packages/studio/src/routes/DashboardLandingRoute/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
import { ClaudeCodeLayout } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout';
import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types';
import { getClaudeCodeChatRoute } from '@studio/routes/utils';
import { GitBranch, Hammer, Search, Send, Terminal } from 'lucide-react';
import { FlaskConical, HatGlasses, KeyRound, Send, Terminal } from 'lucide-react';
import {
type ChangeEvent,
type FC,
Expand All @@ -29,19 +29,19 @@ interface PromptSuggestion {

const PROMPT_SUGGESTIONS: PromptSuggestion[] = [
{
title: 'Explore repo',
prompt: 'Give me a concise map of this repo and the main places I should know about.',
icon: <Search size={18} />,
title: 'Create an agent',
prompt: 'Create a new agent in this workspace and deploy it once it is ready.',
icon: <HatGlasses size={18} />,
},
{
title: 'Draft a change',
prompt: 'Help me plan and implement the next small product improvement in nemo-platform.',
icon: <Hammer size={18} />,
title: 'Start an evaluation',
prompt: 'Start an evaluation for one of my agents using an example evaluation config.',
icon: <FlaskConical size={18} />,
},
{
title: 'Review recent work',
prompt: 'Review the current working tree and call out anything risky or unfinished.',
icon: <GitBranch size={18} />,
title: 'Set up a secret',
prompt: 'Help me create a workspace secret for an API key.',
icon: <KeyRound size={18} />,
},
];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ClaudeCodeStudioLink } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLink';
import { getStudioInternalLinkTarget } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLinkTarget';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

describe('ClaudeCodeStudioLink', () => {
it('accepts same-origin Studio workspace paths', () => {
expect(getStudioInternalLinkTarget('/workspaces/default/agents', 'https://studio.test')).toBe(
'/workspaces/default/agents'
);
expect(
getStudioInternalLinkTarget(
'https://studio.test/workspaces/default/agents?status=ready#agent',
'https://studio.test'
)
).toBe('/workspaces/default/agents?status=ready#agent');
});

it('rejects external links', () => {
expect(
getStudioInternalLinkTarget('https://example.com/settings', 'https://studio.test')
).toBeUndefined();
});

it('internalizes absolute Studio links from other local origins', () => {
expect(
getStudioInternalLinkTarget(
'http://localhost:8080/workspaces/danielleali/customizations',
'http://ns.local.aire.nvidia.com:5173',
'default'
)
).toBe('/workspaces/default/customizations');
});

it('maps legacy Studio entry URLs to the current workspace dashboard', () => {
expect(
getStudioInternalLinkTarget(
'http://localhost:8080/studio',
'http://ns.local.aire.nvidia.com:5173',
'default'
)
).toBe('/workspaces/default/dashboard');
});

it('strips legacy Studio prefixes and rewrites stale workspaces', () => {
expect(
getStudioInternalLinkTarget(
'http://localhost:8080/studio/workspaces/danielleali/agents/spanish-translator?tab=chat-playground',
'http://ns.local.aire.nvidia.com:5173',
'default'
)
).toBe('/workspaces/default/agents/spanish-translator?tab=chat-playground');
});

it('canonicalizes generated evaluation results links to the registered route', () => {
expect(
getStudioInternalLinkTarget(
'/workspaces/default/dashboard/evaluations/results',
'https://studio.test'
)
).toBe('/workspaces/default/evaluation/results');
expect(
getStudioInternalLinkTarget(
'http://localhost:8080/workspaces/danielleali/dashboard/evaluation/results?status=complete',
'http://ns.local.aire.nvidia.com:5173',
'default'
)
).toBe('/workspaces/default/evaluation/results?status=complete');
expect(
getStudioInternalLinkTarget(
'/workspaces/default/evaluations/results#latest',
'https://studio.test'
)
).toBe('/workspaces/default/evaluation/results#latest');
});

it('renders accepted Studio paths as router links', () => {
render(
<MemoryRouter>
<ClaudeCodeStudioLink href="/workspaces/default/agents">Agents</ClaudeCodeStudioLink>
</MemoryRouter>
);

const link = screen.getByRole('link', { name: 'Agents' });

expect(link).toHaveAttribute('href', '/workspaces/default/agents');
expect(link).toHaveClass('inline-flex', 'rounded');
expect(link.className).toContain('bg-[linear-gradient');
});

it('renders rejected paths as inert text', () => {
render(
<MemoryRouter>
<ClaudeCodeStudioLink href="https://example.com">External</ClaudeCodeStudioLink>
</MemoryRouter>
);

expect(screen.queryByRole('link', { name: 'External' })).not.toBeInTheDocument();
expect(screen.getByText('External')).toBeInTheDocument();
});
});
Loading
Loading