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
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { CJobCancellableStatuses } from '@nemo/common/src/constants/query';
import {
getDataDesignerListCreateJobsQueryKey,
useDataDesignerCancelCreateJob,
} from '@nemo/sdk/generated/data-designer/api';
import type { CreateJob as DataDesignerJob } from '@nemo/sdk/generated/data-designer/schema';
import { DeleteJobModal } from '@studio/components/dataViews/DataDesignerJobsDataView/DeleteJobModal';
import { buildClonedJobRequest } from '@studio/components/NewDataDesignerJobForm/utils';
import {
type QuickActionItem,
QuickActionsMenuRoot,
} from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { getDataDesignerJobDetailsRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils';
import { useQueryClient } from '@tanstack/react-query';
import { type FC, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';

interface DataDesignerJobActionsMenuProps {
job: DataDesignerJob;
/** Include a "View details" entry. Used in the table row, omitted on the details page. */
includeViewDetails?: boolean;
/** Called after the job is successfully deleted, e.g. to navigate away from the details page. */
onDeleted?: () => void;
/** Surface a cancel error (or `undefined` to clear) so the caller can render it. */
onCancelError?: (message: string | undefined) => void;
}

/**
* Quick-actions menu for a single Data Designer job: View details (optional), Clone, Cancel
* (when cancellable), and Delete. Shared by the jobs table and the job details page so both
* expose the same actions. Owns its own delete modal; cancel errors are surfaced via callback.
*/
export const DataDesignerJobActionsMenu: FC<DataDesignerJobActionsMenuProps> = ({
job,
includeViewDetails = false,
onDeleted,
onCancelError,
}) => {
const navigate = useNavigate();
const workspace = useWorkspaceFromPath();
const queryClient = useQueryClient();
const [showDeleteModal, setShowDeleteModal] = useState(false);

const cancelJobMutation = useDataDesignerCancelCreateJob({
mutation: {
onSuccess: () => {
queryClient.resetQueries({
queryKey: getDataDesignerListCreateJobsQueryKey(workspace),
});
onCancelError?.(undefined);
},
onError: (error) => {
onCancelError?.(error instanceof Error ? error.message : 'Failed to cancel job');
},
},
});

const handleClone = useCallback(() => {
const cloneJobRequest = buildClonedJobRequest(job);
if (!cloneJobRequest) return;
navigate(getNewDataDesignerJobRoute(workspace), {
state: { cloneJobRequest },
});
Comment thread
steramae-nvidia marked this conversation as resolved.
}, [job, navigate, workspace]);

const handleCancel = useCallback(async () => {
if (!job.workspace || !job.name) return;
try {
onCancelError?.(undefined);
await cancelJobMutation.mutateAsync({ workspace: job.workspace, name: job.name });
} catch {
// Error is surfaced via the mutation's onError callback.
}
}, [job.workspace, job.name, cancelJobMutation, onCancelError]);

const isCancellable = job.status != null && CJobCancellableStatuses.includes(job.status);

const actions: QuickActionItem[] = [
...(includeViewDetails
? [
{
label: 'View details',
onSelect: () => {
if (job.name) {
navigate(getDataDesignerJobDetailsRoute(workspace, job.name));
}
},
},
]
: []),
{
label: 'Clone',
onSelect: handleClone,
},
...(isCancellable
? [
{
label: 'Cancel',
onSelect: handleCancel,
},
]
: []),
{
label: 'Delete',
onSelect: () => setShowDeleteModal(true),
danger: true,
},
];

return (
<>
<QuickActionsMenuRoot actions={actions} />
{showDeleteModal && (
<DeleteJobModal
jobs={[job]}
onClose={() => setShowDeleteModal(false)}
onDeleted={onDeleted}
/>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,30 @@ describe('DatasetFileManagementSidePanel', () => {

expect(await screen.findByText('No Files')).toBeInTheDocument();
});

it('shows subfolder breadcrumb segments when navigating into a folder', async () => {
renderComponent({ currentFolder: 'folder1/subfolder' });

// Breadcrumb segments for each part of the current folder path are rendered
await waitFor(() => {
expect(screen.getByRole('button', { name: 'folder1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'subfolder' })).toBeInTheDocument();
});

// The explorer (search/toolbar) is always rendered
expect(screen.getByTestId('dataset-details-search-input')).toBeInTheDocument();
});

it('navigates to the fileset root when the fileset breadcrumb is clicked', async () => {
const user = userEvent.setup();
const onFolderChange = vi.fn();
renderComponent({
currentFolder: 'folder1',
onFolderChange,
});

await user.click(await screen.findByRole('button', { name: 'test-dataset' }));

expect(onFolderChange).toHaveBeenCalledWith();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea';
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
import { Flex, Panel, Stack, Text } from '@nvidia/foundations-react-core';
import { type Control, type FieldValues, type Path } from 'react-hook-form';

export interface JobBasicsProps<T extends FieldValues = FieldValues> {
control: Control<T>;
nameName: Path<T>;
rowsName: Path<T>;
descriptionName: Path<T>;
disabled?: boolean;
}

/**
* "Job basics" card: name the dataset, set the full-run record count, and describe the job.
*/
export function JobBasics<T extends FieldValues>({
control,
nameName,
rowsName,
descriptionName,
disabled = false,
}: JobBasicsProps<T>) {
return (
<Panel elevation="high" density="standard">
<Stack gap="density-lg">
<Stack gap="density-xs">
<Text kind="label/bold/lg">Job basics</Text>
<Text kind="body/regular/sm" className="text-secondary">
Name your fileset and set the full-run size.
</Text>
</Stack>

<Flex gap="density-md" align="start" className="w-full">
<Stack className="min-w-0 flex-1">
<ControlledTextInput
label="Fileset name"
disabled={disabled}
useControllerProps={{ name: nameName, control }}
/>
</Stack>
<Stack className="w-[200px] shrink-0">
<ControlledTextInput
label="Records to generate"
type="number"
min={1}
step={1}
required
disabled={disabled}
useControllerProps={{ name: rowsName, control }}
/>
</Stack>
</Flex>

<ControlledTextArea
label="Description"
rows={3}
placeholder="What this fileset is for and how it will be used…"
className="w-full"
disabled={disabled}
useControllerProps={{ name: descriptionName, control }}
/>
</Stack>
</Panel>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import {
sanitizeJobRequestName,
} from '@studio/components/NewDataDesignerJobForm/utils';
import type { ChatCompletion } from 'openai/resources/index.mjs';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type Control, type FieldValues, type Path, useForm, useWatch } from 'react-hook-form';
import { useCallback, useMemo, useState } from 'react';
import { type Control, type FieldValues, type Path, useWatch } from 'react-hook-form';

const ERROR_NO_TOOL_CALL =
'Model did not return a tool call. Try again or choose a different model.';
Expand Down Expand Up @@ -55,59 +55,48 @@ function getJobRequestFromChatResponse(
return { jobRequest: sanitizeJobRequestName(applied) };
}

interface JobRequestGeneratorFormFields {
jsonContent: string;
}

export interface JobRequestGeneratorProps<T extends FieldValues = FieldValues> {
control: Control<T>;
descriptionName: Path<T>;
/** Form field holding the (editable) job request JSON. Owned by the parent form. */
jsonContentName: Path<T>;
descriptionRules?: object;
descriptionFormFieldProps?: { slotInfo?: string };
/** Current workspace (used when modelRef is just a model name with no slash). */
workspace: string;
modelRef: string;
provider: string;
servedModelName: string;
onJobRequestChange: (jobRequest: DataDesignerJobRequest | null) => void;
/** Write generated JSON back into the parent's jsonContent field. */
setJsonContent: (value: string) => void;
disabled?: boolean;
}

/**
* Generates a Data Designer job request JSON via LLM tool use. Renders the description
* textarea with Generate button below it, and the JSON editor next to it (side by side).
* Reports the current (parsed) job request to the parent via onJobRequestChange.
* The JSON lives in the parent form's `jsonContentName` field — read here via `useWatch` and
* written back through `setJsonContent` — so the parent form stays the single source of truth.
*/
export function JobRequestGenerator<T extends FieldValues>({
control: parentControl,
control,
descriptionName,
jsonContentName,
descriptionRules,
descriptionFormFieldProps,
workspace,
modelRef,
provider,
servedModelName,
onJobRequestChange,
setJsonContent,
disabled = false,
}: JobRequestGeneratorProps<T>) {
const description = useWatch({ control: parentControl, name: descriptionName }) as string;
const description = useWatch({ control, name: descriptionName }) as string;
const jsonContent = (useWatch({ control, name: jsonContentName }) as string) ?? '';
const chatCompletion = useChatCompletion();
const [generationError, setGenerationError] = useState<string | null>(null);
const [parseError, setParseError] = useState<string | null>(null);

const { control, watch, setValue } = useForm<JobRequestGeneratorFormFields>({
defaultValues: { jsonContent: '' },
});
const jsonContent = watch('jsonContent') ?? '';
const onJobRequestChangeRef = useRef(onJobRequestChange);
onJobRequestChangeRef.current = onJobRequestChange;

// Sync parsed job request to parent when JSON or model context changes
useEffect(() => {
const result = parseJsonContentToJobRequest(jsonContent);
setParseError(result.error ?? null);
onJobRequestChangeRef.current(result.jobRequest);
}, [jsonContent]);
const parseError = useMemo(() => parseJsonContentToJobRequest(jsonContent).error, [jsonContent]);

const runGeneration = useCallback(async () => {
setGenerationError(null);
Expand All @@ -133,13 +122,11 @@ export function JobRequestGenerator<T extends FieldValues>({
return;
}

setValue('jsonContent', JSON.stringify(result.jobRequest, null, 2));
onJobRequestChangeRef.current(result.jobRequest);
setJsonContent(JSON.stringify(result.jobRequest, null, 2));
} catch (err) {
setGenerationError(getErrorMessage(err, 'Generation failed.'));
onJobRequestChangeRef.current(null);
}
}, [modelRef, provider, servedModelName, workspace, description, chatCompletion, setValue]);
}, [modelRef, provider, servedModelName, workspace, description, chatCompletion, setJsonContent]);

const hasContent = !!jsonContent.trim();
const isGenerating = chatCompletion.isPending;
Expand All @@ -155,7 +142,7 @@ export function JobRequestGenerator<T extends FieldValues>({
className="w-full"
useControllerProps={{
name: descriptionName,
control: parentControl,
control,
rules: descriptionRules,
}}
formFieldProps={descriptionFormFieldProps}
Expand All @@ -182,7 +169,7 @@ export function JobRequestGenerator<T extends FieldValues>({
rows={16}
className="w-full font-mono text-sm"
useControllerProps={{
name: 'jsonContent',
name: jsonContentName,
control,
}}
formFieldProps={{
Expand Down
Loading