Skip to content

Commit 550f617

Browse files
authored
Add Tasks panel list view with create form, task actions, and polling (#773)
Build out the Tasks webview panel with collapsible Create Task and Task History sections, task creation with template/preset selection, a scrollable task list with status indicators and per-task action menus, and reusable empty/error state screens. Architecture: extends the shared TasksApi IPC layer with task CRUD and action operations proxied by the extension-side TasksPanel to the Coder API. Uses @tanstack/react-query for data fetching with 10s polling, push notification subscriptions, and webview state persistence. Components: CreateTaskSection, TaskList, TaskItem, ActionMenu, StatusIndicator, PromptInput, StatePanel, and error/empty states. Hooks: useTasksQuery, useTaskMenuItems, useCollapsibleToggle, useScrollableHeight.
1 parent bba3f38 commit 550f617

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+2663
-383
lines changed

CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ Comments explain what code does or why it exists:
5757
- All unit tests: `pnpm test`
5858
- Extension tests: `pnpm test:extension`
5959
- Webview tests: `pnpm test:webview`
60-
- CI mode: `pnpm test:ci`
6160
- Integration tests: `pnpm test:integration`
6261
- Run specific extension test: `pnpm test:extension ./test/unit/filename.test.ts`
6362
- Run specific webview test: `pnpm test:webview ./test/webview/filename.test.ts`

CONTRIBUTING.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,7 @@ The project uses Vitest with separate test configurations for extension and webv
128128
```bash
129129
pnpm test:extension # Extension tests (runs in Electron with mocked VS Code APIs)
130130
pnpm test:webview # Webview tests (runs in jsdom)
131-
pnpm test # Both extension and webview tests
132-
pnpm test:ci # CI mode (same as test with CI=true)
131+
pnpm test # Both extension and webview tests (CI mode)
133132
```
134133

135134
Test files are organized by type:

eslint.config.mjs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { createTypeScriptImportResolver } from "eslint-import-resolver-typescrip
88
import { flatConfigs as importXFlatConfigs } from "eslint-plugin-import-x";
99
import packageJson from "eslint-plugin-package-json";
1010
import reactPlugin from "eslint-plugin-react";
11-
import reactCompilerPlugin from "eslint-plugin-react-compiler";
1211
import reactHooksPlugin from "eslint-plugin-react-hooks";
1312
import globals from "globals";
1413

@@ -177,24 +176,31 @@ export default defineConfig(
177176
},
178177
},
179178

