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:
Hugo Dutka
2025-06-24 19:28:39 +02:00
committed by GitHub
parent 64a221489f
commit 4fd0312776
5 changed files with 231 additions and 156 deletions
+3 -2
View File
@@ -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);
+133 -47
View File
@@ -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()],
+21 -9
View File
@@ -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,
+74 -96
View File
@@ -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;
};
-2
View File
@@ -1,2 +0,0 @@
export const AI_APP_CHAT_SLUG = "claude-code-web";
export const AI_APP_CHAT_URL_PATHNAME = "/chat/embed";