feat: show workspace build logs during tasks creation (#19413)

This is part of https://github.com/coder/coder/issues/19363

**Screenshot:**
<img width="1610" height="974" alt="Screenshot 2025-08-19 at 12 32 54"
src="https://github.com/user-attachments/assets/c7435b67-49ac-4b88-ae2d-014787cea5f2"
/>


**Video demo:**


https://github.com/user-attachments/assets/2249affd-3d51-4ff0-8a5f-a0358a90d659
This commit is contained in:
Bruno Quaresma
2025-08-21 15:01:03 -03:00
committed by GitHub
parent 54440af953
commit 8aafbcb3be
8 changed files with 150 additions and 167 deletions
+94 -53
View File
@@ -2,25 +2,28 @@ import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { template as templateQueryOptions } from "api/queries/templates";
import type { Workspace, WorkspaceStatus } from "api/typesGenerated";
import isChromatic from "chromatic/isChromatic";
import { Button } from "components/Button/Button";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import type { ReactNode } from "react";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { type FC, type ReactNode, useEffect, useRef } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { Link as RouterLink, useParams } from "react-router";
import { ellipsizeText } from "utils/ellipsizeText";
import { pageTitle } from "utils/page";
import {
ActiveTransition,
getActiveTransitionStats,
WorkspaceBuildProgress,
} from "../WorkspacePage/WorkspaceBuildProgress";
import { TaskApps } from "./TaskApps";
import { TaskSidebar } from "./TaskSidebar";
import { TaskTopbar } from "./TaskTopbar";
const TaskPage = () => {
const { workspace: workspaceName, username } = useParams() as {
@@ -37,18 +40,7 @@ const TaskPage = () => {
refetchInterval: 5_000,
});
const { data: template } = useQuery({
...templateQueryOptions(task?.workspace.template_id ?? ""),
enabled: Boolean(task),
});
const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"];
const shouldStreamBuildLogs =
task && waitingStatuses.includes(task.workspace.latest_build.status);
const buildLogs = useWorkspaceBuildLogs(
task?.workspace.latest_build.id ?? "",
shouldStreamBuildLogs,
);
if (error) {
return (
@@ -95,38 +87,9 @@ const TaskPage = () => {
}
let content: ReactNode = null;
const _terminatedStatuses: WorkspaceStatus[] = [
"canceled",
"canceling",
"deleted",
"deleting",
"stopped",
"stopping",
];
if (waitingStatuses.includes(task.workspace.latest_build.status)) {
// If no template yet, use an indeterminate progress bar.
const transition = (template &&
ActiveTransition(template, task.workspace)) || { P50: 0, P95: null };
const lastStage =
buildLogs?.[buildLogs.length - 1]?.stage || "Waiting for build status";
content = (
<div className="w-full min-h-80 flex flex-col">
<div className="flex flex-col items-center grow justify-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Starting your workspace
</h3>
<div className="text-content-secondary text-sm">{lastStage}</div>
</div>
<div className="w-full">
<WorkspaceBuildProgress
workspace={task.workspace}
transitionStats={transition}
variant="task"
/>
</div>
</div>
);
content = <TaskBuildingWorkspace task={task} />;
} else if (task.workspace.latest_build.status === "failed") {
content = (
<div className="w-full min-h-80 flex items-center justify-center">
@@ -170,14 +133,7 @@ const TaskPage = () => {
</Margins>
);
} else {
content = <TaskApps task={task} />;
}
return (
<>
<Helmet>
<title>{pageTitle(ellipsizeText(task.prompt, 64) ?? "Task")}</title>
</Helmet>
content = (
<PanelGroup autoSaveId="task" direction="horizontal">
<Panel defaultSize={25} minSize={20}>
<TaskSidebar task={task} />
@@ -185,14 +141,95 @@ const TaskPage = () => {
<PanelResizeHandle>
<div className="w-1 bg-border h-full hover:bg-border-hover transition-all relative" />
</PanelResizeHandle>
<Panel className="[&>*]:h-full">{content}</Panel>
<Panel className="[&>*]:h-full">
<TaskApps task={task} />
</Panel>
</PanelGroup>
);
}
return (
<>
<Helmet>
<title>{pageTitle(ellipsizeText(task.prompt, 64))}</title>
</Helmet>
<div className="flex flex-col h-full">
<TaskTopbar task={task} />
{content}
</div>
</>
);
};
export default TaskPage;
type TaskBuildingWorkspaceProps = { task: Task };
const TaskBuildingWorkspace: FC<TaskBuildingWorkspaceProps> = ({ task }) => {
const { data: template } = useQuery(
templateQueryOptions(task.workspace.template_id),
);
const buildLogs = useWorkspaceBuildLogs(task?.workspace.latest_build.id);
// If no template yet, use an indeterminate progress bar.
const transitionStats = (template &&
getActiveTransitionStats(template, task.workspace)) || {
P50: 0,
P95: null,
};
const scrollAreaRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: this effect should run when build logs change
useEffect(() => {
if (isChromatic()) {
return;
}
const scrollAreaEl = scrollAreaRef.current;
const scrollAreaViewportEl = scrollAreaEl?.querySelector<HTMLDivElement>(
"[data-radix-scroll-area-viewport]",
);
if (scrollAreaViewportEl) {
scrollAreaViewportEl.scrollTop = scrollAreaViewportEl.scrollHeight;
}
}, [buildLogs]);
return (
<section className="w-full h-full flex justify-center items-center p-6 overflow-y-auto">
<div className="flex flex-col gap-6 items-center w-full">
<header className="flex flex-col items-center text-center">
<h3 className="m-0 font-medium text-content-primary text-xl">
Starting your workspace
</h3>
<div className="text-content-secondary">
Your task will be running in a few moments
</div>
</header>
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
<WorkspaceBuildProgress
workspace={task.workspace}
transitionStats={transitionStats}
variant="task"
/>
<ScrollArea
ref={scrollAreaRef}
className="h-96 border border-solid border-border rounded-lg"
>
<WorkspaceBuildLogs
sticky
className="border-0 rounded-none"
logs={buildLogs ?? []}
/>
</ScrollArea>
</div>
</div>
</section>
);
};
export class WorkspaceDoesNotHaveAITaskError extends Error {
constructor(workspace: Workspace) {
super(
@@ -228,3 +265,7 @@ export const data = {
} satisfies Task;
},
};
const ellipsizeText = (text: string, maxLength = 80): string => {
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`;
};
-70
View File
@@ -1,24 +1,8 @@
import type { WorkspaceApp } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ArrowLeftIcon, EllipsisVerticalIcon } from "lucide-react";
import type { Task } from "modules/tasks/tasks";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import { TaskAppIFrame } from "./TaskAppIframe";
import { TaskStatusLink } from "./TaskStatusLink";
type TaskSidebarProps = {
task: Task;
@@ -84,60 +68,6 @@ export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
return (
<aside className="flex flex-col h-full shrink-0 w-full">
<header className="border-0 border-b border-solid border-border p-4 pt-0">
<div className="flex items-center justify-between py-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="subtle" asChild className="-ml-2">
<RouterLink to="/tasks">
<ArrowLeftIcon />
<span className="sr-only">Back to tasks</span>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent>Back to tasks</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="subtle" className="-mr-2">
<EllipsisVerticalIcon />
<span className="sr-only">Settings</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<h1 className="m-0 mt-1 text-base font-medium truncate">
{task.prompt || task.workspace.name}
</h1>
{task.workspace.latest_app_status?.uri && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
</div>
)}
</header>
{sidebarAppStatus === "healthy" && sidebarApp ? (
<TaskAppIFrame
active
+50
View File
@@ -0,0 +1,50 @@
import { Button } from "components/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ArrowLeftIcon } from "lucide-react";
import type { Task } from "modules/tasks/tasks";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import { TaskStatusLink } from "./TaskStatusLink";
type TaskTopbarProps = { task: Task };
export const TaskTopbar: FC<TaskTopbarProps> = ({ task }) => {
return (
<header className="flex items-center px-3 h-14 border-solid border-border border-0 border-b">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="subtle" asChild>
<RouterLink to="/tasks">
<ArrowLeftIcon />
<span className="sr-only">Back to tasks</span>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent>Back to tasks</TooltipContent>
</Tooltip>
</TooltipProvider>
<h1 className="m-0 text-base font-medium truncate">{task.prompt}</h1>
{task.workspace.latest_app_status?.uri && (
<div className="flex items-center gap-2 flex-wrap ml-4">
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
</div>
)}
<Button asChild size="sm" variant="outline" className="ml-auto">
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</Button>
</header>
);
};
+4 -2
View File
@@ -17,7 +17,7 @@ import { ResourcesSidebar } from "./ResourcesSidebar";
import { resourceOptionValue, useResourcesNav } from "./useResourcesNav";
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
import {
ActiveTransition,
getActiveTransitionStats,
WorkspaceBuildProgress,
} from "./WorkspaceBuildProgress";
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
@@ -68,7 +68,9 @@ export const Workspace: FC<WorkspaceProps> = ({
const navigate = useNavigate();
const transitionStats =
template !== undefined ? ActiveTransition(template, workspace) : undefined;
template !== undefined
? getActiveTransitionStats(template, workspace)
: undefined;
const sidebarOption = useSearchParamsKey({ key: "sidebar" });
const setSidebarOption = (newOption: string) => {
@@ -9,9 +9,9 @@ import { type FC, useEffect, useState } from "react";
dayjs.extend(duration);
// ActiveTransition gets the build estimate for the workspace,
// getActiveTransitionStats gets the build estimate for the workspace,
// if it is in a transition state.
export const ActiveTransition = (
export const getActiveTransitionStats = (
template: Template,
workspace: Workspace,
): TransitionStats | undefined => {
-21
View File
@@ -1,21 +0,0 @@
import { ellipsizeText } from "./ellipsizeText";
import type { Nullable } from "./nullable";
describe("ellipsizeText", () => {
it.each([
[undefined, 10, undefined],
[null, 10, undefined],
["", 10, ""],
["Hello World", "Hello World".length, "Hello World"],
["Hello World", "Hello...".length, "Hello..."],
])(
"ellipsizeText(%p, %p) returns %p",
(
str: Nullable<string>,
maxLength: number | undefined,
output: Nullable<string>,
) => {
expect(ellipsizeText(str, maxLength)).toBe(output);
},
);
});
-14
View File
@@ -1,14 +0,0 @@
import type { Nullable } from "./nullable";
/** Truncates and ellipsizes text if it's longer than maxLength */
export const ellipsizeText = (
text: Nullable<string>,
maxLength = 80,
): string | undefined => {
if (typeof text !== "string") {
return;
}
return text.length <= maxLength
? text
: `${text.substr(0, maxLength - 3)}...`;
};
-5
View File
@@ -1,5 +0,0 @@
/**
* A Nullable may be its concrete type, `null` or `undefined`
* @remark Exact opposite of the native TS type NonNullable<T>
*/
export type Nullable<T> = null | undefined | T;