taskchef 7.20.0 → 7.21.1

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,
@@ -29,12 +41,14 @@ const USAGE_POLL_INTERVAL_MS = 1_500;
29
41
  const MAX_USAGE_POLL_ATTEMPTS = 40;
30
42
 
31
43
  const state = {
44
+ archivePendingThreadIds: new Set(),
32
45
  archivedThreadIds: new Set(),
33
46
  tasks: [],
34
47
  signatures: new Map(),
35
48
  notifications: [],
36
49
  seenNotificationIds: new Set(),
37
50
  initialized: false,
51
+ manualTransition: null,
38
52
  selectedTask: null,
39
53
  };
40
54
  let dateRefreshTimer = null;
@@ -65,6 +79,12 @@ const elements = {
65
79
  emptyState: document.querySelector("#empty-state"),
66
80
  notifications: document.querySelector("#notifications"),
67
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"),
68
88
  openProject: document.querySelector("#open-codex"),
69
89
  projectFilter: document.querySelector("#project-filter"),
70
90
  statusFilter: document.querySelector("#status-filter"),
@@ -426,6 +446,12 @@ function turnTimeline(task) {
426
446
  key: `detail:${task.id}:turn:${turnKey}`,
427
447
  });
428
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
+ }
429
455
  const requestLabel = document.createElement("h4");
430
456
  requestLabel.textContent = "Request";
431
457
  const request = document.createElement("p");
@@ -461,6 +487,139 @@ function turnTimeline(task) {
461
487
  });
462
488
  }
