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:
Ehab Younes
2026-02-19 14:54:59 +01:00
committed by GitHub
parent 02a80eac2e
commit 3fcd8c6128
4 changed files with 339 additions and 53 deletions
+12
View File
@@ -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>
+171 -2
View File
@@ -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({
+154 -49
View File
@@ -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"