180-
// TSX files - React rules
179+
// React hooks and compiler rules (covers .ts hook files too)
180+
{
181+
files: ["packages/**/*.{ts,tsx}"],
182+
...reactHooksPlugin.configs.flat.recommended,
183+
rules: {
184+
...reactHooksPlugin.configs.flat.recommended.rules,
185+
// React Compiler auto-memoizes; exhaustive-deps false-positives on useCallback
186+
"react-hooks/exhaustive-deps": "off",
187+
},
188+
},
189+
190+
// TSX files - React JSX rules
181191
{
182192
files: ["**/*.tsx"],
183193
plugins: {
184194
react: reactPlugin,
185-
"react-compiler": reactCompilerPlugin,
186-
"react-hooks": reactHooksPlugin,
187195
},
188196
settings: {
189197
react: {
190198
version: "detect",
191199
},
192200
},
193201
rules: {
194-
...reactCompilerPlugin.configs.recommended.rules,
195202
...reactPlugin.configs.recommended.rules,
196203
...reactPlugin.configs["jsx-runtime"].rules, // React 17+ JSX transform
197-
...reactHooksPlugin.configs.recommended.rules,
198204
"react/prop-types": "off", // Using TypeScript
199205
},
200206
},

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
"package": "vsce package --no-dependencies",
2929
"package:prerelease": "vsce package --pre-release --no-dependencies",
3030
"test": "CI=true pnpm test:extension && CI=true pnpm test:webview",
31-
"test:ci": "pnpm test",
3231
"test:extension": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension",
3332
"test:integration": "tsc -p test --outDir out --noCheck && node esbuild.mjs && vscode-test",
3433
"test:webview": "vitest --project webview",
@@ -496,6 +495,8 @@
496495
"devDependencies": {
497496
"@eslint/js": "^9.39.2",
498497
"@eslint/markdown": "^7.5.1",
498+
"@tanstack/react-query": "catalog:",
499+
"@testing-library/jest-dom": "^6.9.1",
499500
"@testing-library/react": "^16.3.2",
500501
"@tsconfig/node20": "^20.1.9",
501502
"@types/mocha": "^10.0.10",
@@ -527,7 +528,6 @@
527528
"eslint-plugin-import-x": "^4.16.1",
528529
"eslint-plugin-package-json": "^0.88.2",
529530
"eslint-plugin-react": "^7.37.5",
530-
"eslint-plugin-react-compiler": "catalog:",
531531
"eslint-plugin-react-hooks": "^7.0.1",
532532
"globals": "^17.3.0",
533533
"jsdom": "^28.0.0",
@@ -545,7 +545,7 @@
545545
"extensionPack": [
546546
"ms-vscode-remote.remote-ssh"
547547
],
548-
"packageManager": "pnpm@10.28.2",
548+
"packageManager": "pnpm@10.29.2",
549549
"engines": {
550550
"vscode": "^1.95.0",
551551
"node": ">= 20"

packages/shared/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// IPC protocol types
22
export * from "./ipc/protocol";
33

4-
// Tasks types and API
4+
// Tasks types, utilities, and API
55
export * from "./tasks/types";
6+
export * from "./tasks/utils";
67
export * from "./tasks/api";

packages/shared/src/tasks/api.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,17 @@ export interface CreateTaskParams {
3939
}
4040
const createTask = defineRequest<CreateTaskParams, Task>("createTask");
4141

42-
const deleteTask = defineRequest<{ taskId: string }, void>("deleteTask");
43-
const pauseTask = defineRequest<{ taskId: string }, void>("pauseTask");
44-
const resumeTask = defineRequest<{ taskId: string }, void>("resumeTask");
42+
export interface TaskActionParams {
43+
taskId: string;
44+
taskName: string;
45+
}
46+
const deleteTask = defineRequest<TaskActionParams, void>("deleteTask");
47+
const pauseTask = defineRequest<TaskActionParams, void>("pauseTask");
48+
const resumeTask = defineRequest<TaskActionParams, void>("resumeTask");
49+
const downloadLogs = defineRequest<{ taskId: string }, void>("downloadLogs");
4550

4651
const viewInCoder = defineCommand<{ taskId: string }>("viewInCoder");
4752
const viewLogs = defineCommand<{ taskId: string }>("viewLogs");
48-
const downloadLogs = defineCommand<{ taskId: string }>("downloadLogs");
4953
const sendTaskMessage = defineCommand<{
5054
taskId: string;
5155
message: string;
@@ -68,10 +72,10 @@ export const TasksApi = {
6872
deleteTask,
6973
pauseTask,
7074
resumeTask,
75+
downloadLogs,
7176
// Commands
7277
viewInCoder,
7378
viewLogs,
74-
downloadLogs,
7579
sendTaskMessage,
7680
// Notifications
7781
taskUpdated,

packages/shared/src/tasks/types.ts

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -35,57 +35,14 @@ export type LogsStatus = "ok" | "not_available" | "error";
3535
/**
3636
* Full details for a selected task, including logs and action availability.
3737
*/
38-
export interface TaskDetails extends TaskActions {
38+
export interface TaskDetails extends TaskPermissions {
3939
task: Task;
4040
logs: TaskLogEntry[];
4141
logsStatus: LogsStatus;
4242
}
4343

44-
export interface TaskActions {
44+
export interface TaskPermissions {
4545
canPause: boolean;
4646
pauseDisabled: boolean;
4747
canResume: boolean;
4848
}
49-
50-
const PAUSABLE_STATUSES: readonly TaskStatus[] = [
51-
"active",
52-
"initializing",
53-
"pending",
54-
"error",
55-
"unknown",
56-
];
57-
58-
const PAUSE_DISABLED_STATUSES: readonly TaskStatus[] = [
59-
"pending",
60-
"initializing",
61-
];
62-
63-
const RESUMABLE_STATUSES: readonly TaskStatus[] = [
64-
"paused",
65-
"error",
66-
"unknown",
67-
];
68-
69-
export function getTaskActions(task: Task): TaskActions {
70-
const hasWorkspace = task.workspace_id !== null;
71-
const status = task.status;
72-
return {
73-
canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status),
74-
pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status),
75-
canResume: hasWorkspace && RESUMABLE_STATUSES.includes(status),
76-
};
77-
}
78-
79-
/**
80-
* Task statuses where logs won't change (stable/terminal states).
81-
* "complete" is a TaskState (sub-state of active), checked separately.
82-
*/
83-
const STABLE_STATUSES: readonly TaskStatus[] = ["error", "paused"];
84-
85-
/** Whether a task is in a stable state where its logs won't change. */
86-
export function isStableTask(task: Task): boolean {
87-
return (
88-
STABLE_STATUSES.includes(task.status) ||
89-
(task.current_state !== null && task.current_state.state !== "working")
90-
);
91-
}

packages/shared/src/tasks/utils.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Task, TaskPermissions, TaskStatus } from "./types";
2+
3+
export function getTaskLabel(task: Task): string {
4+
return task.display_name || task.name || task.id;
5+
}
6+
7+
const PAUSABLE_STATUSES: readonly TaskStatus[] = [
8+
"active",
9+
"initializing",
10+
"pending",
11+
"error",
12+
"unknown",
13+
];
14+
15+
const PAUSE_DISABLED_STATUSES: readonly TaskStatus[] = [
16+
"pending",
17+
"initializing",
18+
];
19+
20+
const RESUMABLE_STATUSES: readonly TaskStatus[] = [
21+
"paused",
22+
"error",
23+
"unknown",
24+
];
25+
26+
export function getTaskPermissions(task: Task): TaskPermissions {
27+
const hasWorkspace = task.workspace_id !== null;
28+
const status = task.status;
29+
return {
30+
canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status),
31+
pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status),
32+
canResume: hasWorkspace && RESUMABLE_STATUSES.includes(status),
33+
};
34+
}
35+
36+
/**
37+
* Task statuses where logs won't change (stable/terminal states).
38+
* "complete" is a TaskState (sub-state of active), checked separately.
39+
*/
40+
const STABLE_STATUSES: readonly TaskStatus[] = ["error", "paused"];
41+
42+
/** Whether a task is in a stable state where its logs won't change. */
43+
export function isStableTask(task: Task): boolean {
44+
return (
45+
STABLE_STATUSES.includes(task.status) ||
46+
(task.current_state !== null && task.current_state.state !== "working")
47+
);
48+
}

packages/tasks/src/App.tsx

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,109 @@
1-
import { useQuery } from "@tanstack/react-query";
1+
import { TasksApi, type InitResponse } from "@repo/shared";
2+
import { getState, setState } from "@repo/webview-shared";
3+
import { useIpc } from "@repo/webview-shared/react";
24
import {
3-
VscodeButton,
4-
VscodeIcon,
5+
VscodeCollapsible,
56
VscodeProgressRing,
7+
VscodeScrollable,
68
} from "@vscode-elements/react-elements";
9+
import { useEffect, useRef, useState } from "react";
710

8-
import { useTasksApi } from "./hooks/useTasksApi";
11+
import { CreateTaskSection } from "./components/CreateTaskSection";
12+
import { ErrorState } from "./components/ErrorState";
13+
import { NoTemplateState } from "./components/NoTemplateState";
14+
import { NotSupportedState } from "./components/NotSupportedState";
15+
import { TaskList } from "./components/TaskList";
16+
import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle";
17+
import { useScrollableHeight } from "./hooks/useScrollableHeight";
18+
import { useTasksQuery } from "./hooks/useTasksQuery";
19+
20+
interface PersistedState extends InitResponse {
21+
createExpanded: boolean;
22+
historyExpanded: boolean;
23+
}
24+
25+
type CollapsibleElement = React.ComponentRef<typeof VscodeCollapsible>;
26+
type ScrollableElement = React.ComponentRef<typeof VscodeScrollable>;
927

1028
export default function App() {
11-
const api = useTasksApi();
29+
const [restored] = useState(() => getState<PersistedState>());
30+
const { tasks, templates, tasksSupported, data, isLoading, error, refetch } =
31+
useTasksQuery(restored);
32+
33+
const [createRef, createOpen, setCreateOpen] =
34+
useCollapsibleToggle<CollapsibleElement>(restored?.createExpanded ?? true);
35+
const [historyRef, historyOpen] = useCollapsibleToggle<CollapsibleElement>(
36+
restored?.historyExpanded ?? true,
37+
);
1238

13-
const { data, isLoading, error, refetch } = useQuery({
14-
queryKey: ["tasks-init"],
15-
queryFn: () => api.init(),
16-
});
39+
const createScrollRef = useRef<ScrollableElement>(null);
40+
const historyScrollRef = useRef<ScrollableElement>(null);
41+
useScrollableHeight(createRef, createScrollRef);
42+
useScrollableHeight(historyRef, historyScrollRef);
1743

18-
if (isLoading) {
19-
return <VscodeProgressRing />;
20-
}
44+
const { onNotification } = useIpc();
45+
useEffect(() => {
46+
return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true));
47+
}, [onNotification, setCreateOpen]);
48+
49+
useEffect(() => {
50+
if (data) {
51+
setState<PersistedState>({
52+
...data,
53+
createExpanded: createOpen,
54+
historyExpanded: historyOpen,
55+
});
56+
}
57+
}, [data, createOpen, historyOpen]);
2158

