taskchef 7.19.1 → 7.21.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.
@@ -1,22 +1,34 @@
1
1
  import {
2
2
  canArchiveTask,
3
+ canManuallyTransitionTask,
3
4
  clearNotifications,
4
5
  dismissNotification,
5
6
  filterTasks,
6
7
  findCurrentTask,
7
8
  latestTurnPresentation,
9
+ manualTransitionExpectedState,
8
10
  mergeProjectedTurns,
9
11
  nextDateFilterRefreshDelay,
10
12
  notificationDismissLabel,
11
13
  notificationOpenLabel,
12
14
  notificationTitle,
13
15
  reconcileNotifications,
16
+ reconcileManualTransition,
17
+ reconcileManualTransitionResponse,
14
18
  statusFilterCounts,
15
19
  statusFilterText,
16
20
  taskStatusLabel,
21
+ taskMatchesManualTransitionExpected,
17
22
  turnPresentation,
18
23
  } from "./state.js";
19
- import { archiveTaskFromControl, openTaskFromControl } from "./actions.js";
24
+ import {
25
+ archiveTaskFromControl,
26
+ focusManualTransitionStatus,
27
+ handleManualTransitionEscape,
28
+ manuallyTransitionTaskFromControl,
29
+ openTaskFromControl,
30
+ restoreTaskActionMenuFocus,
31
+ } from "./actions.js";
20
32
  import {
21
33
  githubReferenceAccessibleLabel,
22
34
  githubReferenceDisplayLabels,
@@ -25,13 +37,18 @@ import {
25
37
  } from "./github-links.js";
26
38
  import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
27
39
 
40
+ const USAGE_POLL_INTERVAL_MS = 1_500;
41
+ const MAX_USAGE_POLL_ATTEMPTS = 40;
42
+
28
43
  const state = {
44
+ archivePendingThreadIds: new Set(),
29
45
  archivedThreadIds: new Set(),
30
46
  tasks: [],
31
47
  signatures: new Map(),
32
48
  notifications: [],
33
49
  seenNotificationIds: new Set(),
34
50
  initialized: false,
51
+ manualTransition: null,
35
52
  selectedTask: null,
36
53
  };
37
54
  let dateRefreshTimer = null;
@@ -57,10 +74,17 @@ const elements = {
57
74
  dialogRelatedLinks: document.querySelector("#dialog-related-links"),
58
75
  dialogResults: document.querySelector("#dialog-results"),
59
76
  dialogTitle: document.querySelector("#dialog-title"),
77
+ dialogUsage: document.querySelector("#dialog-usage"),
60
78
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
61
79
  emptyState: document.querySelector("#empty-state"),
62
80
  notifications: document.querySelector("#notifications"),
63
81
  notificationAnnouncer: document.querySelector("#notification-announcer"),
82
+ manualTransitionPanel: document.querySelector("#manual-transition-panel"),
83
+ manualTransitionError: document.querySelector("#manual-transition-error"),
84
+ manualTransitionStatus: document.querySelector("#manual-transition-status"),
85
+ markTaskCompleted: document.querySelector("#mark-task-completed"),
86
+ markTaskFailed: document.querySelector("#mark-task-failed"),
87
+ moreTaskActions: document.querySelector("#more-task-actions"),
64
88
  openProject: document.querySelector("#open-codex"),
65
89
  projectFilter: document.querySelector("#project-filter"),
66
90
  statusFilter: document.querySelector("#status-filter"),
@@ -347,6 +371,58 @@ function detailRow(term, value) {
347
371
  return [dt, dd];
348
372
  }
349
373
 
374
+ const tokenFormatter = new Intl.NumberFormat();
375
+
376
+ function formatEstimatedCost(value) {
377
+ if (typeof value !== "number") return "cost unavailable";
378
+ if (value === 0) return "estimated $0.00";
379
+ return `estimated $${value < 0.01 ? value.toFixed(4) : value.toFixed(2)}`;
380
+ }
381
+
382
+ function usageBreakdownText(usage) {
383
+ return [
384
+ `${tokenFormatter.format(usage.inputTokens)} input`,
385
+ `${tokenFormatter.format(usage.cachedInputTokens)} cached input`,
386
+ `${tokenFormatter.format(usage.outputTokens)} output`,
387
+ `${tokenFormatter.format(usage.reasoningOutputTokens)} reasoning`,
388
+ ].join(" · ");
389
+ }
390
+
391
+ function usagePresentation(usage, { wholeTask = false } = {}) {
392
+ const container = document.createElement("div");
393
+ container.className = `usage-summary usage-${usage?.status ?? "calculating"}`;
394
+ if (!usage || usage.status === "calculating") {
395
+ const indicator = document.createElement("span");
396
+ indicator.className = "usage-spinner";
397
+ indicator.setAttribute("aria-hidden", "true");
398
+ const text = document.createElement("span");
399
+ text.textContent = "Tokens: calculating…";
400
+ container.replaceChildren(indicator, text);
401
+ return container;
402
+ }
403
+ if (usage.status !== "available") {
404
+ container.textContent = `Tokens unavailable${usage.reason ? ` — ${usage.reason}` : "."}`;
405
+ return container;
406
+ }
407
+ const headline = document.createElement("strong");
408
+ headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}`;
409
+ const breakdown = document.createElement("span");
410
+ breakdown.textContent = usageBreakdownText(usage);
411
+ container.append(headline, breakdown);
412
+ const provenance = document.createElement("span");
413
+ const version = usage.provenance?.version ? ` ${usage.provenance.version}` : "";
414
+ const freshness = usage.sourceUpdatedAt ?? usage.sampledAt;
415
+ provenance.textContent = `Source: ccusage${version}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
416
+ container.append(provenance);
417
+ return container;
418
+ }
419
+
420
+ function usageStillCalculating(task) {
421
+ return task.usage?.status === "calculating"
422
+ || (task.status !== "working" && Object.values(task.usage?.turns ?? {})
423
+ .some((turn) => turn.status === "calculating"));
424
+ }
425
+
350
426
  function turnTimeline(task) {
351
427
  if (task.turns.length === 0) {
352
428
  const empty = document.createElement("p");
@@ -370,6 +446,12 @@ function turnTimeline(task) {
370
446
  key: `detail:${task.id}:turn:${turnKey}`,
371
447
  });
372
448
  header.append(status, timestamp);
449
+ if (presentation.sourceLabel) {
450
+ const source = document.createElement("span");
451
+ source.className = "result-history-source";
452
+ source.textContent = presentation.sourceLabel;
453
+ header.insertBefore(source, timestamp);
454
+ }
373
455
  const requestLabel = document.createElement("h4");
374
456
  requestLabel.textContent = "Request";
375
457
  const request = document.createElement("p");
@@ -387,11 +469,157 @@ function turnTimeline(task) {
387
469
  const turnMetadata = document.createElement("p");
388
470
  turnMetadata.className = "result-history-turn";
389
471
  turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
390
- item.append(header, requestLabel, request, resultLabel, result, turnMetadata);
472
+ const turnUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId] ?? (
473
+ turn.result === null
474
+ ? { status: "calculating" }
475
+ : { status: "unavailable", reason: "No reliable turn boundary is available." }
476
+ );
477
+ item.append(
478
+ header,
479
+ requestLabel,
480
+ request,
481
+ resultLabel,
482
+ result,
483
+ usagePresentation(turnUsage),
484
+ turnMetadata,
485
+ );
391
486
  return item;
392
487
  });
