@mblarsen/pi-task-ui 0.1.0

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/core.ts ADDED
@@ -0,0 +1,586 @@
1
+ export const TASK_UI_STATE_VERSION = 5 as const;
2
+
3
+ export const TASK_STATUSES = ["pending", "in_progress", "completed", "failed", "stopped"] as const;
4
+ export const MAX_TASK_OUTPUT_CHARS = 2_000;
5
+ export const MAX_TASK_OUTPUT_ENTRIES = 100;
6
+
7
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
8
+
9
+ export interface TaskOutputEntry {
10
+ text: string;
11
+ timestamp: string;
12
+ }
13
+
14
+ export interface TaskRecord {
15
+ id: string;
16
+ number: number;
17
+ subject: string;
18
+ description?: string;
19
+ label?: string;
20
+ status: TaskStatus;
21
+ progress?: number;
22
+ owner?: string;
23
+ parentId?: string;
24
+ subtaskNumber?: number;
25
+ blockedBy: string[];
26
+ executing: boolean;
27
+ activeForm?: string;
28
+ startedAt?: string;
29
+ inputTokens?: number;
30
+ outputTokens?: number;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ terminalAt?: string;
34
+ output: TaskOutputEntry[];
35
+ }
36
+
37
+ export interface TaskUiState {
38
+ version: typeof TASK_UI_STATE_VERSION;
39
+ tasks: TaskRecord[];
40
+ focusedTaskId?: string;
41
+ nextId: number;
42
+ nextNumber: number;
43
+ }
44
+
45
+ export interface CreateTaskInput {
46
+ id?: string;
47
+ number?: number;
48
+ subject: string;
49
+ description?: string;
50
+ label?: string;
51
+ status?: TaskStatus;
52
+ progress?: number;
53
+ owner?: string;
54
+ parentId?: string;
55
+ subtaskNumber?: number;
56
+ blockedBy?: string[];
57
+ executing?: boolean;
58
+ activeForm?: string;
59
+ startedAt?: string;
60
+ inputTokens?: number;
61
+ outputTokens?: number;
62
+ }
63
+
64
+ export interface UpdateTaskInput {
65
+ taskId: string;
66
+ subject?: string;
67
+ description?: string;
68
+ label?: string;
69
+ status?: TaskStatus;
70
+ progress?: number;
71
+ owner?: string;
72
+ parentId?: string | null;
73
+ blockedBy?: string[];
74
+ executing?: boolean;
75
+ activeForm?: string;
76
+ startedAt?: string;
77
+ inputTokens?: number;
78
+ outputTokens?: number;
79
+ }
80
+
81
+ export interface ExternalTaskInput {
82
+ id: string;
83
+ number?: number;
84
+ subject?: string;
85
+ title?: string;
86
+ name?: string;
87
+ description?: string;
88
+ label?: string;
89
+ status?: string;
90
+ progress?: number;
91
+ owner?: string;
92
+ parentId?: string | null;
93
+ subtaskNumber?: number;
94
+ blockedBy?: string[];
95
+ executing?: boolean;
96
+ activeForm?: string;
97
+ startedAt?: string;
98
+ inputTokens?: number;
99
+ outputTokens?: number;
100
+ createdAt?: string;
101
+ updatedAt?: string;
102
+ terminalAt?: string;
103
+ output?: Array<TaskOutputEntry | string>;
104
+ }
105
+
106
+ export interface TaskDashboard {
107
+ active: TaskRecord[];
108
+ next?: TaskRecord;
109
+ focused?: TaskRecord;
110
+ }
111
+
112
+ export function createInitialTaskUiState(): TaskUiState {
113
+ return { version: TASK_UI_STATE_VERSION, tasks: [], nextId: 1, nextNumber: 1 };
114
+ }
115
+
116
+ function clampProgress(progress: number | undefined): number | undefined {
117
+ if (progress === undefined || !Number.isFinite(progress)) return undefined;
118
+ return Math.max(0, Math.min(100, Math.round(progress)));
119
+ }
120
+
121
+ function clampTokens(tokens: number | undefined): number | undefined {
122
+ if (tokens === undefined || !Number.isFinite(tokens)) return undefined;
123
+ return Math.max(0, Math.round(tokens));
124
+ }
125
+
126
+ function cleanOptional(value: string | undefined): string | undefined {
127
+ const cleaned = value?.trim();
128
+ return cleaned ? cleaned : undefined;
129
+ }
130
+
131
+ function cleanBlockers(blockedBy: string[] | undefined): string[] {
132
+ if (!blockedBy) return [];
133
+ return [...new Set(blockedBy.map((id) => id.trim()).filter(Boolean))];
134
+ }
135
+
136
+ function normalizeOutput(entries: Array<TaskOutputEntry | string>, fallbackTimestamp: string): TaskOutputEntry[] {
137
+ return entries.slice(-MAX_TASK_OUTPUT_ENTRIES).map((entry) => {
138
+ const text = typeof entry === "string" ? entry : entry.text;
139
+ const trimmed = text.trim();
140
+ const displayText = trimmed.length > MAX_TASK_OUTPUT_CHARS ? `${trimmed.slice(0, MAX_TASK_OUTPUT_CHARS - 1)}…` : trimmed;
141
+ return { text: displayText, timestamp: typeof entry === "string" ? fallbackTimestamp : entry.timestamp };
142
+ });
143
+ }
144
+
145
+ function cloneTask(task: TaskRecord): TaskRecord {
146
+ return {
147
+ ...task,
148
+ blockedBy: [...task.blockedBy],
149
+ output: task.output.map((entry) => ({ ...entry })),
150
+ };
151
+ }
152
+
153
+ export function cloneTaskUiState(state: TaskUiState): TaskUiState {
154
+ return { ...state, tasks: state.tasks.map(cloneTask) };
155
+ }
156
+
157
+ export function normalizeTaskStatus(status: string | undefined): TaskStatus {
158
+ switch (status?.toLowerCase()) {
159
+ case "working":
160
+ case "running":
161
+ case "active":
162
+ case "current":
163
+ case "in-progress":
164
+ case "in_progress":
165
+ return "in_progress";
166
+ case "done":
167
+ case "success":
168
+ case "succeeded":
169
+ case "complete":
170
+ case "completed":
171
+ return "completed";
172
+ case "error":
173
+ case "errored":
174
+ case "failure":
175
+ case "failed":
176
+ return "failed";
177
+ case "cancelled":
178
+ case "canceled":
179
+ case "stopped":
180
+ return "stopped";
181
+ default:
182
+ return "pending";
183
+ }
184
+ }
185
+
186
+ function isTerminal(status: TaskStatus): boolean {
187
+ return status === "completed" || status === "failed" || status === "stopped";
188
+ }
189
+
190
+ function chooseFocus(tasks: TaskRecord[], current?: string): string | undefined {
191
+ if (current && tasks.some((task) => task.id === current && !isTerminal(task.status))) return current;
192
+ return tasks.find((task) => task.executing)?.id
193
+ ?? tasks.find((task) => task.status === "in_progress")?.id
194
+ ?? getNextTask(tasks)?.id;
195
+ }
196
+
197
+ function nextGeneratedId(state: TaskUiState): { id: string; nextId: number } {
198
+ let sequence = state.nextId;
199
+ let id = `task-${sequence}`;
200
+ const existing = new Set(state.tasks.map((task) => task.id));
201
+ while (existing.has(id)) {
202
+ sequence += 1;
203
+ id = `task-${sequence}`;
204
+ }
205
+ return { id, nextId: sequence + 1 };
206
+ }
207
+
208
+ function nextTaskNumber(state: TaskUiState, requested?: number): { number: number; nextNumber: number } {
209
+ const used = new Set(state.tasks.map((task) => task.number));
210
+ if (requested !== undefined && Number.isInteger(requested) && requested > 0 && !used.has(requested)) {
211
+ return { number: requested, nextNumber: Math.max(state.nextNumber, requested + 1) };
212
+ }
213
+ let number = state.nextNumber;
214
+ while (used.has(number)) number += 1;
215
+ return { number, nextNumber: number + 1 };
216
+ }
217
+
218
+ function nextSubtaskNumber(tasks: TaskRecord[], parentId: string, requested?: number): number {
219
+ const used = new Set(tasks.filter((task) => task.parentId === parentId).map((task) => task.subtaskNumber));
220
+ if (requested !== undefined && Number.isInteger(requested) && requested > 0 && !used.has(requested)) return requested;
221
+ let number = 1;
222
+ while (used.has(number)) number += 1;
223
+ return number;
224
+ }
225
+
226
+ function assertValidParent(tasks: TaskRecord[], taskId: string, parentId: string | undefined): void {
227
+ if (!parentId) return;
228
+ if (parentId === taskId) throw new Error("A task cannot be its own parent");
229
+ const byId = new Map(tasks.map((task) => [task.id, task]));
230
+ if (!byId.has(parentId)) throw new Error(`Parent task not found: ${parentId}`);
231
+ const seen = new Set<string>([taskId]);
232
+ let current: string | undefined = parentId;
233
+ while (current) {
234
+ if (seen.has(current)) throw new Error("Task parent relationship would create a cycle");
235
+ seen.add(current);
236
+ current = byId.get(current)?.parentId;
237
+ }
238
+ }
239
+
240
+ export function getTaskDepth(task: TaskRecord, tasks: TaskRecord[]): number {
241
+ const byId = new Map(tasks.map((item) => [item.id, item]));
242
+ const seen = new Set<string>([task.id]);
243
+ let depth = 0;
244
+ let current = task.parentId;
245
+ while (current && !seen.has(current) && byId.has(current)) {
246
+ seen.add(current);
247
+ depth += 1;
248
+ current = byId.get(current)?.parentId;
249
+ }
250
+ return depth;
251
+ }
252
+
253
+ export function getTaskDisplayNumber(task: TaskRecord, tasks: TaskRecord[]): string {
254
+ const byId = new Map(tasks.map((item) => [item.id, item]));
255
+ const labels: number[] = [];
256
+ const seen = new Set<string>();
257
+ let current: TaskRecord | undefined = task;
258
+ while (current && !seen.has(current.id)) {
259
+ seen.add(current.id);
260
+ if (!current.parentId || current.subtaskNumber === undefined) {
261
+ labels.unshift(current.number);
262
+ break;
263
+ }
264
+ labels.unshift(current.subtaskNumber);
265
+ current = byId.get(current.parentId);
266
+ }
267
+ return labels.length ? labels.join(".") : String(task.number);
268
+ }
269
+
270
+ function resolveParentUpdate(
271
+ tasks: TaskRecord[],
272
+ current: TaskRecord,
273
+ requested: string | null | undefined,
274
+ ): { parentId: string | undefined; subtaskNumber: number | undefined } {
275
+ if (requested === undefined) return { parentId: current.parentId, subtaskNumber: current.subtaskNumber };
276
+ const parentId = cleanOptional(requested ?? undefined);
277
+ assertValidParent(tasks, current.id, parentId);
278
+ if (!parentId) return { parentId: undefined, subtaskNumber: undefined };
279
+ if (parentId === current.parentId) {
280
+ return { parentId, subtaskNumber: current.subtaskNumber ?? nextSubtaskNumber(tasks, parentId) };
281
+ }
282
+ return { parentId, subtaskNumber: nextSubtaskNumber(tasks, parentId) };
283
+ }
284
+
285
+ export function getNextTask(tasks: TaskRecord[]): TaskRecord | undefined {
286
+ const statusById = new Map(tasks.map((task) => [task.id, task.status]));
287
+ const next = tasks.find((task) =>
288
+ task.status === "pending" && task.blockedBy.every((id) => statusById.get(id) === "completed")
289
+ );
290
+ return next ? cloneTask(next) : undefined;
291
+ }
292
+
293
+ export function getTaskDashboard(state: TaskUiState): TaskDashboard {
294
+ const focused = state.focusedTaskId ? state.tasks.find((task) => task.id === state.focusedTaskId) : undefined;
295
+ return {
296
+ active: state.tasks.filter((task) => task.status === "in_progress").map(cloneTask),
297
+ next: getNextTask(state.tasks),
298
+ focused: focused ? cloneTask(focused) : undefined,
299
+ };
300
+ }
301
+
302
+ export function createTask(
303
+ state: TaskUiState,
304
+ input: CreateTaskInput,
305
+ now = new Date().toISOString(),
306
+ ): { state: TaskUiState; task: TaskRecord } {
307
+ const subject = input.subject.trim();
308
+ if (!subject) throw new Error("Task subject is required");
309
+
310
+ const generated = nextGeneratedId(state);
311
+ const id = cleanOptional(input.id) ?? generated.id;
312
+ if (state.tasks.some((task) => task.id === id)) throw new Error(`Task already exists: ${id}`);
313
+ const numbering = nextTaskNumber(state, input.number);
314
+ const parentId = cleanOptional(input.parentId);
315
+ assertValidParent(state.tasks, id, parentId);
316
+ const subtaskNumber = parentId ? nextSubtaskNumber(state.tasks, parentId, input.subtaskNumber) : undefined;
317
+ const requestedStatus = input.status ?? "pending";
318
+ const executing = input.executing === true && !isTerminal(requestedStatus);
319
+ const status = executing ? "in_progress" : requestedStatus;
320
+
321
+ const task: TaskRecord = {
322
+ id,
323
+ number: numbering.number,
324
+ subject,
325
+ description: cleanOptional(input.description),
326
+ label: cleanOptional(input.label),
327
+ status,
328
+ progress: clampProgress(input.progress),
329
+ owner: cleanOptional(input.owner),
330
+ parentId,
331
+ subtaskNumber,
332
+ blockedBy: cleanBlockers(input.blockedBy),
333
+ executing,
334
+ activeForm: cleanOptional(input.activeForm),
335
+ startedAt: executing ? cleanOptional(input.startedAt) ?? now : cleanOptional(input.startedAt),
336
+ inputTokens: clampTokens(input.inputTokens),
337
+ outputTokens: clampTokens(input.outputTokens),
338
+ createdAt: now,
339
+ updatedAt: now,
340
+ terminalAt: isTerminal(status) ? now : undefined,
341
+ output: [],
342
+ };
343
+ const tasks = [...state.tasks.map(cloneTask), task];
344
+ const focusedTaskId = executing || status === "in_progress" || !state.focusedTaskId
345
+ ? task.id
346
+ : chooseFocus(tasks, state.focusedTaskId);
347
+ return {
348
+ state: {
349
+ version: TASK_UI_STATE_VERSION,
350
+ tasks,
351
+ focusedTaskId,
352
+ nextId: input.id ? state.nextId : generated.nextId,
353
+ nextNumber: numbering.nextNumber,
354
+ },
355
+ task: cloneTask(task),
356
+ };
357
+ }
358
+
359
+ export function createTasks(
360
+ state: TaskUiState,
361
+ inputs: CreateTaskInput[],
362
+ now = new Date().toISOString(),
363
+ ): { state: TaskUiState; tasks: TaskRecord[] } {
364
+ if (inputs.length === 0) throw new Error("At least one task is required");
365
+ let next = cloneTaskUiState(state);
366
+ const pending = inputs.map((input, index) => ({ input, index }));
367
+ const created = new Map<number, TaskRecord>();
368
+ while (pending.length) {
369
+ let progressed = false;
370
+ for (let index = 0; index < pending.length;) {
371
+ const candidate = pending[index];
372
+ const parentId = cleanOptional(candidate.input.parentId);
373
+ if (parentId && !next.tasks.some((task) => task.id === parentId)) {
374
+ index += 1;
375
+ continue;
376
+ }
377
+ const result = createTask(next, candidate.input, now);
378
+ next = result.state;
379
+ created.set(candidate.index, result.task);
380
+ pending.splice(index, 1);
381
+ progressed = true;
382
+ }
383
+ if (!progressed) {
384
+ const declaredIds = new Set(inputs.map((input) => cleanOptional(input.id)).filter((id): id is string => id !== undefined));
385
+ const missingParent = pending
386
+ .map((candidate) => cleanOptional(candidate.input.parentId))
387
+ .find((parentId) => parentId && !declaredIds.has(parentId) && !state.tasks.some((task) => task.id === parentId));
388
+ if (missingParent) throw new Error(`Parent task not found: ${missingParent}`);
389
+ throw new Error("Task parent relationships contain a cycle");
390
+ }
391
+ }
392
+ return { state: next, tasks: inputs.map((_input, index) => cloneTask(created.get(index)!)) };
393
+ }
394
+
395
+ export function updateTask(
396
+ state: TaskUiState,
397
+ input: UpdateTaskInput,
398
+ now = new Date().toISOString(),
399
+ ): { state: TaskUiState; task: TaskRecord } {
400
+ const index = state.tasks.findIndex((task) => task.id === input.taskId);
401
+ if (index < 0) throw new Error(`Task not found: ${input.taskId}`);
402
+
403
+ const tasks = state.tasks.map(cloneTask);
404
+ const current = tasks[index];
405
+ const subject = input.subject === undefined ? current.subject : input.subject.trim();
406
+ if (!subject) throw new Error("Task subject cannot be empty");
407
+ const requestedStatus = input.status ?? current.status;
408
+ const requestedExecuting = input.executing ?? current.executing;
409
+ const status = requestedExecuting && !isTerminal(requestedStatus) ? "in_progress" : requestedStatus;
410
+ const executing = isTerminal(status) ? false : requestedExecuting;
411
+ const startedAt = input.startedAt !== undefined
412
+ ? cleanOptional(input.startedAt)
413
+ : executing && !current.executing ? now : current.startedAt;
414
+ const parent = resolveParentUpdate(tasks, current, input.parentId);
415
+ const terminalAt = isTerminal(status)
416
+ ? isTerminal(current.status) ? current.terminalAt ?? current.updatedAt : now
417
+ : undefined;
418
+ const updated: TaskRecord = {
419
+ ...current,
420
+ subject,
421
+ description: input.description === undefined ? current.description : cleanOptional(input.description),
422
+ label: input.label === undefined ? current.label : cleanOptional(input.label),
423
+ status,
424
+ progress: input.progress === undefined ? current.progress : clampProgress(input.progress),
425
+ owner: input.owner === undefined ? current.owner : cleanOptional(input.owner),
426
+ parentId: parent.parentId,
427
+ subtaskNumber: parent.subtaskNumber,
428
+ blockedBy: input.blockedBy === undefined ? current.blockedBy : cleanBlockers(input.blockedBy),
429
+ executing,
430
+ activeForm: input.activeForm === undefined ? current.activeForm : cleanOptional(input.activeForm),
431
+ startedAt,
432
+ inputTokens: input.inputTokens === undefined ? current.inputTokens : clampTokens(input.inputTokens),
433
+ outputTokens: input.outputTokens === undefined ? current.outputTokens : clampTokens(input.outputTokens),
434
+ updatedAt: now,
435
+ terminalAt,
436
+ };
437
+ tasks[index] = updated;
438
+ const requestedFocus = executing || status === "in_progress" ? updated.id : state.focusedTaskId;
439
+ return {
440
+ state: { ...state, tasks, focusedTaskId: chooseFocus(tasks, requestedFocus) },
441
+ task: cloneTask(updated),
442
+ };
443
+ }
444
+
445
+ export function appendTaskOutput(
446
+ state: TaskUiState,
447
+ taskId: string,
448
+ text: string,
449
+ now = new Date().toISOString(),
450
+ ): { state: TaskUiState; task: TaskRecord } {
451
+ const trimmed = text.trim();
452
+ if (!trimmed) throw new Error("Task output text is required");
453
+ const displayText = trimmed.length > MAX_TASK_OUTPUT_CHARS ? `${trimmed.slice(0, MAX_TASK_OUTPUT_CHARS - 1)}…` : trimmed;
454
+ const index = state.tasks.findIndex((task) => task.id === taskId);
455
+ if (index < 0) throw new Error(`Task not found: ${taskId}`);
456
+
457
+ const tasks = state.tasks.map(cloneTask);
458
+ const output = [...tasks[index].output, { text: displayText, timestamp: now }].slice(-MAX_TASK_OUTPUT_ENTRIES);
459
+ tasks[index] = { ...tasks[index], updatedAt: now, output };
460
+ return { state: { ...state, tasks }, task: cloneTask(tasks[index]) };
461
+ }
462
+
463
+ export function clearTaskOutput(state: TaskUiState, taskId: string, now = new Date().toISOString()): { state: TaskUiState; task: TaskRecord } {
464
+ const index = state.tasks.findIndex((task) => task.id === taskId);
465
+ if (index < 0) throw new Error(`Task not found: ${taskId}`);
466
+ const tasks = state.tasks.map(cloneTask);
467
+ tasks[index] = { ...tasks[index], updatedAt: now, output: [] };
468
+ return { state: { ...state, tasks }, task: cloneTask(tasks[index]) };
469
+ }
470
+
471
+ export function removeTask(state: TaskUiState, taskId: string): TaskUiState {
472
+ const tasks = state.tasks
473
+ .filter((task) => task.id !== taskId)
474
+ .map((task) => task.parentId === taskId ? { ...cloneTask(task), parentId: undefined, subtaskNumber: undefined } : cloneTask(task));
475
+ if (tasks.length === state.tasks.length) throw new Error(`Task not found: ${taskId}`);
476
+ return { ...state, tasks, focusedTaskId: chooseFocus(tasks, state.focusedTaskId) };
477
+ }
478
+
479
+ export function setFocusedTask(state: TaskUiState, taskId?: string): TaskUiState {
480
+ if (taskId !== undefined && !state.tasks.some((task) => task.id === taskId)) throw new Error(`Task not found: ${taskId}`);
481
+ return { ...cloneTaskUiState(state), focusedTaskId: taskId };
482
+ }
483
+
484
+ export function upsertExternalTask(
485
+ state: TaskUiState,
486
+ input: ExternalTaskInput,
487
+ now = new Date().toISOString(),
488
+ ): { state: TaskUiState; task: TaskRecord } {
489
+ const existing = state.tasks.find((task) => task.id === input.id);
490
+ if (!existing) {
491
+ const result = createTask(state, {
492
+ id: input.id,
493
+ number: input.number,
494
+ subject: input.subject ?? input.title ?? input.name ?? input.id,
495
+ description: input.description,
496
+ label: input.label,
497
+ status: normalizeTaskStatus(input.status),
498
+ progress: input.progress,
499
+ owner: input.owner,
500
+ parentId: cleanOptional(input.parentId ?? undefined),
501
+ subtaskNumber: input.subtaskNumber,
502
+ blockedBy: input.blockedBy,
503
+ executing: input.executing,
504
+ activeForm: input.activeForm,
505
+ startedAt: input.startedAt,
506
+ inputTokens: input.inputTokens,
507
+ outputTokens: input.outputTokens,
508
+ }, input.createdAt ?? now);
509
+ const output = normalizeOutput(input.output ?? [], now);
510
+ const tasks = result.state.tasks.map((task) => task.id === input.id ? {
511
+ ...task,
512
+ updatedAt: input.updatedAt ?? task.updatedAt,
513
+ terminalAt: isTerminal(task.status) ? input.terminalAt ?? input.updatedAt ?? task.terminalAt : undefined,
514
+ output,
515
+ } : task);
516
+ return { state: { ...result.state, tasks }, task: cloneTask(tasks.find((task) => task.id === input.id)!) };
517
+ }
518
+
519
+ const result = updateTask(state, {
520
+ taskId: input.id,
521
+ subject: input.subject ?? input.title ?? input.name,
522
+ description: input.description,
523
+ label: input.label,
524
+ status: input.status === undefined ? undefined : normalizeTaskStatus(input.status),
525
+ progress: input.progress,
526
+ owner: input.owner,
527
+ parentId: input.parentId,
528
+ blockedBy: input.blockedBy,
529
+ executing: input.executing,
530
+ activeForm: input.activeForm,
531
+ startedAt: input.startedAt,
532
+ inputTokens: input.inputTokens,
533
+ outputTokens: input.outputTokens,
534
+ }, input.updatedAt ?? now);
535
+ if (input.output || input.terminalAt) {
536
+ const output = input.output ? normalizeOutput(input.output, now) : result.task.output;
537
+ const tasks = result.state.tasks.map((task) => task.id === input.id ? {
538
+ ...task,
539
+ terminalAt: isTerminal(task.status) ? input.terminalAt ?? task.terminalAt : undefined,
540
+ output,
541
+ } : task);
542
+ return { state: { ...result.state, tasks }, task: cloneTask(tasks.find((task) => task.id === input.id)!) };
543
+ }
544
+ return result;
545
+ }
546
+
547
+ export function replaceExternalTasks(
548
+ inputs: ExternalTaskInput[],
549
+ focusedTaskId?: string,
550
+ now = new Date().toISOString(),
551
+ ): TaskUiState {
552
+ let state = createInitialTaskUiState();
553
+ const pending = [...inputs];
554
+ while (pending.length) {
555
+ let progressed = false;
556
+ for (let index = 0; index < pending.length;) {
557
+ const input = pending[index];
558
+ const parentId = cleanOptional(input.parentId ?? undefined);
559
+ if (parentId && !state.tasks.some((task) => task.id === parentId)) {
560
+ index += 1;
561
+ continue;
562
+ }
563
+ state = upsertExternalTask(state, input, now).state;
564
+ pending.splice(index, 1);
565
+ progressed = true;
566
+ }
567
+ if (!progressed) throw new Error("External task parents are missing or cyclic");
568
+ }
569
+ return { ...state, focusedTaskId: chooseFocus(state.tasks, focusedTaskId) };
570
+ }
571
+
572
+ export function normalizeStoredTaskUiState(value: unknown): TaskUiState | undefined {
573
+ if (!value || typeof value !== "object") return undefined;
574
+ const candidate = value as Partial<TaskUiState>;
575
+ if (!Array.isArray(candidate.tasks)) return undefined;
576
+ try {
577
+ const state = replaceExternalTasks(candidate.tasks as ExternalTaskInput[], candidate.focusedTaskId);
578
+ return {
579
+ ...state,
580
+ nextId: typeof candidate.nextId === "number" && candidate.nextId > 0 ? Math.floor(candidate.nextId) : state.nextId,
581
+ nextNumber: typeof candidate.nextNumber === "number" && candidate.nextNumber > 0 ? Math.floor(candidate.nextNumber) : state.nextNumber,
582
+ };
583
+ } catch {
584
+ return undefined;
585
+ }
586
+ }