diff --git a/package.json b/package.json
index 7b749db1..7b79c972 100644
--- a/package.json
+++ b/package.json
@@ -139,14 +139,6 @@
"experimental"
]
},
- "coder.experimental.tasks": {
- "markdownDescription": "Enable the experimental [Tasks](https://coder.com/docs/ai-coder/tasks) panel in VS Code. When enabled, a sidebar panel lets you run and manage AI coding agents in Coder workspaces. This feature is under active development and may change. Requires a Coder deployment with Tasks support.",
- "type": "boolean",
- "default": false,
- "tags": [
- "experimental"
- ]
- },
"coder.sshFlags": {
"markdownDescription": "Additional flags to pass to the `coder ssh` command when establishing SSH connections. Enter each flag as a separate array item; values are passed verbatim and in order. See the [CLI ssh reference](https://coder.com/docs/reference/cli/ssh) for available flags.\n\nNote: `--network-info-dir` and `--ssh-host-prefix` are ignored (managed internally). Prefer `#coder.proxyLogDirectory#` over `--log-dir`/`-l` for full functionality.",
"type": "array",
@@ -219,7 +211,7 @@
"id": "coder.tasksPanel",
"name": "Coder Tasks",
"icon": "media/tasks-logo.svg",
- "when": "coder.authenticated && coder.tasksEnabled"
+ "when": "coder.authenticated"
}
]
},
@@ -426,7 +418,7 @@
},
{
"command": "coder.tasks.refresh",
- "when": "coder.authenticated && coder.tasksEnabled && view == coder.tasksPanel",
+ "when": "coder.authenticated && view == coder.tasksPanel",
"group": "navigation@1"
}
],
@@ -474,7 +466,7 @@
"@peculiar/x509": "^1.14.3",
"@repo/shared": "workspace:*",
"axios": "1.13.6",
- "date-fns": "^4.1.0",
+ "date-fns": "catalog:",
"eventsource": "^4.1.0",
"find-process": "^2.1.0",
"jsonc-parser": "^3.3.1",
diff --git a/packages/shared/src/tasks/types.ts b/packages/shared/src/tasks/types.ts
index 23fc5cba..30e50581 100644
--- a/packages/shared/src/tasks/types.ts
+++ b/packages/shared/src/tasks/types.ts
@@ -31,7 +31,12 @@ export interface TaskPreset {
/** Result of fetching task logs: either logs or an error/unavailable state. */
export type TaskLogs =
- | { status: "ok"; logs: readonly TaskLogEntry[] }
+ | {
+ status: "ok";
+ logs: readonly TaskLogEntry[];
+ snapshot?: boolean;
+ snapshotAt?: string;
+ }
| { status: "not_available" }
| { status: "error" };
diff --git a/packages/shared/src/tasks/utils.ts b/packages/shared/src/tasks/utils.ts
index ace8ecb0..2827d980 100644
--- a/packages/shared/src/tasks/utils.ts
+++ b/packages/shared/src/tasks/utils.ts
@@ -79,3 +79,14 @@ export function isAgentStarting(task: Task): boolean {
export function isWorkspaceStarting(task: Task): boolean {
return isBuildingWorkspace(task) || isAgentStarting(task);
}
+
+/** Label for the log preview header, matching the Coder dashboard pattern. */
+export function logPreviewLabel(count: number): string {
+ if (count === 0) {
+ return "AI chat messages";
+ }
+ if (count === 1) {
+ return "Last message of AI chat";
+ }
+ return `Last ${count} messages of AI chat`;
+}
diff --git a/packages/tasks/package.json b/packages/tasks/package.json
index b938f4f5..f7577396 100644
--- a/packages/tasks/package.json
+++ b/packages/tasks/package.json
@@ -14,6 +14,7 @@
"@tanstack/react-query": "catalog:",
"@vscode-elements/react-elements": "catalog:",
"@vscode/codicons": "catalog:",
+ "date-fns": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
diff --git a/packages/tasks/src/components/AgentChatHistory.tsx b/packages/tasks/src/components/AgentChatHistory.tsx
index 3d4b9cc7..e1c28aed 100644
--- a/packages/tasks/src/components/AgentChatHistory.tsx
+++ b/packages/tasks/src/components/AgentChatHistory.tsx
@@ -1,6 +1,13 @@
+import {
+ logPreviewLabel,
+ type TaskLogEntry,
+ type TaskLogs,
+} from "@repo/shared";
+import { formatDistanceToNowStrict } from "date-fns";
+
import { LogViewer, LogViewerPlaceholder } from "./LogViewer";
-import type { TaskLogEntry, TaskLogs } from "@repo/shared";
+import type { ReactNode } from "react";
interface AgentChatHistoryProps {
taskLogs: TaskLogs;
@@ -18,7 +25,7 @@ function LogEntry({
{isGroupStart && (
- {log.type === "input" ? "You" : "Agent"}
+ {log.type === "input" ? "[User]" : "[Agent]"}
)}
{log.content}
@@ -26,6 +33,31 @@ function LogEntry({
);
}
+function chatHistoryHeader(taskLogs: TaskLogs): ReactNode {
+ if (taskLogs.status !== "ok" || taskLogs.snapshot !== true) {
+ return "Chat history";
+ }
+ const label = logPreviewLabel(taskLogs.logs.length);
+ if (taskLogs.snapshotAt === undefined) {
+ return label;
+ }
+ const relativeTime = formatDistanceToNowStrict(
+ new Date(taskLogs.snapshotAt),
+ { addSuffix: true },
+ );
+ return (
+ <>
+ {label}{" "}
+
+
+
+ Snapshot taken {relativeTime}
+
+
+ >
+ );
+}
+
export function AgentChatHistory({
taskLogs,
isThinking,
@@ -33,7 +65,7 @@ export function AgentChatHistory({
const logs = taskLogs.status === "ok" ? taskLogs.logs : [];
return (
-
+
{logs.length === 0 ? (
{getEmptyMessage(taskLogs.status)}
@@ -57,9 +89,9 @@ export function AgentChatHistory({
function getEmptyMessage(status: TaskLogs["status"]): string {
switch (status) {
case "not_available":
- return "Logs not available in current task state";
+ return "Messages are not available yet";
case "error":
- return "Failed to load logs";
+ return "Failed to load messages";
case "ok":
return "No messages yet";
}
diff --git a/packages/tasks/src/components/CreateTaskSection.tsx b/packages/tasks/src/components/CreateTaskSection.tsx
index c0a302a9..ab0f7113 100644
--- a/packages/tasks/src/components/CreateTaskSection.tsx
+++ b/packages/tasks/src/components/CreateTaskSection.tsx
@@ -53,7 +53,7 @@ export function CreateTaskSection({ templates }: CreateTaskSectionProps) {
onSubmit={handleSubmit}
loading={isPending}
actionIcon="send"
- actionLabel="Send"
+ actionLabel="Create task"
actionEnabled={canSubmit === true}
/>
{error && {error.message}
}
diff --git a/packages/tasks/src/components/LogViewer.tsx b/packages/tasks/src/components/LogViewer.tsx
index 614a6a81..39a3482e 100644
--- a/packages/tasks/src/components/LogViewer.tsx
+++ b/packages/tasks/src/components/LogViewer.tsx
@@ -5,7 +5,7 @@ import { useFollowScroll } from "../hooks/useFollowScroll";
import type { ReactNode } from "react";
interface LogViewerProps {
- header: string;
+ header: ReactNode;
children: ReactNode;
}
diff --git a/packages/tasks/src/components/NoTemplateState.tsx b/packages/tasks/src/components/NoTemplateState.tsx
index 61d0f6de..7c8421ab 100644
--- a/packages/tasks/src/components/NoTemplateState.tsx
+++ b/packages/tasks/src/components/NoTemplateState.tsx
@@ -7,7 +7,7 @@ const DOCS_URL = "https://coder.com/docs/admin/templates";
export function NoTemplateState() {
return (
Learn how to create a template
diff --git a/packages/tasks/src/components/TaskMessageInput.tsx b/packages/tasks/src/components/TaskMessageInput.tsx
index f2820269..cbce056a 100644
--- a/packages/tasks/src/components/TaskMessageInput.tsx
+++ b/packages/tasks/src/components/TaskMessageInput.tsx
@@ -31,20 +31,20 @@ function getPlaceholder(task: Task): string {
return "Waiting for the agent to start...";
case "error":
case "unknown":
- return "Task is in an error state and cannot receive messages";
+ return "This task encountered an error";
case "active":
break;
}
switch (task.current_state?.state) {
case "working":
- return "Agent is working — you can pause or wait for it to finish...";
+ return "Agent is working...";
case "complete":
- return "Task completed — send a follow-up to continue...";
+ return "Send a follow-up to continue...";
case "failed":
- return "Task failed — send a message to retry...";
+ return "Send a message to retry...";
default:
- return "Send a message to the agent...";
+ return "Send a message...";
}
}
diff --git a/packages/tasks/src/components/useTaskMenuItems.ts b/packages/tasks/src/components/useTaskMenuItems.ts
index 630fb83c..d5a0985a 100644
--- a/packages/tasks/src/components/useTaskMenuItems.ts
+++ b/packages/tasks/src/components/useTaskMenuItems.ts
@@ -87,7 +87,7 @@ export function useTaskMenuItems({
menuItems.push({ separator: true });
menuItems.push({
- label: "Delete",
+ label: "Delete Task",
icon: "trash",
onClick: () =>
run("deleting", () => api.deleteTask({ taskId: task.id, taskName })),
diff --git a/packages/tasks/src/hooks/useScrollableHeight.ts b/packages/tasks/src/hooks/useScrollableHeight.ts
index 9b21295f..dba47789 100644
--- a/packages/tasks/src/hooks/useScrollableHeight.ts
+++ b/packages/tasks/src/hooks/useScrollableHeight.ts
@@ -45,9 +45,24 @@ export function useScrollableHeight(
}
});
+ // Observe content child so the layout recalculates when
+ // the content is replaced (e.g. loading state -> form).
+ function observeContent() {
+ if (scroll?.firstElementChild) {
+ observer.observe(scroll.firstElementChild);
+ }
+ }
+
observer.observe(host);
observer.observe(scroll);
+ observeContent();
+
+ const mutations = new MutationObserver(observeContent);
+ mutations.observe(scroll, { childList: true });
- return () => observer.disconnect();
+ return () => {
+ observer.disconnect();
+ mutations.disconnect();
+ };
}, [hostRef, scrollRef]);
}
diff --git a/packages/tasks/src/index.css b/packages/tasks/src/index.css
index f6232e99..35e3f2b4 100644
--- a/packages/tasks/src/index.css
+++ b/packages/tasks/src/index.css
@@ -478,12 +478,46 @@ vscode-icon.disabled {
}
.log-viewer-header {
+ position: relative;
padding: 6px 8px;
- font-size: 0.8em;
+ font-size: 0.9em;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid var(--vscode-input-border);
}
+.snapshot-info {
+ margin-left: 3px;
+}
+
+.snapshot-info .codicon-info {
+ font-size: 1em;
+ color: var(--vscode-descriptionForeground);
+ vertical-align: middle;
+ cursor: pointer;
+}
+
+.snapshot-info-tooltip {
+ display: none;
+ position: absolute;
+ bottom: calc(100% + 4px);
+ left: 8px;
+ right: 8px;
+ width: fit-content;
+ padding: 6px 10px;
+ white-space: nowrap;
+ font-size: 1em;
+ color: var(--vscode-editorHoverWidget-foreground);
+ background: var(--vscode-editorHoverWidget-background);
+ border: 1px solid var(--vscode-editorHoverWidget-border);
+ border-radius: 3px;
+ z-index: 10;
+ pointer-events: none;
+}
+
+.snapshot-info:hover .snapshot-info-tooltip {
+ display: block;
+}
+
.log-viewer-content {
flex: 1;
min-height: 0;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 56a315d0..9f685f62 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,6 +33,9 @@ catalogs:
coder:
specifier: github:coder/coder#main
version: 0.0.0
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
react:
specifier: ^19.2.4
version: 19.2.4
@@ -65,7 +68,7 @@ importers:
specifier: 1.13.6
version: 1.13.6
date-fns:
- specifier: ^4.1.0
+ specifier: 'catalog:'
version: 4.1.0
eventsource:
specifier: ^4.1.0
@@ -181,7 +184,7 @@ importers:
version: 4.1.0
coder:
specifier: 'catalog:'
- version: https://codeload.github.com/coder/coder/tar.gz/cfdbd5251a79ca1d88f46c20b9cc7c66fd167ae0
+ version: https://codeload.github.com/coder/coder/tar.gz/b80dbd2d4eb3e61a8419852647bb078f0845ad6e
concurrently:
specifier: ^9.2.1
version: 9.2.1
@@ -269,6 +272,9 @@ importers:
'@vscode/codicons':
specifier: 'catalog:'
version: 0.0.44
+ date-fns:
+ specifier: 'catalog:'
+ version: 4.1.0
react:
specifier: 'catalog:'
version: 19.2.4
@@ -1950,8 +1956,8 @@ packages:
resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==}
engines: {node: '>=16'}
- coder@https://codeload.github.com/coder/coder/tar.gz/cfdbd5251a79ca1d88f46c20b9cc7c66fd167ae0:
- resolution: {tarball: https://codeload.github.com/coder/coder/tar.gz/cfdbd5251a79ca1d88f46c20b9cc7c66fd167ae0}
+ coder@https://codeload.github.com/coder/coder/tar.gz/b80dbd2d4eb3e61a8419852647bb078f0845ad6e:
+ resolution: {tarball: https://codeload.github.com/coder/coder/tar.gz/b80dbd2d4eb3e61a8419852647bb078f0845ad6e}
version: 0.0.0
color-convert@2.0.1:
@@ -6016,7 +6022,7 @@ snapshots:
cockatiel@3.2.1: {}
- coder@https://codeload.github.com/coder/coder/tar.gz/cfdbd5251a79ca1d88f46c20b9cc7c66fd167ae0: {}
+ coder@https://codeload.github.com/coder/coder/tar.gz/b80dbd2d4eb3e61a8419852647bb078f0845ad6e: {}
color-convert@2.0.1:
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index ff3c5fd7..70f3d76e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -11,6 +11,7 @@ catalog:
"@vscode/codicons": ^0.0.44
babel-plugin-react-compiler: ^1.0.0
coder: github:coder/coder#main
+ date-fns: ^4.1.0
react: ^19.2.4
react-dom: ^19.2.4
typescript: ^5.9.3
diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts
index 58c6d234..60d3cfa6 100644
--- a/src/core/contextManager.ts
+++ b/src/core/contextManager.ts
@@ -4,7 +4,6 @@ const CONTEXT_DEFAULTS = {
"coder.authenticated": false,
"coder.isOwner": false,
"coder.loaded": false,
- "coder.tasksEnabled": false,
"coder.workspace.connected": false,
"coder.workspace.updatable": false,
} as const;
diff --git a/src/extension.ts b/src/extension.ts
index ac27e3ee..9266bf54 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -66,22 +66,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise {
const secretsManager = serviceContainer.getSecretsManager();
const contextManager = serviceContainer.getContextManager();
- const syncTasksFlag = () => {
- const enabled =
- vscode.workspace
- .getConfiguration()
- .get("coder.experimental.tasks") === true;
- contextManager.set("coder.tasksEnabled", enabled);
- };
- syncTasksFlag();
- ctx.subscriptions.push(
- vscode.workspace.onDidChangeConfiguration((e) => {
- if (e.affectsConfiguration("coder.experimental.tasks")) {
- syncTasksFlag();
- }
- }),
- );
-
// Migrate auth storage from old flat format to new label-based format
await migrateAuthStorage(serviceContainer);
diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts
index 5eb31541..6680e857 100644
--- a/src/webviews/tasks/tasksPanelProvider.ts
+++ b/src/webviews/tasks/tasksPanelProvider.ts
@@ -229,7 +229,7 @@ export class TasksPanelProvider
} catch (err) {
const errorMessage = toError(err).message;
this.logger.warn(`Command ${method} failed`, err);
- vscode.window.showErrorMessage(`Command failed: ${errorMessage}`);
+ vscode.window.showErrorMessage(errorMessage);
}
}
@@ -251,7 +251,7 @@ export class TasksPanelProvider
await this.refreshAndNotifyTasks();
vscode.window.showInformationMessage(
- `Task "${getTaskLabel(task)}" created successfully`,
+ `Task "${getTaskLabel(task)}" created`,
);
return task;
}
@@ -261,12 +261,12 @@ export class TasksPanelProvider
taskName: string,
): Promise {
const confirmed = await vscodeProposed.window.showWarningMessage(
- `Delete task "${taskName}"`,
+ `Delete task "${taskName}"?`,
{
modal: true,
useCustom: true,
detail:
- "This action is irreversible and removes all workspace resources and data.",
+ "This will permanently delete the workspace and all associated data.",
},
"Delete",
);
@@ -281,9 +281,7 @@ export class TasksPanelProvider
}
await this.refreshAndNotifyTasks();
- vscode.window.showInformationMessage(
- `Task "${taskName}" deleted successfully`,
- );
+ vscode.window.showInformationMessage(`Task "${taskName}" deleted`);
}
private handlePauseTask(taskId: string): Promise {
@@ -331,7 +329,7 @@ export class TasksPanelProvider
const task = await this.client.getTask("me", taskId);
const { workspace_id } = task;
if (!workspace_id) {
- throw new Error("Task has no workspace");
+ throw new Error("Task has no workspace yet");
}
await legacyCall(workspace_id, task);
await this.refreshAndNotifyTask(taskId);
@@ -354,10 +352,9 @@ export class TasksPanelProvider
isAxiosError(err) &&
(err.response?.status === 409 || err.response?.status === 400)
) {
- throw new Error(
- `Task is not ready to receive messages (${errToStr(err)})`,
- { cause: err },
- );
+ throw new Error(`Agent is not ready for messages (${errToStr(err)})`, {
+ cause: err,
+ });
}
throw err;
}
@@ -386,7 +383,7 @@ export class TasksPanelProvider
private async handleDownloadLogs(taskId: string): Promise {
const result = await this.fetchTaskLogs(taskId);
if (result.status !== "ok") {
- throw new Error("Failed to fetch logs for download");
+ throw new Error("Unable to download logs");
}
if (result.logs.length === 0) {
vscode.window.showWarningMessage("No logs available to download");
@@ -569,7 +566,12 @@ export class TasksPanelProvider
private async fetchTaskLogs(taskId: string): Promise {
try {
const response = await this.client.getTaskLogs("me", taskId);
- return { status: "ok", logs: response.logs };
+ return {
+ status: "ok",
+ logs: response.logs,
+ snapshot: response.snapshot,
+ snapshotAt: response.snapshot_at,
+ };
} catch (err) {
if (isAxiosError(err) && err.response?.status === 409) {
return { status: "not_available" };
diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts
index a1ab44fe..b49b3e8a 100644
--- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts
+++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts
@@ -312,6 +312,31 @@ describe("TasksPanelProvider", () => {
});
});
+ it("passes snapshot and snapshotAt from API response", async () => {
+ const h = createHarness();
+ h.client.getTask.mockResolvedValue(task());
+ h.client.getTaskLogs.mockResolvedValue({
+ logs: [logEntry({ content: "Paused message" })],
+ snapshot: true,
+ snapshot_at: "2024-06-15T10:30:00Z",
+ });
+
+ const res = await h.request(TasksApi.getTaskDetails, {
+ taskId: "task-1",
+ });
+
+ expect(res).toMatchObject({
+ success: true,
+ data: {
+ logs: {
+ status: "ok",
+ snapshot: true,
+ snapshotAt: "2024-06-15T10:30:00Z",
+ },
+ },
+ });
+ });
+
it("returns logsStatus not_available on 409", async () => {
const h = createHarness();
h.client.getTask.mockResolvedValue(task());
@@ -383,7 +408,7 @@ describe("TasksPanelProvider", () => {
});
describe("deleteTask", () => {
- const deleteMessage = 'Delete task "Test Task"';
+ const deleteMessage = 'Delete task "Test Task"?';
it("deletes task after confirmation", async () => {
const h = createHarness();
@@ -574,13 +599,13 @@ describe("TasksPanelProvider", () => {
name: "409 conflict (task pending/paused)",
taskOverrides: { status: "active", current_state: taskState("idle") },
sendError: createAxiosError(409, "Conflict"),
- expectedError: "Task is not ready to receive messages",
+ expectedError: "Agent is not ready for messages",
},
{
name: "400 bad request (task error/unknown)",
taskOverrides: { status: "active", current_state: taskState("idle") },
sendError: createAxiosError(400, "Bad Request"),
- expectedError: "Task is not ready to receive messages",
+ expectedError: "Agent is not ready for messages",
},
])(
"fails on $name",
@@ -711,9 +736,9 @@ describe("TasksPanelProvider", () => {
});
expect(res.success).toBe(false);
- expect(res.error).toBe("Failed to fetch logs for download");
+ expect(res.error).toBe("Unable to download logs");
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- "Failed to fetch logs for download",
+ "Unable to download logs",
);
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
});
@@ -805,7 +830,7 @@ describe("TasksPanelProvider", () => {
await h.command(TasksApi.viewInCoder, { taskId: "task-1" });
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- "Command failed: Task not found",
+ "Task not found",
);
});
});
diff --git a/test/webview/shared/tasks/utils.test.ts b/test/webview/shared/tasks/utils.test.ts
index 3ddd3678..32f9bd8b 100644
--- a/test/webview/shared/tasks/utils.test.ts
+++ b/test/webview/shared/tasks/utils.test.ts
@@ -6,6 +6,7 @@ import {
isBuildingWorkspace,
isStableTask,
isTaskWorking,
+ logPreviewLabel,
type Task,
type TaskPermissions,
} from "@repo/shared";
@@ -237,3 +238,14 @@ describe("isAgentStarting", () => {
).toBe(expected);
});
});
+
+describe("logPreviewLabel", () => {
+ it.each([
+ { count: 0, expected: "AI chat messages" },
+ { count: 1, expected: "Last message of AI chat" },
+ { count: 5, expected: "Last 5 messages of AI chat" },
+ { count: 100, expected: "Last 100 messages of AI chat" },
+ ])("count=$count → $expected", ({ count, expected }) => {
+ expect(logPreviewLabel(count)).toBe(expected);
+ });
+});
diff --git a/test/webview/tasks/AgentChatHistory.test.tsx b/test/webview/tasks/AgentChatHistory.test.tsx
index 4a74e236..cd06511e 100644
--- a/test/webview/tasks/AgentChatHistory.test.tsx
+++ b/test/webview/tasks/AgentChatHistory.test.tsx
@@ -6,6 +6,8 @@ import { AgentChatHistory } from "@repo/tasks/components/AgentChatHistory";
import { logEntry } from "../../mocks/tasks";
import { renderWithQuery } from "../render";
+import type { TaskLogs } from "@repo/shared";
+
describe("AgentChatHistory", () => {
describe("empty states", () => {
it("shows default empty message when no logs", () => {
@@ -26,7 +28,7 @@ describe("AgentChatHistory", () => {
/>,
);
expect(
- screen.getByText("Logs not available in current task state"),
+ screen.getByText("Messages are not available yet"),
).toBeInTheDocument();
});
@@ -34,7 +36,7 @@ describe("AgentChatHistory", () => {
renderWithQuery(
,
);
- const el = screen.getByText("Failed to load logs");
+ const el = screen.getByText("Failed to load messages");
expect(el).toBeInTheDocument();
expect(el).toHaveClass("log-viewer-error");
});
@@ -73,12 +75,12 @@ describe("AgentChatHistory", () => {
/>,
);
- // "You" label at first input, "Agent" label at first output
- expect(screen.getByText("You")).toBeInTheDocument();
- expect(screen.getByText("Agent")).toBeInTheDocument();
+ // "User" label at first input, "Agent" label at first output
+ expect(screen.getByText("[User]")).toBeInTheDocument();
+ expect(screen.getByText("[Agent]")).toBeInTheDocument();
- // Only one "You" label for the two consecutive input messages
- expect(screen.getAllByText("You")).toHaveLength(1);
+ // Only one "User" label for the two consecutive input messages
+ expect(screen.getAllByText("[User]")).toHaveLength(1);
});
it("shows role label again when sender changes back", () => {
@@ -94,9 +96,101 @@ describe("AgentChatHistory", () => {
/>,
);
- // Two "You" labels: one for first input group, one for the second
- expect(screen.getAllByText("You")).toHaveLength(2);
- expect(screen.getAllByText("Agent")).toHaveLength(1);
+ // Two "User" labels: one for first input group, one for the second
+ expect(screen.getAllByText("[User]")).toHaveLength(2);
+ expect(screen.getAllByText("[Agent]")).toHaveLength(1);
+ });
+ });
+
+ describe("snapshot header", () => {
+ interface SnapshotHeaderTestCase {
+ name: string;
+ taskLogs: TaskLogs;
+ expectedHeader: string;
+ hasInfoIcon: boolean;
+ }
+ it.each([
+ {
+ name: "snapshot=false → Chat history",
+ taskLogs: {
+ status: "ok",
+ logs: [logEntry({ id: 1 })],
+ snapshot: false,
+ },
+ expectedHeader: "Chat history",
+ hasInfoIcon: false,
+ },
+ {
+ name: "snapshot=undefined → Chat history",
+ taskLogs: { status: "ok", logs: [logEntry({ id: 1 })] },
+ expectedHeader: "Chat history",
+ hasInfoIcon: false,
+ },
+ {
+ name: "snapshot=true, 0 logs → AI chat logs",
+ taskLogs: { status: "ok", logs: [], snapshot: true },
+ expectedHeader: "AI chat messages",
+ hasInfoIcon: false,
+ },
+ {
+ name: "snapshot=true, 3 logs → Last 3 messages",
+ taskLogs: {
+ status: "ok",
+ logs: [logEntry({ id: 1 }), logEntry({ id: 2 }), logEntry({ id: 3 })],
+ snapshot: true,
+ },
+ expectedHeader: "Last 3 messages of AI chat",
+ hasInfoIcon: false,
+ },
+ {
+ name: "snapshot=true with snapshotAt → info icon",
+ taskLogs: {
+ status: "ok",
+ logs: [logEntry({ id: 1 })],
+ snapshot: true,
+ snapshotAt: "2024-06-15T10:30:00Z",
+ },
+ expectedHeader: "Last message of AI chat",
+ hasInfoIcon: true,
+ },
+ {
+ name: "snapshot=false with snapshotAt → no info icon",
+ taskLogs: {
+ status: "ok",
+ logs: [logEntry({ id: 1 })],
+ snapshot: false,
+ snapshotAt: "2024-06-15T10:30:00Z",
+ },
+ expectedHeader: "Chat history",
+ hasInfoIcon: false,
+ },
+ ])("$name", ({ taskLogs, expectedHeader, hasInfoIcon }) => {
+ renderWithQuery(
+ ,
+ );
+ expect(screen.getByText(expectedHeader)).toBeInTheDocument();
+ if (hasInfoIcon) {
+ expect(document.querySelector(".codicon-info")).toBeInTheDocument();
+ } else {
+ expect(document.querySelector(".codicon-info")).not.toBeInTheDocument();
+ }
+ });
+
+ it("tooltip shows relative time", () => {
+ renderWithQuery(
+ ,
+ );
+ const tooltip = document.querySelector(".snapshot-info-tooltip");
+ expect(tooltip?.textContent).toContain("Snapshot taken");
+ expect(tooltip?.textContent).toContain("ago");
});
});
diff --git a/test/webview/tasks/StatePanel.test.tsx b/test/webview/tasks/StatePanel.test.tsx
index c60c1912..f25ec518 100644
--- a/test/webview/tasks/StatePanel.test.tsx
+++ b/test/webview/tasks/StatePanel.test.tsx
@@ -55,7 +55,7 @@ describe.each([
{
name: "NoTemplateState",
element: ,
- expectedTexts: ["No Task template found"],
+ expectedTexts: ["No task templates found"],
href: "https://coder.com/docs/admin/templates",
},
{
diff --git a/test/webview/tasks/TaskDetailHeader.test.tsx b/test/webview/tasks/TaskDetailHeader.test.tsx
index a082146f..d45f5a6c 100644
--- a/test/webview/tasks/TaskDetailHeader.test.tsx
+++ b/test/webview/tasks/TaskDetailHeader.test.tsx
@@ -86,7 +86,7 @@ describe("TaskDetailHeader", () => {
name: "Delete",
taskOverrides: {},
apiMethod: "deleteTask",
- menuLabel: "Delete",
+ menuLabel: "Delete Task",
expectedLabel: "Deleting...",
},
{
diff --git a/test/webview/tasks/TaskDetailView.test.tsx b/test/webview/tasks/TaskDetailView.test.tsx
index f6ee523b..fcb6a790 100644
--- a/test/webview/tasks/TaskDetailView.test.tsx
+++ b/test/webview/tasks/TaskDetailView.test.tsx
@@ -125,7 +125,7 @@ describe("TaskDetailView", () => {
it("shows logsStatus error in chat history", () => {
const details = taskDetails({ logs: { status: "error" } });
renderWithQuery( {}} />);
- expect(screen.getByText("Failed to load logs")).toBeInTheDocument();
+ expect(screen.getByText("Failed to load messages")).toBeInTheDocument();
});
describe("workspace startup rendering", () => {
diff --git a/test/webview/tasks/TaskMessageInput.test.tsx b/test/webview/tasks/TaskMessageInput.test.tsx
index dc7927b6..ecc23575 100644
--- a/test/webview/tasks/TaskMessageInput.test.tsx
+++ b/test/webview/tasks/TaskMessageInput.test.tsx
@@ -55,32 +55,32 @@ describe("TaskMessageInput", () => {
{
name: "error",
overrides: { status: "error" },
- expected: "Task is in an error state and cannot receive messages",
+ expected: "This task encountered an error",
},
{
name: "unknown",
overrides: { status: "unknown" },
- expected: "Task is in an error state and cannot receive messages",
+ expected: "This task encountered an error",
},
{
name: "active+working",
overrides: { status: "active", current_state: taskState("working") },
- expected: "Agent is working — you can pause or wait for it to finish...",
+ expected: "Agent is working...",
},
{
name: "active+complete",
overrides: { status: "active", current_state: taskState("complete") },
- expected: "Task completed — send a follow-up to continue...",
+ expected: "Send a follow-up to continue...",
},
{
name: "active+failed",
overrides: { status: "active", current_state: taskState("failed") },
- expected: "Task failed — send a message to retry...",
+ expected: "Send a message to retry...",
},
{
name: "active with no current_state",
overrides: { status: "active", current_state: null },
- expected: "Send a message to the agent...",
+ expected: "Send a message...",
},
])("shows placeholder for $name", ({ overrides, expected }) => {
renderWithQuery();
diff --git a/test/webview/tasks/useTaskMenuItems.test.tsx b/test/webview/tasks/useTaskMenuItems.test.tsx
index a7022728..0cbf41f6 100644
--- a/test/webview/tasks/useTaskMenuItems.test.tsx
+++ b/test/webview/tasks/useTaskMenuItems.test.tsx
@@ -68,7 +68,7 @@ function deferPause() {
}
describe("useTaskMenuItems", () => {
- it.each(["View in Coder", "Download Logs", "Delete"])(
+ it.each(["View in Coder", "Download Logs", "Delete Task"])(
'always includes "%s"',
(label) => {
const { result } = renderTask(task());
@@ -80,9 +80,12 @@ describe("useTaskMenuItems", () => {
const { result } = renderTask(task());
const items = result.current.menuItems;
const deleteIdx = items.findIndex(
- (item) => !item.separator && item.label === "Delete",
+ (item) => !item.separator && item.label === "Delete Task",
);
- expect(items[deleteIdx]).toMatchObject({ label: "Delete", danger: true });
+ expect(items[deleteIdx]).toMatchObject({
+ label: "Delete Task",
+ danger: true,
+ });
expect(items[deleteIdx - 1]).toMatchObject({ separator: true });
});
@@ -109,7 +112,7 @@ describe("useTaskMenuItems", () => {
apiMethod: "resumeTask",
testTask: resumableTask(),
},
- { label: "Delete", apiMethod: "deleteTask", testTask: task() },
+ { label: "Delete Task", apiMethod: "deleteTask", testTask: task() },
])("$label calls api.$apiMethod", async ({ label, apiMethod, testTask }) => {
const { result } = renderTask(testTask);
clickItem(result.current.menuItems, label);
@@ -190,7 +193,7 @@ describe("useTaskMenuItems", () => {
errorMsg: "Failed while resuming task",
},
{
- label: "Delete",
+ label: "Delete Task",
apiMethod: "deleteTask",
testTask: task(),
errorMsg: "Failed while deleting task",