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",