Skip to content

Commit 74dbcc4

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 318c084 commit 74dbcc4

19 files changed

+1689
-30
lines changed

media/tasks-logo.svg

Lines changed: 4 additions & 0 deletions
Loading

packages/tasks/src/App.tsx

Lines changed: 202 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,235 @@
1-
import { useTasksApi } from "@repo/webview-shared/react";
1+
import {
2+
getState,
3+
setState,
4+
type Task,
5+
type TasksPushMessage,
6+
type TaskTemplate,
7+
} from "@repo/webview-shared";
8+
import { useMessage, useTasksApi } from "@repo/webview-shared/react";
29
import { VscodeProgressRing } from "@vscode-elements/react-elements";
3-
import { useEffect, useState } from "react";
10+
import { useCallback, useEffect, useState, useRef } from "react";
411

5-
import type { Task, TaskTemplate } from "@repo/webview-shared";
12+
import {
13+
CollapsibleSection,
14+
CreateTaskSection,
15+
ErrorState,
16+
NoTemplateState,
17+
NotSupportedState,
18+
TaskList,
19+
} from "./components";
20+
import { POLLING_CONFIG } from "./config";
21+
import { taskArraysEqual, templateArraysEqual } from "./utils";
22+
23+
interface PersistedState {
24+
tasks: Task[];
25+
templates: TaskTemplate[];
26+
createExpanded: boolean;
27+
historyExpanded: boolean;
28+
tasksSupported: boolean;
29+
}
630

