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:
Bruno Quaresma
2025-09-26 12:36:45 -03:00
committed by GitHub
parent 43415f0144
commit c2d514356f
7 changed files with 245 additions and 298 deletions
+8 -15
View File
@@ -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,
})),
);
}
+36 -3
View File
@@ -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",
};
}
+33 -23
View File
@@ -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">
+142 -146
View File
@@ -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",
},
],
},
],
},
],
},
};
}
+24 -3
View File
@@ -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" />
-108
View File
@@ -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>
);
};