From 8396c2cb7506b1e4546349e5e974203910a31709 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 4 Feb 2026 00:25:15 +0300 Subject: [PATCH 1/5] Add Tasks panel list view with components and polling Adds the task list UI layer including: - Collapsible sections for Create Task and Task History - CreateTaskSection with template/preset selection - TaskList and TaskItem components with status indicators - ActionMenu component for task actions (pause, resume, delete) - StatusIndicator with state-based styling - ErrorState, NotSupportedState, NoTemplateState empty states - State persistence across webview visibility changes - Polling for task list updates with ref-based comparison - Push message handling for real-time updates Utilities: - taskArraysEqual/templateArraysEqual for efficient diffing - getDisplayName/getLoadingLabel helper functions --- packages/tasks/src/App.tsx | 208 ++++++- packages/tasks/src/components/ActionMenu.tsx | 106 ++++ .../src/components/CollapsibleSection.tsx | 30 + .../src/components/CreateTaskSection.tsx | 141 +++++ packages/tasks/src/components/ErrorState.tsx | 16 + .../tasks/src/components/NoTemplateState.tsx | 19 + .../src/components/NotSupportedState.tsx | 23 + .../tasks/src/components/StatusIndicator.tsx | 76 +++ packages/tasks/src/components/TaskItem.tsx | 75 +++ packages/tasks/src/components/TaskList.tsx | 22 + packages/tasks/src/components/index.ts | 11 + .../tasks/src/components/useTaskMenuItems.ts | 124 ++++ packages/tasks/src/components/utils.ts | 16 + packages/tasks/src/config.ts | 4 + packages/tasks/src/index.css | 550 ++++++++++++++++++ packages/tasks/src/utils/compare.ts | 88 +++ packages/tasks/src/utils/index.ts | 1 + test/webview/setup.ts | 24 + test/webview/tasks/components.test.tsx | 183 ++++++ vitest.config.ts | 1 + 20 files changed, 1694 insertions(+), 24 deletions(-) create mode 100644 packages/tasks/src/components/ActionMenu.tsx create mode 100644 packages/tasks/src/components/CollapsibleSection.tsx create mode 100644 packages/tasks/src/components/CreateTaskSection.tsx create mode 100644 packages/tasks/src/components/ErrorState.tsx create mode 100644 packages/tasks/src/components/NoTemplateState.tsx create mode 100644 packages/tasks/src/components/NotSupportedState.tsx create mode 100644 packages/tasks/src/components/StatusIndicator.tsx create mode 100644 packages/tasks/src/components/TaskItem.tsx create mode 100644 packages/tasks/src/components/TaskList.tsx create mode 100644 packages/tasks/src/components/index.ts create mode 100644 packages/tasks/src/components/useTaskMenuItems.ts create mode 100644 packages/tasks/src/components/utils.ts create mode 100644 packages/tasks/src/config.ts create mode 100644 packages/tasks/src/utils/compare.ts create mode 100644 packages/tasks/src/utils/index.ts create mode 100644 test/webview/setup.ts create mode 100644 test/webview/tasks/components.test.tsx diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index 2de812c6..070b993a 100644 --- a/packages/tasks/src/App.tsx +++ b/packages/tasks/src/App.tsx @@ -1,46 +1,206 @@ -import { useQuery } from "@tanstack/react-query"; -import { - VscodeButton, - VscodeIcon, - VscodeProgressRing, -} from "@vscode-elements/react-elements"; +import { getState, setState } from "@repo/webview-shared"; +import { useMessage } from "@repo/webview-shared/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { VscodeProgressRing } from "@vscode-elements/react-elements"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + CollapsibleSection, + CreateTaskSection, + ErrorState, + NoTemplateState, + NotSupportedState, + TaskList, +} from "./components"; +import { POLLING_CONFIG } from "./config"; import { useTasksApi } from "./hooks/useTasksApi"; +import { taskArraysEqual, templateArraysEqual } from "./utils"; + +import type { IpcNotification, Task, TaskTemplate } from "@repo/shared"; + +interface PersistedState { + tasks: Task[]; + templates: TaskTemplate[]; + createExpanded: boolean; + historyExpanded: boolean; + tasksSupported: boolean; +} export default function App() { const api = useTasksApi(); + const queryClient = useQueryClient(); + + const persistedState = useRef(getState()); + const restored = persistedState.current; + + const [createExpanded, setCreateExpanded] = useState( + restored?.createExpanded ?? true, + ); + const [historyExpanded, setHistoryExpanded] = useState( + restored?.historyExpanded ?? true, + ); const { data, isLoading, error, refetch } = useQuery({ queryKey: ["tasks-init"], queryFn: () => api.init(), + initialData: restored?.tasks?.length + ? { + tasks: restored.tasks, + templates: restored.templates, + tasksSupported: restored.tasksSupported, + baseUrl: "", + } + : undefined, }); - if (isLoading) { - return ; - } + const tasks = useMemo(() => [...(data?.tasks ?? [])], [data?.tasks]); + const templates = useMemo( + () => [...(data?.templates ?? [])], + [data?.templates], + ); + const tasksSupported = data?.tasksSupported ?? true; + + useEffect(() => { + setState({ + tasks, + templates, + createExpanded, + historyExpanded, + tasksSupported, + }); + }, [tasks, templates, createExpanded, historyExpanded, tasksSupported]); + + const tasksRef = useRef(tasks); + tasksRef.current = tasks; + + const templatesRef = useRef(templates); + templatesRef.current = templates; + + // Poll for task list updates + useEffect(() => { + if (!data) return; + + let cancelled = false; + const pollInterval = setInterval(() => { + api + .getTasks() + .then((updatedTasks) => { + if (cancelled) return; + if (!taskArraysEqual(tasksRef.current, updatedTasks)) { + queryClient.setQueryData(["tasks-init"], (prev: typeof data) => + prev ? { ...prev, tasks: updatedTasks } : prev, + ); + } + }) + .catch(() => undefined); + }, POLLING_CONFIG.TASK_LIST_INTERVAL_MS); - if (error) { - return

