Skip to content

Commit c3372ac

Browse files
committed
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
1 parent c951db0 commit c3372ac

20 files changed

+1695
-24
lines changed

packages/tasks/src/App.tsx

Lines changed: 184 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,206 @@
1-
import { useQuery } from "@tanstack/react-query";
2-
import {
3-
VscodeButton,
4-
VscodeIcon,
5-
VscodeProgressRing,
6-
} from "@vscode-elements/react-elements";
1+
import { getState, setState } from "@repo/webview-shared";
2+
import { useMessage } from "@repo/webview-shared/react";
3+
import { useQuery, useQueryClient } from "@tanstack/react-query";
4+
import { VscodeProgressRing } from "@vscode-elements/react-elements";
5+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
76

7+
import {
8+
CollapsibleSection,
9+
CreateTaskSection,
10+
ErrorState,
11+
NoTemplateState,
12+
NotSupportedState,
13+
TaskList,
14+
} from "./components";
15+
import { POLLING_CONFIG } from "./config";
816
import { useTasksApi } from "./hooks/useTasksApi";
17+
import { taskArraysEqual, templateArraysEqual } from "./utils";
18+
19+
import type { IpcNotification, Task, TaskTemplate } from "@repo/shared";
20+
21+
interface PersistedState {
22+
tasks: Task[];
23+
templates: TaskTemplate[];
24+
createExpanded: boolean;
25+
historyExpanded: boolean;
26+
tasksSupported: boolean;
27+
}
928

