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:
Bruno Quaresma
2025-09-26 13:30:44 -03:00
committed by GitHub
parent fc58996bbf
commit 89339f6e5d
13 changed files with 114 additions and 782 deletions
+3 -3
View File
@@ -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
View File
@@ -426,7 +426,7 @@ export type GetProvisionerDaemonsParams = {
offline?: boolean;
};
export type TasksFilter = {
type TasksFilter = {
username?: string;
};
+1 -1
View File
@@ -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>
)}
+7 -24
View File
@@ -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>
</>
);
};
+51 -60
View File
@@ -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={() => {
+16 -196
View File
@@ -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();
});
},
};
+7 -125
View File
@@ -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 its reliable.
// Currently, it doesnt always update fast enough for a good UX, so were
// 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;
-179
View File
@@ -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>
);
};
-161
View File
@@ -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
View File
@@ -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>,
),
-28
View File
@@ -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 = [
{