mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
fix: handle chat app not found (#19947)
Sometimes users can misconfigure the app used for chat. When that happens, we should make it clear to the user. <img width="1197" height="727" alt="Screenshot 2025-09-24 at 14 15 12" src="https://github.com/user-attachments/assets/6afe2c22-e7c3-47d4-8446-76000535a492" /> - Handle “chat app not found.” - Simplify stories. - Have `TaskAppIframe` handle all task iframes so we don’t need a separate iframe component for chat.
This commit is contained in:
@@ -16,19 +16,12 @@ export type WorkspaceAppWithAgent = WorkspaceApp & {
|
||||
};
|
||||
|
||||
export function getTaskApps(task: Task): WorkspaceAppWithAgent[] {
|
||||
return (
|
||||
task.workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents ?? [])
|
||||
.flatMap((agent) =>
|
||||
agent.apps.map((app) => ({
|
||||
...app,
|
||||
agent,
|
||||
})),
|
||||
)
|
||||
// The Chat UI app will be displayed in the sidebar, so we don't want to
|
||||
// show it as a tab.
|
||||
.filter(
|
||||
(app) => app.id !== task.workspace.latest_build.ai_task_sidebar_app_id,
|
||||
)
|
||||
);
|
||||
return task.workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents ?? [])
|
||||
.flatMap((agent) =>
|
||||
agent.apps.map((app) => ({
|
||||
...app,
|
||||
agent,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,9 +84,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.health === "healthy" ||
|
||||
app.health === "disabled" ||
|
||||
app.health === "unhealthy" ? (
|
||||
{app.health === "healthy" || app.health === "disabled" ? (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src={link.href}
|
||||
@@ -95,6 +93,41 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
className={"w-full h-full border-0"}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
) : app.health === "unhealthy" ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-4">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base text-center">
|
||||
App "{app.display_name}" is unhealthy
|
||||
</h3>
|
||||
<div className="text-content-secondary text-sm">
|
||||
<span className="block text-center">
|
||||
Here are some troubleshooting steps you can take:
|
||||
</span>
|
||||
<ul className="m-0 pt-4 flex flex-col gap-4">
|
||||
{app.healthcheck && (
|
||||
<li>
|
||||
<span className="block font-medium text-content-primary mb-1">
|
||||
Verify healthcheck
|
||||
</span>
|
||||
Try running the following inside your workspace:{" "}
|
||||
<code className="font-mono text-content-primary select-all">
|
||||
curl -v "{app.healthcheck.url}"
|
||||
</code>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<span className="block font-medium text-content-primary mb-1">
|
||||
Check logs
|
||||
</span>
|
||||
See{" "}
|
||||
<code className="font-mono text-content-primary select-all">
|
||||
/tmp/coder-agent.log
|
||||
</code>{" "}
|
||||
inside your workspace "{task.workspace.name}" for more
|
||||
information.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : app.health === "initializing" ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner loading />
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TaskApps } from "./TaskApps";
|
||||
const mockExternalApp: WorkspaceApp = {
|
||||
...MockWorkspaceApp,
|
||||
external: true,
|
||||
health: "healthy",
|
||||
};
|
||||
|
||||
const meta: Meta<typeof TaskApps> = {
|
||||
@@ -103,6 +104,7 @@ function mockEmbeddedApp(name = MockWorkspaceApp.display_name): WorkspaceApp {
|
||||
slug: kebabCase(name),
|
||||
display_name: name,
|
||||
external: false,
|
||||
health: "healthy",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,35 +28,45 @@ type TaskAppsProps = {
|
||||
};
|
||||
|
||||
export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
|
||||
const apps = getTaskApps(task);
|
||||
const apps = getTaskApps(task).filter(
|
||||
// The Chat UI app will be displayed in the sidebar, so we don't want to
|
||||
// show it as a web app.
|
||||
(app) =>
|
||||
app.id !== task.workspace.latest_build.ai_task_sidebar_app_id &&
|
||||
app.health !== "disabled",
|
||||
);
|
||||
const [embeddedApps, externalApps] = splitEmbeddedAndExternalApps(apps);
|
||||
const [activeAppId, setActiveAppId] = useState(embeddedApps.at(0)?.id);
|
||||
const hasAvailableAppsToDisplay =
|
||||
embeddedApps.length > 0 || externalApps.length > 0;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col h-full">
|
||||
<div className="w-full flex items-center border-0 border-b border-border border-solid">
|
||||
<ScrollArea className="max-w-full">
|
||||
<div className="flex w-max gap-2 items-center p-2 pb-0">
|
||||
{embeddedApps.map((app) => (
|
||||
<TaskAppTab
|
||||
key={app.id}
|
||||
task={task}
|
||||
app={app}
|
||||
active={app.id === activeAppId}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setActiveAppId(app.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" className="h-2" />
|
||||
</ScrollArea>
|
||||
{hasAvailableAppsToDisplay && (
|
||||
<div className="w-full flex items-center border-0 border-b border-border border-solid">
|
||||
<ScrollArea className="max-w-full">
|
||||
<div className="flex w-max gap-2 items-center p-2 pb-0">
|
||||
{embeddedApps.map((app) => (
|
||||
<TaskAppTab
|
||||
key={app.id}
|
||||
task={task}
|
||||
app={app}
|
||||
active={app.id === activeAppId}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setActiveAppId(app.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" className="h-2" />
|
||||
</ScrollArea>
|
||||
|
||||
{externalApps.length > 0 && (
|
||||
<ExternalAppsDropdown task={task} externalApps={externalApps} />
|
||||
)}
|
||||
</div>
|
||||
{externalApps.length > 0 && (
|
||||
<ExternalAppsDropdown task={task} externalApps={externalApps} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{embeddedApps.length > 0 ? (
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -21,15 +21,43 @@ import {
|
||||
} from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceApp,
|
||||
WorkspaceResource,
|
||||
} from "api/typesGenerated";
|
||||
import type { Workspace, WorkspaceApp } from "api/typesGenerated";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import TaskPage, { data, WorkspaceDoesNotHaveAITaskError } from "./TaskPage";
|
||||
|
||||
const MockClaudeCodeApp: WorkspaceApp = {
|
||||
...MockWorkspaceApp,
|
||||
id: "claude-code",
|
||||
display_name: "Claude Code",
|
||||
slug: "claude-code",
|
||||
icon: "/icon/claude.svg",
|
||||
health: "healthy",
|
||||
healthcheck: {
|
||||
url: "http://localhost:3000/health",
|
||||
interval: 10,
|
||||
threshold: 3,
|
||||
},
|
||||
statuses: [
|
||||
MockWorkspaceAppStatus,
|
||||
{
|
||||
...MockWorkspaceAppStatus,
|
||||
id: "2",
|
||||
message: "Planning changes",
|
||||
state: "working",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MockVSCodeApp: WorkspaceApp = {
|
||||
...MockWorkspaceApp,
|
||||
id: "vscode",
|
||||
slug: "vscode",
|
||||
display_name: "VS Code Web",
|
||||
icon: "/icon/code.svg",
|
||||
health: "healthy",
|
||||
};
|
||||
|
||||
const meta: Meta<typeof TaskPage> = {
|
||||
title: "pages/TaskPage",
|
||||
component: TaskPage,
|
||||
@@ -112,24 +140,6 @@ export const TerminatedBuildWithStatus: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WaitingOnStatus: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_app_status: null,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
resources: [
|
||||
{ ...MockWorkspaceResource, agents: [MockWorkspaceAgentReady] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WaitingStartupScripts: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
@@ -171,44 +181,48 @@ export const WaitingStartupScripts: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SidebarAppHealthDisabled: Story = {
|
||||
export const SidebarAppNotFound: Story = {
|
||||
beforeEach: () => {
|
||||
const workspace = mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp);
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
...workspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
has_ai_task: true,
|
||||
ai_task_sidebar_app_id: "claude-code",
|
||||
resources: mockResources({
|
||||
claudeCodeAppOverrides: {
|
||||
health: "disabled",
|
||||
},
|
||||
}),
|
||||
...workspace.latest_build,
|
||||
ai_task_sidebar_app_id: "non-existent-app-id",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SidebarAppLoading: Story = {
|
||||
export const SidebarAppHealthDisabled: 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",
|
||||
},
|
||||
}),
|
||||
workspace: mockTaskWorkspace(
|
||||
{
|
||||
...MockClaudeCodeApp,
|
||||
health: "disabled",
|
||||
},
|
||||
},
|
||||
MockVSCodeApp,
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SidebarAppInitializing: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: mockTaskWorkspace(
|
||||
{
|
||||
...MockClaudeCodeApp,
|
||||
health: "initializing",
|
||||
},
|
||||
MockVSCodeApp,
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -217,19 +231,28 @@ 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",
|
||||
},
|
||||
}),
|
||||
workspace: mockTaskWorkspace(
|
||||
{
|
||||
...MockClaudeCodeApp,
|
||||
health: "healthy",
|
||||
},
|
||||
},
|
||||
MockVSCodeApp,
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SidebarAppUnhealthy: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: mockTaskWorkspace(
|
||||
{
|
||||
...MockClaudeCodeApp,
|
||||
health: "unhealthy",
|
||||
},
|
||||
MockVSCodeApp,
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -238,17 +261,10 @@ const mainAppHealthStory = (health: WorkspaceApp["health"]) => ({
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
resources: mockResources({
|
||||
claudeCodeAppOverrides: {
|
||||
health,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
workspace: mockTaskWorkspace(MockClaudeCodeApp, {
|
||||
...MockVSCodeApp,
|
||||
health,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -256,7 +272,6 @@ const mainAppHealthStory = (health: WorkspaceApp["health"]) => ({
|
||||
export const MainAppHealthy: Story = mainAppHealthStory("healthy");
|
||||
export const MainAppInitializing: Story = mainAppHealthStory("initializing");
|
||||
export const MainAppUnhealthy: Story = mainAppHealthStory("unhealthy");
|
||||
export const MainAppHealthDisabled: Story = mainAppHealthStory("disabled");
|
||||
export const MainAppHealthUnknown: Story = mainAppHealthStory(
|
||||
"unknown" as unknown as WorkspaceApp["health"],
|
||||
);
|
||||
@@ -269,78 +284,12 @@ export const BuildNoAITask: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
interface MockResourcesProps {
|
||||
apps?: WorkspaceApp[];
|
||||
claudeCodeAppOverrides?: Partial<WorkspaceApp>;
|
||||
}
|
||||
|
||||
const mockResources = (
|
||||
props?: MockResourcesProps,
|
||||
): readonly WorkspaceResource[] => [
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
agents: [
|
||||
{
|
||||
...MockWorkspaceAgentReady,
|
||||
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: mockResources({ apps }),
|
||||
},
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
app_id: "claude-code",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const Active: Story = {
|
||||
decorators: [withProxyProvider()],
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: activeWorkspace([]),
|
||||
workspace: mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp),
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -350,7 +299,7 @@ export const Active: Story = {
|
||||
const zedIframe = await canvas.findByTitle("Zed");
|
||||
const claudeIframe = await canvas.findByTitle("Claude Code");
|
||||
|
||||
expect(vscodeIframe).not.toBeVisible();
|
||||
expect(vscodeIframe).toBeVisible();
|
||||
expect(zedIframe).not.toBeVisible();
|
||||
expect(claudeIframe).toBeVisible();
|
||||
},
|
||||
@@ -361,16 +310,14 @@ export const ActivePreview: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: activeWorkspace([
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
slug: "preview",
|
||||
id: "preview",
|
||||
display_name: "Preview",
|
||||
},
|
||||
]),
|
||||
workspace: mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp),
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = await canvas.findByText("Preview", { exact: false });
|
||||
userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceStartFailure: Story = {
|
||||
@@ -486,3 +433,52 @@ export const WorkspaceStartFailureWithDialog: Story = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function mockTaskWorkspace(
|
||||
sidebarApp: WorkspaceApp,
|
||||
activeApp: WorkspaceApp,
|
||||
): Workspace {
|
||||
return {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
has_ai_task: true,
|
||||
ai_task_sidebar_app_id: sidebarApp.id,
|
||||
resources: [
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
agents: [
|
||||
{
|
||||
...MockWorkspaceAgentReady,
|
||||
apps: [
|
||||
sidebarApp,
|
||||
activeApp,
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
slug: "zed",
|
||||
id: "zed",
|
||||
display_name: "Zed",
|
||||
icon: "/icon/zed.svg",
|
||||
health: "healthy",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
slug: "preview",
|
||||
id: "preview",
|
||||
display_name: "Preview",
|
||||
health: "healthy",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
slug: "disabled",
|
||||
id: "disabled",
|
||||
display_name: "Disabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
||||
import { useAgentLogs } from "modules/resources/useAgentLogs";
|
||||
import { TasksSidebar } from "modules/tasks/TasksSidebar/TasksSidebar";
|
||||
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
|
||||
import {
|
||||
AI_PROMPT_PARAMETER_NAME,
|
||||
getTaskApps,
|
||||
type Task,
|
||||
} from "modules/tasks/tasks";
|
||||
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import {
|
||||
@@ -37,8 +41,8 @@ import {
|
||||
getActiveTransitionStats,
|
||||
WorkspaceBuildProgress,
|
||||
} from "../WorkspacePage/WorkspaceBuildProgress";
|
||||
import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { TaskApps } from "./TaskApps";
|
||||
import { TaskSidebar } from "./TaskSidebar";
|
||||
import { TaskTopbar } from "./TaskTopbar";
|
||||
|
||||
const TaskPageLayout: FC<PropsWithChildren> = ({ children }) => {
|
||||
@@ -137,10 +141,27 @@ const TaskPage = () => {
|
||||
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
|
||||
content = <TaskStartingAgent agent={agent} />;
|
||||
} else {
|
||||
const chatApp = getTaskApps(task).find(
|
||||
(app) => app.id === task.workspace.latest_build.ai_task_sidebar_app_id,
|
||||
);
|
||||
content = (
|
||||
<PanelGroup autoSaveId="task" direction="horizontal">
|
||||
<Panel defaultSize={25} minSize={20}>
|
||||
<TaskSidebar task={task} />
|
||||
{chatApp ? (
|
||||
<TaskAppIFrame active task={task} app={chatApp} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center p-6 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Chat app not found
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Please, make sure your template has a chat sidebar app
|
||||
configured.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
<PanelResizeHandle>
|
||||
<div className="w-1 bg-border h-full hover:bg-border-hover transition-all relative" />
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import {
|
||||
getTaskApps,
|
||||
type Task,
|
||||
type WorkspaceAppWithAgent,
|
||||
} from "modules/tasks/tasks";
|
||||
import type { FC } from "react";
|
||||
import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { TaskWildcardWarning } from "./TaskWildcardWarning";
|
||||
|
||||
type TaskSidebarProps = {
|
||||
task: Task;
|
||||
};
|
||||
|
||||
type SidebarAppStatus = "error" | "loading" | "healthy";
|
||||
|
||||
const getSidebarApp = (
|
||||
task: Task,
|
||||
): [WorkspaceAppWithAgent | 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 = getTaskApps(task).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"];
|
||||
}
|
||||
// "disabled" means that the health check is disabled, so we assume
|
||||
// that the app is healthy
|
||||
if (sidebarApp.health === "disabled") {
|
||||
return [sidebarApp, "healthy"];
|
||||
}
|
||||
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 proxy = useProxy();
|
||||
const [sidebarApp, sidebarAppStatus] = getSidebarApp(task);
|
||||
const shouldDisplayWildcardWarning =
|
||||
sidebarApp?.subdomain && proxy.proxy?.preferredWildcardHostname === "";
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col h-full shrink-0 w-full">
|
||||
{sidebarAppStatus === "loading" ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center pb-4">
|
||||
<Spinner loading />
|
||||
</div>
|
||||
) : shouldDisplayWildcardWarning ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center pb-4">
|
||||
<TaskWildcardWarning />
|
||||
</div>
|
||||
) : sidebarAppStatus === "healthy" && sidebarApp ? (
|
||||
<TaskAppIFrame
|
||||
active
|
||||
key={sidebarApp.id}
|
||||
app={sidebarApp}
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user