Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0f9015d3f | |||
| d138b5cefe | |||
| 399928b339 | |||
| 2f998d6f18 | |||
| cc270a25c7 |
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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…
|
||||
</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…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user