@agimon-ai/doompi-task 0.0.1-alpha.37 → 0.0.1-alpha.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ persistence.
14
14
  ## Requirements
15
15
 
16
16
  - Node.js 22.19.0 or newer
17
- - Pi 0.84.2 and Pi TUI 0.84.2
17
+ - Pi 0.84.3 and Pi TUI 0.84.3
18
18
 
19
19
  ## Install
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-task",
3
- "version": "0.0.1-alpha.37",
3
+ "version": "0.0.1-alpha.38",
4
4
  "description": "Persistent, dependency-aware task graphs and subagent delegation for Pi coding sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  "dist",
30
+ "web",
30
31
  "package.json"
31
32
  ],
32
33
  "type": "module",
@@ -158,22 +159,28 @@
158
159
  "dependencies": {
159
160
  "@deepseek-ai/cordis": "4.0.1",
160
161
  "typebox": "1.3.16",
161
- "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.37",
162
- "@agimon-ai/doompi-ui": "0.0.1-alpha.37",
163
- "@agimon-ai/doompi-telemetry": "0.0.1-alpha.37"
162
+ "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.38",
163
+ "@agimon-ai/doompi-telemetry": "0.0.1-alpha.38",
164
+ "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.1",
165
+ "@agimon-ai/doompi-ui": "0.0.1-alpha.38",
166
+ "@agimon-ai/doompi-web-components": "0.0.0"
164
167
  },
165
168
  "devDependencies": {
166
- "@earendil-works/pi-coding-agent": "0.84.2",
167
- "@earendil-works/pi-tui": "0.84.2",
169
+ "@earendil-works/pi-coding-agent": "0.84.3",
170
+ "@earendil-works/pi-tui": "0.84.3",
171
+ "@tanstack/react-store": "0.11.1",
172
+ "@tanstack/store": "0.11.1",
168
173
  "@types/node": "26.2.0",
174
+ "@types/react": "19.2.7",
169
175
  "@vitest/coverage-v8": "4.1.11",
176
+ "react": "19.2.8",
170
177
  "tsdown": "0.22.14",
171
178
  "typescript": "7.0.2",
172
179
  "vitest": "4.1.11"
173
180
  },
174
181
  "peerDependencies": {
175
- "@earendil-works/pi-coding-agent": "0.84.2",
176
- "@earendil-works/pi-tui": "0.84.2"
182
+ "@earendil-works/pi-coding-agent": "0.84.3",
183
+ "@earendil-works/pi-tui": "0.84.3"
177
184
  },