731
export default function App() {
832
const api = useTasksApi();
9-
const [loading, setLoading] = useState(true);
10-
const [error, setError] = useState<string | null>(null);
11-
const [tasks, setTasks] = useState<Task[]>([]);
12-
const [templates, setTemplates] = useState<TaskTemplate[]>([]);
13-
const [tasksSupported, setTasksSupported] = useState(true);
33+
34+
const persistedState = useRef(getState<PersistedState>());
35+
const restored = persistedState.current;
36+
37+
const [initialized, setInitialized] = useState(!!restored?.tasks?.length);
38+
const [tasks, setTasks] = useState<Task[]>(restored?.tasks ?? []);
39+
const [templates, setTemplates] = useState<TaskTemplate[]>(
40+
restored?.templates ?? [],
41+
);
42+
const [tasksSupported, setTasksSupported] = useState(
43+
restored?.tasksSupported ?? true,
44+
);
45+
46+
const [createExpanded, setCreateExpanded] = useState(
47+
restored?.createExpanded ?? true,
48+
);
49+
const [historyExpanded, setHistoryExpanded] = useState(
50+
restored?.historyExpanded ?? true,
51+
);
52+
53+
useEffect(() => {
54+
setState<PersistedState>({
55+
tasks,
56+
templates,
57+
createExpanded,
58+
historyExpanded,
59+
tasksSupported,
60+
});
61+
}, [tasks, templates, createExpanded, historyExpanded, tasksSupported]);
62+
63+
const [initLoading, setInitLoading] = useState(!restored?.tasks?.length);
64+
const [initError, setInitError] = useState<string | null>(null);
1465

1566
useEffect(() => {
67+
let cancelled = false;
68+
69+
async function initialize() {
70+
try {
71+
const data = await api.init();
72+
if (cancelled) return;
73+
74+
setTasks(data.tasks);
75+
setTemplates(data.templates);
76+
setTasksSupported(data.tasksSupported);
77+
setInitialized(true);
78+
setInitError(null);
79+
} catch (err) {
80+
if (cancelled) return;
81+
setInitError(
82+
err instanceof Error ? err.message : "Failed to initialize",
83+
);
84+
} finally {
85+
if (!cancelled) {
86+
setInitLoading(false);
87+
}
88+
}
89+
}
90+
91+
void initialize();
92+
return () => {
93+
cancelled = true;
94+
};
95+
}, [api]);
96+
97+
const tasksRef = useRef<Task[]>(tasks);
98+
tasksRef.current = tasks;
99+
100+
const templatesRef = useRef<TaskTemplate[]>(templates);
101+
templatesRef.current = templates;
102+
103+
// Poll for task list updates
104+
useEffect(() => {
105+
if (!initialized) return;
106+
107+
let cancelled = false;
108+
const pollInterval = setInterval(() => {
109+
api
110+
.getTasks()
111+
.then((updatedTasks) => {
112+
if (cancelled) return;
113+
if (!taskArraysEqual(tasksRef.current, updatedTasks)) {
114+
setTasks(updatedTasks);
115+
}
116+
})
117+
.catch(() => undefined);
118+
}, POLLING_CONFIG.TASK_LIST_INTERVAL_MS);
119+
120+
return () => {
121+
cancelled = true;
122+
clearInterval(pollInterval);
123+
};
124+
}, [api, initialized]);
125+
126+
const handleRetry = useCallback(() => {
127+
setInitLoading(true);
128+
setInitError(null);
129+
16130
api
17131
.init()
18132
.then((data) => {
19133
setTasks(data.tasks);
20134
setTemplates(data.templates);
21135
setTasksSupported(data.tasksSupported);
22-
setLoading(false);
136+
setInitialized(true);
137+
})
138+
.catch((err: unknown) => {
139+
setInitError(
140+
err instanceof Error ? err.message : "Failed to initialize",
141+
);
23142
})
24-
.catch((err) => {
25-
setError(err instanceof Error ? err.message : "Failed to initialize");
26-
setLoading(false);
143+
.finally(() => {
144+
setInitLoading(false);
27145
});
28146
}, [api]);
29147

30-
if (loading) {
148+
useMessage<TasksPushMessage>((msg) => {
149+
switch (msg.type) {
150+
case "tasksUpdated":
151+
setTasks(msg.data);
152+
break;
153+
154+
case "taskUpdated": {
155+
const updatedTask = msg.data;
156+
setTasks((prev) =>
157+
prev.map((t) => (t.id === updatedTask.id ? updatedTask : t)),
158+
);
159+
break;
160+
}
161+
162+
case "refresh": {
163+
api
164+
.getTasks()
165+
.then((updatedTasks) => {
166+
if (!taskArraysEqual(tasksRef.current, updatedTasks)) {
167+
setTasks(updatedTasks);
168+
}
169+
})
170+
.catch(() => undefined);
171+
api
172+
.getTemplates()
173+
.then((updatedTemplates) => {
174+
if (!templateArraysEqual(templatesRef.current, updatedTemplates)) {
175+
setTemplates(updatedTemplates);
176+
}
177+
})
178+
.catch(() => undefined);
179+
break;
180+
}
181+
182+
case "showCreateForm":
183+
setCreateExpanded(true);
184+
break;
185+
186+
case "logsAppend":
187+
// Task detail view will handle this in next PR
188+
break;
189+
}
190+
});
191+
192+
const handleSelectTask = useCallback((_taskId: string) => {
193+
// Task detail view will be added in next PR
194+
}, []);
195+
196+
if (initLoading) {
31197
return (
32198
<div className="loading-container">
33199
<VscodeProgressRing />
34200
</div>
35201
);
36202
}
37203

38-
if (error) {
39-
return (
40-
<div className="error-container">
41-
<p>Error: {error}</p>
42-
</div>
43-
);
204+
if (initError && tasks.length === 0) {
205+
return <ErrorState message={initError} onRetry={handleRetry} />;
44206
}
45207

46-
if (!tasksSupported) {
47-
return (
48-
<div className="not-supported">
49-
<p>Tasks are not supported on this Coder server.</p>
50-
</div>
51-
);
208+
if (initialized && !tasksSupported) {
209+
return <NotSupportedState />;
210+
}
211+
212+
if (initialized && templates.length === 0) {
213+
return <NoTemplateState />;
52214
}
53215

54216
return (
55217
<div className="tasks-panel">
56-
<h3>Tasks Infrastructure Ready</h3>
57-
<p>Templates: {templates.length}</p>
58-
<p>Tasks: {tasks.length}</p>
59-
<p>Full UI coming in next PR...</p>
218+
<CollapsibleSection
219+
title="Create new task"
220+
expanded={createExpanded}
221+
onToggle={() => setCreateExpanded(!createExpanded)}
222+
>
223+
<CreateTaskSection templates={templates} />
224+
</CollapsibleSection>
225+
226+
<CollapsibleSection
227+
title="Task History"
228+
expanded={historyExpanded}
229+
onToggle={() => setHistoryExpanded(!historyExpanded)}
230+
>
231+
<TaskList tasks={tasks} onSelectTask={handleSelectTask} />
232+
</CollapsibleSection>
60233
</div>
61234
);
62235
}
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+
}

0 commit comments

Comments
 (0)