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..014811d9 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,7 @@ "devDependencies": { "@eslint/js": "^9.39.2", "@eslint/markdown": "^7.5.1", + "@tanstack/react-query": "catalog:", "@testing-library/react": "^16.3.2", "@tsconfig/node20": "^20.1.9", "@types/mocha": "^10.0.10", @@ -527,8 +527,7 @@ "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", + "eslint-plugin-react-hooks": "catalog:", "globals": "^17.3.0", "jsdom": "^28.0.0", "jsonc-eslint-parser": "^2.4.2", diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index 2de812c6..f0f21690 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 } from "@repo/shared"; +import { useIpc } from "@repo/webview-shared/react"; import { - VscodeButton, - VscodeIcon, + VscodeCollapsible, VscodeProgressRing, + VscodeScrollable, } from "@vscode-elements/react-elements"; +import { useEffect, useRef } from "react"; -import { useTasksApi } from "./hooks/useTasksApi"; +import { + CreateTaskSection, + ErrorState, + NoTemplateState, + NotSupportedState, + TaskList, +} from "./components"; +import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle"; +import { useScrollableHeight } from "./hooks/useScrollableHeight"; +import { useTasksData } from "./hooks/useTasksData"; + +type CollapsibleElement = React.ComponentRef; +type ScrollableElement = React.ComponentRef; export default function App() { - const api = useTasksApi(); + const { + tasks, + templates, + tasksSupported, + isLoading, + error, + refetch, + initialCreateExpanded, + initialHistoryExpanded, + persistUiState, + } = useTasksData(); - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["tasks-init"], - queryFn: () => api.init(), - }); + const [createRef, createOpen, setCreateOpen] = + useCollapsibleToggle(initialCreateExpanded); + const [historyRef, historyOpen, _setHistoryOpen] = + useCollapsibleToggle(initialHistoryExpanded); - if (isLoading) { - return ; - } + const createScrollRef = useRef(null); + const historyScrollRef = useRef(null); + useScrollableHeight(createRef, createScrollRef); + useScrollableHeight(historyRef, historyScrollRef); - if (error) { - return

Error: {error.message}

; + const { onNotification } = useIpc(); + useEffect(() => { + return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true)); + }, [onNotification, setCreateOpen]); + + useEffect(() => { + persistUiState({ + createExpanded: createOpen, + historyExpanded: historyOpen, + }); + }, [createOpen, historyOpen, persistUiState]); + + 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..548e0d25 --- /dev/null +++ b/packages/tasks/src/components/ActionMenu.tsx @@ -0,0 +1,139 @@ +// VscodeContextMenu is data-driven with { label, value, separator }[] and lacks +// support for icons, per-item danger styling, loading spinners, and disabled states. +import { + VscodeIcon, + VscodeProgressRing, +} from "@vscode-elements/react-elements"; +import { useState, useRef, useEffect, useCallback } from "react"; + +interface ActionMenuAction { + separator?: false; + label: string; + icon: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; + loading?: boolean; +} + +interface ActionMenuSeparator { + separator: true; +} + +export type ActionMenuItem = ActionMenuAction | ActionMenuSeparator; + +interface ActionMenuProps { + items: ActionMenuItem[]; +} + +export function ActionMenu({ items }: ActionMenuProps) { + const [position, setPosition] = useState<{ + top: number; + right: number; + } | null>(null); + const menuRef = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + function toggle() { + setPosition((prev) => { + if (prev) { + return null; + } + const rect = buttonRef.current?.getBoundingClientRect(); + if (!rect) { + return null; + } + return { top: rect.bottom, right: window.innerWidth - rect.right }; + }); + } + + const isOpen = position !== null; + + const dropdownRefCallback = useCallback((node: HTMLDivElement | null) => { + dropdownRef.current = node; + node?.focus(); + }, []); + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape") { + setPosition(null); + } + } + + useEffect(() => { + if (!isOpen) return; + + const close = () => setPosition(null); + + 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 ( +
+
+ +
+ {position && ( +
+ {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..7e9c1f87 --- /dev/null +++ b/packages/tasks/src/components/CreateTaskSection.tsx @@ -0,0 +1,119 @@ +import { useMutation } from "@tanstack/react-query"; +import { + VscodeIcon, + VscodeOption, + VscodeProgressRing, + VscodeSingleSelect, +} from "@vscode-elements/react-elements"; +import { useState } from "react"; + +import { useTasksApi } from "../hooks/useTasksApi"; + +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: (vars: CreateTaskParams) => api.createTask(vars), + 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) { + mutate({ + templateVersionId: selectedTemplate.activeVersionId, + prompt: prompt.trim(), + presetId: presetId || undefined, + }); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+
+