mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: revert redesign tasks page to match AI tools (#20013)
Reverts coder/coder#19962
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
|||||||
DrawerOverlay,
|
DrawerOverlay,
|
||||||
Flex,
|
Flex,
|
||||||
Grid,
|
Grid,
|
||||||
type GridProps,
|
GridProps,
|
||||||
Heading,
|
Heading,
|
||||||
Icon,
|
Icon,
|
||||||
Img,
|
Img,
|
||||||
@@ -28,12 +28,12 @@ import {
|
|||||||
import fm from "front-matter";
|
import fm from "front-matter";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import type { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import NextLink from "next/link";
|
import NextLink from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { MdMenu } from "react-icons/md";
|
import { MdMenu } from "react-icons/md";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from "rehype-raw";
|
||||||
|
|||||||
+1
-1
@@ -426,7 +426,7 @@ export type GetProvisionerDaemonsParams = {
|
|||||||
offline?: boolean;
|
offline?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TasksFilter = {
|
export type TasksFilter = {
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const SelectTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
`gap-2 flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
|
`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
|
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,
|
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
|
focus:ring-2 focus:ring-content-link disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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,14 +41,11 @@ export const TasksSidebar: FC = () => {
|
|||||||
<div className="flex items-center place-content-between">
|
<div className="flex items-center place-content-between">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<Button
|
<Button
|
||||||
asChild
|
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
className={cn(["size-8 p-0 transition-[margin,opacity]"])}
|
className={cn(["size-8 p-0 transition-[margin,opacity]"])}
|
||||||
>
|
>
|
||||||
<RouterLink to="/">
|
<CoderIcon className="fill-content-primary !size-6 !p-0" />
|
||||||
<CoderIcon className="fill-content-primary !size-6 !p-0" />
|
|
||||||
</RouterLink>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
|
|||||||
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
||||||
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
||||||
import { useAgentLogs } from "modules/resources/useAgentLogs";
|
import { useAgentLogs } from "modules/resources/useAgentLogs";
|
||||||
|
import { TasksSidebar } from "modules/tasks/TasksSidebar/TasksSidebar";
|
||||||
import {
|
import {
|
||||||
AI_PROMPT_PARAMETER_NAME,
|
AI_PROMPT_PARAMETER_NAME,
|
||||||
getTaskApps,
|
getTaskApps,
|
||||||
@@ -24,7 +25,13 @@ import {
|
|||||||
} from "modules/tasks/tasks";
|
} from "modules/tasks/tasks";
|
||||||
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
|
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
|
||||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||||
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type ReactNode,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||||
import { Link as RouterLink, useParams } from "react-router";
|
import { Link as RouterLink, useParams } from "react-router";
|
||||||
@@ -38,6 +45,15 @@ import { TaskAppIFrame } from "./TaskAppIframe";
|
|||||||
import { TaskApps } from "./TaskApps";
|
import { TaskApps } from "./TaskApps";
|
||||||
import { TaskTopbar } from "./TaskTopbar";
|
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 TaskPage = () => {
|
||||||
const { workspace: workspaceName, username } = useParams() as {
|
const { workspace: workspaceName, username } = useParams() as {
|
||||||
workspace: string;
|
workspace: string;
|
||||||
@@ -57,7 +73,7 @@ const TaskPage = () => {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<>
|
<TaskPageLayout>
|
||||||
<title>{pageTitle("Error loading task")}</title>
|
<title>{pageTitle("Error loading task")}</title>
|
||||||
|
|
||||||
<div className="w-full min-h-80 flex items-center justify-center">
|
<div className="w-full min-h-80 flex items-center justify-center">
|
||||||
@@ -82,16 +98,16 @@ const TaskPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</TaskPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return (
|
return (
|
||||||
<>
|
<TaskPageLayout>
|
||||||
<title>{pageTitle("Loading task")}</title>
|
<title>{pageTitle("Loading task")}</title>
|
||||||
<Loader className="w-full h-full" />
|
<Loader className="w-full h-full" />
|
||||||
</>
|
</TaskPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +174,12 @@ const TaskPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TaskPageLayout>
|
||||||
<title>{pageTitle(task.workspace.name)}</title>
|
<title>{pageTitle(task.workspace.name)}</title>
|
||||||
|
|
||||||
<TaskTopbar task={task} />
|
<TaskTopbar task={task} />
|
||||||
{content}
|
{content}
|
||||||
</>
|
</TaskPageLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "components/Tooltip/Tooltip";
|
} from "components/Tooltip/Tooltip";
|
||||||
import { useAuthenticated } from "hooks/useAuthenticated";
|
import { useAuthenticated } from "hooks/useAuthenticated";
|
||||||
import { useExternalAuth } from "hooks/useExternalAuth";
|
import { useExternalAuth } from "hooks/useExternalAuth";
|
||||||
import { ArrowUpIcon, RedoIcon, RotateCcwIcon } from "lucide-react";
|
import { RedoIcon, RotateCcwIcon, SendIcon } from "lucide-react";
|
||||||
import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks";
|
import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks";
|
||||||
import { type FC, useEffect, useState } from "react";
|
import { type FC, useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
@@ -106,8 +106,8 @@ const TaskPromptSkeleton: FC = () => {
|
|||||||
|
|
||||||
{/* Bottom controls skeleton */}
|
{/* Bottom controls skeleton */}
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Skeleton className="w-[160px] h-8 rounded-full" />
|
<Skeleton className="w-[208px] h-8" />
|
||||||
<Skeleton className="size-8 rounded-full" />
|
<Skeleton className="w-[96px] h-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,17 +160,15 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
|||||||
} = useExternalAuth(selectedTemplate.active_version_id);
|
} = useExternalAuth(selectedTemplate.active_version_id);
|
||||||
|
|
||||||
// Fetch presets when template changes
|
// Fetch presets when template changes
|
||||||
const { data: presets } = useQuery(
|
const { data: presets, isLoading: isLoadingPresets } = useQuery(
|
||||||
templateVersionPresets(selectedTemplate.active_version_id),
|
templateVersionPresets(selectedTemplate.active_version_id),
|
||||||
);
|
);
|
||||||
|
const defaultPreset = presets?.find((p) => p.Default);
|
||||||
|
|
||||||
// Handle preset selection when data changes
|
// Handle preset selection when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (presets && presets.length > 0) {
|
setSelectedPresetId(defaultPreset?.ID);
|
||||||
const defaultPreset = presets.find((p) => p.Default) || presets[0];
|
}, [defaultPreset?.ID]);
|
||||||
setSelectedPresetId(defaultPreset.ID);
|
|
||||||
}
|
|
||||||
}, [presets]);
|
|
||||||
|
|
||||||
// Extract AI prompt from selected preset
|
// Extract AI prompt from selected preset
|
||||||
const selectedPreset = presets?.find((p) => p.ID === selectedPresetId);
|
const selectedPreset = presets?.find((p) => p.ID === selectedPresetId);
|
||||||
@@ -227,7 +225,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
|||||||
{externalAuthError && <ErrorAlert error={externalAuthError} />}
|
{externalAuthError && <ErrorAlert error={externalAuthError} />}
|
||||||
|
|
||||||
<fieldset
|
<fieldset
|
||||||
className="border border-border border-solid rounded-3xl p-4"
|
className="border border-border border-solid rounded-lg p-4"
|
||||||
disabled={createTaskMutation.isPending}
|
disabled={createTaskMutation.isPending}
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
@@ -253,22 +251,23 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="templateID" className="sr-only">
|
<label
|
||||||
|
htmlFor="templateID"
|
||||||
|
className="text-xs font-medium text-content-primary"
|
||||||
|
>
|
||||||
Template
|
Template
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
name="templateID"
|
name="templateID"
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => setSelectedTemplateId(value)}
|
||||||
setSelectedTemplateId(value);
|
|
||||||
}}
|
|
||||||
defaultValue={templates[0].id}
|
defaultValue={templates[0].id}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
id="templateID"
|
id="templateID"
|
||||||
className="w-fit text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3 rounded-full"
|
className="w-80 text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select a template" />
|
<SelectValue placeholder="Select a template" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -286,34 +285,50 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedPresetId && (
|
{isLoadingPresets ? (
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="presetID" className="sr-only">
|
<label
|
||||||
|
htmlFor="presetID"
|
||||||
|
className="text-xs font-medium text-content-primary"
|
||||||
|
>
|
||||||
Preset
|
Preset
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Skeleton className="w-[320px] h-8" />
|
||||||
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -325,12 +340,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button size="sm" type="submit" disabled={isMissingExternalAuth}>
|
||||||
size="icon"
|
|
||||||
className="rounded-full"
|
|
||||||
type="submit"
|
|
||||||
disabled={isMissingExternalAuth}
|
|
||||||
>
|
|
||||||
<Spinner
|
<Spinner
|
||||||
loading={
|
loading={
|
||||||
isLoadingExternalAuth ||
|
isLoadingExternalAuth ||
|
||||||
@@ -338,9 +348,9 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
|||||||
createTaskMutation.isPending
|
createTaskMutation.isPending
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ArrowUpIcon />
|
<SendIcon />
|
||||||
</Spinner>
|
</Spinner>
|
||||||
<span className="sr-only">Run task</span>
|
Run task
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,7 +380,6 @@ const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({
|
|||||||
<div className="flex items-center gap-2" key={auth.id}>
|
<div className="flex items-center gap-2" key={auth.id}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-full"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPollingExternalAuth || auth.authenticated}
|
disabled={isPollingExternalAuth || auth.authenticated}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
MockAIPromptPresets,
|
||||||
MockNewTaskData,
|
MockNewTaskData,
|
||||||
MockPresets,
|
MockPresets,
|
||||||
MockTask,
|
MockTask,
|
||||||
|
MockTasks,
|
||||||
MockTemplate,
|
MockTemplate,
|
||||||
MockTemplateVersionExternalAuthGithub,
|
MockTemplateVersionExternalAuthGithub,
|
||||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||||
@@ -15,9 +17,9 @@ import {
|
|||||||
} from "testHelpers/storybook";
|
} from "testHelpers/storybook";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { API } from "api/api";
|
import { API } from "api/api";
|
||||||
import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks";
|
|
||||||
import { MockUsers } from "pages/UsersPage/storybookData/users";
|
import { MockUsers } from "pages/UsersPage/storybookData/users";
|
||||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||||
|
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||||
import TasksPage from "./TasksPage";
|
import TasksPage from "./TasksPage";
|
||||||
|
|
||||||
const meta: Meta<typeof TasksPage> = {
|
const meta: Meta<typeof TasksPage> = {
|
||||||
@@ -52,7 +54,7 @@ const meta: Meta<typeof TasksPage> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof TasksPage>;
|
type Story = StoryObj<typeof TasksPage>;
|
||||||
|
|
||||||
export const LoadingTemplates: Story = {
|
export const LoadingAITemplates: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockImplementation(
|
spyOn(API, "getTemplates").mockImplementation(
|
||||||
() => new Promise(() => 1000 * 60 * 60),
|
() => new Promise(() => 1000 * 60 * 60),
|
||||||
@@ -60,7 +62,7 @@ export const LoadingTemplates: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoadingTemplatesError: Story = {
|
export const LoadingAITemplatesError: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockRejectedValue(
|
spyOn(API, "getTemplates").mockRejectedValue(
|
||||||
mockApiError({
|
mockApiError({
|
||||||
@@ -71,41 +73,177 @@ export const LoadingTemplatesError: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoTemplates: Story = {
|
export const EmptyAITemplates: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([]);
|
spyOn(API, "getTemplates").mockResolvedValue([]);
|
||||||
|
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoPreset: Story = {
|
export const LoadingTasks: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
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 WithPreset: Story = {
|
export const LoadingTasksError: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(MockPresets);
|
spyOn(API.experimental, "getTasks").mockRejectedValue(
|
||||||
|
mockApiError({
|
||||||
|
message: "Failed to load tasks",
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PreDefinedPrompt: Story = {
|
export const EmptyTasks: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue([
|
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
|
||||||
{
|
},
|
||||||
...MockPresets[0],
|
};
|
||||||
Parameters: [
|
|
||||||
{ Name: AI_PROMPT_PARAMETER_NAME, Value: "Write a poem about AI" },
|
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([
|
||||||
|
{
|
||||||
|
...firstTask,
|
||||||
|
workspace: {
|
||||||
|
...firstTask.workspace,
|
||||||
|
latest_app_status: {
|
||||||
|
...firstTask.workspace.latest_app_status,
|
||||||
|
state: "idle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...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 = {
|
export const CreateTaskSuccessfully: Story = {
|
||||||
decorators: [withGlobalSnackbar],
|
decorators: [withGlobalSnackbar],
|
||||||
|
parameters: {
|
||||||
|
reactRouter: reactRouterParameters({
|
||||||
|
location: {
|
||||||
|
path: "/tasks",
|
||||||
|
},
|
||||||
|
routing: [
|
||||||
|
{
|
||||||
|
path: "/tasks",
|
||||||
|
useStoryElement: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tasks/:ownerName/:workspaceName",
|
||||||
|
element: <h1>Task page</h1>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
const activeVersionId = `${MockTemplate.active_version_id}-latest`;
|
const activeVersionId = `${MockTemplate.active_version_id}-latest`;
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||||
@@ -113,6 +251,9 @@ export const CreateTaskSuccessfully: Story = {
|
|||||||
...MockTemplate,
|
...MockTemplate,
|
||||||
active_version_id: activeVersionId,
|
active_version_id: activeVersionId,
|
||||||
});
|
});
|
||||||
|
spyOn(API.experimental, "getTasks")
|
||||||
|
.mockResolvedValueOnce(MockTasks)
|
||||||
|
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||||
},
|
},
|
||||||
play: async ({ canvasElement, step }) => {
|
play: async ({ canvasElement, step }) => {
|
||||||
@@ -142,6 +283,13 @@ export const CreateTaskSuccessfully: Story = {
|
|||||||
const successMessage = await body.findByText(/task created/i);
|
const successMessage = await body.findByText(/task created/i);
|
||||||
expect(successMessage).toBeInTheDocument();
|
expect(successMessage).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await step("Find task in the table", async () => {
|
||||||
|
const table = canvasElement.querySelector("table");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(table).toHaveTextContent(MockNewTaskData.prompt);
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,6 +298,7 @@ export const CreateTaskError: Story = {
|
|||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||||
spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
|
spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
|
||||||
|
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||||
spyOn(API.experimental, "createTask").mockRejectedValue(
|
spyOn(API.experimental, "createTask").mockRejectedValue(
|
||||||
mockApiError({
|
mockApiError({
|
||||||
message: "Failed to create task",
|
message: "Failed to create task",
|
||||||
@@ -176,7 +325,10 @@ export const CreateTaskError: Story = {
|
|||||||
|
|
||||||
export const WithAuthenticatedExternalAuth: Story = {
|
export const WithAuthenticatedExternalAuth: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
spyOn(API.experimental, "getTasks")
|
||||||
|
.mockResolvedValueOnce(MockTasks)
|
||||||
|
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||||
|
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||||
]);
|
]);
|
||||||
@@ -199,7 +351,10 @@ export const WithAuthenticatedExternalAuth: Story = {
|
|||||||
|
|
||||||
export const MissingExternalAuth: Story = {
|
export const MissingExternalAuth: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
spyOn(API.experimental, "getTasks")
|
||||||
|
.mockResolvedValueOnce(MockTasks)
|
||||||
|
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||||
|
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||||
MockTemplateVersionExternalAuthGithub,
|
MockTemplateVersionExternalAuthGithub,
|
||||||
]);
|
]);
|
||||||
@@ -222,7 +377,10 @@ export const MissingExternalAuth: Story = {
|
|||||||
|
|
||||||
export const ExternalAuthError: Story = {
|
export const ExternalAuthError: Story = {
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
spyOn(API.experimental, "getTasks")
|
||||||
|
.mockResolvedValueOnce(MockTasks)
|
||||||
|
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||||
|
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||||
spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue(
|
spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue(
|
||||||
mockApiError({
|
mockApiError({
|
||||||
message: "Failed to load external auth",
|
message: "Failed to load external auth",
|
||||||
@@ -244,3 +402,25 @@ 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,8 +1,23 @@
|
|||||||
|
import { API, type TasksFilter } from "api/api";
|
||||||
import { templates } from "api/queries/templates";
|
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 type { FC } from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
|
import { cn } from "utils/cn";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
import { TaskPrompt } from "./TaskPrompt";
|
import { TaskPrompt } from "./TaskPrompt";
|
||||||
|
import { TasksTable } from "./TasksTable";
|
||||||
|
import { UsersCombobox } from "./UsersCombobox";
|
||||||
|
|
||||||
const TasksPage: FC = () => {
|
const TasksPage: FC = () => {
|
||||||
const aiTemplatesQuery = useQuery(
|
const aiTemplatesQuery = useQuery(
|
||||||
@@ -11,24 +26,127 @@ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>{pageTitle("AI Tasks")}</title>
|
<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="p-6 flex items-center justify-center h-full">
|
<main className="pb-8">
|
||||||
<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
|
<TaskPrompt
|
||||||
templates={aiTemplatesQuery.data}
|
templates={aiTemplatesQuery.data}
|
||||||
error={aiTemplatesQuery.error}
|
error={aiTemplatesQuery.error}
|
||||||
onRetry={aiTemplatesQuery.refetch}
|
onRetry={aiTemplatesQuery.refetch}
|
||||||
/>
|
/>
|
||||||
</div>
|
{aiTemplatesQuery.isSuccess && (
|
||||||
</main>
|
<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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
export default TasksPage;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
+3
-8
@@ -332,9 +332,6 @@ const ProvisionerJobsPage = lazy(
|
|||||||
"./pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPage"
|
"./pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPage"
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const TasksLayout = lazy(
|
|
||||||
() => import("./modules/tasks/TasksLayout/TasksLayout"),
|
|
||||||
);
|
|
||||||
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
|
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
|
||||||
const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
|
const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
|
||||||
|
|
||||||
@@ -445,6 +442,8 @@ export const router = createBrowserRouter(
|
|||||||
|
|
||||||
<Route path="/connectionlog" element={<ConnectionLogPage />} />
|
<Route path="/connectionlog" element={<ConnectionLogPage />} />
|
||||||
|
|
||||||
|
<Route path="/tasks" element={<TasksPage />} />
|
||||||
|
|
||||||
<Route path="/organizations" element={<OrganizationSettingsLayout />}>
|
<Route path="/organizations" element={<OrganizationSettingsLayout />}>
|
||||||
<Route path="new" element={<CreateOrganizationPage />} />
|
<Route path="new" element={<CreateOrganizationPage />} />
|
||||||
|
|
||||||
@@ -598,11 +597,7 @@ export const router = createBrowserRouter(
|
|||||||
/>
|
/>
|
||||||
<Route path="/cli-auth" element={<CliAuthPage />} />
|
<Route path="/cli-auth" element={<CliAuthPage />} />
|
||||||
<Route path="/icons" element={<IconsPage />} />
|
<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>
|
||||||
</Route>,
|
</Route>,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4927,6 +4927,34 @@ 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
|
// Mock Tasks for AI Tasks page
|
||||||
export const MockTasks = [
|
export const MockTasks = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user