Error: {error.message}

; + return () => { + cancelled = true; + clearInterval(pollInterval); + }; + }, [api, data, queryClient]); + + useMessage((msg) => { + switch (msg.type) { + case "tasksUpdated": + queryClient.setQueryData(["tasks-init"], (prev: typeof data) => + prev ? { ...prev, tasks: msg.data as Task[] } : prev, + ); + break; + + case "taskUpdated": { + const updatedTask = msg.data as Task; + queryClient.setQueryData(["tasks-init"], (prev: typeof data) => + prev + ? { + ...prev, + tasks: prev.tasks.map((t) => + t.id === updatedTask.id ? updatedTask : t, + ), + } + : prev, + ); + break; + } + + case "refresh": { + api + .getTasks() + .then((updatedTasks) => { + if (!taskArraysEqual(tasksRef.current, updatedTasks)) { + queryClient.setQueryData(["tasks-init"], (prev: typeof data) => + prev ? { ...prev, tasks: updatedTasks } : prev, + ); + } + }) + .catch(() => undefined); + api + .getTemplates() + .then((updatedTemplates) => { + if (!templateArraysEqual(templatesRef.current, updatedTemplates)) { + queryClient.setQueryData(["tasks-init"], (prev: typeof data) => + prev ? { ...prev, templates: updatedTemplates } : prev, + ); + } + }) + .catch(() => undefined); + break; + } + + case "showCreateForm": + setCreateExpanded(true); + break; + + case "logsAppend": + // Task detail view will handle this in next PR + break; + } + }); + + const handleSelectTask = useCallback((_taskId: string) => { + // Task detail view will be added in next PR + }, []); + + if (isLoading) { + return ( +
+ +
+ ); } - if (!data?.tasksSupported) { + if (error && tasks.length === 0) { return ( -

- Tasks not supported -

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

- Connected to {data.baseUrl} -

-

Templates: {data.templates.length}

-

Tasks: {data.tasks.length}

- void refetch()}> - Refresh - +
+ setCreateExpanded(!createExpanded)} + > + + + + setHistoryExpanded(!historyExpanded)} + > + +
); } diff --git a/packages/tasks/src/components/ActionMenu.tsx b/packages/tasks/src/components/ActionMenu.tsx new file mode 100644 index 00000000..3dd790e4 --- /dev/null +++ b/packages/tasks/src/components/ActionMenu.tsx @@ -0,0 +1,106 @@ +import { + VscodeIcon, + VscodeProgressRing, +} from "@vscode-elements/react-elements"; +import { useState, useRef, useEffect, useCallback } from "react"; + +export interface ActionMenuItem { + label: string; + icon?: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; + loading?: boolean; +} + +interface ActionMenuProps { + items: ActionMenuItem[]; +} + +export function ActionMenu({ items }: ActionMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const menuRef = useRef(null); + const buttonRef = useRef(null); + + const updatePosition = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom + 4, + left: rect.right - 150, // Align right edge of menu with button + }); + } + }, []); + + useEffect(() => { + if (!isOpen) return undefined; + + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + function handleScroll() { + setIsOpen(false); + } + + document.addEventListener("mousedown", handleClickOutside); + window.addEventListener("scroll", handleScroll, true); + updatePosition(); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + window.removeEventListener("scroll", handleScroll, true); + }; + }, [isOpen, updatePosition]); + + const handleToggle = () => { + if (!isOpen) { + updatePosition(); + } + setIsOpen(!isOpen); + }; + + return ( +
+
+ +
+ {isOpen && ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/tasks/src/components/CollapsibleSection.tsx b/packages/tasks/src/components/CollapsibleSection.tsx new file mode 100644 index 00000000..18c3930b --- /dev/null +++ b/packages/tasks/src/components/CollapsibleSection.tsx @@ -0,0 +1,30 @@ +import { VscodeIcon } from "@vscode-elements/react-elements"; + +interface CollapsibleSectionProps { + title: string; + expanded: boolean; + onToggle: () => void; + children: React.ReactNode; +} + +export function CollapsibleSection({ + title, + expanded, + onToggle, + children, +}: CollapsibleSectionProps) { + return ( +
+ + {expanded &&
{children}
} +
+ ); +} diff --git a/packages/tasks/src/components/CreateTaskSection.tsx b/packages/tasks/src/components/CreateTaskSection.tsx new file mode 100644 index 00000000..36b0553b --- /dev/null +++ b/packages/tasks/src/components/CreateTaskSection.tsx @@ -0,0 +1,141 @@ +import { + VscodeIcon, + VscodeOption, + VscodeProgressRing, + VscodeSingleSelect, +} from "@vscode-elements/react-elements"; +import { useEffect, useState } from "react"; + +import { useTasksApi } from "../hooks/useTasksApi"; + +import type { TaskTemplate } from "@repo/shared"; + +interface CreateTaskSectionProps { + templates: 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 [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const selectedTemplate = templates.find((t) => t.id === templateId); + const presets = selectedTemplate?.presets ?? []; + + // Sync templateId when templates prop changes + useEffect(() => { + if (templates.length > 0 && !templates.find((t) => t.id === templateId)) { + setTemplateId(templates[0].id); + setPresetId(""); + } + }, [templates, templateId]); + + const handleTemplateChange = (e: Event) => { + const target = e.target as HTMLSelectElement; + const newTemplateId = target.value; + setTemplateId(newTemplateId); + setPresetId(""); + }; + + const handlePresetChange = (e: Event) => { + const target = e.target as HTMLSelectElement; + setPresetId(target.value); + }; + + const handleSubmit = async () => { + if (!prompt.trim() || !selectedTemplate || isSubmitting) return; + + setIsSubmitting(true); + setError(null); + try { + await api.createTask({ + templateVersionId: selectedTemplate.activeVersionId, + prompt: prompt.trim(), + presetId: presetId || undefined, + }); + setPrompt(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create task"); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !isSubmitting) { + e.preventDefault(); + void handleSubmit(); + } + }; + + const canSubmit = + prompt.trim().length > 0 && selectedTemplate && !isSubmitting; + + return ( +
+
+