1029
export default function App() {
1130
const api = useTasksApi();
31+
const queryClient = useQueryClient();
32+
33+
const persistedState = useRef(getState<PersistedState>());
34+
const restored = persistedState.current;
35+
36+
const [createExpanded, setCreateExpanded] = useState(
37+
restored?.createExpanded ?? true,
38+
);
39+
const [historyExpanded, setHistoryExpanded] = useState(
40+
restored?.historyExpanded ?? true,
41+
);
1242

1343
const { data, isLoading, error, refetch } = useQuery({
1444
queryKey: ["tasks-init"],
1545
queryFn: () => api.init(),
46+
initialData: restored?.tasks?.length
47+
? {
48+
tasks: restored.tasks,
49+
templates: restored.templates,
50+
tasksSupported: restored.tasksSupported,
51+
baseUrl: "",
52+
}
53+
: undefined,
1654
});
1755

18-
if (isLoading) {
19-
return <VscodeProgressRing />;
20-
}
56+
const tasks = useMemo(() => [...(data?.tasks ?? [])], [data?.tasks]);
57+
const templates = useMemo(
58+
() => [...(data?.templates ?? [])],
59+
[data?.templates],
60+
);
61+
const tasksSupported = data?.tasksSupported ?? true;
62+
63+
useEffect(() => {
64+
setState<PersistedState>({
65+
tasks,
66+
templates,
67+
createExpanded,
68+
historyExpanded,
69+
tasksSupported,
70+
});
71+
}, [tasks, templates, createExpanded, historyExpanded, tasksSupported]);
72+
73+
const tasksRef = useRef<Task[]>(tasks);
74+
tasksRef.current = tasks;
75+
76+
const templatesRef = useRef<TaskTemplate[]>(templates);
77+
templatesRef.current = templates;
78+
79+
// Poll for task list updates
80+
useEffect(() => {
81+
if (!data) return;
82+
83+
let cancelled = false;
84+
const pollInterval = setInterval(() => {
85+
api
86+
.getTasks()
87+
.then((updatedTasks) => {
88+
if (cancelled) return;
89+
if (!taskArraysEqual(tasksRef.current, updatedTasks)) {
90+
queryClient.setQueryData(["tasks-init"], (prev: typeof data) =>
91+
prev ? { ...prev, tasks: updatedTasks } : prev,
92+
);
93+
}
94+
})
95+
.catch(() => undefined);
96+
}, POLLING_CONFIG.TASK_LIST_INTERVAL_MS);
2197

22-
if (error) {
23-
return <p>Error: {error.message}</p>;
98+
return () => {
99+
cancelled = true;
100+
clearInterval(pollInterval);
101+
};
102+
}, [api, data, queryClient]);
103+
104+
useMessage<IpcNotification>((msg) => {
105+
switch (msg.type) {
106+
case "tasksUpdated":
107+
queryClient.setQueryData(["tasks-init"], (prev: typeof data) =>
108+
prev ? { ...prev, tasks: msg.data as Task[] } : prev,
109+
);
110+
break;
111+
112+
case "taskUpdated": {
113+
const updatedTask = msg.data as Task;
114+
queryClient.setQueryData(["tasks-init"], (prev: typeof data) =>
115+
prev
116+
? {
117+
...prev,
118+
tasks: prev.tasks.map((t) =>
119+
t.id === updatedTask.id ? updatedTask : t,
120+
),
121+
}
122+
: prev,
123+
);
124+
break;
125+
}
126+
127+
case "refresh": {
128+
api
129+
.getTasks()
130+
.then((updatedTasks) => {
131+
if (!taskArraysEqual(tasksRef.current, updatedTasks)) {
132+
queryClient.setQueryData(["tasks-init"], (prev: typeof data) =>
133+
prev ? { ...prev, tasks: updatedTasks } : prev,
134+
);
135+
}
136+
})
137+
.catch(() => undefined);
138+
api
139+
.getTemplates()
140+
.then((updatedTemplates) => {
141+
if (!templateArraysEqual(templatesRef.current, updatedTemplates)) {
142+
queryClient.setQueryData(["tasks-init"], (prev: typeof data) =>
143+
prev ? { ...prev, templates: updatedTemplates } : prev,
144+
);
145+
}
146+
})
147+
.catch(() => undefined);
148+
break;
149+
}
150+
151+
case "showCreateForm":
152+
setCreateExpanded(true);
153+
break;
154+
155+
case "logsAppend":
156+
// Task detail view will handle this in next PR
157+
break;
158+
}
159+
});
160+
161+
const handleSelectTask = useCallback((_taskId: string) => {
162+
// Task detail view will be added in next PR
163+
}, []);
164+
165+
if (isLoading) {
166+
return (
167+
<div className="loading-container">
168+
<VscodeProgressRing />
169+
</div>
170+
);
24171
}
25172

26-
if (!data?.tasksSupported) {
173+
if (error && tasks.length === 0) {
27174
return (
28-
<p>
29-
<VscodeIcon name="warning" /> Tasks not supported
30-
</p>
175+
<ErrorState message={error.message} onRetry={() => void refetch()} />
31176
);
32177
}
33178

179+
if (data && !tasksSupported) {
180+
return <NotSupportedState />;
181+
}
182+
183+
if (data && templates.length === 0) {
184+
return <NoTemplateState />;
185+
}
186+
34187
return (
35-
<div>
36-
<p>
37-
<VscodeIcon name="check" /> Connected to {data.baseUrl}
38-
</p>
39-
<p>Templates: {data.templates.length}</p>
40-
<p>Tasks: {data.tasks.length}</p>
41-
<VscodeButton icon="refresh" onClick={() => void refetch()}>
42-
Refresh
43-
</VscodeButton>
188+
<div className="tasks-panel">
189+
<CollapsibleSection
190+
title="Create new task"
191+
expanded={createExpanded}
192+
onToggle={() => setCreateExpanded(!createExpanded)}
193+
>
194+
<CreateTaskSection templates={templates} />
195+
</CollapsibleSection>
196+
197+
<CollapsibleSection
198+
title="Task History"
199+
expanded={historyExpanded}
200+
onToggle={() => setHistoryExpanded(!historyExpanded)}
201+
>
202+
<TaskList tasks={tasks} onSelectTask={handleSelectTask} />
203+
</CollapsibleSection>
44204
</div>
45205
);
46206
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {
2+
VscodeIcon,
3+
VscodeProgressRing,
4+
} from "@vscode-elements/react-elements";
5+
import { useState, useRef, useEffect, useCallback } from "react";
6+
7+
export interface ActionMenuItem {
8+
label: string;
9+
icon?: string;
10+
onClick: () => void;
11+
disabled?: boolean;
12+
danger?: boolean;
13+
loading?: boolean;
14+
}
15+
16+
interface ActionMenuProps {
17+
items: ActionMenuItem[];
18+
}
19+
20+
export function ActionMenu({ items }: ActionMenuProps) {
21+
const [isOpen, setIsOpen] = useState(false);
22+
const [position, setPosition] = useState({ top: 0, left: 0 });
23+
const menuRef = useRef<HTMLDivElement>(null);
24+
const buttonRef = useRef<HTMLDivElement>(null);
25+
26+
const updatePosition = useCallback(() => {
27+
if (buttonRef.current) {
28+
const rect = buttonRef.current.getBoundingClientRect();
29+
setPosition({
30+
top: rect.bottom + 4,
31+
left: rect.right - 150, // Align right edge of menu with button
32+
});
33+
}
34+
}, []);
35+
36+
useEffect(() => {
37+
if (!isOpen) return undefined;
38+
39+
function handleClickOutside(event: MouseEvent) {
40+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
41+
setIsOpen(false);
42+
}
43+
}
44+
45+
function handleScroll() {
46+
setIsOpen(false);
47+
}
48+
49+
document.addEventListener("mousedown", handleClickOutside);
50+
window.addEventListener("scroll", handleScroll, true);
51+
updatePosition();
52+
53+
return () => {
54+
document.removeEventListener("mousedown", handleClickOutside);
55+
window.removeEventListener("scroll", handleScroll, true);
56+
};
57+
}, [isOpen, updatePosition]);
58+
59+
const handleToggle = () => {
60+
if (!isOpen) {
61+
updatePosition();
62+
}
63+
setIsOpen(!isOpen);
64+
};
65+
66+
return (
67+
<div className="action-menu" ref={menuRef}>
68+
<div ref={buttonRef}>
69+
<VscodeIcon
70+
actionIcon
71+
name="ellipsis"
72+
label="More actions"
73+
onClick={handleToggle}
74+
/>
75+
</div>
76+
{isOpen && (
77+
<div
78+
className="action-menu-dropdown"
79+
style={{ top: position.top, left: Math.max(0, position.left) }}
80+
>
81+
{items.map((item, index) => (
82+
<button
83+
key={`${item.label}-${index}`}
84+
type="button"
85+
className={`action-menu-item ${item.danger ? "danger" : ""} ${item.loading ? "loading" : ""}`}
86+
onClick={() => {
87+
if (!item.loading) {
88+
item.onClick();
89+
setIsOpen(false);
90+
}
91+
}}
92+
disabled={item.disabled ?? item.loading}
93+
>
94+
{item.loading ? (
95+
<VscodeProgressRing className="action-menu-spinner" />
96+
) : item.icon ? (
97+
<VscodeIcon name={item.icon} className="action-menu-icon" />
98+
) : null}
99+
<span>{item.label}</span>
100+
</button>
101+
))}
102+
</div>
103+
)}
104+
</div>
105+
);
106+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { VscodeIcon } from "@vscode-elements/react-elements";
2+
3+
interface CollapsibleSectionProps {
4+
title: string;
5+
expanded: boolean;
6+
onToggle: () => void;
7+
children: React.ReactNode;
8+
}
9+
10+
export function CollapsibleSection({
11+
title,
12+
expanded,
13+
onToggle,
14+
children,
15+
}: CollapsibleSectionProps) {
16+
return (
17+
<div className="collapsible-section">
18+
<button
19+
type="button"
20+
className="section-header"
21+
onClick={onToggle}
22+
aria-expanded={expanded}
23+
>
24+
<VscodeIcon name={expanded ? "chevron-down" : "chevron-right"} />
25+
<span className="section-title">{title}</span>
26+
</button>
27+
{expanded && <div className="section-content">{children}</div>}
28+
</div>
29+
);
30+
}

0 commit comments

Comments
 (0)