mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): show task log preview in paused and failed states (#22063)
Add a `TaskLogPreview` component that displays the last N messages of AI chat logs when a task is paused or its build has failed. The preview fetches log snapshots via a new `getTaskLogs` API method and renders them in a scrollable panel with `[user]` and `[agent]` labels, colored left borders on type transitions, and a snapshot timestamp tooltip. The build-logs auto-scroll in `BuildingWorkspace` was simplified by replacing the `useRef`/`useLayoutEffect` pattern with a `useCallback` ref, and client-side message slicing was removed in favor of server-side limits. `InfoTooltip` now accepts an optional `title` prop.
This commit is contained in:
@@ -2,6 +2,18 @@ import { API } from "api/api";
|
||||
import type { Task } from "api/typesGenerated";
|
||||
import type { QueryClient } from "react-query";
|
||||
|
||||
export const taskLogsKey = (user: string, taskId: string) => [
|
||||
"tasks",
|
||||
user,
|
||||
taskId,
|
||||
"logs",
|
||||
];
|
||||
|
||||
export const taskLogs = (user: string, taskId: string) => ({
|
||||
queryKey: taskLogsKey(user, taskId),
|
||||
queryFn: () => API.getTaskLogs(user, taskId),
|
||||
});
|
||||
|
||||
export const pauseTask = (task: Task, queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { cn } from "utils/cn";
|
||||
|
||||
interface InfoTooltipProps {
|
||||
type?: ThemeRole;
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const InfoTooltip: FC<InfoTooltipProps> = ({
|
||||
<HelpTooltipIcon className={cn(tooltipColorClasses[type])} />
|
||||
</HelpTooltipIconTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>{title}</HelpTooltipTitle>
|
||||
{title && <HelpTooltipTitle>{title}</HelpTooltipTitle>}
|
||||
<HelpTooltipText>{message}</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
|
||||
@@ -31,8 +31,21 @@ import {
|
||||
} from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import type { Task, Workspace, WorkspaceApp } from "api/typesGenerated";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { taskLogsKey } from "api/queries/tasks";
|
||||
import type {
|
||||
Task,
|
||||
TaskLogsResponse,
|
||||
Workspace,
|
||||
WorkspaceApp,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
expect,
|
||||
screen,
|
||||
spyOn,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import TaskPage from "./TaskPage";
|
||||
|
||||
@@ -68,6 +81,61 @@ const MockVSCodeApp: WorkspaceApp = {
|
||||
health: "healthy",
|
||||
};
|
||||
|
||||
const MockTaskLogsResponse: TaskLogsResponse = {
|
||||
logs: [
|
||||
{
|
||||
id: 1,
|
||||
content: "Implement JWT authentication with refresh token rotation.",
|
||||
type: "input",
|
||||
time: "2024-01-01T11:59:55Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content:
|
||||
"I'll help you implement the authentication system. Let me start by examining the existing code structure.",
|
||||
type: "output",
|
||||
time: "2024-01-01T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content:
|
||||
"Looking at the codebase, I can see the following relevant files:\n- src/auth/login.ts\n- src/auth/middleware.ts\n- src/models/user.ts",
|
||||
type: "output",
|
||||
time: "2024-01-01T12:00:05Z",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
content:
|
||||
"I'll now create the JWT token validation middleware. This will intercept all protected routes and verify the bearer token.",
|
||||
type: "output",
|
||||
time: "2024-01-01T12:00:10Z",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
content:
|
||||
"Looks good so far. Also add rate limiting to the token endpoint.",
|
||||
type: "input",
|
||||
time: "2024-01-01T12:00:12Z",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
content:
|
||||
"Successfully updated src/auth/middleware.ts with the new token validation logic.\nRunning tests to verify the changes...",
|
||||
type: "output",
|
||||
time: "2024-01-01T12:00:15Z",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
content:
|
||||
"All 12 tests passed. The authentication middleware is working correctly.\n\nNext, I'll add the refresh token rotation endpoint to prevent token reuse attacks.",
|
||||
type: "output",
|
||||
time: "2024-01-01T12:00:20Z",
|
||||
},
|
||||
],
|
||||
snapshot: true,
|
||||
snapshot_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof TaskPage> = {
|
||||
title: "pages/TaskPage",
|
||||
component: TaskPage,
|
||||
@@ -154,6 +222,20 @@ export const FailedBuild: Story = {
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockFailedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
};
|
||||
|
||||
export const FailedBuildNoSnapshot: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockFailedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue({
|
||||
snapshot: true,
|
||||
logs: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -163,6 +245,7 @@ export const TerminatedBuild: Story = {
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -173,6 +256,7 @@ export const TerminatedBuildWithStatus: Story = {
|
||||
...MockStoppedWorkspace,
|
||||
latest_app_status: MockWorkspaceAppStatus,
|
||||
});
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -206,6 +290,82 @@ export const TaskPaused: Story = {
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausedNoSnapshot: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue({
|
||||
snapshot: true,
|
||||
logs: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausedEmptySnapshot: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue({
|
||||
snapshot: true,
|
||||
snapshot_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
logs: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausedSingleMessage: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue({
|
||||
snapshot: true,
|
||||
snapshot_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
logs: [MockTaskLogsResponse.logs[0]],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausedSnapshotTooltip: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const tooltipTrigger = await canvas.findByRole("button", {
|
||||
name: /info/i,
|
||||
});
|
||||
await userEvent.hover(tooltipTrigger);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
/This log snapshot was taken/,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -223,6 +383,7 @@ export const TaskPausedTimeout: Story = {
|
||||
reason: "task_auto_pause",
|
||||
},
|
||||
});
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -235,6 +396,7 @@ export const TaskCanceled: Story = {
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockCanceledWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -511,6 +673,10 @@ export const TaskPausedOutdated: Story = {
|
||||
],
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
key: taskLogsKey(MockTask.owner_name, MockTask.id),
|
||||
data: MockTaskLogsResponse,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Then: a tooltip should be displayed prompting the user to update the workspace.
|
||||
@@ -576,6 +742,7 @@ export const TaskResuming: Story = {
|
||||
spyOn(API, "startWorkspace").mockResolvedValue(
|
||||
MockStartingWorkspace.latest_build,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
@@ -617,6 +784,7 @@ export const TaskResumeFailure: Story = {
|
||||
spyOn(API, "startWorkspace").mockRejectedValue(
|
||||
new Error("Some unexpected error"),
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
@@ -659,6 +827,7 @@ export const TaskResumeFailureWithDialog: Story = {
|
||||
}),
|
||||
code: "ERR_BAD_REQUEST",
|
||||
});
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage, isApiError } from "api/errors";
|
||||
import { pauseTask, resumeTask } from "api/queries/tasks";
|
||||
import { pauseTask, resumeTask, taskLogs } from "api/queries/tasks";
|
||||
import { template as templateQueryOptions } from "api/queries/templates";
|
||||
import { workspaceByOwnerAndName } from "api/queries/workspaces";
|
||||
import type {
|
||||
Task,
|
||||
TaskLogEntry,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceStatus,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
import isChromatic from "chromatic/isChromatic";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -43,7 +46,9 @@ import { useMutation, useQuery, useQueryClient } 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 { cn } from "utils/cn";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { relativeTime } from "utils/time";
|
||||
import {
|
||||
getActiveTransitionStats,
|
||||
WorkspaceBuildProgress,
|
||||
@@ -137,13 +142,7 @@ const TaskPage = () => {
|
||||
/>
|
||||
);
|
||||
} else if (workspace.latest_build.status === "failed") {
|
||||
content = (
|
||||
<TaskBuildFailed
|
||||
workspaceOwner={workspace.owner_name}
|
||||
workspaceName={workspace.name}
|
||||
buildNumber={workspace.latest_build.build_number}
|
||||
/>
|
||||
);
|
||||
content = <TaskBuildFailed task={task} workspace={workspace} />;
|
||||
} else if (workspace.latest_build.status === "stopping") {
|
||||
content = (
|
||||
<TaskTransitioning
|
||||
@@ -303,32 +302,134 @@ const TaskDeleted: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type TaskBuildFailedProps = {
|
||||
workspaceOwner: string;
|
||||
workspaceName: string;
|
||||
buildNumber: number;
|
||||
type TaskLogPreviewProps = {
|
||||
logs: readonly TaskLogEntry[];
|
||||
maxMessages?: number;
|
||||
headerAction?: ReactNode;
|
||||
snapshotAt?: string;
|
||||
};
|
||||
|
||||
const TaskBuildFailed: FC<TaskBuildFailedProps> = ({
|
||||
workspaceOwner,
|
||||
workspaceName,
|
||||
buildNumber,
|
||||
function logPreviewLabel(count: number): string {
|
||||
if (count === 0) {
|
||||
return "AI chat logs";
|
||||
}
|
||||
if (count === 1) {
|
||||
return "Last message of AI chat logs";
|
||||
}
|
||||
return `Last ${count} messages of AI chat logs`;
|
||||
}
|
||||
|
||||
const TaskLogPreview: FC<TaskLogPreviewProps> = ({
|
||||
logs,
|
||||
headerAction,
|
||||
snapshotAt,
|
||||
}) => {
|
||||
// Scroll to the bottom on mount since snapshot logs are static.
|
||||
const scrollToBottom = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!isChromatic() && el) {
|
||||
el.scrollIntoView({ block: "end" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TaskStateMessage
|
||||
title="Task build failed"
|
||||
description="Please check the logs for more details."
|
||||
icon={<TriangleAlertIcon className="size-4 text-content-destructive" />}
|
||||
actions={
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<RouterLink
|
||||
to={`/@${workspaceOwner}/${workspaceName}/builds/${buildNumber}`}
|
||||
>
|
||||
View full logs
|
||||
</RouterLink>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="w-full max-w-screen-lg mx-auto px-16">
|
||||
<div className="border border-solid border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-0 border-b border-solid border-border bg-surface-secondary text-sm text-content-secondary">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{logPreviewLabel(logs.length)}
|
||||
{snapshotAt && (
|
||||
<InfoTooltip
|
||||
type="info"
|
||||
message={`This log snapshot was taken ${relativeTime(snapshotAt)}.`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{headerAction}
|
||||
</div>
|
||||
{snapshotAt ? (
|
||||
logs.length > 0 ? (
|
||||
<ScrollArea className="h-96">
|
||||
<div
|
||||
ref={scrollToBottom}
|
||||
className="p-4 font-mono text-xs text-content-secondary leading-relaxed whitespace-pre-wrap break-words"
|
||||
>
|
||||
{logs.map((entry, index) => {
|
||||
const prev = index === 0 ? undefined : logs[index - 1];
|
||||
const isNewGroup = !prev || prev.type !== entry.type;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
"pl-3 border-0 border-l-2 border-solid",
|
||||
entry.type === "input"
|
||||
? "border-l-border-pending"
|
||||
: "border-l-border-purple",
|
||||
isNewGroup && index > 0 && "mt-4",
|
||||
)}
|
||||
>
|
||||
{isNewGroup && (
|
||||
<div className="text-content-primary font-semibold mb-1">
|
||||
{entry.type === "input" ? "[user]" : "[agent]"}
|
||||
</div>
|
||||
)}
|
||||
{entry.content || "\u00A0"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<p className="px-4 py-3 text-sm text-content-secondary m-0">
|
||||
No log messages in this snapshot.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-4 py-3 text-sm text-content-secondary m-0">
|
||||
No log snapshot available. Resume your task to view logs.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskBuildFailedProps = {
|
||||
task: Task;
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const TaskBuildFailed: FC<TaskBuildFailedProps> = ({ task, workspace }) => {
|
||||
const { data: logsData } = useQuery({
|
||||
...taskLogs(task.owner_name, task.id),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const buildLogsLink = `/@${workspace.owner_name}/${workspace.name}/builds/${workspace.latest_build.build_number}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TaskStateMessage
|
||||
title="Task build failed"
|
||||
description="Please check the logs for more details."
|
||||
icon={<TriangleAlertIcon className="size-4 text-content-destructive" />}
|
||||
actions={
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<RouterLink to={buildLogsLink}>View full logs</RouterLink>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{logsData && (
|
||||
<TaskLogPreview
|
||||
logs={logsData.logs}
|
||||
snapshotAt={logsData.snapshot_at}
|
||||
headerAction={
|
||||
<Button size="sm" variant="subtle" asChild>
|
||||
<RouterLink to={buildLogsLink}>View full logs</RouterLink>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -352,6 +453,11 @@ const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: logsData } = useQuery({
|
||||
...taskLogs(task.owner_name, task.id),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// After requesting a task resume, it may take a while to become ready.
|
||||
const isWaitingForStart =
|
||||
resumeMutation.isPending || resumeMutation.isSuccess;
|
||||
@@ -401,6 +507,23 @@ const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{logsData && (
|
||||
<TaskLogPreview
|
||||
logs={logsData.logs}
|
||||
snapshotAt={logsData.snapshot_at}
|
||||
headerAction={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isWaitingForStart}
|
||||
onClick={() => resumeMutation.mutate()}
|
||||
>
|
||||
<Spinner loading={isWaitingForStart} />
|
||||
Resume to view full logs
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkspaceErrorDialog
|
||||
open={apiError !== undefined}
|
||||
@@ -438,21 +561,6 @@ const BuildingWorkspace: FC<BuildingWorkspaceProps> = ({
|
||||
P95: null,
|
||||
};
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: this effect should run when build logs change
|
||||
useLayoutEffect(() => {
|
||||
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="p-16 overflow-y-auto">
|
||||
<div className="flex justify-center items-center w-full">
|
||||
@@ -473,10 +581,7 @@ const BuildingWorkspace: FC<BuildingWorkspaceProps> = ({
|
||||
variant="task"
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className="h-96 border border-solid border-border rounded-lg"
|
||||
>
|
||||
<ScrollArea className="h-96 border border-solid border-border rounded-lg">
|
||||
<WorkspaceBuildLogs
|
||||
sticky
|
||||
className="border-0 rounded-none"
|
||||
|
||||
Reference in New Issue
Block a user