diff --git a/package.json b/package.json index 3e938a95..8de16a35 100644 --- a/package.json +++ b/package.json @@ -488,6 +488,7 @@ "proper-lockfile": "^4.1.2", "proxy-agent": "^6.5.0", "semver": "^7.7.4", + "strip-ansi": "^7.1.2", "ua-parser-js": "^1.0.41", "ws": "^8.19.0", "zod": "^4.3.6" diff --git a/packages/shared/src/tasks/api.ts b/packages/shared/src/tasks/api.ts index bc96bfbf..d88c7818 100644 --- a/packages/shared/src/tasks/api.ts +++ b/packages/shared/src/tasks/api.ts @@ -56,9 +56,11 @@ const sendTaskMessage = defineRequest( const viewInCoder = defineCommand("viewInCoder"); const viewLogs = defineCommand("viewLogs"); +const closeWorkspaceLogs = defineCommand("closeWorkspaceLogs"); const taskUpdated = defineNotification("taskUpdated"); const tasksUpdated = defineNotification("tasksUpdated"); +const workspaceLogsAppend = defineNotification("workspaceLogsAppend"); const refresh = defineNotification("refresh"); const showCreateForm = defineNotification("showCreateForm"); @@ -78,9 +80,11 @@ export const TasksApi = { // Commands viewInCoder, viewLogs, + closeWorkspaceLogs, // Notifications taskUpdated, tasksUpdated, + workspaceLogsAppend, refresh, showCreateForm, } as const; diff --git a/packages/shared/src/tasks/types.ts b/packages/shared/src/tasks/types.ts index 51e5a727..2d222ec9 100644 --- a/packages/shared/src/tasks/types.ts +++ b/packages/shared/src/tasks/types.ts @@ -29,16 +29,18 @@ export interface TaskPreset { isDefault: boolean; } -/** Status of log fetching */ -export type LogsStatus = "ok" | "not_available" | "error"; +/** Result of fetching task logs: either logs or an error/unavailable state. */ +export type TaskLogs = + | { status: "ok"; logs: readonly TaskLogEntry[] } + | { status: "not_available" } + | { status: "error" }; /** * Full details for a selected task, including logs and action availability. */ export interface TaskDetails extends TaskPermissions { task: Task; - logs: readonly TaskLogEntry[]; - logsStatus: LogsStatus; + logs: TaskLogs; } export interface TaskPermissions { diff --git a/packages/shared/src/tasks/utils.ts b/packages/shared/src/tasks/utils.ts index 736a8570..cdf65f07 100644 --- a/packages/shared/src/tasks/utils.ts +++ b/packages/shared/src/tasks/utils.ts @@ -4,11 +4,6 @@ export function getTaskLabel(task: Task): string { return task.display_name || task.name || task.id; } -/** Whether the agent is actively working (status is active and state is working). */ -export function isTaskWorking(task: Task): boolean { - return task.status === "active" && task.current_state?.state === "working"; -} - const PAUSABLE_STATUSES: readonly TaskStatus[] = [ "active", "initializing", @@ -42,6 +37,11 @@ export function getTaskPermissions(task: Task): TaskPermissions { }; } +/** Whether the agent is actively working (status is active and state is working). */ +export function isTaskWorking(task: Task): boolean { + return task.status === "active" && task.current_state?.state === "working"; +} + /** * Task statuses where logs won't change (stable/terminal states). * "complete" is a TaskState (sub-state of active), checked separately. @@ -55,3 +55,23 @@ export function isStableTask(task: Task): boolean { (task.current_state !== null && task.current_state.state !== "working") ); } + +/** Whether the task's workspace is building (provisioner running). */ +export function isBuildingWorkspace(task: Task): boolean { + const ws = task.workspace_status; + return ws === "pending" || ws === "starting"; +} + +/** Whether the workspace is running but the agent hasn't reached "ready" yet. */ +export function isAgentStarting(task: Task): boolean { + if (task.workspace_status !== "running") { + return false; + } + const lc = task.workspace_agent_lifecycle; + return lc === "created" || lc === "starting"; +} + +/** Whether the task's workspace is still starting up (building or agent initializing). */ +export function isWorkspaceStarting(task: Task): boolean { + return isBuildingWorkspace(task) || isAgentStarting(task); +} diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index 7a1609db..a1b39539 100644 --- a/packages/tasks/src/App.tsx +++ b/packages/tasks/src/App.tsx @@ -1,6 +1,5 @@ -import { TasksApi, type InitResponse } from "@repo/shared"; +import { type InitResponse } from "@repo/shared"; import { getState, setState } from "@repo/webview-shared"; -import { useIpc } from "@repo/webview-shared/react"; import { VscodeCollapsible, VscodeProgressRing, @@ -17,6 +16,7 @@ import { TaskList } from "./components/TaskList"; import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle"; import { useScrollableHeight } from "./hooks/useScrollableHeight"; import { useSelectedTask } from "./hooks/useSelectedTask"; +import { useTasksApi } from "./hooks/useTasksApi"; import { useTasksQuery } from "./hooks/useTasksQuery"; interface PersistedState extends InitResponse { @@ -46,10 +46,10 @@ export default function App() { useScrollableHeight(createRef, createScrollRef); useScrollableHeight(historyRef, historyScrollRef); - const { onNotification } = useIpc(); + const { onShowCreateForm } = useTasksApi(); useEffect(() => { - return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true)); - }, [onNotification, setCreateOpen]); + return onShowCreateForm(() => setCreateOpen(true)); + }, [onShowCreateForm, setCreateOpen]); useEffect(() => { if (data) { diff --git a/packages/tasks/src/components/AgentChatHistory.tsx b/packages/tasks/src/components/AgentChatHistory.tsx index e0b08753..3d4b9cc7 100644 --- a/packages/tasks/src/components/AgentChatHistory.tsx +++ b/packages/tasks/src/components/AgentChatHistory.tsx @@ -1,12 +1,9 @@ -import { VscodeScrollable } from "@vscode-elements/react-elements"; +import { LogViewer, LogViewerPlaceholder } from "./LogViewer"; -import { useFollowScroll } from "../hooks/useFollowScroll"; - -import type { LogsStatus, TaskLogEntry } from "@repo/shared"; +import type { TaskLogEntry, TaskLogs } from "@repo/shared"; interface AgentChatHistoryProps { - logs: readonly TaskLogEntry[]; - logsStatus: LogsStatus; + taskLogs: TaskLogs; isThinking: boolean; } @@ -30,46 +27,35 @@ function LogEntry({ } export function AgentChatHistory({ - logs, - logsStatus, + taskLogs, isThinking, }: AgentChatHistoryProps) { - const bottomRef = useFollowScroll(); + const logs = taskLogs.status === "ok" ? taskLogs.logs : []; return ( -
-
Agent chat history
- - {logs.length === 0 ? ( -
- {getEmptyMessage(logsStatus)} -
- ) : ( - logs.map((log, index) => ( - - )) - )} - {isThinking && ( -
Thinking...
- )} -
- -
+ + {logs.length === 0 ? ( + + {getEmptyMessage(taskLogs.status)} + + ) : ( + logs.map((log, index) => ( + + )) + )} + {isThinking && ( +
Thinking...
+ )} +
); } -function getEmptyMessage(logsStatus: LogsStatus): string { - switch (logsStatus) { +function getEmptyMessage(status: TaskLogs["status"]): string { + switch (status) { case "not_available": return "Logs not available in current task state"; case "error": diff --git a/packages/tasks/src/components/LogViewer.tsx b/packages/tasks/src/components/LogViewer.tsx new file mode 100644 index 00000000..614a6a81 --- /dev/null +++ b/packages/tasks/src/components/LogViewer.tsx @@ -0,0 +1,38 @@ +import { VscodeScrollable } from "@vscode-elements/react-elements"; + +import { useFollowScroll } from "../hooks/useFollowScroll"; + +import type { ReactNode } from "react"; + +interface LogViewerProps { + header: string; + children: ReactNode; +} + +export function LogViewer({ header, children }: LogViewerProps) { + const bottomRef = useFollowScroll(); + + return ( +
+
{header}
+ + {children} +
+ +
+ ); +} + +export function LogViewerPlaceholder({ + children, + error, +}: { + children: string; + error?: boolean; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/tasks/src/components/TaskDetailView.tsx b/packages/tasks/src/components/TaskDetailView.tsx index f3940846..1e5102c0 100644 --- a/packages/tasks/src/components/TaskDetailView.tsx +++ b/packages/tasks/src/components/TaskDetailView.tsx @@ -1,9 +1,14 @@ -import { isTaskWorking, type TaskDetails } from "@repo/shared"; +import { + isWorkspaceStarting, + isTaskWorking, + type TaskDetails, +} from "@repo/shared"; import { AgentChatHistory } from "./AgentChatHistory"; import { ErrorBanner } from "./ErrorBanner"; import { TaskDetailHeader } from "./TaskDetailHeader"; import { TaskMessageInput } from "./TaskMessageInput"; +import { WorkspaceLogs } from "./WorkspaceLogs"; interface TaskDetailViewProps { details: TaskDetails; @@ -11,19 +16,20 @@ interface TaskDetailViewProps { } export function TaskDetailView({ details, onBack }: TaskDetailViewProps) { - const { task, logs, logsStatus } = details; + const { task, logs } = details; + const starting = isWorkspaceStarting(task); const isThinking = isTaskWorking(task); return (
{task.status === "error" && } - + {starting ? ( + + ) : ( + + )}
); diff --git a/packages/tasks/src/components/WorkspaceLogs.tsx b/packages/tasks/src/components/WorkspaceLogs.tsx new file mode 100644 index 00000000..935e9598 --- /dev/null +++ b/packages/tasks/src/components/WorkspaceLogs.tsx @@ -0,0 +1,26 @@ +import { isBuildingWorkspace, type Task } from "@repo/shared"; + +import { useWorkspaceLogs } from "../hooks/useWorkspaceLogs"; + +import { LogViewer, LogViewerPlaceholder } from "./LogViewer"; + +function LogLine({ children }: { children: string }) { + return
{children}
; +} + +export function WorkspaceLogs({ task }: { task: Task }) { + const lines = useWorkspaceLogs(); + const header = isBuildingWorkspace(task) + ? "Building workspace..." + : "Running startup scripts..."; + + return ( + + {lines.length === 0 ? ( + Waiting for logs... + ) : ( + lines.map((line, i) => {line}) + )} + + ); +} diff --git a/packages/tasks/src/hooks/useSelectedTask.ts b/packages/tasks/src/hooks/useSelectedTask.ts index 8c138e8d..071de29a 100644 --- a/packages/tasks/src/hooks/useSelectedTask.ts +++ b/packages/tasks/src/hooks/useSelectedTask.ts @@ -1,10 +1,4 @@ -import { - TasksApi, - isStableTask, - type Task, - type TaskDetails, -} from "@repo/shared"; -import { useIpc } from "@repo/webview-shared/react"; +import { isStableTask, type Task, type TaskDetails } from "@repo/shared"; import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; @@ -20,7 +14,6 @@ const QUERY_KEY = "task-details"; export function useSelectedTask(tasks: readonly Task[]) { const api = useTasksApi(); const queryClient = useQueryClient(); - const { onNotification } = useIpc(); const [selectedTaskId, setSelectedTaskId] = useState(null); // Auto-deselect when the selected task disappears from the list @@ -48,14 +41,14 @@ export function useSelectedTask(tasks: readonly Task[]) { // Keep selected task in sync with push updates between polls useEffect(() => { - return onNotification(TasksApi.taskUpdated, (updatedTask) => { + return api.onTaskUpdated((updatedTask) => { if (updatedTask.id !== selectedTaskId) return; queryClient.setQueryData( [QUERY_KEY, selectedTaskId], (prev) => (prev ? { ...prev, task: updatedTask } : undefined), ); }); - }, [onNotification, selectedTaskId, queryClient]); + }, [api.onTaskUpdated, selectedTaskId, queryClient]); const deselectTask = () => { setSelectedTaskId(null); diff --git a/packages/tasks/src/hooks/useTasksApi.ts b/packages/tasks/src/hooks/useTasksApi.ts index ca7ac84c..8f2e046c 100644 --- a/packages/tasks/src/hooks/useTasksApi.ts +++ b/packages/tasks/src/hooks/useTasksApi.ts @@ -12,24 +12,13 @@ import { TasksApi, type CreateTaskParams, + type Task, type TaskActionParams, } from "@repo/shared"; -import { logger } from "@repo/webview-shared/logger"; import { useIpc } from "@repo/webview-shared/react"; export function useTasksApi() { - const { request, command } = useIpc(); - - function safeCommand

( - definition: { method: string; _types?: { params: P } }, - ...args: P extends void ? [] : [params: P] - ): void { - try { - command(definition, ...args); - } catch (err) { - logger.error(`Command ${definition.method} failed`, err); - } - } + const { request, command, onNotification } = useIpc(); return { // Requests @@ -53,8 +42,19 @@ export function useTasksApi() { request(TasksApi.sendTaskMessage, { taskId, message }), // Commands - viewInCoder: (taskId: string) => - safeCommand(TasksApi.viewInCoder, { taskId }), - viewLogs: (taskId: string) => safeCommand(TasksApi.viewLogs, { taskId }), + viewInCoder: (taskId: string) => command(TasksApi.viewInCoder, { taskId }), + viewLogs: (taskId: string) => command(TasksApi.viewLogs, { taskId }), + closeWorkspaceLogs: () => command(TasksApi.closeWorkspaceLogs), + + // Notifications + onTaskUpdated: (cb: (task: Task) => void) => + onNotification(TasksApi.taskUpdated, cb), + onTasksUpdated: (cb: (tasks: Task[]) => void) => + onNotification(TasksApi.tasksUpdated, cb), + onWorkspaceLogsAppend: (cb: (lines: string[]) => void) => + onNotification(TasksApi.workspaceLogsAppend, cb), + onRefresh: (cb: () => void) => onNotification(TasksApi.refresh, cb), + onShowCreateForm: (cb: () => void) => + onNotification(TasksApi.showCreateForm, cb), }; } diff --git a/packages/tasks/src/hooks/useTasksQuery.ts b/packages/tasks/src/hooks/useTasksQuery.ts index 730a4cc4..657c74ec 100644 --- a/packages/tasks/src/hooks/useTasksQuery.ts +++ b/packages/tasks/src/hooks/useTasksQuery.ts @@ -1,5 +1,4 @@ -import { TasksApi, type InitResponse, type Task } from "@repo/shared"; -import { useIpc } from "@repo/webview-shared/react"; +import { type InitResponse, type Task } from "@repo/shared"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; @@ -11,7 +10,6 @@ const QUERY_KEY = ["tasks-init"] as const; export function useTasksQuery(initialData?: InitResponse) { const api = useTasksApi(); - const { onNotification } = useIpc(); const queryClient = useQueryClient(); function updateTasks(updater: (tasks: readonly Task[]) => readonly Task[]) { @@ -35,23 +33,23 @@ export function useTasksQuery(initialData?: InitResponse) { // Subscribe to push notifications useEffect(() => { const unsubs = [ - onNotification(TasksApi.tasksUpdated, (updatedTasks) => { + api.onTasksUpdated((updatedTasks) => { updateTasks(() => updatedTasks); }), - onNotification(TasksApi.taskUpdated, (updatedTask) => { + api.onTaskUpdated((updatedTask) => { updateTasks((tasks) => tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)), ); }), - onNotification(TasksApi.refresh, () => { + api.onRefresh(() => { void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); }), ]; return () => unsubs.forEach((fn) => fn()); - }, [onNotification, queryClient]); + }, [api.onTasksUpdated, api.onTaskUpdated, api.onRefresh, queryClient]); return { tasks, templates, tasksSupported, data, isLoading, error, refetch }; } diff --git a/packages/tasks/src/hooks/useWorkspaceLogs.ts b/packages/tasks/src/hooks/useWorkspaceLogs.ts new file mode 100644 index 00000000..0638acec --- /dev/null +++ b/packages/tasks/src/hooks/useWorkspaceLogs.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +import { useTasksApi } from "./useTasksApi"; + +/** + * Subscribes to workspace log lines pushed from the extension. + * Batches updates per animation frame to avoid excessive re-renders + * when many lines arrive in quick succession. + */ +export function useWorkspaceLogs(): string[] { + const { onWorkspaceLogsAppend, closeWorkspaceLogs } = useTasksApi(); + const [lines, setLines] = useState([]); + + useEffect(() => { + let pending: string[] = []; + let frame = 0; + + const unsubscribe = onWorkspaceLogsAppend((newLines) => { + pending.push(...newLines); + if (frame === 0) { + frame = requestAnimationFrame(() => { + const batch = pending; + pending = []; + frame = 0; + setLines((prev) => prev.concat(batch)); + }); + } + }); + + return () => { + unsubscribe(); + cancelAnimationFrame(frame); + closeWorkspaceLogs(); + }; + }, [closeWorkspaceLogs, onWorkspaceLogsAppend]); + + return lines; +} diff --git a/packages/tasks/src/index.css b/packages/tasks/src/index.css index 87812b38..9f2f566a 100644 --- a/packages/tasks/src/index.css +++ b/packages/tasks/src/index.css @@ -443,7 +443,7 @@ vscode-icon.disabled { /* Chat history */ -.agent-chat-history { +.log-viewer { flex: 1; display: flex; flex-direction: column; @@ -453,14 +453,14 @@ vscode-icon.disabled { border-radius: 4px; } -.chat-history-header { +.log-viewer-header { padding: 6px 8px; font-size: 0.8em; color: var(--vscode-descriptionForeground); border-bottom: 1px solid var(--vscode-input-border); } -.chat-history-content { +.log-viewer-content { flex: 1; min-height: 0; padding: 4px 8px; @@ -468,13 +468,13 @@ vscode-icon.disabled { font-size: 0.85em; } -.chat-history-empty { +.log-viewer-empty { padding: 16px 0; color: var(--vscode-descriptionForeground); text-align: center; } -.chat-history-error { +.log-viewer-error { color: var(--vscode-errorForeground); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 339fd001..6d9fb826 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ importers: semver: specifier: 7.7.4 version: 7.7.4 + strip-ansi: + specifier: ^7.1.2 + version: 7.1.2 ua-parser-js: specifier: ^1.0.41 version: 1.0.41 @@ -1663,10 +1666,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.0.1: - resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} - engines: {node: '>=12'} - ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -3996,10 +3995,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} - strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -4971,7 +4966,7 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -5361,7 +5356,7 @@ snapshots: chalk: 5.6.2 debug: 4.4.3(supports-color@8.1.1) pluralize: 8.0.0 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 table: 6.9.0 terminal-link: 4.0.0 transitivePeerDependencies: @@ -5979,8 +5974,6 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.0.1: {} - ansi-regex@6.2.2: {} ansi-styles@4.3.0: @@ -8223,7 +8216,7 @@ snapshots: log-symbols: 6.0.0 stdin-discarder: 0.2.2 string-width: 7.2.0 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 own-keys@1.0.1: dependencies: @@ -8819,13 +8812,13 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 string-width@7.2.0: dependencies: emoji-regex: 10.4.0 get-east-asian-width: 1.3.0 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 string.prototype.matchall@4.0.12: dependencies: @@ -8884,10 +8877,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.0: - dependencies: - ansi-regex: 6.0.1 - strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 @@ -9340,7 +9329,7 @@ snapshots: dependencies: ansi-styles: 6.2.1 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 wrap-ansi@9.0.2: dependencies: diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 93319337..fdedfcb8 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -3,7 +3,6 @@ import { type WorkspaceAgentLog, type ProvisionerJobLog, type Workspace, - type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; import { spawn } from "node:child_process"; import * as vscode from "vscode"; @@ -16,6 +15,36 @@ import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; import { errToStr, createWorkspaceIdentifier } from "./api-helper"; import { type CoderApi } from "./coderApi"; +/** Opens a stream once; subsequent open() calls are no-ops until closed. */ +export class LazyStream { + private stream: UnidirectionalStream | null = null; + private opening: Promise | null = null; + + async open(factory: () => Promise>): Promise { + if (this.stream) return; + + // Deduplicate concurrent calls; close() clears the reference to cancel. + if (!this.opening) { + const promise = factory().then((s) => { + if (this.opening === promise) { + this.stream = s; + this.opening = null; + } else { + s.close(); + } + }); + this.opening = promise; + } + await this.opening; + } + + close(): void { + this.stream?.close(); + this.stream = null; + this.opening = null; + } +} + /** * Start or update a workspace and return the updated workspace. */ @@ -86,70 +115,63 @@ export async function startWorkspaceIfStoppedOrFailed( } /** - * Streams build logs to the emitter in real-time. + * Streams build logs in real-time via a callback. * Returns the websocket for lifecycle management. */ export async function streamBuildLogs( client: CoderApi, - writeEmitter: vscode.EventEmitter, - workspace: Workspace, + onOutput: (line: string) => void, + buildId: string, ): Promise> { - const socket = await client.watchBuildLogsByBuildId( - workspace.latest_build.id, - [], - ); + const socket = await client.watchBuildLogsByBuildId(buildId, []); socket.addEventListener("message", (data) => { if (data.parseError) { - writeEmitter.fire( - errToStr(data.parseError, "Failed to parse message") + "\r\n", - ); + onOutput(errToStr(data.parseError, "Failed to parse message")); } else { - writeEmitter.fire(data.parsedMessage.output + "\r\n"); + onOutput(data.parsedMessage.output); } }); socket.addEventListener("error", (error) => { const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; - writeEmitter.fire( - `Error watching workspace build logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`, + onOutput( + `Error watching workspace build logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, ); }); socket.addEventListener("close", () => { - writeEmitter.fire("Build complete\r\n"); + onOutput("Build complete"); }); return socket; } /** - * Streams agent logs to the emitter in real-time. + * Streams agent logs in real-time via a callback. * Returns the websocket for lifecycle management. */ export async function streamAgentLogs( client: CoderApi, - writeEmitter: vscode.EventEmitter, - agent: WorkspaceAgent, + onOutput: (line: string) => void, + agentId: string, ): Promise> { - const socket = await client.watchWorkspaceAgentLogs(agent.id, []); + const socket = await client.watchWorkspaceAgentLogs(agentId, []); socket.addEventListener("message", (data) => { if (data.parseError) { - writeEmitter.fire( - errToStr(data.parseError, "Failed to parse message") + "\r\n", - ); + onOutput(errToStr(data.parseError, "Failed to parse message")); } else { for (const log of data.parsedMessage) { - writeEmitter.fire(log.output + "\r\n"); + onOutput(log.output); } } }); socket.addEventListener("error", (error) => { const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; - writeEmitter.fire( - `Error watching agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`, + onOutput( + `Error watching agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, ); }); diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index 09b57a12..e188132d 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -1,5 +1,6 @@ import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; import { + LazyStream, startWorkspaceIfStoppedOrFailed, streamAgentLogs, streamBuildLogs, @@ -21,7 +22,6 @@ import type { CoderApi } from "../api/coderApi"; import type { PathResolver } from "../core/pathResolver"; import type { FeatureSet } from "../featureSet"; import type { Logger } from "../logging/logger"; -import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; /** * Manages workspace and agent state transitions until ready for SSH connection. @@ -29,14 +29,11 @@ import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; */ export class WorkspaceStateMachine implements vscode.Disposable { private readonly terminal: TerminalSession; + private readonly buildLogStream = new LazyStream(); + private readonly agentLogStream = new LazyStream(); private agent: { id: string; name: string } | undefined; - private buildLogSocket: UnidirectionalStream | null = null; - - private agentLogSocket: UnidirectionalStream | null = - null; - constructor( private readonly parts: AuthorityParts, private readonly workspaceClient: CoderApi, @@ -61,12 +58,12 @@ export class WorkspaceStateMachine implements vscode.Disposable { switch (workspace.latest_build.status) { case "running": - this.closeBuildLogSocket(); + this.buildLogStream.close(); break; case "stopped": case "failed": { - this.closeBuildLogSocket(); + this.buildLogStream.close(); if (!this.firstConnect && !(await this.confirmStart(workspaceName))) { throw new Error(`Workspace start cancelled`); @@ -91,27 +88,32 @@ export class WorkspaceStateMachine implements vscode.Disposable { case "pending": case "starting": - case "stopping": + case "stopping": { // Clear the agent since it's ID could change after a restart this.agent = undefined; - this.closeAgentLogSocket(); + this.agentLogStream.close(); progress.report({ message: `building ${workspaceName} (${workspace.latest_build.status})...`, }); this.logger.info(`Waiting for ${workspaceName}`); - this.buildLogSocket ??= await streamBuildLogs( - this.workspaceClient, - this.terminal.writeEmitter, - workspace, + const write = (line: string) => + this.terminal.writeEmitter.fire(line + "\r\n"); + await this.buildLogStream.open(() => + streamBuildLogs( + this.workspaceClient, + write, + workspace.latest_build.id, + ), ); return false; + } case "deleted": case "deleting": case "canceled": case "canceling": - this.closeBuildLogSocket(); + this.buildLogStream.close(); throw new Error(`${workspaceName} is ${workspace.latest_build.status}`); } @@ -160,7 +162,7 @@ export class WorkspaceStateMachine implements vscode.Disposable { switch (agent.lifecycle_state) { case "ready": - this.closeAgentLogSocket(); + this.agentLogStream.close(); return true; case "starting": { @@ -176,10 +178,10 @@ export class WorkspaceStateMachine implements vscode.Disposable { }); this.logger.debug(`Running agent ${agent.name} startup scripts`); - this.agentLogSocket ??= await streamAgentLogs( - this.workspaceClient, - this.terminal.writeEmitter, - agent, + const writeAgent = (line: string) => + this.terminal.writeEmitter.fire(line + "\r\n"); + await this.agentLogStream.open(() => + streamAgentLogs(this.workspaceClient, writeAgent, agent.id), ); return false; } @@ -192,14 +194,14 @@ export class WorkspaceStateMachine implements vscode.Disposable { return false; case "start_error": - this.closeAgentLogSocket(); + this.agentLogStream.close(); this.logger.info( `Agent ${agent.name} startup scripts failed, but continuing`, ); return true; case "start_timeout": - this.closeAgentLogSocket(); + this.agentLogStream.close(); this.logger.info( `Agent ${agent.name} startup scripts timed out, but continuing`, ); @@ -209,27 +211,13 @@ export class WorkspaceStateMachine implements vscode.Disposable { case "off": case "shutdown_error": case "shutdown_timeout": - this.closeAgentLogSocket(); + this.agentLogStream.close(); throw new Error( `Invalid lifecycle state '${agent.lifecycle_state}' for ${workspaceName}/${agent.name}`, ); } } - private closeBuildLogSocket(): void { - if (this.buildLogSocket) { - this.buildLogSocket.close(); - this.buildLogSocket = null; - } - } - - private closeAgentLogSocket(): void { - if (this.agentLogSocket) { - this.agentLogSocket.close(); - this.agentLogSocket = null; - } - } - private async confirmStart(workspaceName: string): Promise { const action = await vscodeProposed.window.showInformationMessage( `Unable to connect to the workspace ${workspaceName} because it is not running. Start the workspace?`, @@ -247,8 +235,8 @@ export class WorkspaceStateMachine implements vscode.Disposable { } dispose(): void { - this.closeBuildLogSocket(); - this.closeAgentLogSocket(); + this.buildLogStream.close(); + this.agentLogStream.close(); this.terminal.dispose(); } } diff --git a/src/webviews/tasks/tasksPanel.ts b/src/webviews/tasks/tasksPanel.ts index 0d710b3c..b7d5f389 100644 --- a/src/webviews/tasks/tasksPanel.ts +++ b/src/webviews/tasks/tasksPanel.ts @@ -1,8 +1,11 @@ import { isAxiosError } from "axios"; +import stripAnsi from "strip-ansi"; import * as vscode from "vscode"; import { commandHandler, + isBuildingWorkspace, + isAgentStarting, getTaskPermissions, getTaskLabel, isStableTask, @@ -13,13 +16,18 @@ import { type IpcNotification, type IpcRequest, type IpcResponse, - type LogsStatus, type TaskDetails, + type TaskLogs, type TaskTemplate, } from "@repo/shared"; import { errToStr } from "../../api/api-helper"; import { type CoderApi } from "../../api/coderApi"; +import { + LazyStream, + streamAgentLogs, + streamBuildLogs, +} from "../../api/workspace"; import { toError } from "../../error/errorUtils"; import { type Logger } from "../../logging/logger"; import { vscodeProposed } from "../../vscodeProposed"; @@ -27,9 +35,10 @@ import { getWebviewHtml } from "../util"; import type { Preset, + ProvisionerJobLog, Task, - TaskLogEntry, Template, + WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; /** Build URL to view task build logs in Coder dashboard */ @@ -81,6 +90,11 @@ export class TasksPanel private view?: vscode.WebviewView; private disposables: vscode.Disposable[] = []; + // Workspace log streaming + private readonly buildLogStream = new LazyStream(); + private readonly agentLogStream = new LazyStream(); + private streamingTaskId: string | null = null; + // Template cache with TTL private templatesCache: TaskTemplate[] = []; private templatesCacheTime = 0; @@ -89,8 +103,7 @@ export class TasksPanel // Cache logs for last viewed task in stable state private cachedLogs?: { taskId: string; - logs: readonly TaskLogEntry[]; - status: LogsStatus; + logs: TaskLogs; }; /** @@ -153,6 +166,14 @@ export class TasksPanel [TasksApi.viewLogs.method]: commandHandler(TasksApi.viewLogs, (p) => this.handleViewLogs(p.taskId), ), + [TasksApi.closeWorkspaceLogs.method]: commandHandler( + TasksApi.closeWorkspaceLogs, + () => { + this.buildLogStream.close(); + this.agentLogStream.close(); + this.streamingTaskId = null; + }, + ), }; constructor( @@ -280,8 +301,11 @@ export class TasksPanel private async handleGetTaskDetails(taskId: string): Promise { const task = await this.client.getTask("me", taskId); - const { logs, logsStatus } = await this.getLogsWithCache(task); - return { task, logs, logsStatus, ...getTaskPermissions(task) }; + this.streamWorkspaceLogs(task).catch((err: unknown) => { + this.logger.warn("Failed to stream workspace logs", err); + }); + const logs = await this.getLogsWithCache(task); + return { task, logs, ...getTaskPermissions(task) }; } private async handleCreateTask(params: CreateTaskParams): Promise { @@ -417,7 +441,7 @@ export class TasksPanel private async handleDownloadLogs(taskId: string): Promise { const result = await this.fetchTaskLogs(taskId); - if (result.status === "error") { + if (result.status !== "ok") { throw new Error("Failed to fetch logs for download"); } if (result.logs.length === 0) { @@ -447,6 +471,45 @@ export class TasksPanel } } + private async streamWorkspaceLogs(task: Task): Promise { + if (task.id !== this.streamingTaskId) { + this.buildLogStream.close(); + this.agentLogStream.close(); + this.streamingTaskId = task.id; + } + + const onOutput = (line: string) => { + const clean = stripAnsi(line); + // Skip lines that were purely ANSI codes, but keep intentional blank lines. + if (line.length > 0 && clean.length === 0) return; + this.sendNotification({ + type: TasksApi.workspaceLogsAppend.method, + data: [clean], + }); + }; + + if (isBuildingWorkspace(task) && task.workspace_id) { + this.agentLogStream.close(); + const workspace = await this.client.getWorkspace(task.workspace_id); + await this.buildLogStream.open(() => + streamBuildLogs(this.client, onOutput, workspace.latest_build.id), + ); + return; + } + + if (isAgentStarting(task) && task.workspace_agent_id) { + const agentId = task.workspace_agent_id; + this.buildLogStream.close(); + await this.agentLogStream.open(() => + streamAgentLogs(this.client, onOutput, agentId), + ); + return; + } + + this.buildLogStream.close(); + this.agentLogStream.close(); + } + private async fetchTasksWithStatus(): Promise<{ tasks: readonly Task[]; supported: boolean; @@ -540,38 +603,34 @@ export class TasksPanel /** * Get logs for a task, using cache for stable states (complete/error/paused). */ - private async getLogsWithCache( - task: Task, - ): Promise<{ logs: readonly TaskLogEntry[]; logsStatus: LogsStatus }> { + private async getLogsWithCache(task: Task): Promise { const stable = isStableTask(task); // Use cache if same task in stable state if (this.cachedLogs?.taskId === task.id && stable) { - return { logs: this.cachedLogs.logs, logsStatus: this.cachedLogs.status }; + return this.cachedLogs.logs; } - const { logs, status } = await this.fetchTaskLogs(task.id); + const logs = await this.fetchTaskLogs(task.id); // Cache only for stable states if (stable) { - this.cachedLogs = { taskId: task.id, logs, status }; + this.cachedLogs = { taskId: task.id, logs }; } - return { logs, logsStatus: status }; + return logs; } - private async fetchTaskLogs( - taskId: string, - ): Promise<{ logs: readonly TaskLogEntry[]; status: LogsStatus }> { + private async fetchTaskLogs(taskId: string): Promise { try { const response = await this.client.getTaskLogs("me", taskId); - return { logs: response.logs, status: "ok" }; + return { status: "ok", logs: response.logs }; } catch (err) { if (isAxiosError(err) && err.response?.status === 409) { - return { logs: [], status: "not_available" }; + return { status: "not_available" }; } this.logger.warn("Failed to fetch task logs", err); - return { logs: [], status: "error" }; + return { status: "error" }; } } @@ -584,6 +643,9 @@ export class TasksPanel } dispose(): void { + this.buildLogStream.close(); + this.agentLogStream.close(); + this.streamingTaskId = null; for (const d of this.disposables) { d.dispose(); } diff --git a/test/mocks/tasks.ts b/test/mocks/tasks.ts index 55b041ab..b03fac97 100644 --- a/test/mocks/tasks.ts +++ b/test/mocks/tasks.ts @@ -152,8 +152,7 @@ export function taskDetails( const { task: taskOverrides, ...rest } = overrides; return { task: task(taskOverrides ?? {}), - logs: [], - logsStatus: "ok", + logs: { status: "ok", logs: [] }, canPause: true, pauseDisabled: false, canResume: false, diff --git a/test/mocks/workspace.ts b/test/mocks/workspace.ts new file mode 100644 index 00000000..315f377d --- /dev/null +++ b/test/mocks/workspace.ts @@ -0,0 +1,94 @@ +/** + * Test factory for Coder SDK Workspace type. + */ + +import type { + Workspace, + WorkspaceBuild, +} from "coder/site/src/api/typesGenerated"; + +const defaultBuild: WorkspaceBuild = { + id: "build-1", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + workspace_id: "workspace-1", + workspace_name: "test-workspace", + workspace_owner_id: "owner-1", + workspace_owner_name: "testuser", + template_version_id: "version-1", + template_version_name: "v1", + build_number: 1, + transition: "start", + initiator_id: "owner-1", + initiator_name: "testuser", + job: { + id: "job-1", + created_at: "2024-01-01T00:00:00Z", + status: "succeeded", + file_id: "file-1", + tags: {}, + queue_position: 0, + queue_size: 0, + organization_id: "org-1", + initiator_id: "owner-1", + input: {}, + type: "workspace_build", + metadata: { + template_version_name: "v1", + template_id: "template-1", + template_name: "test-template", + template_display_name: "Test Template", + template_icon: "/icon.svg", + }, + logs_overflowed: false, + }, + reason: "initiator", + resources: [], + status: "running", + daily_cost: 0, + template_version_preset_id: null, +}; + +/** Create a Workspace with sensible defaults for a running task workspace. */ +export function workspace( + overrides: Omit, "latest_build"> & { + latest_build?: Partial; + } = {}, +): Workspace { + const { latest_build: buildOverrides, ...rest } = overrides; + return { + id: "workspace-1", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + owner_id: "owner-1", + owner_name: "testuser", + owner_avatar_url: "", + organization_id: "org-1", + organization_name: "test-org", + template_id: "template-1", + template_name: "test-template", + template_display_name: "Test Template", + template_icon: "/icon.svg", + template_allow_user_cancel_workspace_jobs: true, + template_active_version_id: "version-1", + template_require_active_version: false, + template_use_classic_parameter_flow: false, + latest_build: { ...defaultBuild, ...buildOverrides }, + latest_app_status: null, + outdated: false, + name: "test-workspace", + last_used_at: "2024-01-01T00:00:00Z", + deleting_at: null, + dormant_at: null, + health: { + healthy: true, + failing_agents: [], + }, + automatic_updates: "never", + allow_renames: false, + favorite: false, + next_start_at: null, + is_prebuild: false, + ...rest, + }; +} diff --git a/test/unit/api/workspace.test.ts b/test/unit/api/workspace.test.ts new file mode 100644 index 00000000..182ee390 --- /dev/null +++ b/test/unit/api/workspace.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LazyStream } from "@/api/workspace"; +import { type UnidirectionalStream } from "@/websocket/eventStreamConnection"; + +function mockStream(): UnidirectionalStream { + return { + url: "ws://test", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + close: vi.fn(), + }; +} + +type StreamFactory = () => Promise>; + +/** Creates a factory whose promise can be resolved manually. */ +function deferredFactory() { + let resolve!: (s: UnidirectionalStream) => void; + const factory: StreamFactory = vi.fn().mockReturnValue( + new Promise>((r) => { + resolve = r; + }), + ); + return { + factory, + resolve: (s?: UnidirectionalStream) => resolve(s ?? mockStream()), + }; +} + +describe("LazyStream", () => { + it("opens once and ignores subsequent calls", async () => { + const factory: StreamFactory = vi.fn().mockResolvedValue(mockStream()); + const lazy = new LazyStream(); + + await lazy.open(factory); + await lazy.open(factory); + + expect(factory).toHaveBeenCalledOnce(); + }); + + it("can reopen after close", async () => { + const factory: StreamFactory = vi.fn().mockResolvedValue(mockStream()); + const lazy = new LazyStream(); + + await lazy.open(factory); + lazy.close(); + await lazy.open(factory); + + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("closes the underlying stream", async () => { + const stream = mockStream(); + const lazy = new LazyStream(); + + await lazy.open(() => Promise.resolve(stream)); + lazy.close(); + + expect(stream.close).toHaveBeenCalledOnce(); + }); + + it("deduplicates concurrent opens", async () => { + const { factory, resolve } = deferredFactory(); + const lazy = new LazyStream(); + + const p1 = lazy.open(factory); + const p2 = lazy.open(factory); + resolve(); + await Promise.all([p1, p2]); + + expect(factory).toHaveBeenCalledOnce(); + }); + + it("allows reopening after close during pending open", async () => { + const { factory, resolve } = deferredFactory(); + const lazy = new LazyStream(); + + const p = lazy.open(factory); + lazy.close(); + resolve(); + await p.catch(() => {}); + + const factory2: StreamFactory = vi.fn().mockResolvedValue(mockStream()); + await lazy.open(factory2); + expect(factory2).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/unit/webviews/tasks/tasksPanel.test.ts b/test/unit/webviews/tasks/tasksPanel.test.ts index 2cbb6aa4..c819711f 100644 --- a/test/unit/webviews/tasks/tasksPanel.test.ts +++ b/test/unit/webviews/tasks/tasksPanel.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import * as vscode from "vscode"; +import { streamAgentLogs, streamBuildLogs } from "@/api/workspace"; import { TasksPanel } from "@/webviews/tasks/tasksPanel"; import { @@ -23,10 +24,34 @@ import { createMockLogger, MockUserInteraction, } from "../../../mocks/testHelpers"; +import { workspace } from "../../../mocks/workspace"; -import type { Task } from "coder/site/src/api/typesGenerated"; +import type { + ProvisionerJobLog, + Task, + WorkspaceAgentLog, +} from "coder/site/src/api/typesGenerated"; import type { CoderApi } from "@/api/coderApi"; +import type { UnidirectionalStream } from "@/websocket/eventStreamConnection"; + +function mockStream(): UnidirectionalStream { + return { + url: "", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + close: vi.fn(), + } as UnidirectionalStream; +} + +vi.mock("@/api/workspace", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + streamBuildLogs: vi.fn(), + streamAgentLogs: vi.fn(), + }; +}); /** Subset of CoderApi used by TasksPanel */ type TasksPanelClient = Pick< @@ -42,6 +67,7 @@ type TasksPanelClient = Pick< | "stopWorkspace" | "sendTaskInput" | "getHost" + | "getWorkspace" >; type MockClient = { [K in keyof TasksPanelClient]: Mock }; @@ -59,7 +85,8 @@ function createClient(baseUrl = "https://coder.example.com"): MockClient { stopWorkspace: vi.fn().mockResolvedValue(undefined), sendTaskInput: vi.fn().mockResolvedValue(undefined), getHost: vi.fn().mockReturnValue(baseUrl), - } as MockClient; + getWorkspace: vi.fn().mockResolvedValue(workspace()), + }; } interface Harness { @@ -278,9 +305,11 @@ describe("TasksPanel", () => { expect(res).toMatchObject({ success: true, - data: { task: { id: "task-1" }, logsStatus: "ok" }, + data: { + task: { id: "task-1" }, + logs: { status: "ok", logs: [{ content: "Starting" }] }, + }, }); - expect(res.data?.logs).toHaveLength(1); }); interface LogCachingTestCase { @@ -299,7 +328,7 @@ describe("TasksPanel", () => { expect(res).toMatchObject({ success: true, - data: { logsStatus: "not_available", logs: [] }, + data: { logs: { status: "not_available" } }, }); }); @@ -746,4 +775,108 @@ describe("TasksPanel", () => { ); }); }); + + describe("workspace log streaming", () => { + async function openBuildStream(h: ReturnType) { + const stream = mockStream(); + let onOutput!: (line: string) => void; + vi.mocked(streamBuildLogs).mockImplementation((_client, cb, _buildId) => { + onOutput = cb; + return Promise.resolve(stream); + }); + h.client.getTask.mockResolvedValue( + task({ workspace_status: "starting", workspace_id: "ws-1" }), + ); + await h.request(TasksApi.getTaskDetails, { taskId: "task-1" }); + return { stream, onOutput }; + } + + async function openAgentStream(h: ReturnType) { + const stream = mockStream(); + let onOutput!: (line: string) => void; + vi.mocked(streamAgentLogs).mockImplementation((_client, cb, _agentId) => { + onOutput = cb; + return Promise.resolve(stream); + }); + h.client.getTask.mockResolvedValue( + task({ + workspace_status: "running", + workspace_agent_lifecycle: "starting", + workspace_agent_id: "agent-1", + }), + ); + await h.request(TasksApi.getTaskDetails, { taskId: "task-1" }); + return { stream, onOutput }; + } + + it("forwards build logs to webview", async () => { + const h = createHarness(); + const { onOutput } = await openBuildStream(h); + + onOutput("Building image..."); + + expect(h.messages()).toContainEqual({ + type: TasksApi.workspaceLogsAppend.method, + data: ["Building image..."], + }); + expect(streamBuildLogs).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + "build-1", + ); + }); + + it("forwards agent logs to webview", async () => { + const h = createHarness(); + const { onOutput } = await openAgentStream(h); + + onOutput("Running startup script..."); + + expect(h.messages()).toContainEqual({ + type: TasksApi.workspaceLogsAppend.method, + data: ["Running startup script..."], + }); + expect(streamAgentLogs).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + "agent-1", + ); + }); + + it("does not stream for ready task", async () => { + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ + workspace_status: "running", + workspace_agent_lifecycle: "ready", + }), + ); + + await h.request(TasksApi.getTaskDetails, { taskId: "task-1" }); + + expect(streamBuildLogs).not.toHaveBeenCalled(); + expect(streamAgentLogs).not.toHaveBeenCalled(); + }); + + it("closes streams when switching to a different task", async () => { + const h = createHarness(); + const { stream } = await openBuildStream(h); + + h.client.getTask.mockResolvedValue( + task({ id: "task-2", workspace_status: "running" }), + ); + await h.request(TasksApi.getTaskDetails, { taskId: "task-2" }); + + expect(stream.close).toHaveBeenCalled(); + }); + + it("closes streams on closeWorkspaceLogs command", async () => { + const h = createHarness(); + const { stream } = await openBuildStream(h); + + await h.command(TasksApi.closeWorkspaceLogs); + + expect(stream.close).toHaveBeenCalled(); + }); + }); }); diff --git a/test/webview/shared/tasks/utils.test.ts b/test/webview/shared/tasks/utils.test.ts index 8c58947a..5c5b916f 100644 --- a/test/webview/shared/tasks/utils.test.ts +++ b/test/webview/shared/tasks/utils.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { getTaskPermissions, + isAgentStarting, + isBuildingWorkspace, isStableTask, isTaskWorking, type Task, @@ -14,6 +16,11 @@ import { taskState as state, } from "../../../mocks/tasks"; +import type { + WorkspaceAgentLifecycle, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + describe("getTaskPermissions", () => { interface TaskPermissionsTestCase { name: string; @@ -193,3 +200,40 @@ describe("isStableTask", () => { expect(isStableTask(fullTask(overrides))).toBe(expected); }); }); + +describe("isBuildingWorkspace", () => { + interface BuildingTestCase { + ws: WorkspaceStatus; + expected: boolean; + } + it.each([ + { ws: "pending", expected: true }, + { ws: "starting", expected: true }, + { ws: "stopping", expected: false }, + { ws: "running", expected: false }, + { ws: "stopped", expected: false }, + ])("workspace_status=$ws → $expected", ({ ws, expected }) => { + expect(isBuildingWorkspace(task({ workspace_status: ws }))).toBe(expected); + }); +}); + +describe("isAgentStarting", () => { + interface AgentStartingTestCase { + ws: WorkspaceStatus; + lc: WorkspaceAgentLifecycle | null; + expected: boolean; + } + it.each([ + { ws: "running", lc: "created", expected: true }, + { ws: "running", lc: "starting", expected: true }, + { ws: "running", lc: "ready", expected: false }, + { ws: "running", lc: null, expected: false }, + { ws: "starting", lc: "created", expected: false }, + ])("ws=$ws lc=$lc → $expected", ({ ws, lc, expected }) => { + expect( + isAgentStarting( + task({ workspace_status: ws, workspace_agent_lifecycle: lc }), + ), + ).toBe(expected); + }); +}); diff --git a/test/webview/tasks/AgentChatHistory.test.tsx b/test/webview/tasks/AgentChatHistory.test.tsx index ce35cb6b..4a74e236 100644 --- a/test/webview/tasks/AgentChatHistory.test.tsx +++ b/test/webview/tasks/AgentChatHistory.test.tsx @@ -10,7 +10,10 @@ describe("AgentChatHistory", () => { describe("empty states", () => { it("shows default empty message when no logs", () => { renderWithQuery( - , + , ); expect(screen.getByText("No messages yet")).toBeInTheDocument(); }); @@ -18,8 +21,7 @@ describe("AgentChatHistory", () => { it("shows not-available message", () => { renderWithQuery( , ); @@ -30,11 +32,11 @@ describe("AgentChatHistory", () => { it("shows error message with error styling", () => { renderWithQuery( - , + , ); const el = screen.getByText("Failed to load logs"); expect(el).toBeInTheDocument(); - expect(el).toHaveClass("chat-history-error"); + expect(el).toHaveClass("log-viewer-error"); }); }); @@ -45,7 +47,10 @@ describe("AgentChatHistory", () => { logEntry({ id: 2, type: "output", content: "Hi there" }), ]; renderWithQuery( - , + , ); const input = screen.getByText("Hello").closest(".log-entry"); @@ -62,7 +67,10 @@ describe("AgentChatHistory", () => { logEntry({ id: 3, type: "output", content: "msg3" }), ]; renderWithQuery( - , + , ); // "You" label at first input, "Agent" label at first output @@ -80,7 +88,10 @@ describe("AgentChatHistory", () => { logEntry({ id: 3, type: "input", content: "q2" }), ]; renderWithQuery( - , + , ); // Two "You" labels: one for first input group, one for the second @@ -92,14 +103,20 @@ describe("AgentChatHistory", () => { describe("thinking indicator", () => { it("shows thinking indicator when isThinking is true", () => { renderWithQuery( - , + , ); expect(screen.getByText("Thinking...")).toBeInTheDocument(); }); it("does not show thinking indicator when isThinking is false", () => { renderWithQuery( - , + , ); expect(screen.queryByText("Thinking...")).not.toBeInTheDocument(); }); diff --git a/test/webview/tasks/LogViewer.test.tsx b/test/webview/tasks/LogViewer.test.tsx new file mode 100644 index 00000000..6bb73d4f --- /dev/null +++ b/test/webview/tasks/LogViewer.test.tsx @@ -0,0 +1,39 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + LogViewer, + LogViewerPlaceholder, +} from "@repo/tasks/components/LogViewer"; + +import { renderWithQuery } from "../render"; + +describe("LogViewer", () => { + it("renders header and children", () => { + renderWithQuery( + +

content
+ , + ); + expect(screen.getByText("Test header")).toBeInTheDocument(); + expect(screen.getByText("content")).toBeInTheDocument(); + }); +}); + +describe("LogViewerPlaceholder", () => { + it("renders children with empty styling", () => { + renderWithQuery(No data); + const el = screen.getByText("No data"); + expect(el).toHaveClass("log-viewer-empty"); + expect(el).not.toHaveClass("log-viewer-error"); + }); + + it("adds error styling when error is true", () => { + renderWithQuery( + Something failed, + ); + const el = screen.getByText("Something failed"); + expect(el).toHaveClass("log-viewer-empty"); + expect(el).toHaveClass("log-viewer-error"); + }); +}); diff --git a/test/webview/tasks/TaskDetailView.test.tsx b/test/webview/tasks/TaskDetailView.test.tsx index 6017e2f9..1b849189 100644 --- a/test/webview/tasks/TaskDetailView.test.tsx +++ b/test/webview/tasks/TaskDetailView.test.tsx @@ -13,6 +13,12 @@ vi.mock("@repo/tasks/hooks/useTasksApi", () => ({ useTasksApi: () => new Proxy({}, { get: () => vi.fn() }), })); +const WORKSPACE_LOG_LINE = "mock workspace log line"; + +vi.mock("@repo/tasks/hooks/useWorkspaceLogs", () => ({ + useWorkspaceLogs: () => [WORKSPACE_LOG_LINE], +})); + describe("TaskDetailView", () => { it("passes onBack to header", () => { const onBack = vi.fn(); @@ -25,10 +31,13 @@ describe("TaskDetailView", () => { it("passes logs to chat history", () => { const details = taskDetails({ - logs: [ - logEntry({ id: 1, content: "Starting build..." }), - logEntry({ id: 2, content: "Build complete." }), - ], + logs: { + status: "ok", + logs: [ + logEntry({ id: 1, content: "Starting build..." }), + logEntry({ id: 2, content: "Build complete." }), + ], + }, }); renderWithQuery( {}} />); expect(screen.getByText("Starting build...")).toBeInTheDocument(); @@ -114,8 +123,47 @@ describe("TaskDetailView", () => { }); it("shows logsStatus error in chat history", () => { - const details = taskDetails({ logsStatus: "error" }); + const details = taskDetails({ logs: { status: "error" } }); renderWithQuery( {}} />); expect(screen.getByText("Failed to load logs")).toBeInTheDocument(); }); + + describe("workspace startup rendering", () => { + // WORKSPACE_LOG_LINE comes from the mocked useWorkspaceLogs hook, + // so its presence indicates WorkspaceLogs is rendered. + + interface StartupCase { + name: string; + taskOverrides: Partial; + } + + it.each([ + { + name: "building", + taskOverrides: { workspace_status: "starting" }, + }, + { + name: "agent starting", + taskOverrides: { + workspace_status: "running", + workspace_agent_lifecycle: "created", + }, + }, + ])("$name → shows workspace logs", ({ taskOverrides }) => { + const details = taskDetails({ task: taskOverrides }); + renderWithQuery( {}} />); + expect(screen.getByText(WORKSPACE_LOG_LINE)).toBeInTheDocument(); + }); + + it("ready → shows chat history", () => { + const details = taskDetails({ + task: { + workspace_status: "running", + workspace_agent_lifecycle: "ready", + }, + }); + renderWithQuery( {}} />); + expect(screen.queryByText(WORKSPACE_LOG_LINE)).not.toBeInTheDocument(); + }); + }); }); diff --git a/test/webview/tasks/WorkspaceLogs.test.tsx b/test/webview/tasks/WorkspaceLogs.test.tsx new file mode 100644 index 00000000..44df0fd6 --- /dev/null +++ b/test/webview/tasks/WorkspaceLogs.test.tsx @@ -0,0 +1,53 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { WorkspaceLogs } from "@repo/tasks/components/WorkspaceLogs"; +import * as hookModule from "@repo/tasks/hooks/useWorkspaceLogs"; + +import { task } from "../../mocks/tasks"; +import { renderWithQuery } from "../render"; + +vi.mock("@repo/tasks/hooks/useWorkspaceLogs", () => ({ + useWorkspaceLogs: () => [] as string[], +})); + +describe("WorkspaceLogs", () => { + it("shows building header when workspace is building", () => { + renderWithQuery( + , + ); + expect(screen.getByText("Building workspace...")).toBeInTheDocument(); + }); + + it("shows startup scripts header when agent is starting", () => { + renderWithQuery( + , + ); + expect(screen.getByText("Running startup scripts...")).toBeInTheDocument(); + }); + + it("shows waiting message when no lines", () => { + renderWithQuery( + , + ); + expect(screen.getByText("Waiting for logs...")).toBeInTheDocument(); + }); + + it("renders log lines instead of placeholder", () => { + vi.spyOn(hookModule, "useWorkspaceLogs").mockReturnValue(["Ready"]); + + renderWithQuery( + , + ); + + expect(screen.getByText("Ready")).toBeInTheDocument(); + expect(screen.queryByText("Waiting for logs...")).not.toBeInTheDocument(); + + vi.mocked(hookModule.useWorkspaceLogs).mockReturnValue([]); + }); +}); diff --git a/test/webview/tasks/useWorkspaceLogs.test.ts b/test/webview/tasks/useWorkspaceLogs.test.ts new file mode 100644 index 00000000..6b47c0c7 --- /dev/null +++ b/test/webview/tasks/useWorkspaceLogs.test.ts @@ -0,0 +1,76 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TasksApi } from "@repo/shared"; +import { useWorkspaceLogs } from "@repo/tasks/hooks/useWorkspaceLogs"; + +const sent: unknown[] = []; + +vi.stubGlobal( + "acquireVsCodeApi", + vi.fn(() => ({ + postMessage: (msg: unknown) => sent.push(msg), + getState: () => undefined, + setState: () => {}, + })), +); + +function renderLogs() { + sent.length = 0; + const hook = renderHook(() => useWorkspaceLogs()); + + return { + get lines() { + return hook.result.current; + }, + notify(lines: string[]) { + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: TasksApi.workspaceLogsAppend.method, + data: lines, + }, + }), + ); + // Flush the requestAnimationFrame batch used by useWorkspaceLogs. + vi.runAllTimers(); + }); + }, + unmount() { + hook.unmount(); + return [...sent]; + }, + }; +} + +describe("useWorkspaceLogs", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns empty array initially", () => { + const h = renderLogs(); + expect(h.lines).toEqual([]); + }); + + it("accumulates lines from notifications", () => { + const h = renderLogs(); + + h.notify(["line 1", "line 2"]); + expect(h.lines).toEqual(["line 1", "line 2"]); + + h.notify(["line 3"]); + expect(h.lines).toEqual(["line 1", "line 2", "line 3"]); + }); + + it("sends closeWorkspaceLogs on unmount", () => { + const h = renderLogs(); + const sent = h.unmount(); + + expect(sent).toContainEqual( + expect.objectContaining({ + method: TasksApi.closeWorkspaceLogs.method, + }), + ); + }); +});