diff --git a/CLAUDE.md b/CLAUDE.md index 71de7050..33b99155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,6 @@ Comments explain what code does or why it exists: - All unit tests: `pnpm test` - Extension tests: `pnpm test:extension` - Webview tests: `pnpm test:webview` -- CI mode: `pnpm test:ci` - Integration tests: `pnpm test:integration` - Run specific extension test: `pnpm test:extension ./test/unit/filename.test.ts` - Run specific webview test: `pnpm test:webview ./test/webview/filename.test.ts` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fad6e01..7d6fabd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,8 +128,7 @@ The project uses Vitest with separate test configurations for extension and webv ```bash pnpm test:extension # Extension tests (runs in Electron with mocked VS Code APIs) pnpm test:webview # Webview tests (runs in jsdom) -pnpm test # Both extension and webview tests -pnpm test:ci # CI mode (same as test with CI=true) +pnpm test # Both extension and webview tests (CI mode) ``` Test files are organized by type: diff --git a/eslint.config.mjs b/eslint.config.mjs index a47387b6..87839fc7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,7 +8,6 @@ import { createTypeScriptImportResolver } from "eslint-import-resolver-typescrip import { flatConfigs as importXFlatConfigs } from "eslint-plugin-import-x"; import packageJson from "eslint-plugin-package-json"; import reactPlugin from "eslint-plugin-react"; -import reactCompilerPlugin from "eslint-plugin-react-compiler"; import reactHooksPlugin from "eslint-plugin-react-hooks"; import globals from "globals"; @@ -177,13 +176,22 @@ export default defineConfig( }, }, - // TSX files - React rules + // React hooks and compiler rules (covers .ts hook files too) + { + files: ["packages/**/*.{ts,tsx}"], + ...reactHooksPlugin.configs.flat.recommended, + rules: { + ...reactHooksPlugin.configs.flat.recommended.rules, + // React Compiler auto-memoizes; exhaustive-deps false-positives on useCallback + "react-hooks/exhaustive-deps": "off", + }, + }, + + // TSX files - React JSX rules { files: ["**/*.tsx"], plugins: { react: reactPlugin, - "react-compiler": reactCompilerPlugin, - "react-hooks": reactHooksPlugin, }, settings: { react: { @@ -191,10 +199,8 @@ export default defineConfig( }, }, rules: { - ...reactCompilerPlugin.configs.recommended.rules, ...reactPlugin.configs.recommended.rules, ...reactPlugin.configs["jsx-runtime"].rules, // React 17+ JSX transform - ...reactHooksPlugin.configs.recommended.rules, "react/prop-types": "off", // Using TypeScript }, }, diff --git a/package.json b/package.json index 6f8d0764..5a15b205 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "package": "vsce package --no-dependencies", "package:prerelease": "vsce package --pre-release --no-dependencies", "test": "CI=true pnpm test:extension && CI=true pnpm test:webview", - "test:ci": "pnpm test", "test:extension": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension", "test:integration": "tsc -p test --outDir out --noCheck && node esbuild.mjs && vscode-test", "test:webview": "vitest --project webview", @@ -496,6 +495,8 @@ "devDependencies": { "@eslint/js": "^9.39.2", "@eslint/markdown": "^7.5.1", + "@tanstack/react-query": "catalog:", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@tsconfig/node20": "^20.1.9", "@types/mocha": "^10.0.10", @@ -527,7 +528,6 @@ "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-package-json": "^0.88.2", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-compiler": "catalog:", "eslint-plugin-react-hooks": "^7.0.1", "globals": "^17.3.0", "jsdom": "^28.0.0", @@ -545,7 +545,7 @@ "extensionPack": [ "ms-vscode-remote.remote-ssh" ], - "packageManager": "pnpm@10.28.2", + "packageManager": "pnpm@10.29.2", "engines": { "vscode": "^1.95.0", "node": ">= 20" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 980fec78..2da0281c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ // IPC protocol types export * from "./ipc/protocol"; -// Tasks types and API +// Tasks types, utilities, and API export * from "./tasks/types"; +export * from "./tasks/utils"; export * from "./tasks/api"; diff --git a/packages/shared/src/tasks/api.ts b/packages/shared/src/tasks/api.ts index 26b4f611..80ec0c26 100644 --- a/packages/shared/src/tasks/api.ts +++ b/packages/shared/src/tasks/api.ts @@ -39,13 +39,17 @@ export interface CreateTaskParams { } const createTask = defineRequest("createTask"); -const deleteTask = defineRequest<{ taskId: string }, void>("deleteTask"); -const pauseTask = defineRequest<{ taskId: string }, void>("pauseTask"); -const resumeTask = defineRequest<{ taskId: string }, void>("resumeTask"); +export interface TaskActionParams { + taskId: string; + taskName: string; +} +const deleteTask = defineRequest("deleteTask"); +const pauseTask = defineRequest("pauseTask"); +const resumeTask = defineRequest("resumeTask"); +const downloadLogs = defineRequest<{ taskId: string }, void>("downloadLogs"); const viewInCoder = defineCommand<{ taskId: string }>("viewInCoder"); const viewLogs = defineCommand<{ taskId: string }>("viewLogs"); -const downloadLogs = defineCommand<{ taskId: string }>("downloadLogs"); const sendTaskMessage = defineCommand<{ taskId: string; message: string; @@ -68,10 +72,10 @@ export const TasksApi = { deleteTask, pauseTask, resumeTask, + downloadLogs, // Commands viewInCoder, viewLogs, - downloadLogs, sendTaskMessage, // Notifications taskUpdated, diff --git a/packages/shared/src/tasks/types.ts b/packages/shared/src/tasks/types.ts index 7f81a263..95654b5f 100644 --- a/packages/shared/src/tasks/types.ts +++ b/packages/shared/src/tasks/types.ts @@ -35,57 +35,14 @@ export type LogsStatus = "ok" | "not_available" | "error"; /** * Full details for a selected task, including logs and action availability. */ -export interface TaskDetails extends TaskActions { +export interface TaskDetails extends TaskPermissions { task: Task; logs: TaskLogEntry[]; logsStatus: LogsStatus; } -export interface TaskActions { +export interface TaskPermissions { canPause: boolean; pauseDisabled: boolean; canResume: boolean; } - -const PAUSABLE_STATUSES: readonly TaskStatus[] = [ - "active", - "initializing", - "pending", - "error", - "unknown", -]; - -const PAUSE_DISABLED_STATUSES: readonly TaskStatus[] = [ - "pending", - "initializing", -]; - -const RESUMABLE_STATUSES: readonly TaskStatus[] = [ - "paused", - "error", - "unknown", -]; - -export function getTaskActions(task: Task): TaskActions { - const hasWorkspace = task.workspace_id !== null; - const status = task.status; - return { - canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status), - pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status), - canResume: hasWorkspace && RESUMABLE_STATUSES.includes(status), - }; -} - -/** - * Task statuses where logs won't change (stable/terminal states). - * "complete" is a TaskState (sub-state of active), checked separately. - */ -const STABLE_STATUSES: readonly TaskStatus[] = ["error", "paused"]; - -/** Whether a task is in a stable state where its logs won't change. */ -export function isStableTask(task: Task): boolean { - return ( - STABLE_STATUSES.includes(task.status) || - (task.current_state !== null && task.current_state.state !== "working") - ); -} diff --git a/packages/shared/src/tasks/utils.ts b/packages/shared/src/tasks/utils.ts new file mode 100644 index 00000000..16f42098 --- /dev/null +++ b/packages/shared/src/tasks/utils.ts @@ -0,0 +1,48 @@ +import type { Task, TaskPermissions, TaskStatus } from "./types"; + +export function getTaskLabel(task: Task): string { + return task.display_name || task.name || task.id; +} + +const PAUSABLE_STATUSES: readonly TaskStatus[] = [ + "active", + "initializing", + "pending", + "error", + "unknown", +]; + +const PAUSE_DISABLED_STATUSES: readonly TaskStatus[] = [ + "pending", + "initializing", +]; + +const RESUMABLE_STATUSES: readonly TaskStatus[] = [ + "paused", + "error", + "unknown", +]; + +export function getTaskPermissions(task: Task): TaskPermissions { + const hasWorkspace = task.workspace_id !== null; + const status = task.status; + return { + canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status), + pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status), + canResume: hasWorkspace && RESUMABLE_STATUSES.includes(status), + }; +} + +/** + * Task statuses where logs won't change (stable/terminal states). + * "complete" is a TaskState (sub-state of active), checked separately. + */ +const STABLE_STATUSES: readonly TaskStatus[] = ["error", "paused"]; + +/** Whether a task is in a stable state where its logs won't change. */ +export function isStableTask(task: Task): boolean { + return ( + STABLE_STATUSES.includes(task.status) || + (task.current_state !== null && task.current_state.state !== "working") + ); +} diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index 2de812c6..b507dc5f 100644 --- a/packages/tasks/src/App.tsx +++ b/packages/tasks/src/App.tsx @@ -1,46 +1,109 @@ -import { useQuery } from "@tanstack/react-query"; +import { TasksApi, type InitResponse } from "@repo/shared"; +import { getState, setState } from "@repo/webview-shared"; +import { useIpc } from "@repo/webview-shared/react"; import { - VscodeButton, - VscodeIcon, + VscodeCollapsible, VscodeProgressRing, + VscodeScrollable, } from "@vscode-elements/react-elements"; +import { useEffect, useRef, useState } from "react"; -import { useTasksApi } from "./hooks/useTasksApi"; +import { CreateTaskSection } from "./components/CreateTaskSection"; +import { ErrorState } from "./components/ErrorState"; +import { NoTemplateState } from "./components/NoTemplateState"; +import { NotSupportedState } from "./components/NotSupportedState"; +import { TaskList } from "./components/TaskList"; +import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle"; +import { useScrollableHeight } from "./hooks/useScrollableHeight"; +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 api = useTasksApi(); + const [restored] = useState(() => getState()); + const { tasks, templates, tasksSupported, data, isLoading, error, refetch } = + useTasksQuery(restored); + + const [createRef, createOpen, setCreateOpen] = + useCollapsibleToggle(restored?.createExpanded ?? true); + const [historyRef, historyOpen] = useCollapsibleToggle( + restored?.historyExpanded ?? true, + ); - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["tasks-init"], - queryFn: () => api.init(), - }); + const createScrollRef = useRef(null); + const historyScrollRef = useRef(null); + useScrollableHeight(createRef, createScrollRef); + useScrollableHeight(historyRef, historyScrollRef); - if (isLoading) { - return ; - } + const { onNotification } = useIpc(); + useEffect(() => { + return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true)); + }, [onNotification, setCreateOpen]); + + useEffect(() => { + if (data) { + setState({ + ...data, + createExpanded: createOpen, + historyExpanded: historyOpen, + }); + } + }, [data, createOpen, historyOpen]); - if (error) { - return

