feat: display startup script logs while agent is starting (#19530)

Closes https://github.com/coder/coder/issues/19363

**Screenshot:**

<img width="1318" height="753" alt="Screenshot 2025-08-25 at 11 02 25"
src="https://github.com/user-attachments/assets/6fa1d4c7-dddb-4db9-a9f0-86986b3628d8"
/>

**Demo:**


https://github.com/user-attachments/assets/07a68e30-b776-44f9-b4ca-e2dd8d124281
This commit is contained in:
Bruno Quaresma
2025-08-26 12:45:07 -03:00
committed by GitHub
parent a1546b5414
commit 59525f879b
3 changed files with 164 additions and 57 deletions
+64 -27
View File
@@ -2,17 +2,17 @@ import {
MockFailedWorkspace,
MockStartingWorkspace,
MockStoppedWorkspace,
MockTemplate,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceAgentLogSource,
MockWorkspaceAgentReady,
MockWorkspaceAgentStarting,
MockWorkspaceApp,
MockWorkspaceAppStatus,
MockWorkspaceResource,
mockApiError,
} from "testHelpers/entities";
import { withProxyProvider } from "testHelpers/storybook";
import { withProxyProvider, withWebSocket } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type {
Workspace,
WorkspaceApp,
@@ -61,28 +61,6 @@ export const WaitingOnBuild: Story = {
},
};
export const WaitingOnBuildWithTemplate: Story = {
beforeEach: () => {
spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
spyOn(data, "fetchTask").mockResolvedValue({
prompt: "Create competitors page",
workspace: MockStartingWorkspace,
});
},
};
export const WaitingOnStatus: Story = {
beforeEach: () => {
spyOn(data, "fetchTask").mockResolvedValue({
prompt: "Create competitors page",
workspace: {
...MockWorkspace,
latest_app_status: null,
},
});
},
};
export const FailedBuild: Story = {
beforeEach: () => {
spyOn(data, "fetchTask").mockResolvedValue({
@@ -113,6 +91,65 @@ 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({
prompt: "Create competitors page",
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
has_ai_task: true,
resources: [
{ ...MockWorkspaceResource, agents: [MockWorkspaceAgentStarting] },
],
},
},
});
},
decorators: [withWebSocket],
parameters: {
webSocket: [
{
event: "message",
data: JSON.stringify(
[
"\x1b[91mCloning Git repository...",
"\x1b[2;37;41mStarting Docker Daemon...",
"\x1b[1;95mAdding some 🧙magic🧙...",
"Starting VS Code...",
"\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0\r100 1475 0 1475 0 0 4231 0 --:--:-- --:--:-- --:--:-- 4238",
].map((line, index) => ({
id: index,
level: "info",
output: line,
source_id: MockWorkspaceAgentLogSource.id,
created_at: new Date("2024-01-01T12:00:00Z").toISOString(),
})),
),
},
],
},
};
export const SidebarAppHealthDisabled: Story = {
beforeEach: () => {
spyOn(data, "fetchTask").mockResolvedValue({
@@ -223,7 +260,7 @@ const mockResources = (
...MockWorkspaceResource,
agents: [
{
...MockWorkspaceAgent,
...MockWorkspaceAgentReady,
apps: [
...(props?.apps ?? []),
{
+99 -29
View File
@@ -1,7 +1,11 @@
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 type {
Workspace,
WorkspaceAgent,
WorkspaceStatus,
} from "api/typesGenerated";
import isChromatic from "chromatic/isChromatic";
import { Button } from "components/Button/Button";
import { Loader } from "components/Loader/Loader";
@@ -9,13 +13,16 @@ import { Margins } from "components/Margins/Margins";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
import { useAgentLogs } from "modules/resources/useAgentLogs";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { type FC, type ReactNode, useEffect, useRef } from "react";
import { type FC, type ReactNode, useLayoutEffect, 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 type { FixedSizeList } from "react-window";
import { pageTitle } from "utils/page";
import {
getActiveTransitionStats,
@@ -87,6 +94,7 @@ const TaskPage = () => {
}
let content: ReactNode = null;
const agent = selectAgent(task);
if (waitingStatuses.includes(task.workspace.latest_build.status)) {
content = <TaskBuildingWorkspace task={task} />;
@@ -132,6 +140,8 @@ const TaskPage = () => {
</div>
</Margins>
);
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
content = <TaskStartingAgent agent={agent} />;
} else {
content = (
<PanelGroup autoSaveId="task" direction="horizontal">
@@ -182,7 +192,7 @@ const TaskBuildingWorkspace: FC<TaskBuildingWorkspaceProps> = ({ task }) => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: this effect should run when build logs change
useEffect(() => {
useLayoutEffect(() => {
if (isChromatic()) {
return;
}
@@ -196,34 +206,86 @@ const TaskBuildingWorkspace: FC<TaskBuildingWorkspaceProps> = ({ task }) => {
}, [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>
<section className="p-16 overflow-y-auto">
<div className="flex justify-center items-center w-full">
<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>
<p className="text-content-secondary m-0">
Your task will be running in a few moments
</p>
</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 ?? []}
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
<WorkspaceBuildProgress
workspace={task.workspace}
transitionStats={transitionStats}
variant="task"
/>
</ScrollArea>
<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>
</div>
</section>
);
};
type TaskStartingAgentProps = {
agent: WorkspaceAgent;
};
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
const logs = useAgentLogs(agent, true);
const listRef = useRef<FixedSizeList>(null);
useLayoutEffect(() => {
if (listRef.current) {
listRef.current.scrollToItem(logs.length - 1, "end");
}
}, [logs]);
return (
<section className="p-16 overflow-y-auto">
<div className="flex justify-center items-center w-full">
<div className="flex flex-col gap-8 items-center w-full">
<header className="flex flex-col items-center text-center">
<h3 className="m-0 font-medium text-content-primary text-xl">
Running startup scripts
</h3>
<p className="text-content-secondary m-0">
Your task will be running in a few moments
</p>
</header>
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
<div className="h-96 border border-solid border-border rounded-lg">
<AgentLogs
logs={logs.map((l) => ({
id: l.id,
level: l.level,
output: l.output,
sourceId: l.source_id,
time: l.created_at,
}))}
sources={agent.log_sources}
height={96 * 4}
width="100%"
ref={listRef}
/>
</div>
</div>
</div>
</div>
</section>
@@ -265,3 +327,11 @@ export const data = {
} satisfies Task;
},
};
function selectAgent(task: Task) {
const agents = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a);
return agents.at(0);
}
+1 -1
View File
@@ -22,7 +22,7 @@ type TaskTopbarProps = { task: Task };
export const TaskTopbar: FC<TaskTopbarProps> = ({ task }) => {
return (
<header className="flex items-center px-3 py-4 border-solid border-border border-0 border-b">
<header className="flex flex-shrink-0 items-center px-3 py-4 border-solid border-border border-0 border-b">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>