mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Vendored
+1
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user