feat: filter tasks that are waiting for user input (#19377)

Closes https://github.com/coder/coder/issues/19324

<img width="1624" height="974" alt="Screenshot 2025-08-15 at 14 45 39"
src="https://github.com/user-attachments/assets/a738d9af-f548-413b-a0e7-3f311cb997ee"
/>

---------

Co-authored-by: ケイラ <mckayla@hey.com>
This commit is contained in:
Bruno Quaresma
2025-08-21 15:06:11 -03:00
committed by GitHub
parent 014a2d5b0f
commit d77c3d0226
9 changed files with 891 additions and 799 deletions
+1 -3
View File
@@ -60,7 +60,5 @@
"typos.config": ".github/workflows/typos.toml",
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
"biome.configurationPath": "./site/biome.jsonc",
"biome.lsp.bin": "./site/node_modules/.bin/biome"
}
}
+25
View File
@@ -21,6 +21,7 @@
*/
import globalAxios, { type AxiosInstance, isAxiosError } from "axios";
import type dayjs from "dayjs";
import type { Task } from "modules/tasks/tasks";
import userAgentParser from "ua-parser-js";
import { delay } from "../utils/delay";
import { OneWayWebSocket } from "../utils/OneWayWebSocket";
@@ -422,6 +423,10 @@ export type GetProvisionerDaemonsParams = {
limit?: number;
};
export type TasksFilter = {
username?: string;
};
/**
* This is the container for all API methods. It's split off to make it more
* clear where API methods should go, but it is eventually merged into the Api
@@ -2687,6 +2692,26 @@ class ExperimentalApiMethods {
return response.data;
};
getTasks = async (filter: TasksFilter): Promise<Task[]> => {
const queryExpressions = ["has-ai-task:true"];
if (filter.username) {
queryExpressions.push(`owner:${filter.username}`);
}
const workspaces = await API.getWorkspaces({
q: queryExpressions.join(" "),
});
const prompts = await API.experimental.getAITasksPrompts(
workspaces.workspaces.map((workspace) => workspace.latest_build.id),
);
return workspaces.workspaces.map((workspace) => ({
workspace,
prompt: prompts.prompts[workspace.latest_build.id],
}));
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
+1
View File
@@ -24,6 +24,7 @@ const badgeVariants = cva(
"border border-solid border-border-destructive bg-surface-red text-highlight-red shadow",
green:
"border border-solid border-surface-green bg-surface-green text-highlight-green shadow",
info: "border border-solid border-surface-sky bg-surface-sky text-highlight-sky shadow",
},
size: {
xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5",
+434
View File
@@ -0,0 +1,434 @@
import { getErrorDetail, getErrorMessage } from "api/errors";
import { templateVersionPresets } from "api/queries/templates";
import type {
Preset,
Template,
TemplateVersionExternalAuth,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { displayError } from "components/GlobalSnackbar/utils";
import { Link } from "components/Link/Link";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { Skeleton } from "components/Skeleton/Skeleton";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useAuthenticated } from "hooks/useAuthenticated";
import { useExternalAuth } from "hooks/useExternalAuth";
import { RedoIcon, RotateCcwIcon, SendIcon } from "lucide-react";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import { type FC, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import TextareaAutosize from "react-textarea-autosize";
import { docs } from "utils/docs";
import { data } from "./data";
const textareaPlaceholder = "Prompt your AI agent to start a task...";
type TaskPromptProps = {
templates: Template[] | undefined;
error: unknown;
onRetry: () => void;
};
export const TaskPrompt: FC<TaskPromptProps> = ({
templates,
error,
onRetry,
}) => {
const navigate = useNavigate();
if (error) {
return <TaskPromptLoadingError error={error} onRetry={onRetry} />;
}
if (templates === undefined) {
return <TaskPromptSkeleton />;
}
if (templates.length === 0) {
return <TaskPromptEmpty />;
}
return (
<CreateTaskForm
templates={templates}
onSuccess={(task) => {
navigate(`/tasks/${task.workspace.owner_name}/${task.workspace.name}`);
}}
/>
);
};
const TaskPromptLoadingError: FC<{
error: unknown;
onRetry: () => void;
}> = ({ error, onRetry }) => {
return (
<div className="border border-solid 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 Task templates")}
</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>
);
};
const TaskPromptSkeleton: FC = () => {
return (
<div className="border border-border border-solid rounded-lg p-4">
<div className="space-y-4">
{/* Textarea skeleton */}
<TextareaAutosize
disabled
id="prompt"
name="prompt"
placeholder={textareaPlaceholder}
className={`border-0 resize-none w-full h-full bg-transparent rounded-lg outline-none flex min-h-[60px]
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`}
/>
{/* Bottom controls skeleton */}
<div className="flex items-center justify-between pt-2">
<Skeleton className="w-[208px] h-8" />
<Skeleton className="w-[96px] h-8" />
</div>
</div>
</div>
);
};
const TaskPromptEmpty: FC = () => {
return (
<div className="rounded-lg border border-solid border-border 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 Task templates found
</h3>
<span className="text-content-secondary text-sm">
<Link href={docs("/ai-coder/tasks")} target="_blank" rel="noreferrer">
Learn about Tasks
</Link>{" "}
to get started.
</span>
</div>
</div>
);
};
type CreateTaskMutationFnProps = {
prompt: string;
};
type CreateTaskFormProps = {
templates: Template[];
onSuccess: (task: Task) => void;
};
const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
const { user } = useAuthenticated();
const queryClient = useQueryClient();
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(
templates[0].id,
);
const [selectedPresetId, setSelectedPresetId] = useState<string>();
const selectedTemplate = templates.find(
(t) => t.id === selectedTemplateId,
) as Template;
const {
externalAuth,
externalAuthError,
isPollingExternalAuth,
isLoadingExternalAuth,
} = useExternalAuth(selectedTemplate.active_version_id);
// Fetch presets when template changes
const { data: presets, isLoading: isLoadingPresets } = 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]);
// Extract AI prompt from selected preset
const selectedPreset = presets?.find((p) => p.ID === selectedPresetId);
const presetAIPrompt = selectedPreset?.Parameters?.find(
(param) => param.Name === AI_PROMPT_PARAMETER_NAME,
)?.Value;
const isPromptReadOnly = !!presetAIPrompt;
const missedExternalAuth = externalAuth?.filter(
(auth) => !auth.optional && !auth.authenticated,
);
const isMissingExternalAuth = missedExternalAuth
? missedExternalAuth.length > 0
: true;
const createTaskMutation = useMutation({
mutationFn: async ({ prompt }: CreateTaskMutationFnProps) =>
data.createTask(
prompt,
user.id,
selectedTemplate.active_version_id,
selectedPresetId,
),
onSuccess: async (task) => {
await queryClient.invalidateQueries({
queryKey: ["tasks"],
});
onSuccess(task);
},
});
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const prompt = presetAIPrompt || (formData.get("prompt") as string);
try {
await createTaskMutation.mutateAsync({
prompt,
});
} catch (error) {
const message = getErrorMessage(error, "Error creating task");
const detail = getErrorDetail(error) ?? "Please try again";
displayError(message, detail);
}
};
return (
<form
onSubmit={onSubmit}
aria-label="Create AI task"
className="flex flex-col gap-4"
>
{externalAuthError && <ErrorAlert error={externalAuthError} />}
<fieldset
className="border border-border border-solid rounded-lg p-4"
disabled={createTaskMutation.isPending}
>
<label
htmlFor="prompt"
className={
isPromptReadOnly
? "text-xs font-medium text-content-primary mb-2 block"
: "sr-only"
}
>
{isPromptReadOnly ? "Prompt defined by preset" : "Prompt"}
</label>
<TextareaAutosize
required
id="prompt"
name="prompt"
value={presetAIPrompt || undefined}
readOnly={isPromptReadOnly}
placeholder={textareaPlaceholder}
className={`border-0 resize-none w-full h-full bg-transparent rounded-lg outline-none flex min-h-[60px]
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm ${
isPromptReadOnly ? "opacity-60 cursor-not-allowed" : ""
}`}
/>
<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"
>
Template
</label>
<Select
name="templateID"
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"
>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => {
return (
<SelectItem value={template.id} key={template.id}>
<span className="overflow-hidden text-ellipsis block">
{template.display_name || template.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{isLoadingPresets ? (
<div className="flex flex-col gap-1">
<label
htmlFor="presetID"
className="text-xs font-medium text-content-primary"
>
Preset
</label>
<Skeleton className="w-[320px] h-8" />
</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 className="flex items-center gap-2">
{missedExternalAuth && (
<ExternalAuthButtons
template={selectedTemplate}
missedExternalAuth={missedExternalAuth}
/>
)}
<Button size="sm" type="submit" disabled={isMissingExternalAuth}>
<Spinner
loading={
isLoadingExternalAuth ||
isPollingExternalAuth ||
createTaskMutation.isPending
}
>
<SendIcon />
</Spinner>
Run task
</Button>
</div>
</div>
</fieldset>
</form>
);
};
type ExternalAuthButtonProps = {
template: Template;
missedExternalAuth: TemplateVersionExternalAuth[];
};
const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({
template,
missedExternalAuth,
}) => {
const {
startPollingExternalAuth,
isPollingExternalAuth,
externalAuthPollingState,
} = useExternalAuth(template.active_version_id);
const shouldRetry = externalAuthPollingState === "abandoned";
return missedExternalAuth.map((auth) => {
return (
<div className="flex items-center gap-2" key={auth.id}>
<Button
variant="outline"
size="sm"
disabled={isPollingExternalAuth || auth.authenticated}
onClick={() => {
window.open(
auth.authenticate_url,
"_blank",
"width=900,height=600",
);
startPollingExternalAuth();
}}
>
<Spinner loading={isPollingExternalAuth}>
<ExternalImage src={auth.display_icon} />
</Spinner>
Connect to {auth.display_name}
</Button>
{shouldRetry && !auth.authenticated && (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={startPollingExternalAuth}
>
<RedoIcon />
<span className="sr-only">Refresh external auth</span>
</Button>
</TooltipTrigger>
<TooltipContent>
Retry connecting to {auth.display_name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
});
};
function sortByDefault(a: Preset, b: Preset) {
// Default preset should come first
if (a.Default && !b.Default) return -1;
if (!a.Default && b.Default) return 1;
// Otherwise, sort alphabetically by name
return a.Name.localeCompare(b.Name);
}
+74 -50
View File
@@ -19,12 +19,13 @@ import { API } from "api/api";
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, { data } from "./TasksPage";
import { data } from "./data";
import TasksPage from "./TasksPage";
const meta: Meta<typeof TasksPage> = {
title: "pages/TasksPage",
component: TasksPage,
decorators: [withAuthProvider],
decorators: [withAuthProvider, withProxyProvider()],
parameters: {
user: MockUserOwner,
permissions: {
@@ -38,7 +39,7 @@ const meta: Meta<typeof TasksPage> = {
users: MockUsers,
count: MockUsers.length,
});
spyOn(data, "fetchAITemplates").mockResolvedValue([
spyOn(API, "getTemplates").mockResolvedValue([
MockTemplate,
{
...MockTemplate,
@@ -55,7 +56,7 @@ type Story = StoryObj<typeof TasksPage>;
export const LoadingAITemplates: Story = {
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockImplementation(
spyOn(API, "getTemplates").mockImplementation(
() => new Promise(() => 1000 * 60 * 60),
);
},
@@ -63,7 +64,7 @@ export const LoadingAITemplates: Story = {
export const LoadingAITemplatesError: Story = {
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockRejectedValue(
spyOn(API, "getTemplates").mockRejectedValue(
mockApiError({
message: "Failed to load AI templates",
detail: "You don't have permission to access this resource.",
@@ -74,14 +75,15 @@ export const LoadingAITemplatesError: Story = {
export const EmptyAITemplates: Story = {
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([]);
spyOn(API, "getTemplates").mockResolvedValue([]);
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
},
};
export const LoadingTasks: Story = {
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks").mockImplementation(
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockImplementation(
() => new Promise(() => 1000 * 60 * 60),
);
},
@@ -98,8 +100,8 @@ export const LoadingTasks: Story = {
export const LoadingTasksError: Story = {
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks").mockRejectedValue(
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockRejectedValue(
mockApiError({
message: "Failed to load tasks",
}),
@@ -109,21 +111,19 @@ export const LoadingTasksError: Story = {
export const EmptyTasks: Story = {
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks").mockResolvedValue([]);
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
},
};
export const LoadedTasks: Story = {
decorators: [withProxyProvider()],
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
},
};
export const LoadedTasksWithPresets: Story = {
decorators: [withProxyProvider()],
beforeEach: () => {
const mockTemplateWithPresets = {
...MockTemplate,
@@ -132,11 +132,11 @@ export const LoadedTasksWithPresets: Story = {
display_name: "Template with Presets",
};
spyOn(data, "fetchAITemplates").mockResolvedValue([
spyOn(API, "getTemplates").mockResolvedValue([
MockTemplate,
mockTemplateWithPresets,
]);
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
spyOn(API, "getTemplateVersionPresets").mockImplementation(
async (versionId) => {
// Return presets only for the second template
@@ -150,7 +150,6 @@ export const LoadedTasksWithPresets: Story = {
};
export const LoadedTasksWithAIPromptPresets: Story = {
decorators: [withProxyProvider()],
beforeEach: () => {
const mockTemplateWithPresets = {
...MockTemplate,
@@ -159,11 +158,11 @@ export const LoadedTasksWithAIPromptPresets: Story = {
display_name: "Template with AI Prompt Presets",
};
spyOn(data, "fetchAITemplates").mockResolvedValue([
spyOn(API, "getTemplates").mockResolvedValue([
MockTemplate,
mockTemplateWithPresets,
]);
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
spyOn(API, "getTemplateVersionPresets").mockImplementation(
async (versionId) => {
// Return presets only for the second template
@@ -176,28 +175,57 @@ export const LoadedTasksWithAIPromptPresets: Story = {
},
};
export const LoadedTasksEdgeCases: Story = {
decorators: [withProxyProvider()],
export const LoadedTasksWaitingForInput: Story = {
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
// Test various edge cases for presets
spyOn(API, "getTemplateVersionPresets").mockImplementation(async () => {
return [
{
ID: "malformed",
Name: "Malformed Preset",
Default: true,
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",
},
},
// biome-ignore lint/suspicious/noExplicitAny: Testing malformed data edge cases
] as any;
},
...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: [withProxyProvider()],
parameters: {
reactRouter: reactRouterParameters({
location: {
@@ -216,8 +244,8 @@ export const CreateTaskSuccessfully: Story = {
}),
},
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks")
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
@@ -240,10 +268,10 @@ export const CreateTaskSuccessfully: Story = {
};
export const CreateTaskError: Story = {
decorators: [withProxyProvider(), withGlobalSnackbar],
decorators: [withGlobalSnackbar],
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
spyOn(data, "createTask").mockRejectedValue(
mockApiError({
message: "Failed to create task",
@@ -269,9 +297,8 @@ export const CreateTaskError: Story = {
};
export const WithAuthenticatedExternalAuth: Story = {
decorators: [withProxyProvider()],
beforeEach: () => {
spyOn(data, "fetchTasks")
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
@@ -296,9 +323,8 @@ export const WithAuthenticatedExternalAuth: Story = {
};
export const MissingExternalAuth: Story = {
decorators: [withProxyProvider()],
beforeEach: () => {
spyOn(data, "fetchTasks")
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
@@ -323,9 +349,8 @@ export const MissingExternalAuth: Story = {
};
export const ExternalAuthError: Story = {
decorators: [withProxyProvider()],
beforeEach: () => {
spyOn(data, "fetchTasks")
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
@@ -352,15 +377,14 @@ export const ExternalAuthError: Story = {
};
export const NonAdmin: Story = {
decorators: [withProxyProvider()],
parameters: {
permissions: {
viewDeploymentConfig: false,
},
},
beforeEach: () => {
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
+103 -724
View File
@@ -1,82 +1,54 @@
import Skeleton from "@mui/material/Skeleton";
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { templateVersionPresets } from "api/queries/templates";
import { disabledRefetchOptions } from "api/queries/util";
import type {
Preset,
Template,
TemplateVersionExternalAuth,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
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 { ExternalImage } from "components/ExternalImage/ExternalImage";
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 { displayError } from "components/GlobalSnackbar/utils";
import { Link } from "components/Link/Link";
import { Margins } from "components/Margins/Margins";
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { Spinner } from "components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useAuthenticated } from "hooks";
import { useExternalAuth } from "hooks/useExternalAuth";
import { RedoIcon, RotateCcwIcon, SendIcon } from "lucide-react";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link as RouterLink, useNavigate } from "react-router";
import TextareaAutosize from "react-textarea-autosize";
import { docs } from "utils/docs";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { relativeTime } from "utils/time";
import { type UserOption, UsersCombobox } from "./UsersCombobox";
type TasksFilter = {
user: UserOption | undefined;
};
import { TaskPrompt } from "./TaskPrompt";
import { TasksTable } from "./TasksTable";
import { UsersCombobox } from "./UsersCombobox";
const TasksPage: FC = () => {
const aiTemplatesQuery = useQuery(
templates({
q: "has-ai-task:true",
}),
);
const { user, permissions } = useAuthenticated();
const [filter, setFilter] = useState<TasksFilter>({
user: {
value: user.username,
label: user.name || user.username,
avatarUrl: user.avatar_url,
},
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,
});
const idleTasks = tasksQuery.data?.filter(
(task) => task.workspace.latest_app_status?.state === "idle",
);
const displayedTasks =
tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data;
return (
<>
@@ -93,675 +65,82 @@ const TasksPage: FC = () => {
</PageHeader>
<main className="pb-8">
<TaskFormSection
showFilter={permissions.viewDeploymentConfig}
filter={filter}
onFilterChange={setFilter}
<TaskPrompt
templates={aiTemplatesQuery.data}
error={aiTemplatesQuery.error}
onRetry={aiTemplatesQuery.refetch}
/>
<TasksTable filter={filter} />
{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>
</>
);
};
const textareaPlaceholder = "Prompt your AI agent to start a task...";
type PillButtonProps = ButtonProps & {
active?: boolean;
};
const LoadingTemplatesPlaceholder: FC = () => {
const PillButton: FC<PillButtonProps> = ({ className, active, ...props }) => {
return (
<div className="border border-border border-solid rounded-lg p-4">
<div className="space-y-4">
{/* Textarea skeleton */}
<TextareaAutosize
disabled
id="prompt"
name="prompt"
placeholder={textareaPlaceholder}
className={`border-0 resize-none w-full h-full bg-transparent rounded-lg outline-none flex min-h-[60px]
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`}
/>
{/* Bottom controls skeleton */}
<div className="flex items-center justify-between pt-2">
<Skeleton variant="rounded" width={208} height={32} />
<Skeleton variant="rounded" width={96} height={32} />
</div>
</div>
</div>
<Button
size="sm"
className={cn([
className,
"border-0 gap-2",
{
"bg-surface-primary hover:bg-surface-primary": active,
},
])}
variant={active ? "outline" : "subtle"}
{...props}
/>
);
};
const NoTemplatesPlaceholder: FC = () => {
return (
<div className="rounded-lg border border-solid border-border 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 Task templates found
</h3>
<span className="text-content-secondary text-sm">
<Link href={docs("/ai-coder/tasks")} target="_blank" rel="noreferrer">
Learn about Tasks
</Link>{" "}
to get started.
</span>
</div>
</div>
);
};
const ErrorContent: FC<{
title: string;
detail: string;
onRetry: () => void;
}> = ({ title, detail, onRetry }) => {
return (
<div className="border border-solid 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">
{title}
</h3>
<span className="text-content-secondary text-sm">{detail}</span>
<Button size="sm" onClick={onRetry} className="mt-4">
<RotateCcwIcon />
Try again
</Button>
</div>
</div>
);
};
const TaskFormSection: FC<{
showFilter: boolean;
filter: TasksFilter;
onFilterChange: (filter: TasksFilter) => void;
}> = ({ showFilter, filter, onFilterChange }) => {
const navigate = useNavigate();
const {
data: templates,
error,
refetch,
} = useQuery({
queryKey: ["templates", "ai"],
queryFn: data.fetchAITemplates,
...disabledRefetchOptions,
});
if (error) {
return (
<ErrorContent
title={getErrorMessage(error, "Error loading Task templates")}
detail={getErrorDetail(error) ?? "Please try again"}
onRetry={() => refetch()}
/>
);
}
if (templates === undefined) {
return <LoadingTemplatesPlaceholder />;
}
if (templates.length === 0) {
return <NoTemplatesPlaceholder />;
}
return (
<>
<TaskForm
templates={templates}
onSuccess={(task) => {
navigate(
`/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
);
}}
/>
{showFilter && (
<TasksFilter filter={filter} onFilterChange={onFilterChange} />
)}
</>
);
};
type CreateTaskMutationFnProps = {
prompt: string;
templateVersionId: string;
presetId: string | null;
};
type TaskFormProps = {
templates: Template[];
onSuccess: (task: Task) => void;
};
const TaskForm: FC<TaskFormProps> = ({ templates, onSuccess }) => {
const { user } = useAuthenticated();
const queryClient = useQueryClient();
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(
templates[0].id,
);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const selectedTemplate = templates.find(
(t) => t.id === selectedTemplateId,
) as Template;
const {
externalAuth,
externalAuthError,
isPollingExternalAuth,
isLoadingExternalAuth,
} = useExternalAuth(selectedTemplate.active_version_id);
// Fetch presets when template changes
const { data: presetsData, isLoading: isLoadingPresets } = useQuery<
Preset[] | null,
Error
>(templateVersionPresets(selectedTemplate.active_version_id));
// Handle preset selection when data changes
useEffect(() => {
if (presetsData === undefined) {
// Still loading
return;
}
if (!presetsData || presetsData.length === 0) {
setSelectedPresetId(null);
return;
}
// Always select the default preset when new data arrives
const defaultPreset = presetsData.find((p: Preset) => p.Default);
const defaultPresetID = defaultPreset?.ID || null;
setSelectedPresetId(defaultPresetID);
}, [presetsData]);
// Extract AI prompt from selected preset
const selectedPreset = presetsData?.find((p) => p.ID === selectedPresetId);
const presetAIPrompt = selectedPreset?.Parameters?.find(
(param) => param.Name === AI_PROMPT_PARAMETER_NAME,
)?.Value;
const isPromptReadOnly = !!presetAIPrompt;
const missedExternalAuth = externalAuth?.filter(
(auth) => !auth.optional && !auth.authenticated,
);
const isMissingExternalAuth = missedExternalAuth
? missedExternalAuth.length > 0
: true;
const createTaskMutation = useMutation({
mutationFn: async ({
prompt,
templateVersionId,
presetId,
}: CreateTaskMutationFnProps) =>
data.createTask(prompt, user.id, templateVersionId, presetId),
onSuccess: async (task) => {
await queryClient.invalidateQueries({
queryKey: ["tasks"],
});
onSuccess(task);
},
});
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const prompt = presetAIPrompt || (formData.get("prompt") as string);
try {
await createTaskMutation.mutateAsync({
prompt,
templateVersionId: selectedTemplate.active_version_id,
presetId: selectedPresetId,
});
} catch (error) {
const message = getErrorMessage(error, "Error creating task");
const detail = getErrorDetail(error) ?? "Please try again";
displayError(message, detail);
}
};
return (
<form
onSubmit={onSubmit}
aria-label="Create AI task"
className="flex flex-col gap-4"
>
{externalAuthError && <ErrorAlert error={externalAuthError} />}
<fieldset
className="border border-border border-solid rounded-lg p-4"
disabled={createTaskMutation.isPending}
>
<label
htmlFor="prompt"
className={
isPromptReadOnly
? "text-xs font-medium text-content-primary mb-2 block"
: "sr-only"
}
>
{isPromptReadOnly ? "Prompt defined by preset" : "Prompt"}
</label>
<TextareaAutosize
required
id="prompt"
name="prompt"
value={presetAIPrompt || undefined}
readOnly={isPromptReadOnly}
placeholder={textareaPlaceholder}
className={`border-0 resize-none w-full h-full bg-transparent rounded-lg outline-none flex min-h-[60px]
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm ${
isPromptReadOnly ? "opacity-60 cursor-not-allowed" : ""
}`}
/>
<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"
>
Template
</label>
<Select
name="templateID"
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"
>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => {
return (
<SelectItem value={template.id} key={template.id}>
<span className="overflow-hidden text-ellipsis block">
{template.display_name || template.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{isLoadingPresets ? (
<div className="flex flex-col gap-1">
<label
htmlFor="presetID"
className="text-xs font-medium text-content-primary"
>
Preset
</label>
<Skeleton variant="rounded" width={320} height={32} />
</div>
) : (
presetsData &&
presetsData.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={(value) =>
setSelectedPresetId(value || null)
}
>
<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>
{sortedPresets(presetsData).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 className="flex items-center gap-2">
{missedExternalAuth && (
<ExternalAuthButtons
template={selectedTemplate}
missedExternalAuth={missedExternalAuth}
/>
)}
<Button size="sm" type="submit" disabled={isMissingExternalAuth}>
<Spinner
loading={
isLoadingExternalAuth ||
isPollingExternalAuth ||
createTaskMutation.isPending
}
>
<SendIcon />
</Spinner>
Run task
</Button>
</div>
</div>
</fieldset>
</form>
);
};
type ExternalAuthButtonProps = {
template: Template;
missedExternalAuth: TemplateVersionExternalAuth[];
};
const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({
template,
missedExternalAuth,
}) => {
const {
startPollingExternalAuth,
isPollingExternalAuth,
externalAuthPollingState,
} = useExternalAuth(template.active_version_id);
const shouldRetry = externalAuthPollingState === "abandoned";
return missedExternalAuth.map((auth) => {
return (
<div className="flex items-center gap-2" key={auth.id}>
<Button
variant="outline"
size="sm"
disabled={isPollingExternalAuth || auth.authenticated}
onClick={() => {
window.open(
auth.authenticate_url,
"_blank",
"width=900,height=600",
);
startPollingExternalAuth();
}}
>
<Spinner loading={isPollingExternalAuth}>
<ExternalImage src={auth.display_icon} />
</Spinner>
Connect to {auth.display_name}
</Button>
{shouldRetry && !auth.authenticated && (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={startPollingExternalAuth}
>
<RedoIcon />
<span className="sr-only">Refresh external auth</span>
</Button>
</TooltipTrigger>
<TooltipContent>
Retry connecting to {auth.display_name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
});
};
type TasksFilterProps = {
filter: TasksFilter;
onFilterChange: (filter: TasksFilter) => void;
};
const TasksFilter: FC<TasksFilterProps> = ({ filter, onFilterChange }) => {
return (
<section className="mt-6" aria-labelledby="filters-title">
<h3 id="filters-title" className="sr-only">
Filters
</h3>
<UsersCombobox
selectedOption={filter.user}
onSelect={(userOption) =>
onFilterChange({
...filter,
user: userOption,
})
}
/>
</section>
);
};
type TasksTableProps = {
filter: TasksFilter;
};
const TasksTable: FC<TasksTableProps> = ({ filter }) => {
const {
data: tasks,
error,
refetch,
} = useQuery({
queryKey: ["tasks", filter],
queryFn: () => data.fetchTasks(filter),
refetchInterval: 10_000,
});
let body: ReactNode = null;
if (error) {
const message = getErrorMessage(error, "Error loading tasks");
const detail = getErrorDetail(error) ?? "Please try again";
body = (
<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">
{message}
</h3>
<span className="text-content-secondary text-sm">{detail}</span>
<Button size="sm" onClick={() => refetch()} className="mt-4">
<RotateCcwIcon />
Try again
</Button>
</div>
</div>
</TableCell>
</TableRow>
);
} else if (tasks) {
body =
tasks.length === 0 ? (
<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>
) : (
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>
);
})
);
} else {
body = (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<AvatarDataSkeleton />
</TableCell>
<TableCell>
<Skeleton variant="rounded" width={100} height={24} />
</TableCell>
<TableCell>
<AvatarDataSkeleton />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
);
}
return (
<Table className="mt-4">
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created by</TableHead>
</TableRow>
</TableHeader>
<TableBody>{body}</TableBody>
</Table>
);
};
export const data = {
async fetchAITemplates() {
return API.getTemplates({ q: "has-ai-task:true" });
},
async fetchTasks(filter: TasksFilter) {
let filterQuery = "has-ai-task:true";
if (filter.user) {
filterQuery += ` owner:${filter.user.value}`;
}
const workspaces = await API.getWorkspaces({
q: filterQuery,
});
const prompts = await API.experimental.getAITasksPrompts(
workspaces.workspaces.map((workspace) => workspace.latest_build.id),
);
return workspaces.workspaces.map((workspace) => {
let prompt = prompts.prompts[workspace.latest_build.id];
if (prompt === undefined) {
prompt = "Unknown prompt";
} else if (prompt === "") {
prompt = "Empty prompt";
}
return {
workspace,
prompt,
} satisfies Task;
});
},
async createTask(
prompt: string,
userId: string,
templateVersionId: string,
presetId: string | null = null,
): Promise<Task> {
// If no preset is selected, get the default preset
let preset_id = presetId;
if (!preset_id) {
const presets = await API.getTemplateVersionPresets(templateVersionId);
const defaultPreset = presets?.find((p) => p.Default);
if (defaultPreset) {
preset_id = defaultPreset.ID;
}
}
const workspace = await API.experimental.createTask(userId, {
template_version_id: templateVersionId,
template_version_preset_id: preset_id || undefined,
prompt,
});
return {
workspace,
prompt,
};
},
};
// sortedPresets sorts presets with the default preset first,
// followed by the rest sorted alphabetically by name ascending.
const sortedPresets = (presets: Preset[]): Preset[] => {
return presets.sort((a, b) => {
// Default preset should come first
if (a.Default && !b.Default) return -1;
if (!a.Default && b.Default) return 1;
// Otherwise, sort alphabetically by name
return a.Name.localeCompare(b.Name);
});
};
export default TasksPage;
+179
View File
@@ -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>
);
};
+50 -22
View File
@@ -1,5 +1,6 @@
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 {
@@ -15,44 +16,41 @@ import {
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";
export type UserOption = {
type UserOption = {
label: string;
value: string; // Username
/**
* The username of the user.
*/
value: string;
avatarUrl?: string;
};
type UsersComboboxProps = {
selectedOption: UserOption | undefined;
onSelect: (option: UserOption | undefined) => void;
value: string;
onValueChange: (value: string) => void;
};
export const UsersCombobox: FC<UsersComboboxProps> = ({
selectedOption,
onSelect,
value,
onValueChange,
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const usersQuery = useQuery({
const { user } = useAuthenticated();
const { data: options } = useQuery({
...users({ q: debouncedSearch }),
select: (data) =>
data.users.toSorted((a, _b) => {
return selectedOption && a.username === selectedOption.value ? -1 : 0;
}),
select: (res) => mapUsersToOptions(res.users, user, value),
placeholderData: keepPreviousData,
});
const options = usersQuery.data?.map((user) => ({
label: user.name || user.username,
value: user.username,
avatarUrl: user.avatar_url,
}));
const selectedOption = options?.find((o) => o.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -91,11 +89,7 @@ export const UsersCombobox: FC<UsersComboboxProps> = ({
key={option.value}
value={option.value}
onSelect={() => {
onSelect(
option.value === selectedOption?.value
? undefined
: option,
);
onValueChange(option.value);
setOpen(false);
}}
>
@@ -131,3 +125,37 @@ const UserItem: FC<UserItemProps> = ({ option, className }) => {
</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,
}));
}
+24
View File
@@ -0,0 +1,24 @@
import { API } from "api/api";
import type { Task } from "modules/tasks/tasks";
// TODO: This is a temporary solution while the BE does not return the Task in a
// right shape with a custom name. This should be removed once the BE is fixed.
export const data = {
async createTask(
prompt: string,
userId: string,
templateVersionId: string,
presetId: string | undefined,
): Promise<Task> {
const workspace = await API.experimental.createTask(userId, {
template_version_id: templateVersionId,
template_version_preset_id: presetId,
prompt,
});
return {
workspace,
prompt,
};
},
};