178
185
  "peerDependenciesMeta": {
179
186
  "@earendil-works/pi-coding-agent": {
@@ -186,6 +193,11 @@
186
193
  "engines": {
187
194
  "node": ">=22.19.0"
188
195
  },
196
+ "doompiWeb": {
197
+ "pluginId": "task",
198
+ "channels": [],
199
+ "client": "./web/index.ts"
200
+ },
189
201
  "pi": {
190
202
  "extensions": [
191
203
  "./dist/extensions/pi.mjs"
@@ -194,7 +206,7 @@
194
206
  "scripts": {
195
207
  "build": "tsdown",
196
208
  "test": "vitest --run",
197
- "typecheck": "tsc --noEmit",
209
+ "typecheck": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json",
198
210
  "lint": "oxlint . && oxfmt . --check",
199
211
  "fixcode": "oxlint . --fix && oxfmt ."
200
212
  }
@@ -0,0 +1,119 @@
1
+ import {
2
+ MessageItem,
3
+ MessageItemBody,
4
+ MessageItemHeader,
5
+ MessageItemStatus,
6
+ type StatusTone,
7
+ toolTone,
8
+ } from '@agimon-ai/doompi-web-components';
9
+ import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
10
+ import {
11
+ STATUS_LABEL,
12
+ type TaskResultTone,
13
+ type TaskRow,
14
+ type TaskStatus,
15
+ taskCallView,
16
+ taskResultView,
17
+ } from './taskToolFormat.ts';
18
+
19
+ const STATUS_TONE: Record<TaskStatus, string> = {
20
+ pending: 'text-doom-dim',
21
+ in_progress: 'text-doom-yellow',
22
+ completed: 'text-doom-green',
23
+ failed: 'text-doom-red',
24
+ deleted: 'text-doom-faint',
25
+ };
26
+
27
+ const RESULT_TONE: Record<TaskResultTone, StatusTone> = {
28
+ running: 'running',
29
+ ok: 'ok',
30
+ error: 'error',
31
+ warning: 'running',
32
+ };
33
+
34
+ /** One task row: status glyph, id, subject and chips on the left, the status label flushed right. */
35
+ function TaskRowLine({ row }: { row: TaskRow }) {
36
+ return (
37
+ <li data-testid="tool-result-task-row" data-task-id={row.id} className="flex items-baseline gap-2">
38
+ <span className={`shrink-0 ${STATUS_TONE[row.status]}`}>{row.glyph}</span>
39
+ <span className="shrink-0 text-doom-faint">#{row.id}</span>
40
+ <span
41
+ className={`min-w-0 flex-1 truncate ${row.closed ? 'text-doom-faint line-through' : row.status === 'in_progress' ? 'text-doom-blue' : 'text-doom-text'}`}
42
+ >
43
+ {row.subject}
44
+ {row.agent ? <span className="text-doom-faint"> [{row.agent}]</span> : null}
45
+ {row.activeForm ? <span className="text-doom-faint"> ({row.activeForm})</span> : null}
46
+ {row.blockedBy.length > 0 ? (
47
+ <span className="text-doom-faint"> ⛓ {row.blockedBy.map((id) => `#${id}`).join(',')}</span>
48
+ ) : null}
49
+ </span>
50
+ <span className={`shrink-0 ${STATUS_TONE[row.status]}`}>{STATUS_LABEL[row.status]}</span>
51
+ </li>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * The task tool's timeline item: `glyph action subject` with the assignee or
57
+ * list filter in the header, as renderTaskCall shows it; the rows the action
58
+ * touched, then the batch or list summary, in the body, as renderTaskResult
59
+ * lays it out.
60
+ */
61
+ export function TaskToolMessage({ args, result, output, running, isError }: ToolMessageRenderProps) {
62
+ const call = taskCallView(args);
63
+ const collapsed = taskResultView({ details: result?.details, output, expanded: false, isPartial: running, isError });
64
+ const full = taskResultView({ details: result?.details, output, expanded: true, isPartial: running, isError });
65
+ const hidden = full.rows.length - collapsed.rows.length + (full.errorLines.length - collapsed.errorLines.length);
66
+ return (
67
+ <MessageItem tone={toolTone({ running, isError })} expandable={hidden > 0}>
68
+ {({ expanded }) => {
69
+ const view = expanded ? full : collapsed;
70
+ const empty = view.rows.length === 0 && view.errorLines.length === 0 && view.status === null;
71
+ return (
72
+ <>
73
+ <MessageItemHeader title="task">
74
+ <span data-testid="tool-call-task" className="flex min-w-0 flex-1 items-center gap-1.5">
75
+ <span className="shrink-0 text-doom-text">
76
+ {call.glyph} {call.action}
77
+ </span>
78
+ {call.subject ? (
79
+ <span
80
+ className={`min-w-0 truncate ${call.subjectTone === 'accent' ? 'text-doom-blue' : 'text-doom-dim'}`}
81
+ >
82
+ {call.subject}
83
+ </span>
84
+ ) : null}
85
+ {call.detail ? <span className="shrink-0 text-doom-faint">{call.detail}</span> : null}
86
+ </span>
87
+ </MessageItemHeader>
88
+ {empty ? null : (
89
+ <MessageItemBody data-testid="tool-result-task" className="flex flex-col gap-1">
90
+ {view.rows.length > 0 ? (
91
+ <ul className="flex flex-col gap-0.5">
92
+ {view.rows.map((row) => (
93
+ <TaskRowLine key={row.id} row={row} />
94
+ ))}
95
+ </ul>
96
+ ) : null}
97
+ {view.errorLines.length > 0 ? (
98
+ <pre className="whitespace-pre-wrap break-words font-mono text-doom-dim">
99
+ {view.errorLines.join('\n')}
100
+ </pre>
101
+ ) : null}
102
+ {!expanded && hidden > 0 ? <MessageItemStatus expands>{hidden} more</MessageItemStatus> : null}
103
+ {view.status ? (
104
+ <MessageItemStatus
105
+ data-testid="tool-result-task-status"
106
+ tone={RESULT_TONE[view.status.tone]}
107
+ glyph={view.status.glyph}
108
+ >
109
+ {view.status.text}
110
+ </MessageItemStatus>
111
+ ) : null}
112
+ </MessageItemBody>
113
+ )}
114
+ </>
115
+ );
116
+ }}
117
+ </MessageItem>
118
+ );
119
+ }
package/web/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
2
+ import { TaskToolMessage } from './TaskToolMessage.tsx';
3
+
4
+ /**
5
+ * This package's cockpit presence: the task tool's timeline card, the web
6
+ * half of src/tui/format.ts (renderTaskCall and renderTaskResult).
7
+ */
8
+ export const webPlugin = defineWebPlugin({
9
+ id: 'task',
10
+ toolRenderers: [{ tools: ['task'], message: TaskToolMessage }],
11
+ });
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Pure view logic for the task tool card, the browser counterpart of
3
+ * src/tui/format.ts (renderTaskCall, renderTaskResult). The plugin may reach
4
+ * only its own files and src/types, so the store shapes it reads are
5
+ * restated here as the wire JSON of a tool_execution frame.
6
+ */
7
+
8
+ export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'deleted';
9
+ export type TaskAction = 'upsert' | 'list' | 'get' | 'delete' | 'clear' | 'assign' | 'cancel';
10
+
11
+ export const STATUS_GLYPH: Record<TaskStatus, string> = {
12
+ pending: '○',
13
+ in_progress: '◐',
14
+ completed: '●',
15
+ failed: '✗',
16
+ deleted: '⊘',
17
+ };
18
+
19
+ export const STATUS_LABEL: Record<TaskStatus, string> = {
20
+ pending: 'pending',
21
+ in_progress: 'in progress',
22
+ completed: 'completed',
23
+ failed: 'failed',
24
+ deleted: 'deleted',
25
+ };
26
+
27
+ export const ACTION_GLYPH: Record<TaskAction, string> = {
28
+ upsert: '✎',
29
+ delete: '×',
30
+ get: '›',
31
+ list: '☰',
32
+ clear: '∅',
33
+ assign: '⇒',
34
+ cancel: '⊗',
35
+ };
36
+
37
+ /**
38
+ * `upsert` is two operations behind one name, so a homogeneous batch narrows to
39
+ * the glyph the operator already reads: `+` means new work appeared, `→` means
40
+ * work moved. A mixed batch keeps the neutral write glyph.
41
+ */
42
+ const UPSERT_CREATE_GLYPH = '+';
43
+ const UPSERT_UPDATE_GLYPH = '→';
44
+ /** Task rows kept in a collapsed result, before the card is expanded. */
45
+ const TRANSCRIPT_TASK_ROWS = 8;
46
+ const TASK_STATUSES: readonly TaskStatus[] = ['pending', 'in_progress', 'completed', 'failed', 'deleted'];
47
+ const TASK_ACTIONS: readonly TaskAction[] = ['upsert', 'list', 'get', 'delete', 'clear', 'assign', 'cancel'];
48
+ const INLINE_WHITESPACE = /\s+/gu;
49
+
50
+ /** One task as the result details carry it (src/services/store/types.ts Task). */
51
+ export interface TaskView {
52
+ id: number;
53
+ subject: string;
54
+ status: TaskStatus;
55
+ activeForm?: string;
56
+ blockedBy?: number[];
57
+ delegation?: { agent?: string; state?: string };
58
+ }
59
+
60
+ /** The result details (src/services/store/types.ts TaskDetails), narrowed to what the card reads. */
61
+ export interface TaskDetailsView {
62
+ action: TaskAction;
63
+ params: Record<string, unknown>;
64
+ tasks: TaskView[];
65
+ error?: string;
66
+ upsert?: { applied: number[]; failed: number };
67
+ assignment?: { assigned: number[]; failed: number };
68
+ }
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
72
+ }
73
+
74
+ function isStatus(value: unknown): value is TaskStatus {
75
+ return typeof value === 'string' && (TASK_STATUSES as readonly string[]).includes(value);
76
+ }
77
+
78
+ function isAction(value: unknown): value is TaskAction {
79
+ return typeof value === 'string' && (TASK_ACTIONS as readonly string[]).includes(value);
80
+ }
81
+
82
+ function taskView(value: unknown): TaskView | null {
83
+ if (!isRecord(value) || typeof value.id !== 'number' || typeof value.subject !== 'string') return null;
84
+ if (!isStatus(value.status)) return null;
85
+ const task: TaskView = { id: value.id, subject: value.subject, status: value.status };
86
+ if (typeof value.activeForm === 'string') task.activeForm = value.activeForm;
87
+ if (Array.isArray(value.blockedBy)) {
88
+ task.blockedBy = value.blockedBy.filter((id): id is number => typeof id === 'number');
89
+ }
90
+ if (isRecord(value.delegation)) {
91
+ task.delegation = {};
92
+ if (typeof value.delegation.agent === 'string') task.delegation.agent = value.delegation.agent;
93
+ if (typeof value.delegation.state === 'string') task.delegation.state = value.delegation.state;
94
+ }
95
+ return task;
96
+ }
97
+
98
+ function idList(value: unknown): number[] {
99
+ return Array.isArray(value) ? value.filter((id): id is number => typeof id === 'number') : [];
100
+ }
101
+
102
+ /** Narrows the wire details; null when they are not a task result at all. */
103
+ export function taskDetailsView(details: unknown): TaskDetailsView | null {
104
+ if (!isRecord(details) || !isAction(details.action) || !Array.isArray(details.tasks)) return null;
105
+ const view: TaskDetailsView = {
106
+ action: details.action,
107
+ params: isRecord(details.params) ? details.params : {},
108
+ tasks: details.tasks.map(taskView).filter((task): task is TaskView => task !== null),
109
+ };
110
+ if (typeof details.error === 'string') view.error = details.error;
111
+ if (isRecord(details.upsert)) {
112
+ view.upsert = {
113
+ applied: idList(details.upsert.applied),
114
+ failed: typeof details.upsert.failed === 'number' ? details.upsert.failed : 0,
115
+ };
116
+ }
117
+ if (isRecord(details.assignment)) {
118
+ view.assignment = {
119
+ assigned: idList(details.assignment.assigned),
120
+ failed: typeof details.assignment.failed === 'number' ? details.assignment.failed : 0,
121
+ };
122
+ }
123
+ return view;
124
+ }
125
+
126
+ /** `#id` for the wire's id, which the TUI would resolve to a subject through the store. */
127
+ function idLabel(value: unknown): string {
128
+ return typeof value === 'number' || typeof value === 'string' ? `#${value}` : '#?';
129
+ }
130
+
131
+ /** Keep dynamic content on one line. */
132
+ function inlineText(text: string): string {
133
+ return text.replace(INLINE_WHITESPACE, ' ').trim();
134
+ }
135
+
136
+ export type TaskCallTone = 'dim' | 'accent';
137
+
138
+ /** The call header: glyph, action, then the subject or a count, as renderTaskCall lays it out. */
139
+ export interface TaskCallView {
140
+ glyph: string;
141
+ action: string;
142
+ subject?: string;
143
+ subjectTone: TaskCallTone;
144
+ /** The assignee (`→ agent`) or the list filter. */
145
+ detail?: string;
146
+ }
147
+
148
+ function upsertGlyph(items: readonly Record<string, unknown>[]): string {
149
+ if (items.length === 0) return ACTION_GLYPH.upsert;
150
+ if (items.every((item) => item.id === undefined)) return UPSERT_CREATE_GLYPH;
151
+ if (items.every((item) => item.id !== undefined)) return UPSERT_UPDATE_GLYPH;
152
+ return ACTION_GLYPH.upsert;
153
+ }
154
+
155
+ /**
156
+ * The TUI resolves an id to its subject through the task store; the card has
157
+ * no store and shows `#id` until the result names the task.
158
+ */
159
+ export function taskCallView(args: Record<string, unknown>): TaskCallView {
160
+ const action = typeof args.action === 'string' ? args.action : 'task';
161
+ if (!isAction(action)) return { glyph: '?', action, subjectTone: 'dim' };
162
+ const items = action === 'upsert' && Array.isArray(args.tasks) ? args.tasks.filter(isRecord) : [];
163
+ const view: TaskCallView = {
164
+ glyph: action === 'upsert' ? upsertGlyph(items) : ACTION_GLYPH[action],
165
+ action,
166
+ subjectTone: 'dim',
167
+ };
168
+
169
+ if (action === 'upsert') {
170
+ // One entry reads exactly as create or update did; a batch reads as a count
171
+ // rather than a subject it would have to pick arbitrarily.
172
+ if (items.length === 1) {
173
+ const only = items[0] ?? {};
174
+ if (only.id === undefined) {
175
+ if (typeof only.subject === 'string') view.subject = inlineText(only.subject);
176
+ } else {
177
+ view.subject = typeof only.subject === 'string' ? inlineText(only.subject) : idLabel(only.id);
178
+ view.subjectTone = 'accent';
179
+ }
180
+ } else if (items.length > 1) {
181
+ view.subject = `${items.length} tasks`;
182
+ }
183
+ } else if (action === 'assign') {
184
+ const assignments = Array.isArray(args.assignments) ? args.assignments.filter(isRecord) : [];
185
+ if (assignments.length === 1) {
186
+ const only = assignments[0] ?? {};
187
+ view.subject = idLabel(only.id);
188
+ view.subjectTone = 'accent';
189
+ if (typeof only.agent === 'string') view.detail = `→ ${only.agent}`;
190
+ } else if (assignments.length > 1) {
191
+ view.subject = `${assignments.length} tasks`;
192
+ }
193
+ } else if ((action === 'get' || action === 'delete' || action === 'cancel') && args.id !== undefined) {
194
+ view.subject = idLabel(args.id);
195
+ view.subjectTone = 'accent';
196
+ } else if (action === 'list' && isStatus(args.status)) {
197
+ view.detail = STATUS_LABEL[args.status];
198
+ }
199
+ return view;
200
+ }
201
+
202
+ /** One row of the result: the task line on the left, its status flushed right. */
203
+ export interface TaskRow {
204
+ id: number;
205
+ glyph: string;
206
+ subject: string;
207
+ status: TaskStatus;
208
+ /** Struck through: the task is done with. */
209
+ closed: boolean;
210
+ agent?: string;
211
+ activeForm?: string;
212
+ blockedBy: number[];
213
+ }
214
+
215
+ export type TaskResultTone = 'running' | 'ok' | 'error' | 'warning';
216
+
217
+ export interface TaskResultView {
218
+ rows: TaskRow[];
219
+ /** Error text lines when the tool threw or the reducer reported an error. */
220
+ errorLines: string[];
221
+ status: { glyph: string; tone: TaskResultTone; text: string } | null;
222
+ }
223
+
224
+ export function taskRow(task: TaskView): TaskRow {
225
+ const row: TaskRow = {
226
+ id: task.id,
227
+ glyph: STATUS_GLYPH[task.status],
228
+ subject: inlineText(task.subject),
229
+ status: task.status,
230
+ closed: task.status === 'completed' || task.status === 'deleted',
231
+ blockedBy: task.blockedBy ?? [],
232
+ };
233
+ if (task.delegation?.agent) row.agent = inlineText(task.delegation.agent);
234
+ if (task.status === 'in_progress' && task.activeForm) row.activeForm = inlineText(task.activeForm);
235
+ return row;
236
+ }
237
+
238
+ function nonEmptyLines(text: string): string[] {
239
+ return text.split('\n').filter((line) => line.trim().length > 0);
240
+ }
241
+
242
+ /**
243
+ * The rows `list` reported to the model: `details.tasks` is the whole
244
+ * document, so the call's filters apply again here or the card would disagree
245
+ * with the text the model was given.
246
+ */
247
+ function listedTasks(details: TaskDetailsView): TaskView[] {
248
+ let view = details.tasks;
249
+ if (details.params.includeDeleted !== true) view = view.filter((task) => task.status !== 'deleted');
250
+ if (isStatus(details.params.status)) view = view.filter((task) => task.status === details.params.status);
251
+ return view;
252
+ }
253
+
254
+ function byIds(details: TaskDetailsView, ids: readonly number[]): TaskView[] {
255
+ return ids.flatMap((id) => details.tasks.find((task) => task.id === id) ?? []);
256
+ }
257
+
258
+ function batch(
259
+ tasks: readonly TaskView[],
260
+ failed: number,
261
+ verb: string,
262
+ expanded: boolean,
263
+ ): Pick<TaskResultView, 'rows' | 'status'> {
264
+ // One applied task and nothing failed is the common case: keep the single
265
+ // row it has always had rather than dressing it as a bulk report.
266
+ if (tasks.length === 1 && failed === 0) return { rows: [taskRow(tasks[0] as TaskView)], status: null };
267
+ const shown = expanded ? tasks : tasks.slice(0, TRANSCRIPT_TASK_ROWS);
268
+ let summary = `${tasks.length} ${verb}`;
269
+ if (failed > 0) summary += ` · ${failed} failed`;
270
+ if (tasks.length > shown.length) summary += ` · ${tasks.length - shown.length} more`;
271
+ return {
272
+ rows: shown.map(taskRow),
273
+ status: { glyph: failed > 0 ? '!' : '✓', tone: failed > 0 ? 'warning' : 'ok', text: summary },
274
+ };
275
+ }
276
+
277
+ export function taskResultView(input: {
278
+ details: unknown;
279
+ output: string;
280
+ expanded: boolean;
281
+ isPartial: boolean;
282
+ isError: boolean;
283
+ }): TaskResultView {
284
+ const details = taskDetailsView(input.details);
285
+
286
+ // Running. `assign` and `cancel` stream a placeholder before the run settles.
287
+ if (input.isPartial) {
288
+ const text = nonEmptyLines(input.output).at(-1) ?? 'working';
289
+ return { rows: [], errorLines: [], status: { glyph: '◐', tone: 'running', text } };
290
+ }
291
+
292
+ // Failed. Every failure path throws, so Pi hands back error text and no
293
+ // details; without this the card would show a green tick on a failure.
294
+ if (input.isError || details?.error !== undefined) {
295
+ const all = nonEmptyLines(details?.error ?? input.output);
296
+ if (all.length <= 1) return { rows: [], errorLines: [], status: { glyph: '✗', tone: 'error', text: all[0] ?? '' } };
297
+ const errorLines = input.expanded ? all : all.slice(0, TRANSCRIPT_TASK_ROWS);
298
+ return { rows: [], errorLines, status: { glyph: '✗', tone: 'error', text: '' } };
299
+ }
300
+
301
+ if (details === null) return { rows: [], errorLines: [], status: { glyph: '✓', tone: 'ok', text: '' } };
302
+
303
+ if (details.action === 'list') {
304
+ const tasks = listedTasks(details);
305
+ const shown = input.expanded ? tasks : tasks.slice(0, TRANSCRIPT_TASK_ROWS);
306
+ let summary = tasks.length === 0 ? 'no tasks' : `${tasks.length} task${tasks.length === 1 ? '' : 's'}`;
307
+ if (tasks.length > shown.length) summary += ` · ${tasks.length - shown.length} more`;
308
+ return { rows: shown.map(taskRow), errorLines: [], status: { glyph: '✓', tone: 'ok', text: summary } };
309
+ }
310
+
311
+ if (details.action === 'clear') {
312
+ return { rows: [], errorLines: [], status: { glyph: '✓', tone: 'ok', text: 'cleared' } };
313
+ }
314
+
315
+ if (details.action === 'assign' && details.assignment) {
316
+ const assigned = byIds(details, details.assignment.assigned);
317
+ return { errorLines: [], ...batch(assigned, details.assignment.failed, 'assigned', input.expanded) };
318
+ }
319
+
320
+ if (details.action === 'upsert') {
321
+ const touched = byIds(details, details.upsert?.applied ?? []);
322
+ return { errorLines: [], ...batch(touched, details.upsert?.failed ?? 0, 'applied', input.expanded) };
323
+ }
324
+
325
+ // The single task an id-bearing action acted on, so the card names it rather than a bare tick.
326
+ const id = details.params.id;
327
+ const task = typeof id === 'number' ? details.tasks.find((candidate) => candidate.id === id) : undefined;
328
+ if (!task) return { rows: [], errorLines: [], status: { glyph: '✓', tone: 'ok', text: '' } };
329
+ return { rows: [taskRow(task)], errorLines: [], status: null };
330
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noImplicitAny": true,
10
+ "noUnusedLocals": true,
11
+ "noUnusedParameters": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "noEmit": true,
15
+ "resolveJsonModule": true,
16
+ "esModuleInterop": true,
17
+ "skipLibCheck": true,
18
+ "allowImportingTsExtensions": true,
19
+ "forceConsistentCasingInFileNames": true,
20
+ "types": ["node"]
21
+ },
22
+ "include": [".", "../src/types"]
23
+ }