463
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
+
464
623
  function renderDialog(task) {
465
624
  if (state.selectedTask?.id !== task.id) {
466
625
  copyTaskIdGeneration += 1;
@@ -526,13 +685,15 @@ function renderDialog(task) {
526
685
  configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
527
686
  const canArchive = canArchiveTask(task);
528
687
  const archived = state.archivedThreadIds.has(task.threadId);
688
+ const archivePending = state.archivePendingThreadIds.has(task.threadId);
529
689
  elements.archiveTask.hidden = !canArchive;
530
- elements.archiveTask.disabled = archived;
531
- elements.archiveTask.textContent = archived ? "Archived" : "Archive chat";
690
+ elements.archiveTask.disabled = archived || archivePending;
691
+ elements.archiveTask.textContent = archived ? "Archived" : archivePending ? "Archiving…" : "Archive chat";
532
692
  elements.archiveTask.setAttribute(
533
693
  "aria-label",
534
694
  archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
535
695
  );
696
+ renderManualTransition(detailedTask);
536
697
  }
537
698
 
538
699
  async function openDialog(task) {
@@ -715,9 +876,47 @@ elements.clearNotifications.addEventListener("click", () => {
715
876
  state.notifications = clearNotifications();
716
877
  renderNotifications();
717
878
  });
718
- elements.closeDialog.addEventListener("click", () => elements.dialog.close());
879
+ elements.closeDialog.addEventListener("click", () => {
880
+ if (!manualTransitionPending()) elements.dialog.close();
881
+ });
719
882
  elements.dialog.addEventListener("click", (event) => {
720
- 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);
721
920
  });
722
921
  elements.copyTaskId.addEventListener("click", async () => {
723
922
  const taskId = state.selectedTask?.id;
@@ -747,12 +946,20 @@ elements.openProject.addEventListener("click", async (event) => {
747
946
  });
748
947
  elements.archiveTask.addEventListener("click", async (event) => {
749
948
  const task = state.selectedTask;
750
- if (!task || !canArchiveTask(task)) return;
751
- await archiveTaskFromControl(event, task, {
752
- onArchived: (threadId) => {
753
- state.archivedThreadIds.add(threadId);
754
- if (state.selectedTask?.id === task.id) renderDialog(state.selectedTask);
755
- },
756
- showMessage,
757
- });
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
+ }
758
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,8 +112,17 @@
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>
119
128
  <section>
@@ -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,
@@ -11,6 +11,10 @@
11
11
  --danger: #8b3a35;
12
12
  --warning: #8a5c17;
13
13
  --shadow: 0 12px 30px rgb(24 33 29 / 10%);
14
+ --control-height: 38px;
15
+ --control-padding-inline: 11px;
16
+ --control-radius: 7px;
17
+ --control-font-size: 0.86rem;
14
18
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
19
  }
16
20
 
@@ -112,7 +116,7 @@ time, .timestamp-missing { font-size: 0.75rem; }
112
116
  .timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
113
117
  .task-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
114
118
  .task-open { flex: none; }
115
- .primary-button.task-action, .secondary-button.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 9px; font-size: 0.78rem; line-height: 1.2; white-space: nowrap; }
119
+ .primary-button.task-action, .secondary-button.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: var(--control-height); padding: 0 var(--control-padding-inline); font-size: var(--control-font-size); line-height: 1; white-space: nowrap; }
116
120
  .codex-icon { display: block; flex: none; width: 18px; height: 18px; }
117
121
  .codex-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
118
122
 
@@ -148,8 +152,15 @@ dialog { width: min(760px, calc(100vw - 32px)); max-height: min(82vh, 900px); pa
148
152
  dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px); }
149
153
  .dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
150
154
  .dialog-header h2 { margin-bottom: 12px; font-size: 1.6rem; }
151
- .dialog-actions { display: flex; gap: 8px; margin-bottom: 26px; }
152
- .primary-button, .secondary-button, .danger-button { padding: 8px 12px; border-radius: 7px; font-weight: 700; cursor: pointer; }
155
+ .dialog-actions { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 8px; margin-bottom: 26px; }
156
+ .more-task-actions { width: var(--control-height); min-width: var(--control-height); padding-inline: 0; font-size: 1.2rem; line-height: 1; }
157
+ .manual-transition-panel, .manual-transition-controls { display: contents; }
158
+ .manual-transition-panel[hidden] { display: none; }
159
+ .manual-transition-error, .manual-transition-pending { flex-basis: 100%; margin: 0; }
160
+ .manual-transition-controls > button[hidden] { display: none; }
161
+ .manual-transition-error { color: var(--danger); font-size: 0.86rem; }
162
+ .manual-transition-pending { color: var(--muted); font-size: 0.86rem; }
163
+ .primary-button, .secondary-button, .danger-button { display: inline-flex; align-items: center; justify-content: center; min-height: var(--control-height); padding: 0 var(--control-padding-inline); border-radius: var(--control-radius); font-size: var(--control-font-size); font-weight: 700; line-height: 1; cursor: pointer; }
153
164
  .primary-button { border: 1px solid var(--accent); background: var(--accent); color: white; }
154
165
  .secondary-button { border: 1px solid var(--border); background: var(--surface); }
155
166
  .danger-button { border: 1px solid var(--danger); background: var(--surface); color: var(--danger); }
@@ -160,6 +171,8 @@ dialog section + section { margin-top: 24px; }
160
171
  .result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
161
172
  .result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
162
173
  .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
174
+ .result-history-header .timestamp-toggle { margin-left: auto; }
175
+ .result-history-source { color: var(--muted); font-size: 0.72rem; font-weight: 700; }
163
176
  .result-history-item p { margin-bottom: 7px; }
164
177
  .result-history-item p:last-child { margin-bottom: 0; }
165
178
  .result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
@@ -182,6 +195,7 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
182
195
  :focus-visible { outline: 3px solid rgb(33 95 74 / 35%); outline-offset: 3px; }
183
196
 
184
197
  @media (max-width: 650px) {
198
+ :root { --control-height: 40px; }
185
199
  .site-header { align-items: flex-start; gap: 14px; padding-top: 28px; }
186
200
  .title-row { align-items: flex-start; }
187
201
  .toolbar-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -196,7 +210,6 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
196
210
  .task-open { align-self: flex-start; }
197
211
  select { min-height: 40px; }
198
212
  .status-filter-option span { min-height: 38px; }
199
- .primary-button.task-action, .secondary-button.task-action { min-height: 40px; }
200
213
  .dialog-actions { align-items: center; flex-flow: row wrap; }
201
214
  .metadata { grid-template-columns: 1fr; }
202
215
  .metadata dt { padding-bottom: 0; border-bottom: 0; }
package/src/dashboard.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  acquireWorkspaceLock,
17
17
  canonicalDirectory,
18
18
  canonicalGitRoot,
19
+ manuallyTransitionTask,
19
20
  parseTaskLogContent,
20
21
  readConfig,
21
22
  readTask,
@@ -31,6 +32,7 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
31
32
  const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
32
33
  const DEFAULT_MAX_TASKS = 2_000;
33
34
  const DEFAULT_MAX_EVENT_CLIENTS = 16;
35
+ const MAX_MANUAL_TRANSITION_BODY_BYTES = 4 * 1024;
34
36
  export const DASHBOARD_HEALTH_PATH = "/api/health";
35
37
  export const DASHBOARD_HEALTH_MAX_BYTES = 8 * 1024;
36
38
  const CONTENT_SECURITY_POLICY = [
@@ -160,6 +162,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
160
162
  boundedText(turn.turnRef, 512, `${name} turn ${turnIndex + 1} turn ref`);
161
163
  boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
162
164
  boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
165
+ boundedText(
166
+ turn.provenance?.actionId,
167
+ 512,
168
+ `${name} turn ${turnIndex + 1} manual action ID`,
169
+ );
163
170
  }
164
171
  const results = task.results ?? [];
165
172
  if (results.length > 10_000) {
@@ -169,6 +176,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
169
176
  boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
170
177
  boundedText(result.turnRef, 512, `${name} result ${resultIndex + 1} turn ref`);
171
178
  boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
179
+ boundedText(
180
+ result.provenance?.actionId,
181
+ 512,
182
+ `${name} result ${resultIndex + 1} manual action ID`,
183
+ );
172
184
  }
173
185
  boundedText(task.project.name, 1_000, `${name} project name`);
174
186
  boundedText(task.project.path, 8_192, `${name} project path`);
@@ -383,6 +395,79 @@ function sendJson(response, status, value) {
383
395
  response.end(`${JSON.stringify(value)}\n`);
384
396
  }
385
397
 
398
+ async function readBoundedJsonBody(request, maximumBytes = MAX_MANUAL_TRANSITION_BODY_BYTES) {
399
+ const contentType = request.headers["content-type"] ?? "";
400
+ if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
401
+ const error = new Error("Request content type must be application/json.");
402
+ error.code = "unsupported_media_type";
403
+ throw error;
404
+ }
405
+ const chunks = [];
406
+ let total = 0;
407
+ for await (const chunk of request) {
408
+ total += chunk.length;
409
+ if (total > maximumBytes) {
410
+ const error = new Error("Request body is too large.");
411
+ error.code = "body_too_large";
412
+ throw error;
413
+ }
414
+ chunks.push(chunk);
415
+ }
416
+ let value;
417
+ try {
418
+ value = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
419
+ } catch {
420
+ const error = new Error("Request body must be valid JSON.");
421
+ error.code = "malformed_json";
422
+ throw error;
423
+ }
424
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
425
+ const error = new Error("Request body must be a JSON object.");
426
+ error.code = "invalid_request";
427
+ throw error;
428
+ }
429
+ return value;
430
+ }
431
+
432
+ function manualTransitionInput(body) {
433
+ const fields = new Set(["schemaVersion", "actionId", "expected", "targetStatus"]);
434
+ const unexpected = Object.keys(body).find((key) => !fields.has(key));
435
+ const missing = [...fields].find((key) => !(key in body));
436
+ if (unexpected || missing || body.schemaVersion !== 1) {
437
+ const error = new Error("Manual transition request has an invalid shape.");
438
+ error.code = "invalid_request";
439
+ throw error;
440
+ }
441
+ const { schemaVersion: _schemaVersion, ...input } = body;
442
+ return input;
443
+ }
444
+
445
+ function manualTransitionErrorResponse(error) {
446
+ const definitions = new Map([
447
+ ["malformed_json", [400, "Request body must be valid JSON."]],
448
+ ["invalid_request", [400, "Manual transition request is invalid."]],
449
+ ["task_not_found", [404, "Task not found."]],
450
+ ["stale_task", [409, "This task changed. Review its current state and try again."]],
451
+ ["invalid_transition", [409, "This task can no longer be changed manually."]],
452
+ ["idempotency_conflict", [409, "This manual action ID was already used."]],
453
+ ["body_too_large", [413, "Request body is too large."]],
454
+ ["unsupported_media_type", [415, "Request content type must be application/json."]],
455
+ ["ELOCKED", [503, "TaskChef is busy updating the workspace. Try again."]],
456
+ ]);
457
+ const definition = definitions.get(error?.code);
458
+ const [status, message] = definition ?? [500, "Dashboard request failed."];
459
+ return {
460
+ status,
461
+ body: {
462
+ code: error?.code === "ELOCKED"
463
+ ? "workspace_busy"
464
+ : (definition ? error.code : "dashboard_error"),
465
+ message,
466
+ ...(error?.task ? { task: taskDetailProjection(error.task) } : {}),
467
+ },
468
+ };
469
+ }
470
+
386
471
  function ssePayload(event, value) {
387
472
  return `event: ${event}\ndata: ${JSON.stringify(value)}\n\n`;
388
473
  }
