From 1bd2ac71e6eff2058c84955ce098f6cee3251c0e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 19 Feb 2026 12:12:15 +0100 Subject: [PATCH] Split init into separate queries and simplify tasks panel - Replace InitResponse with separate getTasks and getTemplates queries with independent poll intervals (10s tasks, 5min templates) - Remove extension-side template cache in favor of React Query staleTime - Add resume button to TaskMessageInput for paused tasks - Reject message sends to paused tasks instead of auto-starting workspace - Extract TasksPanel component and usePersistedState hook from App - Centralize query keys in config.ts - Rename TasksPanel to TasksPanelProvider on the extension side - Add refresh bar indicator for initial load and user-triggered refresh --- package.json | 7 +- packages/shared/src/tasks/api.ts | 21 +- packages/shared/src/tasks/utils.ts | 3 +- packages/tasks/src/App.tsx | 120 +------ packages/tasks/src/components/PromptInput.tsx | 4 +- .../tasks/src/components/TaskMessageInput.tsx | 68 +++- packages/tasks/src/components/TasksPanel.tsx | 105 ++++++ packages/tasks/src/hooks/usePersistedState.ts | 23 ++ packages/tasks/src/hooks/useSelectedTask.ts | 11 +- packages/tasks/src/hooks/useTasksApi.ts | 4 +- packages/tasks/src/hooks/useTasksQuery.ts | 92 +++-- packages/tasks/src/hooks/useWorkspaceLogs.ts | 6 +- packages/tasks/src/index.css | 30 ++ packages/tasks/src/utils/config.ts | 9 + src/extension.ts | 22 +- .../{tasksPanel.ts => tasksPanelProvider.ts} | 174 +++++----- ...nel.test.ts => tasksPanelProvider.test.ts} | 316 +++++++++--------- test/webview/shared/tasks/utils.test.ts | 2 +- test/webview/tasks/TaskDetailView.test.tsx | 2 +- test/webview/tasks/TaskMessageInput.test.tsx | 43 ++- test/webview/tasks/useWorkspaceLogs.test.ts | 4 +- 21 files changed, 611 insertions(+), 455 deletions(-) create mode 100644 packages/tasks/src/components/TasksPanel.tsx create mode 100644 packages/tasks/src/hooks/usePersistedState.ts rename src/webviews/tasks/{tasksPanel.ts => tasksPanelProvider.ts} (84%) rename test/unit/webviews/tasks/{tasksPanel.test.ts => tasksPanelProvider.test.ts} (81%) diff --git a/package.json b/package.json index 8de16a35..0dfa1642 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,7 @@ "id": "coder.tasksPanel", "name": "Coder Tasks", "icon": "media/tasks-logo.svg", - "when": "coder.tasksEnabled" + "when": "coder.authenticated && coder.tasksEnabled" } ] }, @@ -228,11 +228,6 @@ "view": "myWorkspaces", "contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)", "when": "!coder.authenticated && coder.loaded" - }, - { - "view": "coder.tasksPanel", - "contents": "[Login](command:coder.login) to view tasks.", - "when": "!coder.authenticated && coder.loaded && coder.tasksEnabled" } ], "commands": [ diff --git a/packages/shared/src/tasks/api.ts b/packages/shared/src/tasks/api.ts index d88c7818..efd70509 100644 --- a/packages/shared/src/tasks/api.ts +++ b/packages/shared/src/tasks/api.ts @@ -17,20 +17,14 @@ import { import type { Task, TaskDetails, TaskTemplate } from "./types"; -export interface InitResponse { - tasks: readonly Task[]; - templates: readonly TaskTemplate[]; - baseUrl: string; - tasksSupported: boolean; -} - export interface TaskIdParams { taskId: string; } -const init = defineRequest("init"); -const getTasks = defineRequest("getTasks"); -const getTemplates = defineRequest("getTemplates"); +const getTasks = defineRequest("getTasks"); +const getTemplates = defineRequest( + "getTemplates", +); const getTask = defineRequest("getTask"); const getTaskDetails = defineRequest( "getTaskDetails", @@ -56,7 +50,9 @@ const sendTaskMessage = defineRequest( const viewInCoder = defineCommand("viewInCoder"); const viewLogs = defineCommand("viewLogs"); -const closeWorkspaceLogs = defineCommand("closeWorkspaceLogs"); +const stopStreamingWorkspaceLogs = defineCommand( + "stopStreamingWorkspaceLogs", +); const taskUpdated = defineNotification("taskUpdated"); const tasksUpdated = defineNotification("tasksUpdated"); @@ -66,7 +62,6 @@ const showCreateForm = defineNotification("showCreateForm"); export const TasksApi = { // Requests - init, getTasks, getTemplates, getTask, @@ -80,7 +75,7 @@ export const TasksApi = { // Commands viewInCoder, viewLogs, - closeWorkspaceLogs, + stopStreamingWorkspaceLogs, // Notifications taskUpdated, tasksUpdated, diff --git a/packages/shared/src/tasks/utils.ts b/packages/shared/src/tasks/utils.ts index cdf65f07..ad68e376 100644 --- a/packages/shared/src/tasks/utils.ts +++ b/packages/shared/src/tasks/utils.ts @@ -27,8 +27,7 @@ export function getTaskPermissions(task: Task): TaskPermissions { const hasWorkspace = task.workspace_id !== null; const status = task.status; const canSendMessage = - task.status === "paused" || - (task.status === "active" && task.current_state?.state !== "working"); + task.status === "active" && task.current_state?.state !== "working"; return { canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status), pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status), diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index a1b39539..f4a2dea6 100644 --- a/packages/tasks/src/App.tsx +++ b/packages/tasks/src/App.tsx @@ -1,86 +1,19 @@ -import { type InitResponse } from "@repo/shared"; -import { getState, setState } from "@repo/webview-shared"; -import { - VscodeCollapsible, - VscodeProgressRing, - VscodeScrollable, -} from "@vscode-elements/react-elements"; -import { useEffect, useRef, useState } from "react"; - -import { CreateTaskSection } from "./components/CreateTaskSection"; import { ErrorState } from "./components/ErrorState"; -import { NoTemplateState } from "./components/NoTemplateState"; import { NotSupportedState } from "./components/NotSupportedState"; -import { TaskDetailView } from "./components/TaskDetailView"; -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 { TasksPanel } from "./components/TasksPanel"; +import { usePersistedState } from "./hooks/usePersistedState"; import { useTasksQuery } from "./hooks/useTasksQuery"; -interface PersistedState extends InitResponse { - createExpanded: boolean; - historyExpanded: boolean; -} - -type CollapsibleElement = React.ComponentRef; -type ScrollableElement = React.ComponentRef; - export default function App() { - const [restored] = useState(() => getState()); - const { tasks, templates, tasksSupported, data, isLoading, error, refetch } = - useTasksQuery(restored); - - const { selectedTask, isLoadingDetails, selectTask, deselectTask } = - useSelectedTask(tasks); - - const [createRef, createOpen, setCreateOpen] = - useCollapsibleToggle(restored?.createExpanded ?? true); - const [historyRef, historyOpen] = useCollapsibleToggle( - restored?.historyExpanded ?? true, - ); - - const createScrollRef = useRef(null); - const historyScrollRef = useRef(null); - useScrollableHeight(createRef, createScrollRef); - useScrollableHeight(historyRef, historyScrollRef); - - const { onShowCreateForm } = useTasksApi(); - useEffect(() => { - return onShowCreateForm(() => setCreateOpen(true)); - }, [onShowCreateForm, setCreateOpen]); - - useEffect(() => { - if (data) { - setState({ - ...data, - createExpanded: createOpen, - historyExpanded: historyOpen, - }); - } - }, [data, createOpen, historyOpen]); - - function renderHistory() { - if (selectedTask) { - return ; - } - if (isLoadingDetails) { - return ( -
- -
- ); - } - return ; - } + const persisted = usePersistedState(); + const { tasksSupported, tasks, templates, refreshing, error, refetch } = + useTasksQuery({ + initialTasks: persisted.initialTasks, + initialTemplates: persisted.initialTemplates, + }); - if (isLoading) { - return ( -
- -
- ); + if (!tasksSupported) { + return ; } if (error && tasks.length === 0) { @@ -89,35 +22,10 @@ export default function App() { ); } - if (!tasksSupported) { - return ; - } - - if (templates.length === 0) { - return ; - } - return ( -
- - - - - - - -
- {renderHistory()} -
-
-
+ <> + {refreshing &&
} + + ); } diff --git a/packages/tasks/src/components/PromptInput.tsx b/packages/tasks/src/components/PromptInput.tsx index 67f6f482..f64194c6 100644 --- a/packages/tasks/src/components/PromptInput.tsx +++ b/packages/tasks/src/components/PromptInput.tsx @@ -5,14 +5,14 @@ import { import { isSubmit } from "../utils/keys"; -interface PromptInputProps { +export interface PromptInputProps { value: string; onChange: (value: string) => void; onSubmit: () => void; disabled?: boolean; loading?: boolean; placeholder?: string; - actionIcon: "send" | "debug-pause"; + actionIcon: "send" | "debug-pause" | "debug-start"; actionLabel: string; actionEnabled: boolean; } diff --git a/packages/tasks/src/components/TaskMessageInput.tsx b/packages/tasks/src/components/TaskMessageInput.tsx index ee343dd3..9434dcd8 100644 --- a/packages/tasks/src/components/TaskMessageInput.tsx +++ b/packages/tasks/src/components/TaskMessageInput.tsx @@ -1,8 +1,8 @@ import { getTaskLabel, + getTaskPermissions, isTaskWorking, type Task, - getTaskPermissions, } from "@repo/shared"; import { logger } from "@repo/webview-shared/logger"; import { useMutation } from "@tanstack/react-query"; @@ -10,12 +10,22 @@ import { useState } from "react"; import { useTasksApi } from "../hooks/useTasksApi"; -import { PromptInput } from "./PromptInput"; +import { PromptInput, type PromptInputProps } from "./PromptInput"; + +type ActionProps = Pick< + PromptInputProps, + | "onSubmit" + | "disabled" + | "loading" + | "actionIcon" + | "actionLabel" + | "actionEnabled" +>; function getPlaceholder(task: Task): string { switch (task.status) { case "paused": - return "Send a message to resume the task..."; + return "Resume the task to send messages"; case "initializing": case "pending": return "Waiting for the agent to start..."; @@ -46,34 +56,62 @@ export function TaskMessageInput({ task }: TaskMessageInputProps) { const api = useTasksApi(); const [message, setMessage] = useState(""); - const { canPause, canSendMessage } = getTaskPermissions(task); - const placeholder = getPlaceholder(task); - const showPauseButton = isTaskWorking(task) && canPause; - const canSubmitMessage = canSendMessage && message.trim().length > 0; - const { mutate: pauseTask, isPending: isPausing } = useMutation({ mutationFn: () => api.pauseTask({ taskId: task.id, taskName: getTaskLabel(task) }), onError: (err) => logger.error("Failed to pause task", err), }); + const { mutate: resumeTask, isPending: isResuming } = useMutation({ + mutationFn: () => + api.resumeTask({ taskId: task.id, taskName: getTaskLabel(task) }), + onError: (err) => logger.error("Failed to resume task", err), + }); + const { mutate: sendMessage, isPending: isSending } = useMutation({ mutationFn: (msg: string) => api.sendTaskMessage(task.id, msg), onSuccess: () => setMessage(""), onError: (err) => logger.error("Failed to send message", err), }); + const { canPause, canResume, canSendMessage } = getTaskPermissions(task); + + let actionProps: ActionProps; + if (isTaskWorking(task) && canPause) { + actionProps = { + onSubmit: pauseTask, + loading: isPausing, + actionIcon: "debug-pause", + actionLabel: "Pause task", + disabled: false, + actionEnabled: true, + }; + } else if (canResume) { + actionProps = { + onSubmit: resumeTask, + loading: isResuming, + actionIcon: "debug-start", + actionLabel: "Resume task", + disabled: true, + actionEnabled: true, + }; + } else { + actionProps = { + onSubmit: () => sendMessage(message), + loading: isSending, + actionIcon: "send", + actionLabel: "Send message", + disabled: !canSendMessage, + actionEnabled: canSendMessage && message.trim().length > 0, + }; + } + return ( sendMessage(message)} - disabled={!canSendMessage && !showPauseButton} - loading={showPauseButton ? isPausing : isSending} - actionIcon={showPauseButton ? "debug-pause" : "send"} - actionLabel={showPauseButton ? "Pause task" : "Send message"} - actionEnabled={showPauseButton ? true : canSubmitMessage} + {...actionProps} /> ); } diff --git a/packages/tasks/src/components/TasksPanel.tsx b/packages/tasks/src/components/TasksPanel.tsx new file mode 100644 index 00000000..9c2c5792 --- /dev/null +++ b/packages/tasks/src/components/TasksPanel.tsx @@ -0,0 +1,105 @@ +import { + VscodeCollapsible, + VscodeProgressRing, + VscodeScrollable, +} from "@vscode-elements/react-elements"; +import { useEffect, useRef } from "react"; + +import { useCollapsibleToggle } from "../hooks/useCollapsibleToggle"; +import { useScrollableHeight } from "../hooks/useScrollableHeight"; +import { useSelectedTask } from "../hooks/useSelectedTask"; +import { useTasksApi } from "../hooks/useTasksApi"; + +import { CreateTaskSection } from "./CreateTaskSection"; +import { NoTemplateState } from "./NoTemplateState"; +import { TaskDetailView } from "./TaskDetailView"; +import { TaskList } from "./TaskList"; + +import type { Task, TaskTemplate } from "@repo/shared"; + +import type { PersistedState } from "../hooks/usePersistedState"; + +type CollapsibleElement = React.ComponentRef; +type ScrollableElement = React.ComponentRef; + +interface TasksPanelProps { + tasks: readonly Task[]; + templates: readonly TaskTemplate[]; + persisted: { + initialCreateExpanded: boolean; + initialHistoryExpanded: boolean; + save: (state: PersistedState) => void; + }; +} + +export function TasksPanel({ tasks, templates, persisted }: TasksPanelProps) { + const { selectedTask, isLoadingDetails, selectTask, deselectTask } = + useSelectedTask(tasks); + + const [createRef, createOpen, setCreateOpen] = + useCollapsibleToggle(persisted.initialCreateExpanded); + const [historyRef, historyOpen] = useCollapsibleToggle( + persisted.initialHistoryExpanded, + ); + + const createScrollRef = useRef(null); + const historyScrollRef = useRef(null); + useScrollableHeight(createRef, createScrollRef); + useScrollableHeight(historyRef, historyScrollRef); + + const { onShowCreateForm } = useTasksApi(); + useEffect(() => { + return onShowCreateForm(() => setCreateOpen(true)); + }, [onShowCreateForm, setCreateOpen]); + + useEffect(() => { + persisted.save({ + tasks, + templates, + createExpanded: createOpen, + historyExpanded: historyOpen, + }); + }, [persisted, tasks, templates, createOpen, historyOpen]); + + function renderHistory() { + if (selectedTask) { + return ; + } + if (isLoadingDetails) { + return ( +
+ +
+ ); + } + return ; + } + + return ( +
+ + + {templates.length === 0 ? ( + + ) : ( + + )} + + + + +
+ {renderHistory()} +
+
+
+ ); +} diff --git a/packages/tasks/src/hooks/usePersistedState.ts b/packages/tasks/src/hooks/usePersistedState.ts new file mode 100644 index 00000000..0908b881 --- /dev/null +++ b/packages/tasks/src/hooks/usePersistedState.ts @@ -0,0 +1,23 @@ +import { getState, setState } from "@repo/webview-shared"; +import { useState } from "react"; + +import type { Task, TaskTemplate } from "@repo/shared"; + +export interface PersistedState { + tasks: readonly Task[] | null; + templates: readonly TaskTemplate[] | null; + createExpanded: boolean; + historyExpanded: boolean; +} + +export function usePersistedState() { + const [restored] = useState(() => getState()); + + return { + initialTasks: restored?.tasks, + initialTemplates: restored?.templates, + initialCreateExpanded: restored?.createExpanded ?? true, + initialHistoryExpanded: restored?.historyExpanded ?? true, + save: (state: PersistedState) => setState(state), + }; +} diff --git a/packages/tasks/src/hooks/useSelectedTask.ts b/packages/tasks/src/hooks/useSelectedTask.ts index 071de29a..23648253 100644 --- a/packages/tasks/src/hooks/useSelectedTask.ts +++ b/packages/tasks/src/hooks/useSelectedTask.ts @@ -5,12 +5,11 @@ import { useEffect, useState } from "react"; import { TASK_ACTIVE_INTERVAL_MS, TASK_IDLE_INTERVAL_MS, + queryKeys, } from "../utils/config"; import { useTasksApi } from "./useTasksApi"; -const QUERY_KEY = "task-details"; - export function useSelectedTask(tasks: readonly Task[]) { const api = useTasksApi(); const queryClient = useQueryClient(); @@ -27,7 +26,9 @@ export function useSelectedTask(tasks: readonly Task[]) { } const { data: selectedTask, isLoading: isLoadingDetails } = useQuery({ - queryKey: [QUERY_KEY, selectedTaskId], + queryKey: selectedTaskId + ? queryKeys.taskDetail(selectedTaskId) + : queryKeys.details, queryFn: selectedTaskId ? () => api.getTaskDetails(selectedTaskId) : skipToken, @@ -44,7 +45,7 @@ export function useSelectedTask(tasks: readonly Task[]) { return api.onTaskUpdated((updatedTask) => { if (updatedTask.id !== selectedTaskId) return; queryClient.setQueryData( - [QUERY_KEY, selectedTaskId], + queryKeys.taskDetail(selectedTaskId), (prev) => (prev ? { ...prev, task: updatedTask } : undefined), ); }); @@ -52,7 +53,7 @@ export function useSelectedTask(tasks: readonly Task[]) { const deselectTask = () => { setSelectedTaskId(null); - queryClient.removeQueries({ queryKey: [QUERY_KEY] }); + queryClient.removeQueries({ queryKey: queryKeys.details }); }; return { diff --git a/packages/tasks/src/hooks/useTasksApi.ts b/packages/tasks/src/hooks/useTasksApi.ts index 8f2e046c..04660738 100644 --- a/packages/tasks/src/hooks/useTasksApi.ts +++ b/packages/tasks/src/hooks/useTasksApi.ts @@ -22,7 +22,6 @@ export function useTasksApi() { return { // Requests - init: () => request(TasksApi.init), getTasks: () => request(TasksApi.getTasks), getTemplates: () => request(TasksApi.getTemplates), getTask: (taskId: string) => request(TasksApi.getTask, { taskId }), @@ -44,7 +43,8 @@ export function useTasksApi() { // Commands viewInCoder: (taskId: string) => command(TasksApi.viewInCoder, { taskId }), viewLogs: (taskId: string) => command(TasksApi.viewLogs, { taskId }), - closeWorkspaceLogs: () => command(TasksApi.closeWorkspaceLogs), + stopStreamingWorkspaceLogs: () => + command(TasksApi.stopStreamingWorkspaceLogs), // Notifications onTaskUpdated: (cb: (task: Task) => void) => diff --git a/packages/tasks/src/hooks/useTasksQuery.ts b/packages/tasks/src/hooks/useTasksQuery.ts index 657c74ec..09f917ae 100644 --- a/packages/tasks/src/hooks/useTasksQuery.ts +++ b/packages/tasks/src/hooks/useTasksQuery.ts @@ -1,55 +1,81 @@ -import { type InitResponse, type Task } from "@repo/shared"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; -import { TASK_LIST_POLL_INTERVAL_MS } from "../utils/config"; +import { + TASK_LIST_POLL_INTERVAL_MS, + TEMPLATE_POLL_INTERVAL_MS, + queryKeys, +} from "../utils/config"; import { useTasksApi } from "./useTasksApi"; -const QUERY_KEY = ["tasks-init"] as const; +import type { Task, TaskTemplate } from "@repo/shared"; -export function useTasksQuery(initialData?: InitResponse) { +interface UseTasksQueryOptions { + initialTasks?: readonly Task[] | null; + initialTemplates?: readonly TaskTemplate[] | null; +} + +export function useTasksQuery(options?: UseTasksQueryOptions) { const api = useTasksApi(); const queryClient = useQueryClient(); - function updateTasks(updater: (tasks: readonly Task[]) => readonly Task[]) { - queryClient.setQueryData( - QUERY_KEY, - (prev) => prev && { ...prev, tasks: updater(prev.tasks) }, - ); - } + const hasInitialData = + options?.initialTasks !== undefined || + options?.initialTemplates !== undefined; + + const [refreshing, setRefreshing] = useState(hasInitialData); - const { data, isLoading, error, refetch } = useQuery({ - queryKey: QUERY_KEY, - queryFn: () => api.init(), + const refreshAll = () => + queryClient + .invalidateQueries({ queryKey: queryKeys.all }) + .finally(() => setRefreshing(false)); + + const tasksQuery = useQuery({ + queryKey: queryKeys.tasks, + queryFn: () => api.getTasks(), refetchInterval: TASK_LIST_POLL_INTERVAL_MS, - initialData, + initialData: options?.initialTasks, }); - const tasks = data?.tasks ?? []; - const templates = data?.templates ?? []; - const tasksSupported = data?.tasksSupported ?? true; + const templatesQuery = useQuery({ + queryKey: queryKeys.templates, + queryFn: () => api.getTemplates(), + refetchInterval: TEMPLATE_POLL_INTERVAL_MS, + staleTime: TEMPLATE_POLL_INTERVAL_MS, + initialData: options?.initialTemplates, + }); - // Subscribe to push notifications useEffect(() => { const unsubs = [ - api.onTasksUpdated((updatedTasks) => { - updateTasks(() => updatedTasks); - }), - - api.onTaskUpdated((updatedTask) => { - updateTasks((tasks) => - tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)), - ); - }), - + api.onTasksUpdated((tasks) => + queryClient.setQueryData(queryKeys.tasks, () => tasks), + ), + api.onTaskUpdated((updated) => + queryClient.setQueryData( + queryKeys.tasks, + (prev) => + prev?.map((t) => (t.id === updated.id ? updated : t)) ?? prev, + ), + ), api.onRefresh(() => { - void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + setRefreshing(true); + void refreshAll(); }), ]; - return () => unsubs.forEach((fn) => fn()); - }, [api.onTasksUpdated, api.onTaskUpdated, api.onRefresh, queryClient]); + }, [api, queryClient]); + + useEffect(() => { + if (hasInitialData) void refreshAll(); + }, []); - return { tasks, templates, tasksSupported, data, isLoading, error, refetch }; + return { + tasksSupported: tasksQuery.data !== null && templatesQuery.data !== null, + tasks: tasksQuery.data ?? [], + templates: templatesQuery.data ?? [], + refreshing: refreshing || tasksQuery.isLoading || templatesQuery.isLoading, + error: tasksQuery.error, + refetch: tasksQuery.refetch, + }; } diff --git a/packages/tasks/src/hooks/useWorkspaceLogs.ts b/packages/tasks/src/hooks/useWorkspaceLogs.ts index 0638acec..f1589bd0 100644 --- a/packages/tasks/src/hooks/useWorkspaceLogs.ts +++ b/packages/tasks/src/hooks/useWorkspaceLogs.ts @@ -8,7 +8,7 @@ import { useTasksApi } from "./useTasksApi"; * when many lines arrive in quick succession. */ export function useWorkspaceLogs(): string[] { - const { onWorkspaceLogsAppend, closeWorkspaceLogs } = useTasksApi(); + const { onWorkspaceLogsAppend, stopStreamingWorkspaceLogs } = useTasksApi(); const [lines, setLines] = useState([]); useEffect(() => { @@ -30,9 +30,9 @@ export function useWorkspaceLogs(): string[] { return () => { unsubscribe(); cancelAnimationFrame(frame); - closeWorkspaceLogs(); + stopStreamingWorkspaceLogs(); }; - }, [closeWorkspaceLogs, onWorkspaceLogsAppend]); + }, [stopStreamingWorkspaceLogs, onWorkspaceLogsAppend]); return lines; } diff --git a/packages/tasks/src/index.css b/packages/tasks/src/index.css index 9f2f566a..b2f92251 100644 --- a/packages/tasks/src/index.css +++ b/packages/tasks/src/index.css @@ -20,6 +20,36 @@ body { min-height: 100px; } +/* Refresh indicator */ + +.refresh-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 1px; + overflow: hidden; + z-index: 1; +} + +.refresh-bar::after { + content: ""; + display: block; + height: 100%; + width: 30%; + background: var(--vscode-progressBar-background); + animation: refreshSlide 1.2s ease-in-out infinite; +} + +@keyframes refreshSlide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(433%); + } +} + /* Panel layout */ .tasks-panel { diff --git a/packages/tasks/src/utils/config.ts b/packages/tasks/src/utils/config.ts index a6d37f69..cc7ec2cd 100644 --- a/packages/tasks/src/utils/config.ts +++ b/packages/tasks/src/utils/config.ts @@ -1,6 +1,15 @@ export const TASK_LIST_POLL_INTERVAL_MS = 10_000; +export const TEMPLATE_POLL_INTERVAL_MS = 5 * 60 * 1_000; // 5 seconds when task is actively working export const TASK_ACTIVE_INTERVAL_MS = 5_000; // 10 seconds when task is idle/complete/paused export const TASK_IDLE_INTERVAL_MS = 10_000; + +export const queryKeys = { + all: ["tasks"], + tasks: ["tasks", "list"], + templates: ["tasks", "templates"], + details: ["tasks", "detail"], + taskDetail: (id: string) => ["tasks", "detail", id], +}; diff --git a/src/extension.ts b/src/extension.ts index ce3b5237..ac27e3ee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,7 +19,7 @@ import { Remote } from "./remote/remote"; import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; import { initVscodeProposed } from "./vscodeProposed"; -import { TasksPanel } from "./webviews/tasks/tasksPanel"; +import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; import { WorkspaceProvider, WorkspaceQuery, @@ -205,15 +205,25 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const commands = new Commands(serviceContainer, client, deploymentManager); // Register Tasks webview panel with dependencies - const tasksPanel = new TasksPanel(ctx.extensionUri, client, output); + const tasksPanelProvider = new TasksPanelProvider( + ctx.extensionUri, + client, + output, + ); ctx.subscriptions.push( - tasksPanel, - vscode.window.registerWebviewViewProvider(TasksPanel.viewType, tasksPanel), + tasksPanelProvider, + vscode.window.registerWebviewViewProvider( + TasksPanelProvider.viewType, + tasksPanelProvider, + { webviewOptions: { retainContextWhenHidden: true } }, + ), vscode.commands.registerCommand("coder.tasks.refresh", () => - tasksPanel.refresh(), + tasksPanelProvider.refresh(), ), // Refresh tasks panel when deployment changes (login/logout/switch) - secretsManager.onDidChangeCurrentDeployment(() => tasksPanel.refresh()), + secretsManager.onDidChangeCurrentDeployment(() => + tasksPanelProvider.refresh(), + ), ); ctx.subscriptions.push( diff --git a/src/webviews/tasks/tasksPanel.ts b/src/webviews/tasks/tasksPanelProvider.ts similarity index 84% rename from src/webviews/tasks/tasksPanel.ts rename to src/webviews/tasks/tasksPanelProvider.ts index b7d5f389..5e2e2f7e 100644 --- a/src/webviews/tasks/tasksPanel.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -12,7 +12,6 @@ import { requestHandler, TasksApi, type CreateTaskParams, - type InitResponse, type IpcNotification, type IpcRequest, type IpcResponse, @@ -74,7 +73,7 @@ function isIpcCommand( ); } -export class TasksPanel +export class TasksPanelProvider implements vscode.WebviewViewProvider, vscode.Disposable { public static readonly viewType = "coder.tasksPanel"; @@ -95,11 +94,6 @@ export class TasksPanel private readonly agentLogStream = new LazyStream(); private streamingTaskId: string | null = null; - // Template cache with TTL - private templatesCache: TaskTemplate[] = []; - private templatesCacheTime = 0; - private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes - // Cache logs for last viewed task in stable state private cachedLogs?: { taskId: string; @@ -114,13 +108,9 @@ export class TasksPanel string, (params: unknown) => Promise > = { - [TasksApi.init.method]: requestHandler(TasksApi.init, () => - this.handleInit(), + [TasksApi.getTasks.method]: requestHandler(TasksApi.getTasks, () => + this.fetchTasks(), ), - [TasksApi.getTasks.method]: requestHandler(TasksApi.getTasks, async () => { - const result = await this.fetchTasksWithStatus(); - return result.tasks; - }), [TasksApi.getTemplates.method]: requestHandler(TasksApi.getTemplates, () => this.fetchTemplates(), ), @@ -166,12 +156,12 @@ export class TasksPanel [TasksApi.viewLogs.method]: commandHandler(TasksApi.viewLogs, (p) => this.handleViewLogs(p.taskId), ), - [TasksApi.closeWorkspaceLogs.method]: commandHandler( - TasksApi.closeWorkspaceLogs, + [TasksApi.stopStreamingWorkspaceLogs.method]: commandHandler( + TasksApi.stopStreamingWorkspaceLogs, () => { + this.streamingTaskId = null; this.buildLogStream.close(); this.agentLogStream.close(); - this.streamingTaskId = null; }, ), }; @@ -187,7 +177,6 @@ export class TasksPanel } public refresh(): void { - this.templatesCacheTime = 0; this.cachedLogs = undefined; this.sendNotification({ type: TasksApi.refresh.method }); } @@ -261,7 +250,7 @@ export class TasksPanel success: false, error: errorMessage, }); - if (TasksPanel.USER_ACTION_METHODS.has(method)) { + if (TasksPanelProvider.USER_ACTION_METHODS.has(method)) { vscode.window.showErrorMessage(errorMessage); } } @@ -286,19 +275,6 @@ export class TasksPanel } } - private async handleInit(): Promise { - const [tasksResult, templates] = await Promise.all([ - this.fetchTasksWithStatus(), - this.fetchTemplates(), - ]); - return { - tasks: tasksResult.tasks, - templates, - baseUrl: this.client.getHost() ?? "", - tasksSupported: tasksResult.supported, - }; - } - private async handleGetTaskDetails(taskId: string): Promise { const task = await this.client.getTask("me", taskId); this.streamWorkspaceLogs(task).catch((err: unknown) => { @@ -392,13 +368,7 @@ export class TasksPanel const task = await this.client.getTask("me", taskId); if (task.status === "paused") { - if (!task.workspace_id) { - throw new Error("Task has no workspace"); - } - await this.client.startWorkspace( - task.workspace_id, - task.template_version_id, - ); + throw new Error("Resume the task before sending a message"); } try { @@ -473,9 +443,9 @@ export class TasksPanel private async streamWorkspaceLogs(task: Task): Promise { if (task.id !== this.streamingTaskId) { + this.streamingTaskId = task.id; this.buildLogStream.close(); this.agentLogStream.close(); - this.streamingTaskId = task.id; } const onOutput = (line: string) => { @@ -488,21 +458,36 @@ export class TasksPanel }); }; + const onStreamClose = () => { + if (this.streamingTaskId !== task.id) return; + this.refreshAndNotifyTask(task.id).catch((err: unknown) => { + this.logger.warn("Failed to refresh task after stream close", err); + }); + }; + 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), - ); + await this.buildLogStream.open(async () => { + const stream = await streamBuildLogs( + this.client, + onOutput, + workspace.latest_build.id, + ); + stream.addEventListener("close", onStreamClose); + return stream; + }); 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), - ); + await this.agentLogStream.open(async () => { + const stream = await streamAgentLogs(this.client, onOutput, agentId); + stream.addEventListener("close", onStreamClose); + return stream; + }); return; } @@ -510,20 +495,16 @@ export class TasksPanel this.agentLogStream.close(); } - private async fetchTasksWithStatus(): Promise<{ - tasks: readonly Task[]; - supported: boolean; - }> { + private async fetchTasks(): Promise { if (!this.client.getHost()) { - return { tasks: [], supported: true }; + return []; } try { - const tasks = await this.client.getTasks({ owner: "me" }); - return { tasks, supported: true }; + return await this.client.getTasks({ owner: "me" }); } catch (err) { if (isAxiosError(err) && err.response?.status === 404) { - return { tasks: [], supported: false }; + return null; } throw err; } @@ -531,11 +512,13 @@ export class TasksPanel private async refreshAndNotifyTasks(): Promise { try { - const tasks = await this.fetchTasksWithStatus(); - this.sendNotification({ - type: TasksApi.tasksUpdated.method, - data: tasks.tasks, - }); + const tasks = await this.fetchTasks(); + if (tasks !== null) { + this.sendNotification({ + type: TasksApi.tasksUpdated.method, + data: tasks, + }); + } } catch (err) { this.logger.warn("Failed to refresh tasks after action", err); } @@ -553,51 +536,46 @@ export class TasksPanel } } - private async fetchTemplates(): Promise { + private async fetchTemplates(): Promise { if (!this.client.getHost()) { return []; } - const now = Date.now(); - if ( - this.templatesCache.length > 0 && - now - this.templatesCacheTime < this.CACHE_TTL_MS - ) { - return this.templatesCache; - } - - const templates = await this.client.getTemplates({}); - - const result = await Promise.all( - templates.map(async (template: Template): Promise => { - let presets: Preset[] = []; - try { - presets = - (await this.client.getTemplateVersionPresets( - template.active_version_id, - )) ?? []; - } catch { - // Presets may not be available - } - - return { - id: template.id, - name: template.name, - displayName: template.display_name || template.name, - icon: template.icon, - activeVersionId: template.active_version_id, - presets: presets.map((p) => ({ - id: p.ID, - name: p.Name, - isDefault: p.Default, - })), - }; - }), - ); + try { + const templates = await this.client.getTemplates({}); + + return await Promise.all( + templates.map(async (template: Template): Promise => { + let presets: Preset[] = []; + try { + presets = + (await this.client.getTemplateVersionPresets( + template.active_version_id, + )) ?? []; + } catch { + // Presets may not be available + } - this.templatesCache = result; - this.templatesCacheTime = now; - return result; + return { + id: template.id, + name: template.name, + displayName: template.display_name || template.name, + icon: template.icon, + activeVersionId: template.active_version_id, + presets: presets.map((p) => ({ + id: p.ID, + name: p.Name, + isDefault: p.Default, + })), + }; + }), + ); + } catch (err) { + if (isAxiosError(err) && err.response?.status === 404) { + return null; + } + throw err; + } } /** diff --git a/test/unit/webviews/tasks/tasksPanel.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts similarity index 81% rename from test/unit/webviews/tasks/tasksPanel.test.ts rename to test/unit/webviews/tasks/tasksPanelProvider.test.ts index c819711f..cef5c94e 100644 --- a/test/unit/webviews/tasks/tasksPanel.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -2,7 +2,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 { TasksPanelProvider } from "@/webviews/tasks/tasksPanelProvider"; import { TasksApi, @@ -36,11 +36,18 @@ import type { CoderApi } from "@/api/coderApi"; import type { UnidirectionalStream } from "@/websocket/eventStreamConnection"; function mockStream(): UnidirectionalStream { + const listeners = new Map void>>(); return { url: "", - addEventListener: vi.fn(), + addEventListener: vi.fn((event: string, cb: () => void) => { + const list = listeners.get(event) ?? []; + list.push(cb); + listeners.set(event, list); + }), removeEventListener: vi.fn(), - close: vi.fn(), + close: vi.fn(() => { + for (const cb of listeners.get("close") ?? []) cb(); + }), } as UnidirectionalStream; } @@ -53,7 +60,7 @@ vi.mock("@/api/workspace", async (importOriginal) => { }; }); -/** Subset of CoderApi used by TasksPanel */ +/** Subset of CoderApi used by TasksPanelProvider */ type TasksPanelClient = Pick< CoderApi, | "getTasks" @@ -90,7 +97,7 @@ function createClient(baseUrl = "https://coder.example.com"): MockClient { } interface Harness { - panel: TasksPanel; + panel: TasksPanelProvider; client: MockClient; ui: MockUserInteraction; /** Send a request and wait for the response */ @@ -109,9 +116,9 @@ interface Harness { function createHarness(): Harness { const ui = new MockUserInteraction(); const client = createClient(); - const panel = new TasksPanel( + const panel = new TasksPanelProvider( vscode.Uri.file("/test/extension"), - // Cast needed: mock only implements the subset of CoderApi methods used by TasksPanel + // Cast needed: mock only implements the subset of CoderApi methods used by TasksPanelProvider client as unknown as CoderApi, createMockLogger(), ); @@ -187,67 +194,42 @@ function createHarness(): Harness { }; } -describe("TasksPanel", () => { +describe("TasksPanelProvider", () => { beforeEach(() => { // Reset shared vscode mocks between tests vi.resetAllMocks(); }); - describe("init", () => { - it("returns tasks, templates, and baseUrl when logged in", async () => { - const h = createHarness(); - h.client.getTasks.mockResolvedValue([task()]); - h.client.getTemplates.mockResolvedValue([template()]); - h.client.getTemplateVersionPresets.mockResolvedValue([preset()]); - - const res = await h.request(TasksApi.init); - - expect(res).toMatchObject({ - success: true, - data: { - tasksSupported: true, - baseUrl: "https://coder.example.com", - tasks: [{ id: "task-1" }], - templates: [{ id: "template-1", presets: [{ id: "preset-1" }] }], - }, - }); - }); - - it("returns empty when not logged in", async () => { + describe("getTasks", () => { + it("returns list of tasks", async () => { const h = createHarness(); - h.client.getHost.mockReturnValue(undefined); + h.client.getTasks.mockResolvedValue([ + task({ id: "t1" }), + task({ id: "t2" }), + ]); - const res = await h.request(TasksApi.init); + const res = await h.request(TasksApi.getTasks); expect(res.success).toBe(true); - expect(res.data).toMatchObject({ tasks: [], templates: [] }); + expect(res.data).toHaveLength(2); }); - it("returns tasksSupported=false on 404", async () => { + it("returns null on 404", async () => { const h = createHarness(); h.client.getTasks.mockRejectedValue(createAxiosError(404, "Not found")); - const res = await h.request(TasksApi.init); + const res = await h.request(TasksApi.getTasks); - expect(res).toMatchObject({ - success: true, - data: { tasksSupported: false }, - }); + expect(res).toMatchObject({ success: true, data: null }); }); - }); - describe("getTasks", () => { - it("returns list of tasks", async () => { + it("returns empty when not logged in", async () => { const h = createHarness(); - h.client.getTasks.mockResolvedValue([ - task({ id: "t1" }), - task({ id: "t2" }), - ]); + h.client.getHost.mockReturnValue(undefined); const res = await h.request(TasksApi.getTasks); - expect(res.success).toBe(true); - expect(res.data).toHaveLength(2); + expect(res).toMatchObject({ success: true, data: [] }); }); }); @@ -279,15 +261,15 @@ describe("TasksPanel", () => { ]); }); - it("caches templates", async () => { + it("returns null on 404", async () => { const h = createHarness(); - h.client.getTemplates.mockResolvedValue([template()]); - h.client.getTemplateVersionPresets.mockResolvedValue([]); + h.client.getTemplates.mockRejectedValue( + createAxiosError(404, "Not found"), + ); - await h.request(TasksApi.getTemplates); - await h.request(TasksApi.getTemplates); + const res = await h.request(TasksApi.getTemplates); - expect(h.client.getTemplates).toHaveBeenCalledTimes(1); + expect(res).toMatchObject({ success: true, data: null }); }); }); @@ -459,29 +441,11 @@ describe("TasksPanel", () => { }); describe("sendTaskMessage", () => { - interface SendTestCase { - name: string; - taskOverrides: Partial; - resumesWorkspace: boolean; - } - it.each([ - { - name: "active task with idle state", - taskOverrides: { status: "active", current_state: taskState("idle") }, - resumesWorkspace: false, - }, - { - name: "paused task (resumes first)", - taskOverrides: { - status: "paused", - workspace_id: "ws-1", - template_version_id: "tv-1", - }, - resumesWorkspace: true, - }, - ])("sends input for $name", async ({ taskOverrides, resumesWorkspace }) => { + it("sends input for active task with idle state", async () => { const h = createHarness(); - h.client.getTask.mockResolvedValue(task(taskOverrides)); + h.client.getTask.mockResolvedValue( + task({ status: "active", current_state: taskState("idle") }), + ); const res = await h.request(TasksApi.sendTaskMessage, { taskId: "task-1", @@ -494,17 +458,30 @@ describe("TasksPanel", () => { "task-1", "Hello", ); - if (resumesWorkspace) { - expect(h.client.startWorkspace).toHaveBeenCalledWith("ws-1", "tv-1"); - } else { - expect(h.client.startWorkspace).not.toHaveBeenCalled(); - } + expect(h.client.startWorkspace).not.toHaveBeenCalled(); + }); + + it("throws error for paused task", async () => { + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ status: "paused", workspace_id: "ws-1" }), + ); + + const res = await h.request(TasksApi.sendTaskMessage, { + taskId: "task-1", + message: "Hello", + }); + + expect(res.success).toBe(false); + expect(res.error).toContain("Resume the task before sending a message"); + expect(h.client.sendTaskInput).not.toHaveBeenCalled(); + expect(h.client.startWorkspace).not.toHaveBeenCalled(); }); interface SendErrorTestCase { name: string; taskOverrides: Partial; - sendError?: ReturnType; + sendError: ReturnType; expectedError: string; } it.each([ @@ -520,19 +497,12 @@ describe("TasksPanel", () => { sendError: createAxiosError(400, "Bad Request"), expectedError: "Task is not ready to receive messages", }, - { - name: "paused task with no workspace", - taskOverrides: { status: "paused", workspace_id: null }, - expectedError: "no workspace", - }, ])( "fails on $name", async ({ taskOverrides, sendError, expectedError }) => { const h = createHarness(); h.client.getTask.mockResolvedValue(task(taskOverrides)); - if (sendError) { - h.client.sendTaskInput.mockRejectedValue(sendError); - } + h.client.sendTaskInput.mockRejectedValue(sendError); const res = await h.request(TasksApi.sendTaskMessage, { taskId: "task-1", @@ -601,31 +571,19 @@ describe("TasksPanel", () => { }); describe("downloadLogs", () => { - it("saves logs to file", async () => { - const h = createHarness(); - h.client.getTaskLogs.mockResolvedValue({ logs: [logEntry()] }); - const saveUri = vscode.Uri.file("/downloads/logs.txt"); - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(saveUri); - h.ui.setResponse(`Logs saved to ${saveUri.fsPath}`, "Open File"); - - const res = await h.request(TasksApi.downloadLogs, { - taskId: "task-1", - }); - - expect(res.success).toBe(true); - expect(vscode.workspace.fs.writeFile).toHaveBeenCalledWith( - saveUri, - expect.any(Buffer), - ); - expect(vscode.window.showTextDocument).toHaveBeenCalledWith(saveUri); - }); - - it("does not open file when notification is dismissed", async () => { + interface DownloadSaveTestCase { + name: string; + response: "Open File" | undefined; + } + it.each([ + { name: "opens file when confirmed", response: "Open File" }, + { name: "does not open file when dismissed", response: undefined }, + ])("saves logs and $name", async ({ response }) => { const h = createHarness(); h.client.getTaskLogs.mockResolvedValue({ logs: [logEntry()] }); const saveUri = vscode.Uri.file("/downloads/logs.txt"); vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(saveUri); - h.ui.setResponse(`Logs saved to ${saveUri.fsPath}`, undefined); + h.ui.setResponse(`Logs saved to ${saveUri.fsPath}`, response); const res = await h.request(TasksApi.downloadLogs, { taskId: "task-1", @@ -636,7 +594,11 @@ describe("TasksPanel", () => { saveUri, expect.any(Buffer), ); - expect(vscode.window.showTextDocument).not.toHaveBeenCalled(); + if (response === "Open File") { + expect(vscode.window.showTextDocument).toHaveBeenCalledWith(saveUri); + } else { + expect(vscode.window.showTextDocument).not.toHaveBeenCalled(); + } }); it("shows warning when no logs", async () => { @@ -706,7 +668,7 @@ describe("TasksPanel", () => { const h = createHarness(); h.client.getTasks.mockRejectedValue(new Error("Network error")); - const res = await h.request(TasksApi.init); + const res = await h.request(TasksApi.getTasks); expect(res).toMatchObject({ success: false, error: "Network error" }); }); @@ -719,19 +681,6 @@ describe("TasksPanel", () => { expect(res.error).toContain("Unknown method"); }); - it("propagates template fetch errors during init", async () => { - const h = createHarness(); - h.client.getTasks.mockResolvedValue([task()]); - h.client.getTemplates.mockRejectedValue( - new Error("Template service unavailable"), - ); - - const res = await h.request(TasksApi.init); - - expect(res.success).toBe(false); - expect(res.error).toContain("Template service unavailable"); - }); - it("createTask succeeds even when refreshing the task list fails", async () => { const h = createHarness(); const newTask = task({ id: "new-task" }); @@ -809,39 +758,50 @@ describe("TasksPanel", () => { 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); + interface LogForwardingTestCase { + name: string; + openStream: (h: Harness) => Promise<{ onOutput: (line: string) => void }>; + message: string; + expectStreamCall: () => void; + } + it.each([ + { + name: "build", + openStream: openBuildStream, + message: "Building image...", + expectStreamCall: () => + expect(streamBuildLogs).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + "build-1", + ), + }, + { + name: "agent", + openStream: openAgentStream, + message: "Running startup script...", + expectStreamCall: () => + expect(streamAgentLogs).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + "agent-1", + ), + }, + ])( + "forwards $name logs to webview", + async ({ openStream, message, expectStreamCall }) => { + const h = createHarness(); + const { onOutput } = await openStream(h); - onOutput("Running startup script..."); + onOutput(message); - expect(h.messages()).toContainEqual({ - type: TasksApi.workspaceLogsAppend.method, - data: ["Running startup script..."], - }); - expect(streamAgentLogs).toHaveBeenCalledWith( - expect.anything(), - expect.any(Function), - "agent-1", - ); - }); + expect(h.messages()).toContainEqual({ + type: TasksApi.workspaceLogsAppend.method, + data: [message], + }); + expectStreamCall(); + }, + ); it("does not stream for ready task", async () => { const h = createHarness(); @@ -870,13 +830,53 @@ describe("TasksPanel", () => { expect(stream.close).toHaveBeenCalled(); }); - it("closes streams on closeWorkspaceLogs command", async () => { + it("closes streams on stopStreamingWorkspaceLogs command", async () => { const h = createHarness(); const { stream } = await openBuildStream(h); - await h.command(TasksApi.closeWorkspaceLogs); + await h.command(TasksApi.stopStreamingWorkspaceLogs); expect(stream.close).toHaveBeenCalled(); }); + + interface StreamCloseTestCase { + name: string; + openStream: ( + h: Harness, + ) => Promise<{ stream: UnidirectionalStream }>; + } + it.each([ + { name: "build", openStream: openBuildStream }, + { name: "agent", openStream: openAgentStream }, + ])("refreshes task when $name stream closes", async ({ openStream }) => { + const h = createHarness(); + const { stream } = await openStream(h); + + h.client.getTask.mockResolvedValue( + task({ id: "task-1", workspace_status: "running" }), + ); + stream.close(); + + await vi.waitFor(() => { + expect(h.messages()).toContainEqual( + expect.objectContaining({ + type: TasksApi.taskUpdated.method, + data: expect.objectContaining({ id: "task-1" }), + }), + ); + }); + }); + + it("does not refresh when stream is manually stopped", async () => { + const h = createHarness(); + const { stream } = await openBuildStream(h); + + await h.command(TasksApi.stopStreamingWorkspaceLogs); + h.client.getTask.mockClear(); + stream.close(); + + await Promise.resolve(); + expect(h.client.getTask).not.toHaveBeenCalled(); + }); }); }); diff --git a/test/webview/shared/tasks/utils.test.ts b/test/webview/shared/tasks/utils.test.ts index 5c5b916f..e3900f72 100644 --- a/test/webview/shared/tasks/utils.test.ts +++ b/test/webview/shared/tasks/utils.test.ts @@ -55,7 +55,7 @@ describe("getTaskPermissions", () => { canPause: false, pauseDisabled: false, canResume: true, - canSendMessage: true, + canSendMessage: false, }, }, { diff --git a/test/webview/tasks/TaskDetailView.test.tsx b/test/webview/tasks/TaskDetailView.test.tsx index 1b849189..98e00174 100644 --- a/test/webview/tasks/TaskDetailView.test.tsx +++ b/test/webview/tasks/TaskDetailView.test.tsx @@ -118,7 +118,7 @@ describe("TaskDetailView", () => { renderWithQuery( {}} />); expect(screen.getByRole("textbox")).toHaveAttribute( "placeholder", - "Send a message to resume the task...", + "Resume the task to send messages", ); }); diff --git a/test/webview/tasks/TaskMessageInput.test.tsx b/test/webview/tasks/TaskMessageInput.test.tsx index 53ae18f7..bdad1e44 100644 --- a/test/webview/tasks/TaskMessageInput.test.tsx +++ b/test/webview/tasks/TaskMessageInput.test.tsx @@ -12,6 +12,7 @@ import type { Task } from "@repo/shared"; const { mockApi } = vi.hoisted(() => ({ mockApi: { pauseTask: vi.fn(), + resumeTask: vi.fn(), sendTaskMessage: vi.fn(), }, })); @@ -39,7 +40,7 @@ describe("TaskMessageInput", () => { { name: "paused", overrides: { status: "paused" }, - expected: "Send a message to resume the task...", + expected: "Resume the task to send messages", }, { name: "pending", @@ -141,7 +142,10 @@ describe("TaskMessageInput", () => { }); it("keeps input enabled when canSendMessage is true", () => { - const t = task({ status: "paused" }); + const t = task({ + status: "active", + current_state: taskState("complete"), + }); renderWithQuery(); expect(getTextarea()).not.toBeDisabled(); }); @@ -168,6 +172,41 @@ describe("TaskMessageInput", () => { }); }); + it("shows enabled action button for paused task with workspace", () => { + const t = task({ + status: "paused", + workspace_id: "ws-1", + }); + const { container } = renderWithQuery(); + const icon = qs(container, "vscode-icon"); + expect(icon).not.toHaveClass("disabled"); + }); + + it("disables input for paused task", () => { + const t = task({ + status: "paused", + workspace_id: "ws-1", + }); + renderWithQuery(); + expect(getTextarea()).toBeDisabled(); + }); + + it("calls resumeTask on Ctrl+Enter when resume button is showing", async () => { + mockApi.resumeTask.mockResolvedValueOnce(undefined); + const t = task({ + status: "paused", + workspace_id: "ws-1", + }); + renderWithQuery(); + fireEvent.keyDown(getTextarea(), { key: "Enter", ctrlKey: true }); + await waitFor(() => { + expect(mockApi.resumeTask).toHaveBeenCalledWith({ + taskId: "task-1", + taskName: "Test Task", + }); + }); + }); + it("calls pauseTask on Ctrl+Enter when pause button is showing", async () => { const t = task({ status: "active", diff --git a/test/webview/tasks/useWorkspaceLogs.test.ts b/test/webview/tasks/useWorkspaceLogs.test.ts index 6b47c0c7..aa220867 100644 --- a/test/webview/tasks/useWorkspaceLogs.test.ts +++ b/test/webview/tasks/useWorkspaceLogs.test.ts @@ -63,13 +63,13 @@ describe("useWorkspaceLogs", () => { expect(h.lines).toEqual(["line 1", "line 2", "line 3"]); }); - it("sends closeWorkspaceLogs on unmount", () => { + it("sends stopStreamingWorkspaceLogs on unmount", () => { const h = renderLogs(); const sent = h.unmount(); expect(sent).toContainEqual( expect.objectContaining({ - method: TasksApi.closeWorkspaceLogs.method, + method: TasksApi.stopStreamingWorkspaceLogs.method, }), ); });