Error: {error.message}

; + if (isLoading) { + return ( +
+ +
+ ); } - if (!data?.tasksSupported) { + if (error && tasks.length === 0) { return ( -

- Tasks not supported -

+ void refetch()} /> ); } + if (!tasksSupported) { + return ; + } + + if (templates.length === 0) { + return ; + } + return ( -
-

- Connected to {data.baseUrl} -

-

Templates: {data.templates.length}

-

Tasks: {data.tasks.length}

- void refetch()}> - Refresh - +
+ + + + + + + + + { + // Task detail view will be added in next PR + }} + /> + +
); } diff --git a/packages/tasks/src/components/ActionMenu.tsx b/packages/tasks/src/components/ActionMenu.tsx new file mode 100644 index 00000000..33c7c378 --- /dev/null +++ b/packages/tasks/src/components/ActionMenu.tsx @@ -0,0 +1,114 @@ +import { + VscodeIcon, + VscodeProgressRing, +} from "@vscode-elements/react-elements"; +import { useState, useRef, useEffect } from "react"; + +import { isEscape } from "../utils/keys"; + +interface ActionMenuAction { + label: string; + icon: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; + loading?: boolean; +} + +export type ActionMenuItem = + | { separator: true } + | ({ separator?: false } & ActionMenuAction); + +interface ActionMenuProps { + items: ActionMenuItem[]; +} + +/* + * VscodeContextMenu is data-driven with { label, value, separator }[] and lacks + * support for icons, per-item danger styling, loading spinners, and disabled states. + */ +export function ActionMenu({ items }: ActionMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + const close = () => setIsOpen(false); + + useEffect(() => { + if (!isOpen) return; + + function onMouseDown(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + close(); + } + } + + document.addEventListener("mousedown", onMouseDown); + window.addEventListener("scroll", close, true); + + return () => { + document.removeEventListener("mousedown", onMouseDown); + window.removeEventListener("scroll", close, true); + }; + }, [isOpen]); + + return ( +
+ setIsOpen((prev) => !prev)} + /> + {isOpen && ( +
{ + if (!node || !menuRef.current) { + return; + } + const rect = menuRef.current.getBoundingClientRect(); + node.style.top = `${rect.bottom + 4}px`; + node.style.right = `${window.innerWidth - rect.right}px`; + node.focus({ preventScroll: true }); + }} + className="action-menu-dropdown" + tabIndex={-1} + onKeyDown={(e) => isEscape(e) && close()} + > + {items.map((item, index) => + item.separator ? ( +
+ ) : ( + + ), + )} +
+ )} +
+ ); +} diff --git a/packages/tasks/src/components/CreateTaskSection.tsx b/packages/tasks/src/components/CreateTaskSection.tsx new file mode 100644 index 00000000..a7e8b061 --- /dev/null +++ b/packages/tasks/src/components/CreateTaskSection.tsx @@ -0,0 +1,98 @@ +import { logger } from "@repo/webview-shared/logger"; +import { useMutation } from "@tanstack/react-query"; +import { + VscodeOption, + VscodeSingleSelect, +} from "@vscode-elements/react-elements"; +import { useState } from "react"; + +import { useTasksApi } from "../hooks/useTasksApi"; + +import { PromptInput } from "./PromptInput"; + +import type { CreateTaskParams, TaskTemplate } from "@repo/shared"; + +interface CreateTaskSectionProps { + templates: readonly TaskTemplate[]; +} + +export function CreateTaskSection({ templates }: CreateTaskSectionProps) { + const api = useTasksApi(); + const [prompt, setPrompt] = useState(""); + const [templateId, setTemplateId] = useState(templates[0]?.id || ""); + const [presetId, setPresetId] = useState(""); + + const { mutate, isPending, error } = useMutation({ + mutationFn: (params: CreateTaskParams) => api.createTask(params), + onSuccess: () => setPrompt(""), + }); + + const selectedTemplate = templates.find((t) => t.id === templateId); + const presets = selectedTemplate?.presets ?? []; + const canSubmit = prompt.trim().length > 0 && selectedTemplate && !isPending; + + const handleSubmit = () => { + if (!canSubmit) { + logger.warn("handleSubmit called while submission is disabled"); + return; + } + mutate({ + templateVersionId: selectedTemplate.activeVersionId, + prompt: prompt.trim(), + presetId: presetId || undefined, + }); + }; + + return ( +
+ + {error &&
{error.message}
} +
+
+ Template: + { + setTemplateId((e.target as HTMLSelectElement).value); + setPresetId(""); + }} + disabled={isPending} + > + {templates.map((template) => ( + + {template.displayName} + + ))} + +
+ {presets.length > 0 && ( +
+ Preset: + + setPresetId((e.target as HTMLSelectElement).value) + } + disabled={isPending} + > + No preset + {presets.map((preset) => ( + + {preset.name} + {preset.isDefault ? " (Default)" : ""} + + ))} + +
+ )} +
+
+ ); +} diff --git a/packages/tasks/src/components/ErrorState.tsx b/packages/tasks/src/components/ErrorState.tsx new file mode 100644 index 00000000..f990f4b8 --- /dev/null +++ b/packages/tasks/src/components/ErrorState.tsx @@ -0,0 +1,19 @@ +import { VscodeButton, VscodeIcon } from "@vscode-elements/react-elements"; + +import { StatePanel } from "./StatePanel"; + +interface ErrorStateProps { + message: string; + onRetry: () => void; +} + +export function ErrorState({ message, onRetry }: ErrorStateProps) { + return ( + } + description={message} + action={Retry} + /> + ); +} diff --git a/packages/tasks/src/components/NoTemplateState.tsx b/packages/tasks/src/components/NoTemplateState.tsx new file mode 100644 index 00000000..61d0f6de --- /dev/null +++ b/packages/tasks/src/components/NoTemplateState.tsx @@ -0,0 +1,18 @@ +import { VscodeIcon } from "@vscode-elements/react-elements"; + +import { StatePanel } from "./StatePanel"; + +const DOCS_URL = "https://coder.com/docs/admin/templates"; + +export function NoTemplateState() { + return ( + + Learn how to create a template + + } + /> + ); +} diff --git a/packages/tasks/src/components/NotSupportedState.tsx b/packages/tasks/src/components/NotSupportedState.tsx new file mode 100644 index 00000000..d32d9191 --- /dev/null +++ b/packages/tasks/src/components/NotSupportedState.tsx @@ -0,0 +1,20 @@ +import { VscodeIcon } from "@vscode-elements/react-elements"; + +import { StatePanel } from "./StatePanel"; + +const DOCS_URL = "https://coder.com/docs/tasks"; + +export function NotSupportedState() { + return ( + } + title="Tasks not available" + description="This Coder server does not support tasks." + action={ + + Learn more + + } + /> + ); +} diff --git a/packages/tasks/src/components/PromptInput.tsx b/packages/tasks/src/components/PromptInput.tsx new file mode 100644 index 00000000..b454d17d --- /dev/null +++ b/packages/tasks/src/components/PromptInput.tsx @@ -0,0 +1,59 @@ +import { + VscodeIcon, + VscodeProgressRing, +} from "@vscode-elements/react-elements"; + +import { isSubmit } from "../utils/keys"; + +interface PromptInputProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + disabled?: boolean; + loading?: boolean; + placeholder?: string; +} + +export function PromptInput({ + value, + onChange, + onSubmit, + disabled = false, + loading = false, + placeholder = "Prompt your AI agent to start a task...", +}: PromptInputProps) { + const canSubmit = value.trim().length > 0 && !disabled && !loading; + + return ( +
+