Compare commits

...

5 Commits

Author SHA1 Message Date
Ehab Younes e0f9015d3f test(site): add tests for TaskLogSnapshot component
Add unit tests and Storybook stories for the TaskLogSnapshot component:

Unit tests (TaskLogSnapshot.test.tsx):
- Loading state
- Logs with user/agent prefixes
- Log count in header
- Empty state when no logs
- Error state on fetch failure
- Action label as link vs text
- API call with correct params

Storybook stories:
- TaskLogSnapshot: Loading, WithLogs, Empty, FetchError, etc.
- TaskPage: Updated existing stories to mock getTaskLogs
- New stories: TerminatedBuildEmptyLogs, TerminatedBuildLogsError, FailedBuildEmptyLogs

Also adds MockTaskLogs to test helpers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:39:07 +03:00
Ehab Younes d138b5cefe feat(site): display task log snapshots when paused or failed
Add TaskLogSnapshot component that displays AI chat conversation history
when a task is paused or has failed. This allows users to see recent
conversation context even when the workspace is not running.

Changes:
- Add getTaskLogs API method to fetch task log snapshots
- Create TaskLogSnapshot component with loading, error, and empty states
- Update WorkspaceNotRunning with task-centric language ("Task paused")
- Add log snapshot display to both paused and failed task states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:49:49 +03:00
Ehab Younes 399928b339 Add documentation to ai-coder/tasks.md 2026-01-28 15:23:51 +03:00
Ehab Younes 2f998d6f18 Simplify tests 2026-01-28 15:23:51 +03:00
Ehab Younes cc270a25c7 feat(site): add pause/resume action buttons to tasks table
Add TaskActionButton component with pause and resume actions
that allow users to stop and start task workspaces directly from the
tasks table.
2026-01-28 15:13:18 +03:00
13 changed files with 1008 additions and 85 deletions
+11
View File
@@ -133,6 +133,17 @@ If a workspace app has the special `"preview"` slug, a navbar will appear above
We plan to introduce more customization options in future releases.
## Pausing and Resuming Tasks
You can pause and resume tasks directly from the Tasks table using the action
buttons. Pausing a task stops the underlying workspace, freeing up compute
resources while preserving task state. This is useful for long-running tasks
you want to pause during periods of inactivity.
Running tasks show a pause button, while paused or errored tasks show a resume
button. Resuming restarts the workspace, which takes time while it starts up
and the agent reconnects.
## Automatically name your tasks
Coder can automatically generate a name your tasks if you set the `ANTHROPIC_API_KEY` environment variable on the Coder server. Otherwise, tasks will be given randomly generated names.
+10
View File
@@ -2796,6 +2796,16 @@ class ApiMethods {
});
};
getTaskLogs = async (
user: string,
taskId: string,
): Promise<TypesGen.TaskLogsResponse> => {
const response = await this.axios.get<TypesGen.TaskLogsResponse>(
`/api/v2/tasks/${user}/${taskId}/logs`,
);
return response.data;
};
getAIBridgeInterceptions = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams(
"/api/v2/aibridge/interceptions",
@@ -0,0 +1,135 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type { TaskLogsResponse } from "api/typesGenerated";
import { spyOn } from "storybook/test";
import { TaskLogSnapshot } from "./TaskLogSnapshot";
const MockTaskLogsResponse: TaskLogsResponse = {
logs: [
{
id: 1,
content: "What's the latest GH issue?",
type: "input",
time: "2024-01-01T12:00:00Z",
},
{
id: 2,
content:
"I'll fetch that for you...\nThe latest issue is #21309: Feature: Improve database migration handling for large tables.",
type: "output",
time: "2024-01-01T12:00:05Z",
},
{
id: 3,
content: "Can you summarize the discussion?",
type: "input",
time: "2024-01-01T12:00:30Z",
},
{
id: 4,
content:
"The discussion focuses on optimizing migration performance for tables with millions of rows. Key points include:\n\n1. Using batch processing to avoid lock timeouts\n2. Adding progress indicators for long-running migrations\n3. Supporting rollback for failed migrations",
type: "output",
time: "2024-01-01T12:00:35Z",
},
],
};
const meta: Meta<typeof TaskLogSnapshot> = {
title: "pages/TaskPage/TaskLogSnapshot",
component: TaskLogSnapshot,
args: {
username: "testuser",
taskId: "test-task-id",
actionLabel: "Restart to view full logs",
},
};
export default meta;
type Story = StoryObj<typeof TaskLogSnapshot>;
export const Loading: Story = {
beforeEach: () => {
spyOn(API, "getTaskLogs").mockImplementation(() => new Promise(() => {}));
},
};
export const WithLogs: Story = {
beforeEach: () => {
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
},
};
export const WithLogsAndLink: Story = {
args: {
actionLabel: "View full logs",
actionHref: "/@testuser/test-workspace/builds/1",
},
beforeEach: () => {
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
},
};
export const Empty: Story = {
beforeEach: () => {
spyOn(API, "getTaskLogs").mockResolvedValue({ logs: [] });
},
};
export const FetchError: Story = {
beforeEach: () => {
spyOn(API, "getTaskLogs").mockRejectedValue(new Error("Failed to fetch"));
},
};
export const SingleUserMessage: Story = {
beforeEach: () => {
spyOn(API, "getTaskLogs").mockResolvedValue({
logs: [
{
id: 1,
content: "Help me implement a new feature for user authentication",
type: "input",
time: "2024-01-01T12:00:00Z",
},
],
});
},
};
export const SingleAgentMessage: Story = {
beforeEach: () => {
spyOn(API, "getTaskLogs").mockResolvedValue({
logs: [
{
id: 1,
content:
"I'll help you implement user authentication. Let me analyze the codebase first...",
type: "output",
time: "2024-01-01T12:00:00Z",
},
],
});
},
};
export const LongConversation: Story = {
beforeEach: () => {
const logs = [];
for (let i = 0; i < 20; i++) {
logs.push({
id: i * 2 + 1,
content: `User message ${i + 1}: Can you help me with task ${i + 1}?`,
type: "input" as const,
time: new Date(Date.now() + i * 60000).toISOString(),
});
logs.push({
id: i * 2 + 2,
content: `Agent response ${i + 1}: Sure, I'll help you with task ${i + 1}. Here's what I found...`,
type: "output" as const,
time: new Date(Date.now() + i * 60000 + 5000).toISOString(),
});
}
spyOn(API, "getTaskLogs").mockResolvedValue({ logs });
},
};
@@ -0,0 +1,158 @@
import { MockTaskLogs } from "testHelpers/entities";
import { render } from "testHelpers/renderHelpers";
import { screen, waitFor } from "@testing-library/react";
import { API } from "api/api";
import { describe, expect, it, vi } from "vitest";
import { TaskLogSnapshot } from "./TaskLogSnapshot";
describe("TaskLogSnapshot", () => {
it("shows loading state initially", async () => {
vi.spyOn(API, "getTaskLogs").mockImplementation(
() => new Promise(() => {}),
);
render(
<TaskLogSnapshot
username="testuser"
taskId="test-task"
actionLabel="Restart to view full logs"
/>,
);
expect(await screen.findByTestId("loader")).toBeInTheDocument();
});
it("shows logs with user and agent prefixes", async () => {
vi.spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
render(
<TaskLogSnapshot
username="testuser"
taskId="test-task"
actionLabel="Restart to view full logs"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("[user]").length).toBeGreaterThan(0);
expect(screen.getAllByText("[agent]").length).toBeGreaterThan(0);
});
expect(
screen.getByText(/What's the latest GH issue\?/),
).toBeInTheDocument();
expect(screen.getByText(/I'll fetch that for you/)).toBeInTheDocument();
});
it("shows log count in header", async () => {
vi.spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
render(
<TaskLogSnapshot
username="testuser"
taskId="test-task"
actionLabel="Restart to view full logs"
/>,
);
await waitFor(() => {
expect(
screen.getByText(/Last 4 lines of AI chat logs/),
).toBeInTheDocument();
});
});
it("shows empty state when no logs", async () => {
vi.spyOn(API, "getTaskLogs").mockResolvedValue({ logs: [] });
render(
<TaskLogSnapshot
username="testuser"
taskId="test-task"
actionLabel="Restart to view full logs"
/>,
);
await waitFor(() => {
expect(
screen.getByText(/No conversation history available/),
).toBeInTheDocument();
});
});
it("shows error state on fetch failure", async () => {
vi.spyOn(API, "getTaskLogs").mockRejectedValue(new Error("Network error"));
render(
<TaskLogSnapshot
username="testuser"
taskId="test-task"
actionLabel="Restart to view full logs"
/>,
);
await waitFor(() => {
expect(
screen.getByText(/Unable to load conversation history/),
).toBeInTheDocument();
});
});
it("renders action label as link when actionHref provided", async () => {
vi.spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
render(
<TaskLogSnapshot
username="testuser"
taskId="test-task"
actionLabel="View full logs"
actionHref="/@testuser/workspace/builds/1"
/>,
);
await waitFor(() => {
const link = screen.getByRole("link", { name: /View full logs/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/@testuser/workspace/builds/1");
});
});
it("renders action label as text when no href or onAction", async () => {
vi.spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
render(
<TaskLogSnapshot
username="testuser"
taskId="test-task"
actionLabel="Restart to view full logs"
/>,
);
await waitFor(() => {
expect(screen.getByText("Restart to view full logs")).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /Restart to view full logs/ }),
).not.toBeInTheDocument();
});
});
it("calls API with correct username and taskId", async () => {
const getTaskLogsSpy = vi
.spyOn(API, "getTaskLogs")
.mockResolvedValue({ logs: [] });
render(
<TaskLogSnapshot
username="myuser"
taskId="my-task-123"
actionLabel="Restart"
/>,
);
await waitFor(() => {
expect(getTaskLogsSpy).toHaveBeenCalledWith("myuser", "my-task-123");
});
getTaskLogsSpy.mockRestore();
});
});
+126
View File
@@ -0,0 +1,126 @@
import { API } from "api/api";
import type { TaskLogEntry } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import type { FC } from "react";
import { useQuery } from "react-query";
import { Link as RouterLink } from "react-router";
type TaskLogSnapshotProps = {
username: string;
taskId: string;
actionLabel: string;
actionHref?: string;
onAction?: () => void;
};
export const TaskLogSnapshot: FC<TaskLogSnapshotProps> = ({
username,
taskId,
actionLabel,
actionHref,
onAction,
}) => {
const { data, isLoading, error } = useQuery({
queryKey: ["taskLogs", username, taskId],
queryFn: () => API.getTaskLogs(username, taskId),
retry: false,
});
if (isLoading) {
return (
<div className="w-full max-w-screen-lg mx-auto mt-6">
<div className="border border-solid border-border rounded-lg h-64 flex items-center justify-center">
<Loader />
</div>
</div>
);
}
if (error) {
return (
<div className="w-full max-w-screen-lg mx-auto mt-6">
<div className="border border-solid border-border rounded-lg h-64 flex items-center justify-center p-6">
<p className="text-content-secondary text-sm text-center">
Unable to load conversation history. Please try again.
</p>
</div>
</div>
);
}
const logs = data?.logs ?? [];
if (logs.length === 0) {
return (
<div className="w-full max-w-screen-lg mx-auto mt-6">
<div className="border border-solid border-border rounded-lg h-64 flex items-center justify-center p-6">
<p className="text-content-secondary text-sm text-center">
No conversation history available. The snapshot may not have been
captured during pause. Restart your task to continue the
conversation.
</p>
</div>
</div>
);
}
return (
<div className="w-full max-w-screen-lg mx-auto mt-6">
<div className="border border-solid border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-surface-secondary border-b border-solid border-border">
<span className="text-content-secondary text-sm">
Last {logs.length} lines of AI chat logs
</span>
{actionHref ? (
<RouterLink
to={actionHref}
className="text-content-link text-sm hover:underline"
>
{actionLabel}
</RouterLink>
) : onAction ? (
<button
type="button"
onClick={onAction}
className="text-content-link text-sm hover:underline bg-transparent border-none cursor-pointer p-0"
>
{actionLabel}
</button>
) : (
<span className="text-content-secondary text-sm">
{actionLabel}
</span>
)}
</div>
<ScrollArea className="h-64">
<div className="p-4 font-mono text-sm whitespace-pre-wrap">
{logs.map((log, index) => (
<LogMessage
key={log.id}
log={log}
isLast={index === logs.length - 1}
/>
))}
</div>
</ScrollArea>
</div>
</div>
);
};
type LogMessageProps = {
log: TaskLogEntry;
isLast: boolean;
};
const LogMessage: FC<LogMessageProps> = ({ log, isLast }) => {
const prefix = log.type === "input" ? "[user]" : "[agent]";
return (
<div className={isLast ? "" : "mb-4"}>
<div className="text-content-secondary">{prefix}</div>
<div className="text-content-primary">{log.content}</div>
</div>
);
};
+51 -9
View File
@@ -5,6 +5,7 @@ import {
MockStartingWorkspace,
MockStoppedWorkspace,
MockTask,
MockTaskLogs,
MockTasks,
MockUserOwner,
MockWorkspace,
@@ -149,6 +150,7 @@ export const FailedBuild: Story = {
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockFailedWorkspace,
);
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
},
};
@@ -158,6 +160,7 @@ export const TerminatedBuild: Story = {
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
},
};
@@ -168,6 +171,37 @@ export const TerminatedBuildWithStatus: Story = {
...MockStoppedWorkspace,
latest_app_status: MockWorkspaceAppStatus,
});
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
},
};
export const TerminatedBuildEmptyLogs: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
spyOn(API, "getTaskLogs").mockResolvedValue({ logs: [] });
},
};
export const TerminatedBuildLogsError: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
spyOn(API, "getTaskLogs").mockRejectedValue(new Error("Failed to fetch"));
},
};
export const FailedBuildEmptyLogs: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockFailedWorkspace,
);
spyOn(API, "getTaskLogs").mockResolvedValue({ logs: [] });
},
};
@@ -177,6 +211,8 @@ export const DeletedWorkspace: Story = {
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockDeletedWorkspace,
);
// No log snapshot shown for deleted workspaces, but mock anyway
spyOn(API, "getTaskLogs").mockResolvedValue({ logs: [] });
},
};
@@ -405,6 +441,9 @@ export const MainAppUnhealthy: Story = mainAppHealthStory("unhealthy");
export const OutdatedWorkspace: Story = {
// Given: an 'outdated' workspace (that is, the latest build does not use template's active version)
beforeEach: () => {
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
},
parameters: {
queries: [
{
@@ -497,6 +536,7 @@ export const WorkspaceStarting: Story = {
spyOn(API, "startWorkspace").mockResolvedValue(
MockStartingWorkspace.latest_build,
);
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
},
parameters: {
reactRouter: reactRouterParameters({
@@ -514,10 +554,10 @@ export const WorkspaceStarting: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
const restartButton = await canvas.findByText("Restart");
expect(restartButton).toBeInTheDocument();
await userEvent.click(startButton);
await userEvent.click(restartButton);
await waitFor(async () => {
expect(API.startWorkspace).toBeCalled();
@@ -535,6 +575,7 @@ export const WorkspaceStartFailure: Story = {
spyOn(API, "startWorkspace").mockRejectedValue(
new Error("Some unexpected error"),
);
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
},
parameters: {
reactRouter: reactRouterParameters({
@@ -552,10 +593,10 @@ export const WorkspaceStartFailure: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
const restartButton = await canvas.findByText("Restart");
expect(restartButton).toBeInTheDocument();
await userEvent.click(startButton);
await userEvent.click(restartButton);
await waitFor(async () => {
const errorMessage = await canvas.findByText("Some unexpected error");
@@ -577,6 +618,7 @@ export const WorkspaceStartFailureWithDialog: Story = {
}),
code: "ERR_BAD_REQUEST",
});
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogs);
},
parameters: {
reactRouter: reactRouterParameters({
@@ -594,10 +636,10 @@ export const WorkspaceStartFailureWithDialog: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
const restartButton = await canvas.findByText("Restart");
expect(restartButton).toBeInTheDocument();
await userEvent.click(startButton);
await userEvent.click(restartButton);
await waitFor(async () => {
const body = within(canvasElement.ownerDocument.body);
+60 -39
View File
@@ -48,6 +48,7 @@ import {
import { ModifyPromptDialog } from "./ModifyPromptDialog";
import { TaskAppIFrame } from "./TaskAppIframe";
import { TaskApps } from "./TaskApps";
import { TaskLogSnapshot } from "./TaskLogSnapshot";
import { TaskTopbar } from "./TaskTopbar";
const TaskPageLayout: FC<PropsWithChildren> = ({ children }) => {
@@ -135,29 +136,36 @@ const TaskPage = () => {
/>
);
} else if (workspace.latest_build.status === "failed") {
const buildLogsUrl = `/@${workspace.owner_name}/${workspace.name}/builds/${workspace.latest_build.build_number}`;
content = (
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task build failed
</h3>
<span className="text-content-secondary text-sm">
Please check the logs for more details.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink
to={`/@${workspace.owner_name}/${workspace.name}/builds/${workspace.latest_build.build_number}`}
>
View logs
</RouterLink>
</Button>
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task build failed
</h3>
<span className="text-content-secondary text-sm">
Please check the logs for more details.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink to={buildLogsUrl}>View full logs</RouterLink>
</Button>
</div>
</div>
</div>
<TaskLogSnapshot
username={username}
taskId={taskId}
actionLabel="View full logs"
actionHref={buildLogsUrl}
/>
</Margins>
);
} else if (workspace.latest_build.status !== "running") {
content = (
<WorkspaceNotRunning
workspace={workspace}
username={username}
taskId={taskId}
onEditPrompt={() => setIsModifyDialogOpen(true)}
/>
);
@@ -221,11 +229,15 @@ export default TaskPage;
type WorkspaceNotRunningProps = {
workspace: Workspace;
username: string;
taskId: string;
onEditPrompt: () => void;
};
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({
workspace,
username,
taskId,
onEditPrompt,
}) => {
const queryClient = useQueryClient();
@@ -254,33 +266,38 @@ const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({
const deleted = workspace.latest_build?.transition === ("delete" as const);
return deleted ? (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task workspace was deleted.
</h3>
<span className="text-content-secondary text-sm">
This task cannot be resumed. Delete this task and create a new one.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink to="/tasks" data-testid="task-create-new">
Create a new task
</RouterLink>
</Button>
if (deleted) {
return (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task workspace was deleted.
</h3>
<span className="text-content-secondary text-sm">
This task cannot be resumed. Delete this task and create a new
one.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink to="/tasks" data-testid="task-create-new">
Create a new task
</RouterLink>
</Button>
</div>
</div>
</div>
</Margins>
) : (
</Margins>
);
}
return (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Workspace is not running
Task paused
</h3>
<span className="text-content-secondary text-sm">
Apps and previous statuses are not available
Your task timed out. Restart it to continue.
</span>
{workspace.outdated && (
<div
@@ -304,15 +321,19 @@ const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({
}}
>
<Spinner loading={isWaitingForStart} />
Start workspace
Restart
</Button>
<Button size="sm" onClick={onEditPrompt} variant="outline">
Edit Prompt
Edit prompt
</Button>
</div>
</div>
</div>
<TaskLogSnapshot
username={username}
taskId={taskId}
actionLabel="Restart to view full logs"
/>
<WorkspaceErrorDialog
open={apiError !== undefined}
error={apiError}
@@ -0,0 +1,69 @@
import { renderComponent } from "testHelpers/renderHelpers";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { TaskActionButton } from "./TaskActionButton";
describe("TaskActionButton", () => {
it("renders with correct aria label for each action", async () => {
const { rerender } = renderComponent(
<TaskActionButton action="pause" onClick={() => {}} />,
);
expect(
await screen.findByRole("button", { name: /pause task/i }),
).toBeInTheDocument();
rerender(<TaskActionButton action="resume" onClick={() => {}} />);
expect(
await screen.findByRole("button", { name: /resume task/i }),
).toBeInTheDocument();
});
it("is disabled when disabled or loading", async () => {
const { rerender } = renderComponent(
<TaskActionButton action="pause" disabled onClick={() => {}} />,
);
expect(
await screen.findByRole("button", { name: /pause task/i }),
).toBeDisabled();
rerender(<TaskActionButton action="pause" loading onClick={() => {}} />);
expect(
await screen.findByRole("button", { name: /pause task/i }),
).toBeDisabled();
});
it("calls onClick when clicked and stops propagation", async () => {
const user = userEvent.setup();
const parentClick = vi.fn();
const buttonClick = vi.fn();
renderComponent(
// biome-ignore lint/a11y/useKeyWithClickEvents: Test-only element
<div onClick={parentClick}>
<TaskActionButton action="pause" onClick={buttonClick} />
</div>,
);
await user.click(
await screen.findByRole("button", { name: /pause task/i }),
);
expect(buttonClick).toHaveBeenCalledTimes(1);
expect(parentClick).not.toHaveBeenCalled();
});
it("does not call onClick when disabled", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
renderComponent(
<TaskActionButton action="pause" disabled onClick={handleClick} />,
);
await user.click(
await screen.findByRole("button", { name: /pause task/i }),
);
expect(handleClick).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,63 @@
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { PauseIcon, PlayIcon } from "lucide-react";
import type { FC } from "react";
type TaskActionButtonProps = {
action: "pause" | "resume";
disabled?: boolean;
loading?: boolean;
onClick: () => void;
};
const actionConfig = {
pause: {
icon: PauseIcon,
label: "Pause task",
tooltip: "Pause the task to save resources. You can resume later.",
},
resume: {
icon: PlayIcon,
label: "Resume task",
tooltip: "Resuming takes time while the workspace starts.",
},
} as const;
export const TaskActionButton: FC<TaskActionButtonProps> = ({
action,
disabled,
loading,
onClick,
}) => {
const config = actionConfig[action];
const Icon = config.icon;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-lg"
variant="outline"
disabled={disabled || loading}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Spinner loading={loading} />
{!loading && <Icon aria-hidden="true" />}
<span className="sr-only">{config.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{config.tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -2,6 +2,7 @@ import {
MockDisplayNameTasks,
MockInitializingTasks,
MockSystemNotificationTemplates,
MockTask,
MockTasks,
MockTemplate,
MockUserOwner,
@@ -288,6 +289,58 @@ export const InitializingTasks: Story = {
},
};
export const AllTaskStatuses: Story = {
parameters: {
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: [
{
...MockTask,
id: "active-task",
display_name: "Active Task",
status: "active",
},
{
...MockTask,
id: "initializing-task",
display_name: "Initializing Task",
status: "initializing",
},
{
...MockTask,
id: "pending-task",
display_name: "Pending Task",
status: "pending",
},
{
...MockTask,
id: "paused-task",
display_name: "Paused Task",
status: "paused",
},
{
...MockTask,
id: "error-task",
display_name: "Error Task",
status: "error",
},
{
...MockTask,
id: "unknown-task",
display_name: "Unknown Task",
status: "unknown",
},
],
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
],
},
};
export const BatchActionsEnabled: Story = {
parameters: {
features: ["task_batch_actions"],
@@ -0,0 +1,140 @@
import { MockTask, MockTasks } from "testHelpers/entities";
import { render } from "testHelpers/renderHelpers";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import { describe, expect, it, vi } from "vitest";
import { TasksTable } from "./TasksTable";
describe("TasksTable", () => {
it("shows pause button for active tasks, resume for paused/error", async () => {
const mixedTasks = [
{ ...MockTask, id: "active-task", status: "active" as const },
{ ...MockTask, id: "paused-task", status: "paused" as const },
{ ...MockTask, id: "error-task", status: "error" as const },
];
render(
<TasksTable tasks={mixedTasks} error={undefined} onRetry={() => {}} />,
);
await screen.findByRole("table");
expect(screen.getAllByRole("button", { name: /pause task/i })).toHaveLength(
1,
);
expect(
screen.getAllByRole("button", { name: /resume task/i }),
).toHaveLength(2);
});
it("shows disabled pause button for pending/initializing tasks", async () => {
const pendingTasks = [{ ...MockTask, status: "pending" as const }];
render(
<TasksTable tasks={pendingTasks} error={undefined} onRetry={() => {}} />,
);
const pauseButton = await screen.findByRole("button", {
name: /pause task/i,
});
expect(pauseButton).toBeDisabled();
});
it("hides buttons for unknown status", async () => {
const unknownTasks = [{ ...MockTask, status: "unknown" as const }];
render(
<TasksTable tasks={unknownTasks} error={undefined} onRetry={() => {}} />,
);
await screen.findByRole("table");
expect(
screen.queryByRole("button", { name: /pause task/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /resume task/i }),
).not.toBeInTheDocument();
});
it("calls stopWorkspace on pause click", async () => {
const user = userEvent.setup();
const stopWorkspaceSpy = vi
.spyOn(API, "stopWorkspace")
.mockResolvedValue({} as never);
const activeTasks = [{ ...MockTask, status: "active" as const }];
render(
<TasksTable tasks={activeTasks} error={undefined} onRetry={() => {}} />,
);
await user.click(
await screen.findByRole("button", { name: /pause task/i }),
);
await waitFor(() => {
expect(stopWorkspaceSpy).toHaveBeenCalledWith(MockTask.workspace_id);
});
stopWorkspaceSpy.mockRestore();
});
it("calls startWorkspace on resume click", async () => {
const user = userEvent.setup();
const startWorkspaceSpy = vi
.spyOn(API, "startWorkspace")
.mockResolvedValue({} as never);
const pausedTasks = [{ ...MockTask, status: "paused" as const }];
render(
<TasksTable tasks={pausedTasks} error={undefined} onRetry={() => {}} />,
);
await user.click(
await screen.findByRole("button", { name: /resume task/i }),
);
await waitFor(() => {
expect(startWorkspaceSpy).toHaveBeenCalledWith(
MockTask.workspace_id,
MockTask.template_version_id,
undefined,
undefined,
);
});
startWorkspaceSpy.mockRestore();
});
it("renders loading state", async () => {
render(
<TasksTable tasks={undefined} error={undefined} onRetry={() => {}} />,
);
expect(await screen.findByRole("table")).toBeInTheDocument();
});
it("renders empty state", async () => {
render(<TasksTable tasks={[]} error={undefined} onRetry={() => {}} />);
expect(await screen.findByText(/no tasks found/i)).toBeInTheDocument();
});
it("renders error state", async () => {
render(
<TasksTable
tasks={undefined}
error={new Error("Failed to load tasks")}
onRetry={() => {}}
/>,
);
expect(
await screen.findByText(/failed to load tasks/i),
).toBeInTheDocument();
});
it("renders data state", async () => {
render(
<TasksTable tasks={MockTasks} error={undefined} onRetry={() => {}} />,
);
for (const task of MockTasks) {
expect(await screen.findByText(task.display_name)).toBeInTheDocument();
}
});
});
+101 -37
View File
@@ -1,3 +1,4 @@
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import type { Task } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
@@ -12,6 +13,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { displayError } from "components/GlobalSnackbar/utils";
import { Skeleton } from "components/Skeleton/Skeleton";
import {
Table,
@@ -35,9 +37,10 @@ import {
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus";
import { type FC, type ReactNode, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { relativeTime } from "utils/time";
import { TaskActionButton } from "./TaskActionButton";
type TasksTableProps = {
tasks: readonly Task[] | undefined;
@@ -192,6 +195,50 @@ const TaskRow: FC<TaskRowProps> = ({
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const templateDisplayName = task.template_display_name ?? task.template_name;
const navigate = useNavigate();
const queryClient = useQueryClient();
const showPause =
task.status === "active" ||
task.status === "initializing" ||
task.status === "pending";
const pauseDisabled =
task.status === "pending" || task.status === "initializing";
const showResume = task.status === "paused" || task.status === "error";
const pauseMutation = useMutation({
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Workspace ID is not available");
}
return API.stopWorkspace(task.workspace_id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: (error: unknown) => {
displayError(getErrorMessage(error, "Failed to pause task."));
},
});
const resumeMutation = useMutation({
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Workspace ID is not available");
}
return API.startWorkspace(
task.workspace_id,
task.template_version_id,
undefined,
undefined,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: (error: unknown) => {
displayError(getErrorMessage(error, "Failed to resume task."));
},
});
const taskPageLink = `/tasks/${task.owner_name}/${task.id}`;
// Discard role, breaks Chromatic.
@@ -258,42 +305,59 @@ const TaskRow: FC<TaskRowProps> = ({
/>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open task actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate(
`/@${task.owner_name}/${task.workspace_name}/settings/sharing`,
);
}}
>
<Share2Icon />
Share
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-end gap-1">
{showPause && (
<TaskActionButton
action="pause"
disabled={pauseDisabled}
loading={pauseMutation.isPending}
onClick={() => pauseMutation.mutate()}
/>
)}
{showResume && (
<TaskActionButton
action="resume"
loading={resumeMutation.isPending}
onClick={() => resumeMutation.mutate()}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open task actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate(
`/@${task.owner_name}/${task.workspace_name}/settings/sharing`,
);
}}
>
<Share2Icon />
Share
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
+31
View File
@@ -5172,6 +5172,37 @@ export const MockDisplayNameTasks = [
},
] satisfies TypesGen.Task[];
export const MockTaskLogs: TypesGen.TaskLogsResponse = {
logs: [
{
id: 1,
content: "What's the latest GH issue?",
type: "input",
time: "2024-01-01T12:00:00Z",
},
{
id: 2,
content:
"I'll fetch that for you...\nThe latest issue is #21309: Feature: Improve database migration handling.",
type: "output",
time: "2024-01-01T12:00:05Z",
},
{
id: 3,
content: "Can you summarize the discussion?",
type: "input",
time: "2024-01-01T12:00:30Z",
},
{
id: 4,
content:
"The discussion focuses on optimizing migration performance for large tables.",
type: "output",
time: "2024-01-01T12:00:35Z",
},
],
};
export const MockInterception: TypesGen.AIBridgeInterception = {
id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
initiator: {