mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: use backend-supplied sidebar app id on the /task/$id page (#18458)
Related to https://github.com/coder/coder/issues/18454. It will close https://github.com/coder/internal/issues/734.
This commit is contained in:
@@ -15,7 +15,6 @@ import { type FC, useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { cn } from "utils/cn";
|
||||
import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { AI_APP_CHAT_SLUG } from "./constants";
|
||||
|
||||
type TaskAppsProps = {
|
||||
task: Task;
|
||||
@@ -30,7 +29,9 @@ export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
|
||||
// it here
|
||||
const apps = agents
|
||||
.flatMap((a) => a?.apps)
|
||||
.filter((a) => !!a && a.slug !== AI_APP_CHAT_SLUG);
|
||||
.filter(
|
||||
(a) => !!a && a.id !== task.workspace.latest_build.ai_task_sidebar_app_id,
|
||||
);
|
||||
|
||||
const embeddedApps = apps.filter((app) => !app.external);
|
||||
const externalApps = apps.filter((app) => app.external);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, spyOn, within } from "@storybook/test";
|
||||
import type { Workspace, WorkspaceApp } from "api/typesGenerated";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceApp,
|
||||
WorkspaceResource,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
MockFailedWorkspace,
|
||||
MockStartingWorkspace,
|
||||
@@ -13,11 +17,12 @@ import {
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import { withProxyProvider } from "testHelpers/storybook";
|
||||
import TaskPage, { data } from "./TaskPage";
|
||||
import TaskPage, { data, WorkspaceDoesNotHaveAITaskError } from "./TaskPage";
|
||||
|
||||
const meta: Meta<typeof TaskPage> = {
|
||||
title: "pages/TaskPage",
|
||||
component: TaskPage,
|
||||
decorators: [withProxyProvider()],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
@@ -96,61 +101,142 @@ export const TerminatedBuildWithStatus: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
function activeWorkspace(apps: WorkspaceApp[]): Workspace {
|
||||
export const SidebarAppDisabled: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
has_ai_task: true,
|
||||
ai_task_sidebar_app_id: "claude-code",
|
||||
resources: mockResources({
|
||||
claudeCodeAppOverrides: {
|
||||
health: "disabled",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SidebarAppLoading: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
has_ai_task: true,
|
||||
ai_task_sidebar_app_id: "claude-code",
|
||||
resources: mockResources({
|
||||
claudeCodeAppOverrides: {
|
||||
health: "initializing",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SidebarAppHealthy: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
has_ai_task: true,
|
||||
ai_task_sidebar_app_id: "claude-code",
|
||||
resources: mockResources({
|
||||
claudeCodeAppOverrides: {
|
||||
health: "healthy",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const BuildNoAITask: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockImplementation(() => {
|
||||
throw new WorkspaceDoesNotHaveAITaskError(MockWorkspace);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
interface MockResourcesProps {
|
||||
apps?: WorkspaceApp[];
|
||||
claudeCodeAppOverrides?: Partial<WorkspaceApp>;
|
||||
}
|
||||
|
||||
const mockResources = (
|
||||
props?: MockResourcesProps,
|
||||
): readonly WorkspaceResource[] => [
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
agents: [
|
||||
{
|
||||
...MockWorkspaceAgent,
|
||||
apps: [
|
||||
...(props?.apps ?? []),
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
id: "claude-code",
|
||||
display_name: "Claude Code",
|
||||
slug: "claude-code",
|
||||
icon: "/icon/claude.svg",
|
||||
statuses: [
|
||||
MockWorkspaceAppStatus,
|
||||
{
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "2",
|
||||
message: "Planning changes",
|
||||
state: "working",
|
||||
},
|
||||
],
|
||||
...(props?.claudeCodeAppOverrides ?? {}),
|
||||
},
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
id: "vscode",
|
||||
slug: "vscode",
|
||||
display_name: "VS Code Web",
|
||||
icon: "/icon/code.svg",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
slug: "zed",
|
||||
id: "zed",
|
||||
display_name: "Zed",
|
||||
icon: "/icon/zed.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const activeWorkspace = (apps: WorkspaceApp[]): Workspace => {
|
||||
return {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
resources: [
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
agents: [
|
||||
{
|
||||
...MockWorkspaceAgent,
|
||||
apps: [
|
||||
...apps,
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
id: "claude-code",
|
||||
display_name: "Claude Code",
|
||||
slug: "claude-code",
|
||||
icon: "/icon/claude.svg",
|
||||
statuses: [
|
||||
MockWorkspaceAppStatus,
|
||||
{
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "2",
|
||||
message: "Planning changes",
|
||||
state: "working",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
id: "vscode",
|
||||
slug: "vscode",
|
||||
display_name: "VS Code Web",
|
||||
icon: "/icon/code.svg",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
slug: "zed",
|
||||
id: "zed",
|
||||
display_name: "Zed",
|
||||
icon: "/icon/zed.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
resources: mockResources({ apps }),
|
||||
},
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
app_id: "claude-code",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const Active: Story = {
|
||||
decorators: [withProxyProvider()],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import type { WorkspaceStatus } from "api/typesGenerated";
|
||||
import type { Workspace, WorkspaceStatus } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
@@ -164,7 +164,7 @@ const TaskPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle(ellipsizeText(task.prompt, 64)!)}</title>
|
||||
<title>{pageTitle(ellipsizeText(task.prompt, 64) ?? "Task")}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="h-full flex justify-stretch">
|
||||
@@ -177,22 +177,34 @@ const TaskPage = () => {
|
||||
|
||||
export default TaskPage;
|
||||
|
||||
export class WorkspaceDoesNotHaveAITaskError extends Error {
|
||||
constructor(workspace: Workspace) {
|
||||
super(
|
||||
`Workspace ${workspace.owner_name}/${workspace.name} is not running an AI task`,
|
||||
);
|
||||
this.name = "WorkspaceDoesNotHaveAITaskError";
|
||||
}
|
||||
}
|
||||
|
||||
export const data = {
|
||||
fetchTask: async (workspaceOwnerUsername: string, workspaceName: string) => {
|
||||
const workspace = await API.getWorkspaceByOwnerAndName(
|
||||
workspaceOwnerUsername,
|
||||
workspaceName,
|
||||
);
|
||||
if (
|
||||
workspace.latest_build.job.completed_at &&
|
||||
!workspace.latest_build.has_ai_task
|
||||
) {
|
||||
throw new WorkspaceDoesNotHaveAITaskError(workspace);
|
||||
}
|
||||
|
||||
const parameters = await API.getWorkspaceBuildParameters(
|
||||
workspace.latest_build.id,
|
||||
);
|
||||
const prompt = parameters.find(
|
||||
(p) => p.name === AI_PROMPT_PARAMETER_NAME,
|
||||
)?.value;
|
||||
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
const prompt =
|
||||
parameters.find((p) => p.name === AI_PROMPT_PARAMETER_NAME)?.value ??
|
||||
"Unknown prompt";
|
||||
|
||||
return {
|
||||
workspace,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GitHub from "@mui/icons-material/GitHub";
|
||||
import type { WorkspaceApp } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -21,27 +21,72 @@ import {
|
||||
ExternalLinkIcon,
|
||||
GitPullRequestArrowIcon,
|
||||
} from "lucide-react";
|
||||
import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon";
|
||||
import type { Task } from "modules/tasks/tasks";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { cn } from "utils/cn";
|
||||
import { timeFrom } from "utils/time";
|
||||
import { truncateURI } from "utils/uri";
|
||||
import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { AI_APP_CHAT_SLUG, AI_APP_CHAT_URL_PATHNAME } from "./constants";
|
||||
|
||||
type TaskSidebarProps = {
|
||||
task: Task;
|
||||
};
|
||||
|
||||
export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
|
||||
const chatApp = task.workspace.latest_build.resources
|
||||
type SidebarAppStatus = "error" | "loading" | "healthy";
|
||||
|
||||
const getSidebarApp = (task: Task): [WorkspaceApp | null, SidebarAppStatus] => {
|
||||
const sidebarAppId = task.workspace.latest_build.ai_task_sidebar_app_id;
|
||||
// a task workspace with a finished build must have a sidebar app id
|
||||
if (!sidebarAppId && task.workspace.latest_build.job.completed_at) {
|
||||
console.error(
|
||||
"Task workspace has a finished build but no sidebar app id",
|
||||
task.workspace,
|
||||
);
|
||||
return [null, "error"];
|
||||
}
|
||||
|
||||
const sidebarApp = task.workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents)
|
||||
.flatMap((a) => a?.apps)
|
||||
.find((a) => a?.slug === AI_APP_CHAT_SLUG);
|
||||
const showChatApp =
|
||||
chatApp && (chatApp.health === "disabled" || chatApp.health === "healthy");
|
||||
.find((a) => a?.id === sidebarAppId);
|
||||
|
||||
if (!task.workspace.latest_build.job.completed_at) {
|
||||
// while the workspace build is running, we don't have a sidebar app yet
|
||||
return [null, "loading"];
|
||||
}
|
||||
if (!sidebarApp) {
|
||||
// The workspace build is complete but the expected sidebar app wasn't found in the resources.
|
||||
// This could happen due to timing issues or temporary inconsistencies in the data.
|
||||
// We return "loading" instead of "error" to avoid showing an error state if the app
|
||||
// becomes available shortly after. The tradeoff is that users may see a loading state
|
||||
// indefinitely if there's a genuine issue, but this is preferable to false error alerts.
|
||||
return [null, "loading"];
|
||||
}
|
||||
if (sidebarApp.health === "disabled") {
|
||||
return [sidebarApp, "error"];
|
||||
}
|
||||
if (sidebarApp.health === "healthy") {
|
||||
return [sidebarApp, "healthy"];
|
||||
}
|
||||
if (sidebarApp.health === "initializing") {
|
||||
return [sidebarApp, "loading"];
|
||||
}
|
||||
if (sidebarApp.health === "unhealthy") {
|
||||
return [sidebarApp, "error"];
|
||||
}
|
||||
|
||||
// exhaustiveness check
|
||||
const _: never = sidebarApp.health;
|
||||
// this should never happen
|
||||
console.error(
|
||||
"Task workspace has a finished build but the sidebar app is in an unknown health state",
|
||||
task.workspace,
|
||||
);
|
||||
return [null, "error"];
|
||||
};
|
||||
|
||||
export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
|
||||
const [sidebarApp, sidebarAppStatus] = getSidebarApp(task);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -50,8 +95,7 @@ export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
|
||||
"flex flex-col h-full shrink-0",
|
||||
"border-0 border-r border-solid border-border",
|
||||
],
|
||||
// We want to make the sidebar wider for chat apps
|
||||
showChatApp ? "w-[520px]" : "w-[320px]",
|
||||
"w-[520px]",
|
||||
])}
|
||||
>
|
||||
<header className="border-0 border-b border-solid border-border p-4 pt-0">
|
||||
@@ -98,7 +142,7 @@ export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
|
||||
</div>
|
||||
|
||||
<h1 className="m-0 mt-1 text-base font-medium truncate">
|
||||
{task.prompt}
|
||||
{task.prompt || task.workspace.name}
|
||||
</h1>
|
||||
|
||||
{task.workspace.latest_app_status?.uri && (
|
||||
@@ -108,100 +152,34 @@ export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showChatApp ? (
|
||||
{sidebarAppStatus === "healthy" && sidebarApp ? (
|
||||
<TaskAppIFrame
|
||||
active
|
||||
key={chatApp.id}
|
||||
app={chatApp}
|
||||
key={sidebarApp.id}
|
||||
app={sidebarApp}
|
||||
task={task}
|
||||
pathname={AI_APP_CHAT_URL_PATHNAME}
|
||||
/>
|
||||
) : sidebarAppStatus === "loading" ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<Spinner loading className="mb-4" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskStatuses task={task} />
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Error
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
<span>Failed to load the sidebar app.</span>
|
||||
{sidebarApp?.health != null && (
|
||||
<span> The app is {sidebarApp.health}.</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskStatusesProps = {
|
||||
task: Task;
|
||||
};
|
||||
|
||||
const TaskStatuses: FC<TaskStatusesProps> = ({ task }) => {
|
||||
let statuses = task.workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents)
|
||||
.flatMap((a) => a?.apps)
|
||||
.flatMap((a) => a?.statuses)
|
||||
.filter((s) => !!s)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
|
||||
// This happens when the workspace is not running so it has no resources to
|
||||
// get the statuses so we can fallback to the latest status received from the
|
||||
// workspace.
|
||||
if (statuses.length === 0 && task.workspace.latest_app_status) {
|
||||
statuses = [task.workspace.latest_app_status];
|
||||
}
|
||||
|
||||
return statuses ? (
|
||||
<ScrollArea className="h-full">
|
||||
{statuses.length === 0 && (
|
||||
<article className="px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<h3 className="m-0 font-medium text-sm leading-normal">
|
||||
Running your task
|
||||
</h3>
|
||||
<time
|
||||
dateTime={task.workspace.latest_build.created_at}
|
||||
className="font-medium text-xs text-content-secondary first-letter:uppercase"
|
||||
>
|
||||
{timeFrom(new Date(task.workspace.latest_build.created_at))}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<AppStatusStateIcon state="working" latest className="size-5" />
|
||||
</article>
|
||||
)}
|
||||
{statuses.map((status, index) => {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
["px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4"],
|
||||
{
|
||||
"opacity-50 hover:opacity-100": index !== 0,
|
||||
},
|
||||
)}
|
||||
key={status.id}
|
||||
>
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<h3 className="m-0 font-medium text-sm leading-normal">
|
||||
{status.message}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={status.created_at}
|
||||
className="font-medium text-xs text-content-secondary first-letter:uppercase"
|
||||
>
|
||||
{timeFrom(new Date(status.created_at))}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<AppStatusStateIcon
|
||||
state={status.state}
|
||||
latest={index === 0}
|
||||
disabled={task.workspace.latest_build.status !== "running"}
|
||||
className={cn(["size-5", { "opacity-0": index !== 0 }])}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Spinner loading />
|
||||
);
|
||||
};
|
||||
|
||||
type TaskStatusLinkProps = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const AI_APP_CHAT_SLUG = "claude-code-web";
|
||||
export const AI_APP_CHAT_URL_PATHNAME = "/chat/embed";
|
||||
Reference in New Issue
Block a user