Skip to content
Merged
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
4 changes: 4 additions & 0 deletions web/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ helm/

# Lock file
pnpm-lock.yaml

# Build output
dist/
packages/*/dist/
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ROUTES } from '@studio/constants/routes';
import { DashboardLandingRoute } from '@studio/routes/DashboardLandingRoute';
import { TestProviders } from '@studio/tests/util/TestProviders';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryRouter, generatePath, RouterProvider } from 'react-router';

const workspace = 'default';

const renderRoute = () => {
const route = generatePath(ROUTES.workspace.dashboard, { workspace });
const router = createMemoryRouter(
[{ path: ROUTES.workspace.dashboard, element: <DashboardLandingRoute /> }],
{
initialEntries: [route],
}
);

return render(
<TestProviders>
<RouterProvider router={router} />
</TestProviders>
);
};

describe('DashboardLandingRoute', () => {
it('renders the dashboard landing page', async () => {
renderRoute();

expect(await screen.findByText('What would you like to do?')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Message Claude' })).toBeInTheDocument();
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();
});

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

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

expect(screen.getByRole('textbox', { name: 'Message Claude' })).toHaveValue(
'Give me a concise map of this repo and the main places I should know about.'
);
});

it('only enables the send affordance once the composer has text', async () => {
const user = userEvent.setup();
renderRoute();

const composer = await screen.findByRole('textbox', { name: 'Message Claude' });
const sendButton = screen.getByRole('button', { name: 'Send message' });

expect(sendButton).toBeDisabled();

await user.type(composer, 'Sketch a dashboard');

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Send message' })).toBeEnabled();
});
});
});
145 changes: 145 additions & 0 deletions web/packages/studio/src/routes/DashboardLandingRoute/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Button, Card, Flex, Text, TextArea, Tooltip } from '@nvidia/foundations-react-core';
import { AccessibleTitle } from '@studio/components/AccessibleTitle';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
import { GitBranch, Hammer, Search, Send, Terminal } from 'lucide-react';
import {
type ChangeEvent,
type FC,
type FormEvent,
type ReactNode,
useCallback,
useState,
} from 'react';

interface PromptSuggestion {
title: string;
prompt: string;
icon: ReactNode;
}

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: 'Draft a change',
prompt: 'Help me plan and implement the next small product improvement in nemo-platform.',
icon: <Hammer size={18} />,
},
{
title: 'Review recent work',
prompt: 'Review the current working tree and call out anything risky or unfinished.',
icon: <GitBranch size={18} />,
},
];

const PromptCard = ({
suggestion,
onSelect,
}: {
suggestion: PromptSuggestion;
onSelect: () => void;
}) => (
<Card asChild interactive className="min-h-28 w-full cursor-pointer shadow-none!">
<button type="button" onClick={onSelect}>
<span className="flex size-8 items-center justify-center rounded bg-surface-raised text-accent">
{suggestion.icon}
</span>
<span className="min-w-0">
<Text kind="label/bold/md">{suggestion.title}</Text>
<Text kind="body/regular/sm" color="secondary" className="mt-1 line-clamp-2">
{suggestion.prompt}
</Text>
</span>
</button>
</Card>
);

const LandingComposer = ({
input,
onChange,
}: {
input: string;
onChange: (value: string) => void;
}) => {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
};

return (
<form
onSubmit={handleSubmit}
className="w-full rounded-2xl border border-base bg-surface-base p-2 shadow-xl"
>
<TextArea
aria-label="Message Claude"
value={input}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
placeholder="Message Claude"
rows={3}
resizeable="auto"
className="max-h-56 w-full border-0 bg-transparent"
/>
<Flex className="flex items-center justify-between gap-3 px-1 pt-2">
<Flex className="flex items-center gap-2 text-secondary">
<Terminal size={16} />
<Text kind="body/regular/sm">Claude Code</Text>
</Flex>
<Tooltip slotContent="Send">
<Button
color="brand"
size="small"
aria-label="Send message"
type="submit"
disabled={!input.trim()}
>
<Send size={16} />
</Button>
</Tooltip>
</Flex>
</form>
);
};

export const DashboardLandingRoute: FC = () => {
const [input, setInput] = useState('');

useBreadcrumbs({
items: [{ slotLabel: 'Dashboard' }],
});

const handlePromptSelect = useCallback((prompt: string) => {
setInput(prompt);
}, []);

return (
<AccessibleTitle title="Dashboard">
<main className="flex h-full min-h-[calc(100vh-var(--nv-app-bar-height))] items-center justify-center bg-surface-sunken px-4 py-10 text-primary">
<Flex className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8">
<Flex className="flex flex-col items-center gap-3 text-center">
<Text kind="body/bold/2xl" className="text-center">
What would you like to do?
</Text>
</Flex>

<LandingComposer input={input} onChange={setInput} />

<Flex className="grid w-full grid-cols-1 gap-3 md:grid-cols-3">
{PROMPT_SUGGESTIONS.map((suggestion) => (
<PromptCard
key={suggestion.title}
suggestion={suggestion}
onSelect={() => handlePromptSelect(suggestion.prompt)}
/>
))}
</Flex>
</Flex>
</main>
</AccessibleTitle>
);
};
71 changes: 71 additions & 0 deletions web/packages/studio/src/routes/RootRedirect/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ROUTES } from '@studio/constants/routes';
import { LOCATION_DISPLAY_TEST_ID } from '@studio/tests/util/constants';
import { LocationDisplay } from '@studio/tests/util/LocationDisplay';
import { TestProviders } from '@studio/tests/util/TestProviders';
import { render, screen } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router';

const renderRootRedirect = async (initialPath = '/') => {
const { RootRedirect } = await import('@studio/routes/RootRedirect');
const router = createMemoryRouter(
[
{
path: '/',
element: <RootRedirect />,
},
{
path: '/workspaces',
element: <RootRedirect />,
},
{
path: ROUTES.workspace.dashboard,
element: <LocationDisplay />,
},
{
path: ROUTES.workspace.agentsList,
element: <LocationDisplay />,
},
{
path: ROUTES.workspace.index,
element: <LocationDisplay />,
},
],
{ initialEntries: [initialPath] }
);

return render(
<TestProviders>
<RouterProvider router={router} />
</TestProviders>
);
};

describe('RootRedirect', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('uses coding agent studio as the root landing page when enabled', async () => {
vi.resetModules();
vi.stubEnv('VITE_FF_CODING_AGENT_STUDIO_ENABLED', 'true');

await renderRootRedirect();

const location = await screen.findByTestId(LOCATION_DISPLAY_TEST_ID);
expect(location).toHaveTextContent('/workspaces/');
expect(location).toHaveTextContent('/dashboard');
});

it('uses the dashboard route as the /workspaces landing page when coding agent studio is enabled', async () => {
vi.resetModules();
vi.stubEnv('VITE_FF_CODING_AGENT_STUDIO_ENABLED', 'true');
await renderRootRedirect('/workspaces');

const location = await screen.findByTestId(LOCATION_DISPLAY_TEST_ID);
expect(location).toHaveTextContent('/workspaces/');
expect(location).toHaveTextContent('/dashboard');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NavigationDrawer } from '@studio/components/Layouts/NavigationDrawer';
import {
AGENTS_ENABLED,
BASE_MODELS_ENABLED,
CODING_AGENT_STUDIO_ENABLED,
CUSTOMIZER_ENABLED,
DASHBOARD_ENABLED,
DATA_DESIGNER_ENABLED,
Expand Down Expand Up @@ -68,16 +69,17 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => {
const workspace = useWorkspaceFromPath();

const items = useMemo(() => {
const dashboardNav = DASHBOARD_ENABLED
? [
{
id: 'dashboard',
slotIcon: <Home className={iconColorClass} />,
slotLabel: 'Dashboard',
href: getWorkspaceDashboardRoute(workspace),
},
]
: [];
const dashboardNav =
DASHBOARD_ENABLED || CODING_AGENT_STUDIO_ENABLED
? [
{
id: 'dashboard',
slotIcon: <Home className={iconColorClass} />,
slotLabel: 'Dashboard',
href: getWorkspaceDashboardRoute(workspace),
},
]
: [];
const jobsNav = JOBS_ENABLED
? [
{
Expand Down
7 changes: 7 additions & 0 deletions web/packages/studio/src/routes/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ describe('Routes', () => {
).toBe(true);
});

it('should include the dashboard route if coding agent studio is enabled', async () => {
vi.stubEnv('VITE_FF_CODING_AGENT_STUDIO_ENABLED', 'true');
vi.stubEnv('VITE_FF_DASHBOARD_ENABLED', 'false');
const { routes } = await import('./index');
expect(findIfRouteExists(routes, ROUTES.workspace.dashboard)).toBe(true);
});

it('should exclude safe synthesizer routes if safe synthesizer is disabled', async () => {
vi.stubEnv('VITE_FF_SAFE_SYNTHESIZER_ENABLED', 'false');
const { routes } = await import('./index');
Expand Down
14 changes: 13 additions & 1 deletion web/packages/studio/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ErrorPanel } from '@studio/components/ErrorPanel';
import { Loading } from '@studio/components/Layouts/Loading';
import {
AGENTS_ENABLED,
CODING_AGENT_STUDIO_ENABLED,
DATA_DESIGNER_ENABLED,
DEPLOYMENTS_ENABLED,
GUARDRAILS_ENABLED,
Expand Down Expand Up @@ -170,6 +171,11 @@ const PromptTuningFormRoute = lazy(() =>
default: module.PromptTuningFormRoute,
}))
);
const DashboardLandingRoute = lazy(() =>
import('@studio/routes/DashboardLandingRoute').then((module) => ({
default: module.DashboardLandingRoute,
}))
);
const ModelCompareRoute =
MODEL_COMPARE_ENABLED &&
lazy(() =>
Expand Down Expand Up @@ -391,7 +397,13 @@ export const routes: RouteObject[] = [
...gateDashboardRoutes([
{
path: ROUTES.workspace.dashboard,
element: <WorkspaceDashboardRoute />,
element: CODING_AGENT_STUDIO_ENABLED ? (
<Suspense fallback={<Loading description="Loading Dashboard..." />}>
<DashboardLandingRoute />
</Suspense>
) : (
<WorkspaceDashboardRoute />
),
errorElement: <ErrorPanel title="Workspace" />,
},
]),
Expand Down
Loading
Loading