@@ -591,6 +676,37 @@ export async function createDashboardServer({
591
676
  return;
592
677
  }
593
678
 
679
+ const transitionMatch = url.pathname.match(
680
+ /^\/api\/tasks\/([a-zA-Z0-9._-]+)\/manual-transition$/,
681
+ );
682
+ if (transitionMatch && method === "POST") {
683
+ if (request.headers.origin !== allowedOrigin) {
684
+ sendJson(response, 403, {
685
+ code: "invalid_origin",
686
+ message: "Dashboard origin validation failed.",
687
+ });
688
+ return;
689
+ }
690
+ try {
691
+ const body = await readBoundedJsonBody(request);
692
+ const result = await manuallyTransitionTask(
693
+ monitor.workspace,
694
+ transitionMatch[1],
695
+ manualTransitionInput(body),
696
+ );
697
+ await monitor.refresh({ force: true }).catch(() => {});
698
+ sendJson(response, 200, {
699
+ schemaVersion: 1,
700
+ task: taskDetailProjection(result.task),
701
+ idempotent: result.idempotent,
702
+ });
703
+ } catch (error) {
704
+ const failure = manualTransitionErrorResponse(error);
705
+ sendJson(response, failure.status, failure.body);
706
+ }
707
+ return;
708
+ }
709
+
594
710
  const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
595
711
  if (taskMatch && method === "POST") {
596
712
  if (request.headers.origin !== allowedOrigin) {