From 3fcd8c6128b32732b77758cb78fd7eaf2add46d4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 19 Feb 2026 14:54:59 +0100 Subject: [PATCH] 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. --- site/src/api/queries/tasks.ts | 12 ++ .../components/InfoTooltip/InfoTooltip.tsx | 4 +- site/src/pages/TaskPage/TaskPage.stories.tsx | 173 ++++++++++++++- site/src/pages/TaskPage/TaskPage.tsx | 203 +++++++++++++----- 4 files changed, 339 insertions(+), 53 deletions(-) diff --git a/site/src/api/queries/tasks.ts b/site/src/api/queries/tasks.ts index 97b51dd878..3627f4b8d7 100644 --- a/site/src/api/queries/tasks.ts +++ b/site/src/api/queries/tasks.ts @@ -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 () => { diff --git a/site/src/components/InfoTooltip/InfoTooltip.tsx b/site/src/components/InfoTooltip/InfoTooltip.tsx index 8e13a13c11..10fbbee0c3 100644 --- a/site/src/components/InfoTooltip/InfoTooltip.tsx +++ b/site/src/components/InfoTooltip/InfoTooltip.tsx @@ -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 = ({ - {title} + {title && {title}} {message} diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index f18b427c37..5e0c348ec9 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -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 = { 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({ diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 5067daf1a0..1cbd435f0b 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -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 = ( - - ); + content = ; } else if (workspace.latest_build.status === "stopping") { content = ( { ); }; -type TaskBuildFailedProps = { - workspaceOwner: string; - workspaceName: string; - buildNumber: number; +type TaskLogPreviewProps = { + logs: readonly TaskLogEntry[]; + maxMessages?: number; + headerAction?: ReactNode; + snapshotAt?: string; }; -const TaskBuildFailed: FC = ({ - 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 = ({ + 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 ( - } - actions={ - - } - /> +
+
+
+ + {logPreviewLabel(logs.length)} + {snapshotAt && ( + + )} + + {headerAction} +
+ {snapshotAt ? ( + logs.length > 0 ? ( + +
+ {logs.map((entry, index) => { + const prev = index === 0 ? undefined : logs[index - 1]; + const isNewGroup = !prev || prev.type !== entry.type; + return ( +
0 && "mt-4", + )} + > + {isNewGroup && ( +
+ {entry.type === "input" ? "[user]" : "[agent]"} +
+ )} + {entry.content || "\u00A0"} +
+ ); + })} +
+
+ ) : ( +

+ No log messages in this snapshot. +

+ ) + ) : ( +

+ No log snapshot available. Resume your task to view logs. +

+ )} +
+
+ ); +}; + +type TaskBuildFailedProps = { + task: Task; + workspace: Workspace; +}; + +const TaskBuildFailed: FC = ({ 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 ( + <> + } + actions={ + + } + /> + {logsData && ( + + View full logs + + } + /> + )} + ); }; @@ -352,6 +453,11 @@ const TaskPaused: FC = ({ 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 = ({ task, workspace, onEditPrompt }) => { } /> + {logsData && ( + resumeMutation.mutate()} + > + + Resume to view full logs + + } + /> + )} = ({ P95: null, }; - const scrollAreaRef = useRef(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( - "[data-radix-scroll-area-viewport]", - ); - if (scrollAreaViewportEl) { - scrollAreaViewportEl.scrollTop = scrollAreaViewportEl.scrollHeight; - } - }, [buildLogs]); - return (
@@ -473,10 +581,7 @@ const BuildingWorkspace: FC = ({ variant="task" /> - +