Skip to content

feat: show workspace build logs during tasks creation #19413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 94 additions & 53 deletions site/src/pages/TaskPage/TaskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 (
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -170,29 +133,103 @@ 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} />
</Panel>
<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(
Expand Down Expand Up @@ -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 changes: 0 additions & 70 deletions site/src/pages/TaskPage/TaskSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions site/src/pages/TaskPage/TaskTopbar.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
6 changes: 4 additions & 2 deletions site/src/pages/WorkspacePage/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ty for the rename

// if it is in a transition state.
export const ActiveTransition = (
export const getActiveTransitionStats = (
template: Template,
workspace: Workspace,
): TransitionStats | undefined => {
Expand Down
Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy