mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: redesign tasks page to match AI tools (#19962)
**Demo:** <img width="3228" height="1940" alt="image" src="https://github.com/user-attachments/assets/d29555ae-8122-4043-9ee2-2603506e5f3c" />
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
Grid,
|
||||
GridProps,
|
||||
type GridProps,
|
||||
Heading,
|
||||
Icon,
|
||||
Img,
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
import fm from "front-matter";
|
||||
import { readFileSync } from "fs";
|
||||
import _ from "lodash";
|
||||
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
||||
import type { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import NextLink from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import path from "path";
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MdMenu } from "react-icons/md";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
|
||||
+1
-1
@@ -426,7 +426,7 @@ export type GetProvisionerDaemonsParams = {
|
||||
offline?: boolean;
|
||||
};
|
||||
|
||||
export type TasksFilter = {
|
||||
type TasksFilter = {
|
||||
username?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const SelectTrigger = React.forwardRef<
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(
|
||||
`flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
|
||||
`gap-2 flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
|
||||
border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm
|
||||
ring-offset-background text-content-secondary placeholder:text-content-secondary focus:outline-none,
|
||||
focus:ring-2 focus:ring-content-link disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { TasksSidebar } from "../TasksSidebar/TasksSidebar";
|
||||
|
||||
const TasksLayout: FC<PropsWithChildren> = () => {
|
||||
return (
|
||||
<div className="flex items-stretch h-full">
|
||||
<TasksSidebar />
|
||||
<div className="flex flex-col h-full flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TasksLayout;
|
||||
@@ -41,11 +41,14 @@ export const TasksSidebar: FC = () => {
|
||||
<div className="flex items-center place-content-between">
|
||||
{!isCollapsed && (
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className={cn(["size-8 p-0 transition-[margin,opacity]"])}
|
||||
>
|
||||
<CoderIcon className="fill-content-primary !size-6 !p-0" />
|
||||
<RouterLink to="/">
|
||||
<CoderIcon className="fill-content-primary !size-6 !p-0" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
|
||||
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
||||
import { useAgentLogs } from "modules/resources/useAgentLogs";
|
||||
import { TasksSidebar } from "modules/tasks/TasksSidebar/TasksSidebar";
|
||||
import {
|
||||
AI_PROMPT_PARAMETER_NAME,
|
||||
getTaskApps,
|
||||
@@ -25,13 +24,7 @@ import {
|
||||
} from "modules/tasks/tasks";
|
||||
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Link as RouterLink, useParams } from "react-router";
|
||||
@@ -45,15 +38,6 @@ import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { TaskApps } from "./TaskApps";
|
||||
import { TaskTopbar } from "./TaskTopbar";
|
||||
|
||||
const TaskPageLayout: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex items-stretch h-full">
|
||||
<TasksSidebar />
|
||||
<div className="flex flex-col h-full flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TaskPage = () => {
|
||||
const { workspace: workspaceName, username } = useParams() as {
|
||||
workspace: string;
|
||||
@@ -73,7 +57,7 @@ const TaskPage = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TaskPageLayout>
|
||||
<>
|
||||
<title>{pageTitle("Error loading task")}</title>
|
||||
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
@@ -98,16 +82,16 @@ const TaskPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TaskPageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<TaskPageLayout>
|
||||
<>
|
||||
<title>{pageTitle("Loading task")}</title>
|
||||
<Loader className="w-full h-full" />
|
||||
</TaskPageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,12 +158,11 @@ const TaskPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskPageLayout>
|
||||
<>
|
||||
<title>{pageTitle(task.workspace.name)}</title>
|
||||
|
||||
<TaskTopbar task={task} />
|
||||
{content}
|
||||
</TaskPageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useAuthenticated } from "hooks/useAuthenticated";
|
||||
import { useExternalAuth } from "hooks/useExternalAuth";
|
||||
import { RedoIcon, RotateCcwIcon, SendIcon } from "lucide-react";
|
||||
import { ArrowUpIcon, RedoIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
@@ -106,8 +106,8 @@ const TaskPromptSkeleton: FC = () => {
|
||||
|
||||
{/* Bottom controls skeleton */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Skeleton className="w-[208px] h-8" />
|
||||
<Skeleton className="w-[96px] h-8" />
|
||||
<Skeleton className="w-[160px] h-8 rounded-full" />
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,15 +160,17 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
} = useExternalAuth(selectedTemplate.active_version_id);
|
||||
|
||||
// Fetch presets when template changes
|
||||
const { data: presets, isLoading: isLoadingPresets } = useQuery(
|
||||
const { data: presets } = useQuery(
|
||||
templateVersionPresets(selectedTemplate.active_version_id),
|
||||
);
|
||||
const defaultPreset = presets?.find((p) => p.Default);
|
||||
|
||||
// Handle preset selection when data changes
|
||||
useEffect(() => {
|
||||
setSelectedPresetId(defaultPreset?.ID);
|
||||
}, [defaultPreset?.ID]);
|
||||
if (presets && presets.length > 0) {
|
||||
const defaultPreset = presets.find((p) => p.Default) || presets[0];
|
||||
setSelectedPresetId(defaultPreset.ID);
|
||||
}
|
||||
}, [presets]);
|
||||
|
||||
// Extract AI prompt from selected preset
|
||||
const selectedPreset = presets?.find((p) => p.ID === selectedPresetId);
|
||||
@@ -225,7 +227,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
{externalAuthError && <ErrorAlert error={externalAuthError} />}
|
||||
|
||||
<fieldset
|
||||
className="border border-border border-solid rounded-lg p-4"
|
||||
className="border border-border border-solid rounded-3xl p-4"
|
||||
disabled={createTaskMutation.isPending}
|
||||
>
|
||||
<label
|
||||
@@ -251,23 +253,22 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
}`}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="templateID"
|
||||
className="text-xs font-medium text-content-primary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<label htmlFor="templateID" className="sr-only">
|
||||
Template
|
||||
</label>
|
||||
<Select
|
||||
name="templateID"
|
||||
onValueChange={(value) => setSelectedTemplateId(value)}
|
||||
onValueChange={(value) => {
|
||||
setSelectedTemplateId(value);
|
||||
}}
|
||||
defaultValue={templates[0].id}
|
||||
required
|
||||
>
|
||||
<SelectTrigger
|
||||
id="templateID"
|
||||
className="w-80 text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3"
|
||||
className="w-fit text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3 rounded-full"
|
||||
>
|
||||
<SelectValue placeholder="Select a template" />
|
||||
</SelectTrigger>
|
||||
@@ -285,50 +286,34 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoadingPresets ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="presetID"
|
||||
className="text-xs font-medium text-content-primary"
|
||||
>
|
||||
{selectedPresetId && (
|
||||
<div>
|
||||
<label htmlFor="presetID" className="sr-only">
|
||||
Preset
|
||||
</label>
|
||||
<Skeleton className="w-[320px] h-8" />
|
||||
<Select
|
||||
key={`preset-select-${selectedTemplate.active_version_id}`}
|
||||
name="presetID"
|
||||
value={selectedPresetId}
|
||||
onValueChange={setSelectedPresetId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="presetID"
|
||||
className="w-fit text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3 rounded-full"
|
||||
>
|
||||
<SelectValue placeholder="Select a preset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presets?.toSorted(sortByDefault).map((preset) => (
|
||||
<SelectItem value={preset.ID} key={preset.ID}>
|
||||
<span className="overflow-hidden text-ellipsis block">
|
||||
{preset.Name} {preset.Default && "(Default)"}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
presets &&
|
||||
presets.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="presetID"
|
||||
className="text-xs font-medium text-content-primary"
|
||||
>
|
||||
Preset
|
||||
</label>
|
||||
<Select
|
||||
key={`preset-select-${selectedTemplate.active_version_id}`}
|
||||
name="presetID"
|
||||
value={selectedPresetId || undefined}
|
||||
onValueChange={setSelectedPresetId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="presetID"
|
||||
className="w-80 text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3"
|
||||
>
|
||||
<SelectValue placeholder="Select a preset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presets.toSorted(sortByDefault).map((preset) => (
|
||||
<SelectItem value={preset.ID} key={preset.ID}>
|
||||
<span className="overflow-hidden text-ellipsis block">
|
||||
{preset.Name} {preset.Default && "(Default)"}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -340,7 +325,12 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button size="sm" type="submit" disabled={isMissingExternalAuth}>
|
||||
<Button
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
type="submit"
|
||||
disabled={isMissingExternalAuth}
|
||||
>
|
||||
<Spinner
|
||||
loading={
|
||||
isLoadingExternalAuth ||
|
||||
@@ -348,9 +338,9 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
createTaskMutation.isPending
|
||||
}
|
||||
>
|
||||
<SendIcon />
|
||||
<ArrowUpIcon />
|
||||
</Spinner>
|
||||
Run task
|
||||
<span className="sr-only">Run task</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,6 +370,7 @@ const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({
|
||||
<div className="flex items-center gap-2" key={auth.id}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
size="sm"
|
||||
disabled={isPollingExternalAuth || auth.authenticated}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
MockAIPromptPresets,
|
||||
MockNewTaskData,
|
||||
MockPresets,
|
||||
MockTask,
|
||||
MockTasks,
|
||||
MockTemplate,
|
||||
MockTemplateVersionExternalAuthGithub,
|
||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||
@@ -17,9 +15,9 @@ import {
|
||||
} from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks";
|
||||
import { MockUsers } from "pages/UsersPage/storybookData/users";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import TasksPage from "./TasksPage";
|
||||
|
||||
const meta: Meta<typeof TasksPage> = {
|
||||
@@ -54,7 +52,7 @@ const meta: Meta<typeof TasksPage> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TasksPage>;
|
||||
|
||||
export const LoadingAITemplates: Story = {
|
||||
export const LoadingTemplates: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockImplementation(
|
||||
() => new Promise(() => 1000 * 60 * 60),
|
||||
@@ -62,7 +60,7 @@ export const LoadingAITemplates: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingAITemplatesError: Story = {
|
||||
export const LoadingTemplatesError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockRejectedValue(
|
||||
mockApiError({
|
||||
@@ -73,177 +71,41 @@ export const LoadingAITemplatesError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyAITemplates: Story = {
|
||||
export const NoTemplates: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingTasks: Story = {
|
||||
export const NoPreset: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API.experimental, "getTasks").mockImplementation(
|
||||
() => new Promise(() => 1000 * 60 * 60),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Select the first AI template", async () => {
|
||||
const form = await canvas.findByRole("form");
|
||||
const combobox = await within(form).findByRole("combobox");
|
||||
expect(combobox).toHaveTextContent(MockTemplate.display_name);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingTasksError: Story = {
|
||||
export const WithPreset: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API.experimental, "getTasks").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to load tasks",
|
||||
}),
|
||||
);
|
||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(MockPresets);
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyTasks: Story = {
|
||||
export const PreDefinedPrompt: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasks: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasksWithPresets: Story = {
|
||||
beforeEach: () => {
|
||||
const mockTemplateWithPresets = {
|
||||
...MockTemplate,
|
||||
id: "test-template-2",
|
||||
name: "template-with-presets",
|
||||
display_name: "Template with Presets",
|
||||
};
|
||||
|
||||
spyOn(API, "getTemplates").mockResolvedValue([
|
||||
MockTemplate,
|
||||
mockTemplateWithPresets,
|
||||
]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API, "getTemplateVersionPresets").mockImplementation(
|
||||
async (versionId) => {
|
||||
// Return presets only for the second template
|
||||
if (versionId === mockTemplateWithPresets.active_version_id) {
|
||||
return MockPresets;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasksWithAIPromptPresets: Story = {
|
||||
beforeEach: () => {
|
||||
const mockTemplateWithPresets = {
|
||||
...MockTemplate,
|
||||
id: "test-template-2",
|
||||
name: "template-with-presets",
|
||||
display_name: "Template with AI Prompt Presets",
|
||||
};
|
||||
|
||||
spyOn(API, "getTemplates").mockResolvedValue([
|
||||
MockTemplate,
|
||||
mockTemplateWithPresets,
|
||||
]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API, "getTemplateVersionPresets").mockImplementation(
|
||||
async (versionId) => {
|
||||
// Return presets only for the second template
|
||||
if (versionId === mockTemplateWithPresets.active_version_id) {
|
||||
return MockAIPromptPresets;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasksWaitingForInput: Story = {
|
||||
beforeEach: () => {
|
||||
const [firstTask, ...otherTasks] = MockTasks;
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue([
|
||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue([
|
||||
{
|
||||
...firstTask,
|
||||
workspace: {
|
||||
...firstTask.workspace,
|
||||
latest_app_status: {
|
||||
...firstTask.workspace.latest_app_status,
|
||||
state: "idle",
|
||||
},
|
||||
},
|
||||
...MockPresets[0],
|
||||
Parameters: [
|
||||
{ Name: AI_PROMPT_PARAMETER_NAME, Value: "Write a poem about AI" },
|
||||
],
|
||||
},
|
||||
...otherTasks,
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasksWaitingForInputTab: Story = {
|
||||
beforeEach: () => {
|
||||
const [firstTask, ...otherTasks] = MockTasks;
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue([
|
||||
{
|
||||
...firstTask,
|
||||
workspace: {
|
||||
...firstTask.workspace,
|
||||
latest_app_status: {
|
||||
...firstTask.workspace.latest_app_status,
|
||||
state: "idle" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
...otherTasks,
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Switch to 'Waiting for input' tab", async () => {
|
||||
const waitingForInputTab = await canvas.findByRole("button", {
|
||||
name: /waiting for input/i,
|
||||
});
|
||||
await userEvent.click(waitingForInputTab);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateTaskSuccessfully: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
path: "/tasks",
|
||||
},
|
||||
routing: [
|
||||
{
|
||||
path: "/tasks",
|
||||
useStoryElement: true,
|
||||
},
|
||||
{
|
||||
path: "/tasks/:ownerName/:workspaceName",
|
||||
element: <h1>Task page</h1>,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
beforeEach: () => {
|
||||
const activeVersionId = `${MockTemplate.active_version_id}-latest`;
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
@@ -251,9 +113,6 @@ export const CreateTaskSuccessfully: Story = {
|
||||
...MockTemplate,
|
||||
active_version_id: activeVersionId,
|
||||
});
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
@@ -283,13 +142,6 @@ export const CreateTaskSuccessfully: Story = {
|
||||
const successMessage = await body.findByText(/task created/i);
|
||||
expect(successMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await step("Find task in the table", async () => {
|
||||
const table = canvasElement.querySelector("table");
|
||||
await waitFor(() => {
|
||||
expect(table).toHaveTextContent(MockNewTaskData.prompt);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -298,7 +150,6 @@ export const CreateTaskError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API.experimental, "createTask").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to create task",
|
||||
@@ -325,10 +176,7 @@ export const CreateTaskError: Story = {
|
||||
|
||||
export const WithAuthenticatedExternalAuth: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||
]);
|
||||
@@ -351,10 +199,7 @@ export const WithAuthenticatedExternalAuth: Story = {
|
||||
|
||||
export const MissingExternalAuth: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||
MockTemplateVersionExternalAuthGithub,
|
||||
]);
|
||||
@@ -377,10 +222,7 @@ export const MissingExternalAuth: Story = {
|
||||
|
||||
export const ExternalAuthError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to load external auth",
|
||||
@@ -402,25 +244,3 @@ export const ExternalAuthError: Story = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const NonAdmin: Story = {
|
||||
parameters: {
|
||||
permissions: {
|
||||
viewDeploymentConfig: false,
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Can't see filters", async () => {
|
||||
await canvas.findByRole("table");
|
||||
expect(
|
||||
canvas.queryByRole("region", { name: /filters/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { API, type TasksFilter } from "api/api";
|
||||
import { templates } from "api/queries/templates";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Button, type ButtonProps } from "components/Button/Button";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { TaskPrompt } from "./TaskPrompt";
|
||||
import { TasksTable } from "./TasksTable";
|
||||
import { UsersCombobox } from "./UsersCombobox";
|
||||
|
||||
const TasksPage: FC = () => {
|
||||
const aiTemplatesQuery = useQuery(
|
||||
@@ -26,127 +11,24 @@ const TasksPage: FC = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { user, permissions } = useAuthenticated();
|
||||
const userFilter = useSearchParamsKey({
|
||||
key: "username",
|
||||
defaultValue: user.username,
|
||||
});
|
||||
const tab = useSearchParamsKey({
|
||||
key: "tab",
|
||||
defaultValue: "all",
|
||||
});
|
||||
const filter: TasksFilter = {
|
||||
username: userFilter.value,
|
||||
};
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["tasks", filter],
|
||||
queryFn: () => API.experimental.getTasks(filter),
|
||||
refetchInterval: 10_000,
|
||||
// TODO: Switch to sorting by latest_status_app.created_at once it’s reliable.
|
||||
// Currently, it doesn’t always update fast enough for a good UX, so we’re
|
||||
// temporarily sorting by workspace.created_at instead.
|
||||
select: (tasks) =>
|
||||
tasks.toSorted(
|
||||
(a, b) =>
|
||||
new Date(b.workspace.created_at).getTime() -
|
||||
new Date(a.workspace.created_at).getTime(),
|
||||
),
|
||||
});
|
||||
const idleTasks = tasksQuery.data?.filter(
|
||||
(task) => task.workspace.latest_app_status?.state === "idle",
|
||||
);
|
||||
const displayedTasks =
|
||||
tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle("AI Tasks")}</title>
|
||||
<Margins>
|
||||
<PageHeader>
|
||||
<span className="flex flex-row gap-2">
|
||||
<PageHeaderTitle>Tasks</PageHeaderTitle>
|
||||
<FeatureStageBadge contentType={"beta"} size="md" />
|
||||
</span>
|
||||
<PageHeaderSubtitle>Automate tasks with AI</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<main className="pb-8">
|
||||
<main className="p-6 flex items-center justify-center h-full">
|
||||
<div className="w-full max-w-2xl">
|
||||
<h1 className="text-center m-0 pb-10 font-medium">
|
||||
What do you want to get done today?
|
||||
</h1>
|
||||
<TaskPrompt
|
||||
templates={aiTemplatesQuery.data}
|
||||
error={aiTemplatesQuery.error}
|
||||
onRetry={aiTemplatesQuery.refetch}
|
||||
/>
|
||||
{aiTemplatesQuery.isSuccess && (
|
||||
<section>
|
||||
{permissions.viewDeploymentConfig && (
|
||||
<section
|
||||
className="mt-6 flex justify-between"
|
||||
aria-label="Controls"
|
||||
>
|
||||
<div className="flex items-center bg-surface-secondary rounded p-1">
|
||||
<PillButton
|
||||
active={tab.value === "all"}
|
||||
onClick={() => tab.setValue("all")}
|
||||
>
|
||||
All tasks
|
||||
</PillButton>
|
||||
<PillButton
|
||||
disabled={!idleTasks || idleTasks.length === 0}
|
||||
active={tab.value === "waiting-for-input"}
|
||||
onClick={() => tab.setValue("waiting-for-input")}
|
||||
>
|
||||
Waiting for input
|
||||
{idleTasks && idleTasks.length > 0 && (
|
||||
<Badge className="-mr-0.5" size="xs" variant="info">
|
||||
{idleTasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</PillButton>
|
||||
</div>
|
||||
|
||||
<UsersCombobox
|
||||
value={userFilter.value}
|
||||
onValueChange={(username) => {
|
||||
userFilter.setValue(
|
||||
username === userFilter.value ? "" : username,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<TasksTable
|
||||
tasks={displayedTasks}
|
||||
error={tasksQuery.error}
|
||||
onRetry={tasksQuery.refetch}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</Margins>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type PillButtonProps = ButtonProps & {
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const PillButton: FC<PillButtonProps> = ({ className, active, ...props }) => {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn([
|
||||
className,
|
||||
"border-0 gap-2",
|
||||
{
|
||||
"bg-surface-primary hover:bg-surface-primary": active,
|
||||
},
|
||||
])}
|
||||
variant={active ? "outline" : "subtle"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TasksPage;
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Skeleton } from "components/Skeleton/Skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import type { Task } from "modules/tasks/tasks";
|
||||
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { relativeTime } from "utils/time";
|
||||
|
||||
type TasksTableProps = {
|
||||
tasks: Task[] | undefined;
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
export const TasksTable: FC<TasksTableProps> = ({ tasks, error, onRetry }) => {
|
||||
let body: ReactNode = null;
|
||||
|
||||
if (error) {
|
||||
body = <TasksErrorBody error={error} onRetry={onRetry} />;
|
||||
} else if (!tasks) {
|
||||
body = <TasksSkeleton />;
|
||||
} else if (tasks.length === 0) {
|
||||
body = <TasksEmpty />;
|
||||
} else {
|
||||
body = <Tasks tasks={tasks} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table className="mt-4">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created by</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{body}</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
type TasksErrorBodyProps = {
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
const TasksErrorBody: FC<TasksErrorBodyProps> = ({ error, onRetry }) => {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
<div className="rounded-lg w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
{getErrorMessage(error, "Error loading tasks")}
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
{getErrorDetail(error) ?? "Please try again"}
|
||||
</span>
|
||||
<Button size="sm" onClick={onRetry} className="mt-4">
|
||||
<RotateCcwIcon />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const TasksEmpty: FC = () => {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
<div className="w-full min-h-80 p-4 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
No tasks found
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Use the form above to run a task
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
type TasksProps = { tasks: Task[] };
|
||||
|
||||
const Tasks: FC<TasksProps> = ({ tasks }) => {
|
||||
return tasks.map(({ workspace, prompt }) => {
|
||||
const templateDisplayName =
|
||||
workspace.template_display_name ?? workspace.template_name;
|
||||
|
||||
return (
|
||||
<TableRow key={workspace.id} className="relative" hover>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={
|
||||
<>
|
||||
<span className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{prompt}
|
||||
</span>
|
||||
<RouterLink
|
||||
to={`/tasks/${workspace.owner_name}/${workspace.name}`}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<span className="sr-only">Access task</span>
|
||||
</RouterLink>
|
||||
</>
|
||||
}
|
||||
subtitle={templateDisplayName}
|
||||
avatar={
|
||||
<Avatar
|
||||
size="lg"
|
||||
variant="icon"
|
||||
src={workspace.template_icon}
|
||||
fallback={templateDisplayName}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WorkspaceAppStatus
|
||||
disabled={workspace.latest_build.status !== "running"}
|
||||
status={workspace.latest_app_status}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={workspace.owner_name}
|
||||
subtitle={
|
||||
<span className="block first-letter:uppercase">
|
||||
{relativeTime(new Date(workspace.created_at))}
|
||||
</span>
|
||||
}
|
||||
src={workspace.owner_avatar_url}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const TasksSkeleton: FC = () => {
|
||||
return (
|
||||
<TableLoaderSkeleton>
|
||||
<TableRowSkeleton>
|
||||
<TableCell>
|
||||
<AvatarDataSkeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="w-[100px] h-6" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AvatarDataSkeleton />
|
||||
</TableCell>
|
||||
</TableRowSkeleton>
|
||||
</TableLoaderSkeleton>
|
||||
);
|
||||
};
|
||||
@@ -1,161 +0,0 @@
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { users } from "api/queries/users";
|
||||
import type { User } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "components/Command/Command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useDebouncedValue } from "hooks/debounce";
|
||||
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
type UserOption = {
|
||||
label: string;
|
||||
/**
|
||||
* The username of the user.
|
||||
*/
|
||||
value: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
type UsersComboboxProps = {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const UsersCombobox: FC<UsersComboboxProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const { user } = useAuthenticated();
|
||||
const { data: options } = useQuery({
|
||||
...users({ q: debouncedSearch }),
|
||||
select: (res) => mapUsersToOptions(res.users, user, value),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const selectedOption = options?.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
disabled={!options}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[280px] justify-between"
|
||||
>
|
||||
{options ? (
|
||||
selectedOption ? (
|
||||
<UserItem option={selectedOption} className="-ml-1" />
|
||||
) : (
|
||||
"Select user..."
|
||||
)
|
||||
) : (
|
||||
<Skeleton variant="text" className="w-[120px] h-3" />
|
||||
)}
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search user..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options?.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<UserItem option={option} />
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-2 h-4 w-4",
|
||||
option.value === selectedOption?.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
type UserItemProps = {
|
||||
option: UserOption;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const UserItem: FC<UserItemProps> = ({ option, className }) => {
|
||||
return (
|
||||
<div className={cn("flex flex-1 items-center gap-2", className)}>
|
||||
<Avatar src={option.avatarUrl} fallback={option.label} />
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function mapUsersToOptions(
|
||||
users: readonly User[],
|
||||
/**
|
||||
* Includes the authenticated user in the list if they are not already
|
||||
* present. So the current user can always select themselves easily.
|
||||
*/
|
||||
authUser: User,
|
||||
/**
|
||||
* Username of the currently selected user.
|
||||
*/
|
||||
selectedValue: string,
|
||||
): UserOption[] {
|
||||
const includeAuthenticatedUser = (users: readonly User[]) => {
|
||||
const hasAuthenticatedUser = users.some(
|
||||
(u) => u.username === authUser.username,
|
||||
);
|
||||
if (hasAuthenticatedUser) {
|
||||
return users;
|
||||
}
|
||||
return [authUser, ...users];
|
||||
};
|
||||
|
||||
const sortSelectedFirst = (a: User) =>
|
||||
selectedValue && a.username === selectedValue ? -1 : 0;
|
||||
|
||||
return includeAuthenticatedUser(users)
|
||||
.toSorted(sortSelectedFirst)
|
||||
.map((user) => ({
|
||||
label: user.name || user.username,
|
||||
value: user.username,
|
||||
avatarUrl: user.avatar_url,
|
||||
}));
|
||||
}
|
||||
+8
-3
@@ -332,6 +332,9 @@ const ProvisionerJobsPage = lazy(
|
||||
"./pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPage"
|
||||
),
|
||||
);
|
||||
const TasksLayout = lazy(
|
||||
() => import("./modules/tasks/TasksLayout/TasksLayout"),
|
||||
);
|
||||
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
|
||||
const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
|
||||
|
||||
@@ -442,8 +445,6 @@ export const router = createBrowserRouter(
|
||||
|
||||
<Route path="/connectionlog" element={<ConnectionLogPage />} />
|
||||
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
|
||||
<Route path="/organizations" element={<OrganizationSettingsLayout />}>
|
||||
<Route path="new" element={<CreateOrganizationPage />} />
|
||||
|
||||
@@ -597,7 +598,11 @@ export const router = createBrowserRouter(
|
||||
/>
|
||||
<Route path="/cli-auth" element={<CliAuthPage />} />
|
||||
<Route path="/icons" element={<IconsPage />} />
|
||||
<Route path="/tasks/:username/:workspace" element={<TaskPage />} />
|
||||
|
||||
<Route path="/tasks" element={<TasksLayout />}>
|
||||
<Route index element={<TasksPage />} />
|
||||
<Route path=":username/:workspace" element={<TaskPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>,
|
||||
),
|
||||
|
||||
@@ -4883,34 +4883,6 @@ export const MockPresets: TypesGen.Preset[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const MockAIPromptPresets: TypesGen.Preset[] = [
|
||||
{
|
||||
ID: "ai-preset-1",
|
||||
Name: "Code Review",
|
||||
Description: "",
|
||||
Icon: "",
|
||||
Parameters: [
|
||||
{ Name: "AI Prompt", Value: "Review the code for best practices" },
|
||||
{ Name: "cpu", Value: "4" },
|
||||
{ Name: "memory", Value: "8GB" },
|
||||
],
|
||||
Default: true,
|
||||
DesiredPrebuildInstances: 0,
|
||||
},
|
||||
{
|
||||
ID: "ai-preset-2",
|
||||
Name: "Custom Prompt",
|
||||
Description: "",
|
||||
Icon: "",
|
||||
Parameters: [
|
||||
{ Name: "cpu", Value: "4" },
|
||||
{ Name: "memory", Value: "8GB" },
|
||||
],
|
||||
Default: false,
|
||||
DesiredPrebuildInstances: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock Tasks for AI Tasks page
|
||||
export const MockTasks = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user