22-
if (error) {
23-
return <p>Error: {error.message}</p>;
59+
if (isLoading) {
60+
return (
61+
<div className="loading-container">
62+
<VscodeProgressRing />
63+
</div>
64+
);
2465
}
2566

26-
if (!data?.tasksSupported) {
67+
if (error && tasks.length === 0) {
2768
return (
28-
<p>
29-
<VscodeIcon name="warning" /> Tasks not supported
30-
</p>
69+
<ErrorState message={error.message} onRetry={() => void refetch()} />
3170
);
3271
}
3372

73+
if (!tasksSupported) {
74+
return <NotSupportedState />;
75+
}
76+
77+
if (templates.length === 0) {
78+
return <NoTemplateState />;
79+
}
80+
3481
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>
82+
<div className="tasks-panel">
83+
<VscodeCollapsible
84+
ref={createRef}
85+
heading="Create new task"
86+
open={createOpen}
87+
>
88+
<VscodeScrollable ref={createScrollRef}>
89+
<CreateTaskSection templates={templates} />
90+
</VscodeScrollable>
91+
</VscodeCollapsible>
92+
93+
<VscodeCollapsible
94+
ref={historyRef}
95+
heading="Task History"
96+
open={historyOpen}
97+
>
98+
<VscodeScrollable ref={historyScrollRef}>
99+
<TaskList
100+
tasks={tasks}
101+
onSelectTask={(_taskId: string) => {
102+
// Task detail view will be added in next PR
103+
}}
104+
/>
105+
</VscodeScrollable>
106+
</VscodeCollapsible>
44107
</div>
45108
);
46109
}

0 commit comments

Comments
 (0)