393
488
  }
394
489
 
490
+ function manualTransitionPending() {
491
+ return state.manualTransition?.stage === "pending";
492
+ }
493
+
494
+ function resetManualTransition({ focus = false } = {}) {
495
+ state.manualTransition = null;
496
+ elements.closeDialog.disabled = false;
497
+ if (state.selectedTask) renderManualTransition(state.selectedTask);
498
+ if (focus) elements.moreTaskActions.focus();
499
+ }
500
+
501
+ function replaceCurrentTask(task) {
502
+ state.tasks = state.tasks.map((candidate) => candidate.id === task.id ? task : candidate);
503
+ state.selectedTask = task;
504
+ }
505
+
506
+ function renderManualTransition(task) {
507
+ const activeElement = document.activeElement;
508
+ const focusWasInPanel = elements.manualTransitionPanel.contains?.(activeElement) ?? false;
509
+ const eligible = canManuallyTransitionTask(task);
510
+ state.manualTransition = reconcileManualTransition(state.manualTransition, task);
511
+ const pending = manualTransitionPending();
512
+ const expanded = Boolean(state.manualTransition);
513
+ elements.moreTaskActions.disabled = pending;
514
+ elements.moreTaskActions.textContent = expanded ? "←" : "…";
515
+ elements.moreTaskActions.setAttribute(
516
+ "aria-label",
517
+ expanded ? "Hide more task actions" : "More task actions",
518
+ );
519
+ elements.moreTaskActions.setAttribute("title", expanded ? "Hide more task actions" : "More task actions");
520
+ elements.moreTaskActions.setAttribute("aria-expanded", String(expanded));
521
+ elements.closeDialog.disabled = pending;
522
+ elements.manualTransitionPanel.hidden = state.manualTransition === null;
523
+ elements.manualTransitionPanel.setAttribute("aria-busy", String(pending));
524
+ elements.copyTaskId.disabled = pending || !task.id;
525
+ elements.markTaskCompleted.hidden = !eligible;
526
+ elements.markTaskFailed.hidden = !eligible;
527
+ elements.markTaskCompleted.disabled = pending;
528
+ elements.markTaskFailed.disabled = pending;
529
+ const archivePending = state.archivePendingThreadIds.has(task.threadId);
530
+ const archived = state.archivedThreadIds.has(task.threadId);
531
+ elements.archiveTask.disabled = pending || archivePending || archived;
532
+ elements.archiveTask.textContent = archived ? "Archived" : archivePending ? "Archiving…" : "Archive chat";
533
+ elements.archiveTask.setAttribute(
534
+ "aria-label",
535
+ archived
536
+ ? `${task.title} is archived in Codex`
537
+ : archivePending
538
+ ? `Archiving ${task.title} in Codex`
539
+ : `Archive ${task.title} in Codex`,
540
+ );
541
+ elements.manualTransitionStatus.hidden = !pending;
542
+ elements.manualTransitionStatus.textContent = pending ? "Saving task state…" : "";
543
+ const error = state.manualTransition?.error ?? "";
544
+ elements.manualTransitionError.hidden = !error;
545
+ elements.manualTransitionError.textContent = error;
546
+ if (!state.manualTransition) {
547
+ elements.closeDialog.disabled = false;
548
+ if (focusWasInPanel) elements.moreTaskActions.focus();
549
+ return;
550
+ }
551
+ if (pending) {
552
+ focusManualTransitionStatus(elements.manualTransitionPanel);
553
+ } else {
554
+ restoreTaskActionMenuFocus(
555
+ elements.manualTransitionPanel,
556
+ activeElement,
557
+ elements.moreTaskActions,
558
+ );
559
+ }
560
+ }
561
+
562
+ async function submitManualTransition(targetStatus, event) {
563
+ const task = state.selectedTask;
564
+ if (!task || !canManuallyTransitionTask(task) || manualTransitionPending()) return;
565
+ const previous = state.manualTransition;
566
+ const actionId = previous?.targetStatus === targetStatus && previous.actionId
567
+ ? previous.actionId
568
+ : crypto.randomUUID();
569
+ const attempt = {
570
+ ...previous,
571
+ taskId: task.id,
572
+ stage: "pending",
573
+ targetStatus,
574
+ actionId,
575
+ expected: previous?.expected ?? manualTransitionExpectedState(task),
576
+ error: null,
577
+ };
578
+ state.manualTransition = attempt;
579
+ renderManualTransition(task);
580
+ const result = await manuallyTransitionTaskFromControl(
581
+ event,
582
+ { ...task, ...attempt.expected },
583
+ targetStatus,
584
+ attempt.actionId,
585
+ );
586
+ const current = reconcileManualTransitionResponse({
587
+ requestTask: task,
588
+ expected: attempt.expected,
589
+ responseTask: result.task,
590
+ selectedTask: state.selectedTask,
591
+ });
592
+ if (result.ok) {
593
+ state.manualTransition = null;
594
+ if (current === result.task) replaceCurrentTask(result.task);
595
+ renderDialog(current);
596
+ render();
597
+ showMessage(result.task.summary);
598
+ elements.dialogTitle.focus?.();
599
+ return;
600
+ }
601
+ if (current === result.task) replaceCurrentTask(result.task);
602
+ if (
603
+ !canManuallyTransitionTask(current)
604
+ || !taskMatchesManualTransitionExpected(current, attempt.expected)
605
+ ) {
606
+ state.manualTransition = null;
607
+ renderDialog(current);
608
+ render();
609
+ showMessage("This task changed. Review its current state before trying again.");
610
+ elements.dialogTitle.focus?.();
611
+ return;
612
+ }
613
+ state.manualTransition = {
614
+ ...attempt,
615
+ stage: "choose",
616
+ error: result.message,
617
+ actionId: result.code === "stale_task" ? crypto.randomUUID() : attempt.actionId,
618
+ };
619
+ renderDialog(current);
620
+ elements.manualTransitionError.focus();
621
+ }
622
+
395
623
  function renderDialog(task) {
396
624
  if (state.selectedTask?.id !== task.id) {
397
625
  copyTaskIdGeneration += 1;
@@ -418,6 +646,12 @@ function renderDialog(task) {
418
646
  (task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
419
647
  );
420
648
  elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
649
+ elements.dialogUsage.replaceChildren(usagePresentation(
650
+ task.usage?.status === "available" && task.usage.task
651
+ ? { status: "available", ...task.usage.task }
652
+ : task.usage ?? { status: task.threadId ? "calculating" : "unavailable" },
653
+ { wholeTask: true },
654
+ ));
421
655
  elements.dialogInstruction.textContent = task.instruction;
422
656
  elements.copyTaskId.disabled = !task.id;
423
657
  setCopyTaskIdLabel("Copy Task ID");
@@ -451,35 +685,43 @@ function renderDialog(task) {
451
685
  configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
452
686
  const canArchive = canArchiveTask(task);
453
687
  const archived = state.archivedThreadIds.has(task.threadId);
688
+ const archivePending = state.archivePendingThreadIds.has(task.threadId);
454
689
  elements.archiveTask.hidden = !canArchive;
455
- elements.archiveTask.disabled = archived;
456
- elements.archiveTask.textContent = archived ? "Archived" : "Archive chat";
690
+ elements.archiveTask.disabled = archived || archivePending;
691
+ elements.archiveTask.textContent = archived ? "Archived" : archivePending ? "Archiving…" : "Archive chat";
457
692
  elements.archiveTask.setAttribute(
458
693
  "aria-label",
459
694
  archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
460
695
  );
696
+ renderManualTransition(detailedTask);
461
697
  }
462
698
 
463
699
  async function openDialog(task) {
464
700
  const requestGeneration = ++detailRequestGeneration;
465
701
  renderDialog(task);
466
702
  if (!elements.dialog.open) elements.dialog.showModal();
467
- try {
468
- const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
469
- if (!response.ok) throw new Error("Task details are unavailable.");
470
- const detail = await response.json();
471
- if (
472
- requestGeneration === detailRequestGeneration
473
- && state.selectedTask?.id === task.id
474
- && elements.dialog.open
475
- ) {
476
- renderDialog(detail.task);
477
- }
478
- } catch {
479
- if (state.selectedTask?.id === task.id) {
480
- showMessage("Task activity timeline is temporarily unavailable.");
703
+ const load = async (attempt = 0) => {
704
+ try {
705
+ const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
706
+ if (!response.ok) throw new Error("Task details are unavailable.");
707
+ const detail = await response.json();
708
+ if (
709
+ requestGeneration === detailRequestGeneration
710
+ && state.selectedTask?.id === task.id
711
+ && elements.dialog.open
712
+ ) {
713
+ renderDialog(detail.task);
714
+ if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
715
+ setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
716
+ }
717
+ }
718
+ } catch {
719
+ if (state.selectedTask?.id === task.id) {
720
+ showMessage("Task activity timeline is temporarily unavailable.");
721
+ }
481
722
  }
482
- }
723
+ };
724
+ await load();
483
725
  }
484
726
 
485
727
  function taskCard(task) {
@@ -634,9 +876,47 @@ elements.clearNotifications.addEventListener("click", () => {
634
876
  state.notifications = clearNotifications();
635
877
  renderNotifications();
636
878
  });
637
- elements.closeDialog.addEventListener("click", () => elements.dialog.close());
879
+ elements.closeDialog.addEventListener("click", () => {
880
+ if (!manualTransitionPending()) elements.dialog.close();
881
+ });
638
882
  elements.dialog.addEventListener("click", (event) => {
639
- if (event.target === elements.dialog) elements.dialog.close();
883
+ if (event.target === elements.dialog && !manualTransitionPending()) elements.dialog.close();
884
+ });
885
+ elements.dialog.addEventListener("keydown", (event) => {
886
+ handleManualTransitionEscape(event, {
887
+ active: Boolean(state.manualTransition),
888
+ pending: manualTransitionPending(),
889
+ cancel: () => resetManualTransition({ focus: true }),
890
+ });
891
+ });
892
+ elements.dialog.addEventListener("cancel", (event) => {
893
+ if (!state.manualTransition) return;
894
+ event.preventDefault();
895
+ if (!manualTransitionPending()) resetManualTransition({ focus: true });
896
+ });
897
+ elements.dialog.addEventListener("close", () => {
898
+ if (!manualTransitionPending()) state.manualTransition = null;
899
+ });
900
+ elements.moreTaskActions.addEventListener("click", () => {
901
+ const task = state.selectedTask;
902
+ if (!task || manualTransitionPending()) return;
903
+ if (state.manualTransition) {
904
+ resetManualTransition({ focus: true });
905
+ return;
906
+ }
907
+ state.manualTransition = {
908
+ taskId: task.id,
909
+ stage: "choose",
910
+ expected: manualTransitionExpectedState(task),
911
+ };
912
+ renderManualTransition(task);
913
+ elements.copyTaskId.focus();
914
+ });
915
+ elements.markTaskCompleted.addEventListener("click", (event) => {
916
+ return submitManualTransition("completed", event);
917
+ });
918
+ elements.markTaskFailed.addEventListener("click", (event) => {
919
+ return submitManualTransition("failed", event);
640
920
  });
641
921
  elements.copyTaskId.addEventListener("click", async () => {
642
922
  const taskId = state.selectedTask?.id;
@@ -666,12 +946,20 @@ elements.openProject.addEventListener("click", async (event) => {
666
946
  });
667
947
  elements.archiveTask.addEventListener("click", async (event) => {
668
948
  const task = state.selectedTask;
669
- if (!task || !canArchiveTask(task)) return;
670
- await archiveTaskFromControl(event, task, {
671
- onArchived: (threadId) => {
672
- state.archivedThreadIds.add(threadId);
673
- if (state.selectedTask?.id === task.id) renderDialog(state.selectedTask);
674
- },
675
- showMessage,
676
- });
949
+ if (
950
+ !task
951
+ || !canArchiveTask(task)
952
+ || state.archivePendingThreadIds.has(task.threadId)
953
+ ) return;
954
+ state.archivePendingThreadIds.add(task.threadId);
955
+ renderManualTransition(task);
956
+ try {
957
+ await archiveTaskFromControl(event, task, {
958
+ onArchived: (threadId) => state.archivedThreadIds.add(threadId),
959
+ showMessage,
960
+ });
961
+ } finally {
962
+ state.archivePendingThreadIds.delete(task.threadId);
963
+ if (state.selectedTask?.id === task.id) renderDialog(state.selectedTask);
964
+ }
677
965
  });
@@ -100,11 +100,11 @@
100
100
  <div class="dialog-header">
101
101
  <div>
102
102
  <p id="dialog-project" class="eyebrow"></p>
103
- <h2 id="dialog-title"></h2>
103
+ <h2 id="dialog-title" tabindex="-1"></h2>
104
104
  </div>
105
105
  <button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
106
106
  </div>
107
- <div class="dialog-actions">
107
+ <div class="dialog-actions" role="group" aria-label="Task actions">
108
108
  <button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
109
109
  <picture class="codex-icon" aria-hidden="true">
110
110
  <source srcset="/assets/codex-app-dark.png" media="(prefers-color-scheme: dark)">
@@ -112,10 +112,23 @@
112
112
  </picture>
113
113
  <span>Open task</span>
114
114
  </button>
115
- <button id="copy-task-id" class="secondary-button" type="button" aria-label="Copy Task ID">Copy Task ID</button>
116
- <button id="archive-codex" class="danger-button" type="button" aria-label="Archive this chat in Codex" hidden>Archive chat</button>
115
+ <button id="more-task-actions" class="secondary-button more-task-actions" type="button" aria-label="More task actions" aria-expanded="false" aria-controls="manual-transition-panel">…</button>
116
+ <div id="manual-transition-panel" class="manual-transition-panel" aria-busy="false" hidden>
117
+ <div class="manual-transition-controls">
118
+ <button id="copy-task-id" class="secondary-button" type="button" aria-label="Copy Task ID" data-manual-focus="copy">Copy Task ID</button>
119
+ <button id="mark-task-completed" class="secondary-button" type="button" data-manual-focus="target-completed">Mark completed</button>
120
+ <button id="mark-task-failed" class="danger-button" type="button" data-manual-focus="target-failed">Mark failed</button>
121
+ <button id="archive-codex" class="danger-button" type="button" aria-label="Archive this chat in Codex" hidden>Archive chat</button>
122
+ </div>
123
+ <p id="manual-transition-status" class="manual-transition-pending" role="status" aria-live="polite" tabindex="-1" data-manual-focus="pending" hidden></p>
124
+ <p id="manual-transition-error" class="manual-transition-error" role="alert" tabindex="-1" data-manual-focus="error" hidden></p>
125
+ </div>
117
126
  </div>
118
127
  <nav id="dialog-related-links" class="github-links" aria-label="Related GitHub links" hidden></nav>
128
+ <section>
129
+ <h3>Usage</h3>
130
+ <div id="dialog-usage" class="usage-panel" aria-live="polite"></div>
131
+ </section>
119
132
  <section>
120
133
  <h3>Activity timeline</h3>
121
134
  <div id="dialog-results" class="result-history"></div>
@@ -22,6 +22,8 @@ const NOTIFICATION_TITLES = new Map([
22
22
  ["completed", "Task completed"],
23
23
  ["needs_input", "Task needs input"],
24
24
  ["failed", "Task failed"],
25
+ ["manual_completed", "Task manually completed"],
26
+ ["manual_failed", "Task manually failed"],
25
27
  ["unresolved", "Task updated"],
26
28
  ]);
27
29
 
@@ -45,6 +47,50 @@ export function canArchiveTask(task) {
45
47
  && CODEX_THREAD_ID_PATTERN.test(task.threadId);
46
48
  }
47
49
 
50
+ export function canManuallyTransitionTask(task) {
51
+ return ["working", "needs_input"].includes(task.status);
52
+ }
53
+
54
+ export function manualTransitionExpectedState(task) {
55
+ return {
56
+ status: task.status,
57
+ turnRef: task.turnRef,
58
+ threadId: task.threadId,
59
+ updatedAt: task.updatedAt,
60
+ };
61
+ }
62
+
63
+ export function taskMatchesManualTransitionExpected(task, expected) {
64
+ return Boolean(expected)
65
+ && task.status === expected.status
66
+ && task.turnRef === expected.turnRef
67
+ && task.threadId === expected.threadId
68
+ && task.updatedAt === expected.updatedAt;
69
+ }
70
+
71
+ export function reconcileManualTransition(transition, task) {
72
+ if (!transition || transition.taskId !== task.id) return null;
73
+ if (transition.stage === "pending") return transition;
74
+ if (taskMatchesManualTransitionExpected(task, transition.expected)) return transition;
75
+ return {
76
+ taskId: task.id,
77
+ stage: "choose",
78
+ expected: manualTransitionExpectedState(task),
79
+ };
80
+ }
81
+
82
+ export function reconcileManualTransitionResponse({
83
+ requestTask,
84
+ expected,
85
+ responseTask,
86
+ selectedTask,
87
+ }) {
88
+ const current = selectedTask?.id === requestTask.id ? selectedTask : requestTask;
89
+ return taskMatchesManualTransitionExpected(current, expected)
90
+ ? (responseTask ?? current)
91
+ : current;
92
+ }
93
+
48
94
  export function latestTurnPresentation(task) {
49
95
  const turn = task.latestTurn ?? null;
50
96
  const result = turn?.result ?? null;
@@ -70,6 +116,9 @@ export function turnPresentation(turn) {
70
116
  status: result?.status ?? "working",
71
117
  summary: result?.summary ?? "In progress",
72
118
  updatedAt: result?.updatedAt ?? turn.startedAt,
119
+ ...(turn.provenance?.kind === "dashboard_manual"
120
+ ? { sourceLabel: "Manual dashboard change" }
121
+ : {}),
73
122
  };
74
123
  }
75
124
 
@@ -200,6 +249,9 @@ function lifecycleEvent(task) {
200
249
  if (task.status === "working") {
201
250
  return task.lastResult ? "follow_up_started" : "task_started";
202
251
  }
252
+ if (task.latestTurn?.provenance?.kind === "dashboard_manual") {
253
+ return `manual_${task.status}`;
254
+ }
203
255
  return task.status ?? "unresolved";
204
256
  }
205
257
 
@@ -223,7 +275,9 @@ function eventTimestamp(task, event) {
223
275
  }
224
276
 
225
277
  function eventSummary(task, event) {
226
- if (!["completed", "needs_input", "failed"].includes(event)) return null;
278
+ if (!["completed", "needs_input", "failed", "manual_completed", "manual_failed"].includes(event)) {
279
+ return null;
280
+ }
227
281
  if (
228
282
  task.lastResult?.status === task.status
229
283
  && (task.lastResult?.turnRef ?? task.lastResult?.turnId)
@@ -252,16 +306,19 @@ export function notificationSnapshot(task, event = lifecycleEvent(task)) {
252
306
  function resultNotificationSnapshot(task) {
253
307
  const result = task.lastResult;
254
308
  if (!result) return null;
309
+ const event = result.provenance?.kind === "dashboard_manual"
310
+ ? `manual_${result.status}`
311
+ : result.status;
255
312
  return Object.freeze({
256
313
  id: notificationIdentity({
257
314
  ...task,
258
315
  turnRef: result.turnRef ?? result.turnId,
259
316
  turnId: result.turnId,
260
- }, result.status),
317
+ }, event),
261
318
  taskId: task.id,
262
319
  title: task.title,
263
320
  status: result.status,
264
- event: result.status,
321
+ event,
265
322
  turnRef: result.turnRef ?? result.turnId ?? null,
266
323
  turnId: result.turnId ?? null,
267
324
  timestamp: result.updatedAt,