taskchef 8.7.1 → 9.0.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.
Files changed (149) hide show
  1. package/.codex-plugin/plugin.json +7 -10
  2. package/.mcp.json +3 -2
  3. package/README.md +31 -158
  4. package/docs/mcp-app.md +3 -3
  5. package/docs/spec.md +38 -746
  6. package/docs/taskchef-next-storage.md +2 -2
  7. package/index.js +2 -99
  8. package/package.json +12 -49
  9. package/skills/delegate/SKILL.md +44 -0
  10. package/skills/delegate/agents/openai.yaml +4 -0
  11. package/src/codex-app.js +3 -152
  12. package/src/codex-session-scanner.js +2 -2
  13. package/src/file-lock.js +25 -0
  14. package/src/mcp-app/react/NextTaskDetail.test.tsx +1 -1
  15. package/src/mcp-app/react/NextTaskDetail.tsx +8 -8
  16. package/src/mcp-app/react/main.test.tsx +3 -3
  17. package/src/mcp-app/react/main.tsx +7 -7
  18. package/src/mcp-app.js +2 -2
  19. package/src/mcp.js +4 -542
  20. package/src/next-cache.js +2 -2
  21. package/src/next-github.js +3 -3
  22. package/src/next-notifications.js +2 -2
  23. package/src/{dashboard/react → ui}/components/CardStatusIcons.tsx +1 -1
  24. package/src/{dashboard/react → ui}/components/GitHubLinks.tsx +1 -1
  25. package/src/{dashboard/react → ui}/components/LinkedText.tsx +1 -1
  26. package/src/{dashboard/react → ui}/components/RelativeTime.test.tsx +1 -1
  27. package/src/{dashboard/react → ui}/components/RelativeTime.tsx +1 -1
  28. package/src/{dashboard/react → ui}/components/ReplyImagePreview.test.tsx +1 -1
  29. package/src/{dashboard/react → ui}/components/TaskBoard.tsx +1 -1
  30. package/src/{dashboard/react → ui}/components/TaskCard.tsx +1 -1
  31. package/src/{dashboard/react → ui}/components/TurnContent.tsx +1 -1
  32. package/src/{dashboard/react → ui}/presentation.ts +1 -1
  33. package/src/version.js +0 -1
  34. package/BACKLOG.md +0 -48
  35. package/assets/e2e-benchmark-example.json +0 -50
  36. package/assets/taskchef-dispatcher-instructions.md +0 -44
  37. package/bin/taskchef.js +0 -20
  38. package/docs/advanced-guide.md +0 -497
  39. package/docs/dashboard-lifecycle.md +0 -149
  40. package/docs/dashboard-ui.md +0 -40
  41. package/docs/firstmate-taskchef-comparison.md +0 -283
  42. package/docs/images/ccusage-token-consumption.png +0 -0
  43. package/docs/images/dashboard-identity.jpg +0 -0
  44. package/docs/images/dashboard-token-pending.jpg +0 -0
  45. package/docs/images/interrupted-turn-recovery.jpg +0 -0
  46. package/docs/images/notification-event-snapshots.jpg +0 -0
  47. package/docs/images/readme-board.png +0 -0
  48. package/docs/images/readme-completed.png +0 -0
  49. package/docs/images/readme-list.png +0 -0
  50. package/docs/images/readme-needs-input.png +0 -0
  51. package/docs/images/readme-roles.png +0 -0
  52. package/docs/images/reported-work-duration-desktop.jpg +0 -0
  53. package/docs/images/reported-work-duration-mobile.jpg +0 -0
  54. package/docs/images/result-history-dashboard.jpg +0 -0
  55. package/docs/images/taskchef-pr-icon-comparison.jpg +0 -0
  56. package/docs/images/taskchef-unified-status-popup.jpg +0 -0
  57. package/docs/workflows.md +0 -476
  58. package/mcp/dashboard-session.js +0 -51
  59. package/scripts/benchmark-dispatch-prepare.js +0 -90
  60. package/scripts/e2e-benchmark.js +0 -206
  61. package/scripts/roles/resolve_roles.py +0 -293
  62. package/skills/taskchef-bootstrap/SKILL.md +0 -96
  63. package/skills/taskchef-bootstrap/agents/openai.yaml +0 -4
  64. package/skills/taskchef-bootstrap/references/project-index.md +0 -154
  65. package/skills/taskchef-copilot/SKILL.md +0 -75
  66. package/skills/taskchef-copilot/agents/openai.yaml +0 -4
  67. package/skills/taskchef-dashboard/SKILL.md +0 -24
  68. package/skills/taskchef-dashboard/agents/openai.yaml +0 -4
  69. package/skills/taskchef-delegate/SKILL.md +0 -102
  70. package/skills/taskchef-delegate/agents/openai.yaml +0 -4
  71. package/skills/taskchef-delegate/references/model-roles.md +0 -29
  72. package/skills/taskchef-executor/SKILL.md +0 -154
  73. package/skills/taskchef-executor/agents/openai.yaml +0 -4
  74. package/skills/taskchef-executor/references/compatibility.md +0 -22
  75. package/skills/taskchef-executor/references/ending-actions.md +0 -26
  76. package/src/backups.js +0 -359
  77. package/src/cli.js +0 -733
  78. package/src/config-audit.js +0 -99
  79. package/src/config-mutations.js +0 -106
  80. package/src/dashboard/actions.js +0 -136
  81. package/src/dashboard/app.js +0 -1172
  82. package/src/dashboard/dist/app.js +0 -269
  83. package/src/dashboard/dist/app.js.map +0 -1
  84. package/src/dashboard/dist/index.html +0 -16
  85. package/src/dashboard/dist/styles.css +0 -1
  86. package/src/dashboard/index.html +0 -148
  87. package/src/dashboard/react/App.test.tsx +0 -346
  88. package/src/dashboard/react/App.tsx +0 -515
  89. package/src/dashboard/react/api.test.ts +0 -40
  90. package/src/dashboard/react/api.ts +0 -103
  91. package/src/dashboard/react/components/ActivityTimeline.tsx +0 -118
  92. package/src/dashboard/react/components/ModelSettings.test.tsx +0 -180
  93. package/src/dashboard/react/components/ModelSettings.tsx +0 -106
  94. package/src/dashboard/react/components/NotificationCenter.test.tsx +0 -76
  95. package/src/dashboard/react/components/NotificationCenter.tsx +0 -111
  96. package/src/dashboard/react/components/PullRequestBadges.tsx +0 -18
  97. package/src/dashboard/react/components/TaskDetail.test.tsx +0 -255
  98. package/src/dashboard/react/components/TaskDetail.tsx +0 -271
  99. package/src/dashboard/react/components/UsagePanel.tsx +0 -96
  100. package/src/dashboard/react/index.html +0 -15
  101. package/src/dashboard/react/main.tsx +0 -12
  102. package/src/dashboard/react/overlay-state.test.ts +0 -110
  103. package/src/dashboard/react/overlay-state.ts +0 -60
  104. package/src/dashboard/react/presentation.test.tsx +0 -571
  105. package/src/dashboard/styles.css +0 -261
  106. package/src/dashboard-control.js +0 -34
  107. package/src/dashboard-manager.js +0 -339
  108. package/src/dashboard-session-process.js +0 -88
  109. package/src/dashboard-session.js +0 -86
  110. package/src/dashboard.js +0 -1303
  111. package/src/delegation.js +0 -277
  112. package/src/github.js +0 -107
  113. package/src/model-roles.js +0 -70
  114. package/src/recorded-usage-tracker.js +0 -92
  115. package/src/usage-tracker.js +0 -650
  116. package/src/usage.js +0 -367
  117. package/src/workspace-path.js +0 -48
  118. package/src/workspace.js +0 -3629
  119. /package/src/{dashboard/react → ui}/components/AnimatedUsage.test.tsx +0 -0
  120. /package/src/{dashboard/react → ui}/components/CardStatusIcons.test.tsx +0 -0
  121. /package/src/{dashboard/react → ui}/components/ImageGallery.tsx +0 -0
  122. /package/src/{dashboard/react → ui}/components/OpenChatButton.test.tsx +0 -0
  123. /package/src/{dashboard/react → ui}/components/OpenChatButton.tsx +0 -0
  124. /package/src/{dashboard/react → ui}/components/PrGlyph.tsx +0 -0
  125. /package/src/{dashboard/react → ui}/components/QueuedPrompts.test.tsx +0 -0
  126. /package/src/{dashboard/react → ui}/components/QueuedPrompts.tsx +0 -0
  127. /package/src/{dashboard/react → ui}/components/ReplyCover.test.tsx +0 -0
  128. /package/src/{dashboard/react → ui}/components/ReplyCover.tsx +0 -0
  129. /package/src/{dashboard/react → ui}/components/ReplyImagePreview.tsx +0 -0
  130. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.test.tsx +0 -0
  131. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.tsx +0 -0
  132. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.test.tsx +0 -0
  133. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.tsx +0 -0
  134. /package/src/{dashboard/react → ui}/components/ShimmerText.tsx +0 -0
  135. /package/src/{dashboard/react → ui}/components/StatsGrid.tsx +0 -0
  136. /package/src/{dashboard/react → ui}/components/StatusBadge.tsx +0 -0
  137. /package/src/{dashboard/react → ui}/components/TaskBoard.test.tsx +0 -0
  138. /package/src/{dashboard/react → ui}/components/TaskCardStats.test.tsx +0 -0
  139. /package/src/{dashboard/react → ui}/components/TaskCardStats.tsx +0 -0
  140. /package/src/{dashboard/react → ui}/components/TurnContent.test.tsx +0 -0
  141. /package/src/{dashboard/react → ui}/fixtures.ts +0 -0
  142. /package/src/{dashboard → ui}/github-links.js +0 -0
  143. /package/src/{dashboard/react → ui}/image-cache.test.ts +0 -0
  144. /package/src/{dashboard/react → ui}/image-cache.ts +0 -0
  145. /package/src/{dashboard → ui}/state.js +0 -0
  146. /package/src/{dashboard/react → ui}/styles.css +0 -0
  147. /package/src/{dashboard/react → ui}/test-setup.ts +0 -0
  148. /package/src/{dashboard → ui}/time.js +0 -0
  149. /package/src/{dashboard/react → ui}/types.ts +0 -0
@@ -1,1172 +0,0 @@
1
- import {
2
- MAX_NOTIFICATIONS,
3
- canArchiveTask,
4
- canManuallyTransitionTask,
5
- clearNotifications,
6
- dismissNotification,
7
- filterTasks,
8
- findCurrentTask,
9
- latestTurnPresentation,
10
- manualTransitionExpectedState,
11
- mergeProjectedTurns,
12
- nextDateFilterRefreshDelay,
13
- notificationDismissLabel,
14
- notificationOpenLabel,
15
- notificationTitle,
16
- reconcileNotifications,
17
- reconcileManualTransition,
18
- reconcileManualTransitionResponse,
19
- statusFilterCounts,
20
- statusFilterText,
21
- taskStatusLabel,
22
- taskMatchesManualTransitionExpected,
23
- turnPresentation,
24
- } from "./state.js";
25
- import {
26
- archiveTaskFromControl,
27
- focusManualTransitionStatus,
28
- handleManualTransitionEscape,
29
- manuallyTransitionTaskFromControl,
30
- openTaskFromControl,
31
- restoreTaskActionMenuFocus,
32
- } from "./actions.js";
33
- import {
34
- githubReferenceAccessibleLabel,
35
- githubReferenceDisplayLabels,
36
- groupRelatedGitHubLinks,
37
- referenceSegments,
38
- } from "./github-links.js";
39
- import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
40
-
41
- const USAGE_POLL_INTERVAL_MS = 1_500;
42
- const MAX_USAGE_POLL_ATTEMPTS = 40;
43
-
44
- const state = {
45
- archivePendingThreadIds: new Set(),
46
- archivedThreadIds: new Set(),
47
- tasks: [],
48
- signatures: new Map(),
49
- notifications: [],
50
- seenNotificationIds: new Set(),
51
- initialized: false,
52
- detailNavigation: null,
53
- manualTransition: null,
54
- selectedTask: null,
55
- };
56
- let dateRefreshTimer = null;
57
- let detailRequestGeneration = 0;
58
- let copyTaskIdGeneration = 0;
59
- let copyTaskIdTimer = null;
60
- let archiveUpdateSerial = 0;
61
- const relativeTimes = new RelativeTimeController();
62
-
63
- const elements = {
64
- archiveTask: document.querySelector("#archive-codex"),
65
- clearNotifications: document.querySelector("#clear-notifications"),
66
- closeDialog: document.querySelector("#close-dialog"),
67
- connectionDot: document.querySelector("#connection-dot"),
68
- connectionLabel: document.querySelector("#connection-label"),
69
- copyTaskId: document.querySelector("#copy-task-id"),
70
- dashboardMessage: document.querySelector("#dashboard-message"),
71
- dashboardMessageText: document.querySelector("#dashboard-message-text"),
72
- dateFilter: document.querySelector("#date-filter"),
73
- dialog: document.querySelector("#task-dialog"),
74
- dialogInstruction: document.querySelector("#dialog-instruction"),
75
- dialogMetadata: document.querySelector("#dialog-metadata"),
76
- dialogProject: document.querySelector("#dialog-project"),
77
- dialogRelatedLinks: document.querySelector("#dialog-related-links"),
78
- dialogResults: document.querySelector("#dialog-results"),
79
- dialogTitle: document.querySelector("#dialog-title"),
80
- dialogUsage: document.querySelector("#dialog-usage"),
81
- dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
82
- emptyState: document.querySelector("#empty-state"),
83
- notifications: document.querySelector("#notifications"),
84
- notificationHost: document.querySelector("#notification-host"),
85
- notificationAnnouncer: document.querySelector("#notification-announcer"),
86
- manualTransitionPanel: document.querySelector("#manual-transition-panel"),
87
- manualTransitionError: document.querySelector("#manual-transition-error"),
88
- manualTransitionStatus: document.querySelector("#manual-transition-status"),
89
- markTaskCompleted: document.querySelector("#mark-task-completed"),
90
- markTaskFailed: document.querySelector("#mark-task-failed"),
91
- moreTaskActions: document.querySelector("#more-task-actions"),
92
- openProject: document.querySelector("#open-codex"),
93
- projectFilter: document.querySelector("#project-filter"),
94
- statusFilter: document.querySelector("#status-filter"),
95
- taskCount: document.querySelector("#task-count"),
96
- taskchefVersion: document.querySelector("#taskchef-version"),
97
- taskList: document.querySelector("#task-list"),
98
- toastList: document.querySelector("#toast-list"),
99
- };
100
- let notificationDescriptionSerial = 0;
101
-
102
- function referenceLink(link, { compact = false, displayLabel } = {}) {
103
- const anchor = document.createElement("a");
104
- anchor.className = compact ? "github-link github-link-compact" : "github-link";
105
- anchor.href = link.url;
106
- anchor.target = "_blank";
107
- anchor.rel = "noopener noreferrer";
108
- anchor.dataset.focusKey = `link:${link.url}`;
109
- const label = link.label ?? link.text;
110
- anchor.textContent = displayLabel ?? label;
111
- const accessibleLabel = githubReferenceAccessibleLabel(link);
112
- const githubKind = link.type === "issue"
113
- ? ", GitHub issue"
114
- : link.type === "pull"
115
- ? ", GitHub pull request"
116
- : link.provider === "github" || link.owner ? " on GitHub" : "";
117
- anchor.setAttribute(
118
- "aria-label",
119
- `${accessibleLabel}${githubKind} (opens in a new tab)`,
120
- );
121
- anchor.addEventListener("click", (event) => event.stopPropagation());
122
- return anchor;
123
- }
124
-
125
- function appendLinkedText(container, text, task) {
126
- const segments = referenceSegments(text, {
127
- projectRepositories: task.project?.githubRepos,
128
- taskRepository: task.relatedGitHubRepository,
129
- });
130
- const links = segments.filter((segment) => segment.kind === "link");
131
- const displayLabels = githubReferenceDisplayLabels(links);
132
- let linkIndex = 0;
133
- const children = segments.map((segment) => {
134
- if (segment.kind === "text") return document.createTextNode(segment.text);
135
- if (segment.kind === "link") {
136
- const displayLabel = displayLabels[linkIndex];
137
- linkIndex += 1;
138
- return referenceLink(segment, { displayLabel });
139
- }
140
- const ambiguous = document.createElement("span");
141
- ambiguous.className = "github-reference-ambiguous";
142
- ambiguous.textContent = segment.text;
143
- ambiguous.tabIndex = 0;
144
- ambiguous.title = segment.reason;
145
- ambiguous.setAttribute("aria-label", segment.reason);
146
- return ambiguous;
147
- });
148
- container.replaceChildren(...children);
149
- }
150
-
151
- function relatedGitHubLinks(task, { compact = false } = {}) {
152
- const groups = groupRelatedGitHubLinks(task.relatedGitHubLinks);
153
- const links = groups.flat();
154
- const container = document.createElement("nav");
155
- container.className = `github-links${compact ? " github-links-compact" : ""}`;
156
- container.setAttribute("aria-label", `Related GitHub links for ${task.title}`);
157
- const displayLabels = githubReferenceDisplayLabels(links, { related: true });
158
- let linkIndex = 0;
159
- const children = groups.map((group) => {
160
- const line = document.createElement("div");
161
- line.className = "github-links-group";
162
- line.replaceChildren(...group.map((link) => {
163
- const anchor = referenceLink(link, {
164
- compact,
165
- displayLabel: displayLabels[linkIndex],
166
- });
167
- linkIndex += 1;
168
- return anchor;
169
- }));
170
- return line;
171
- });
172
- if (task.relatedGitHubLinksTruncated) {
173
- const more = document.createElement("span");
174
- more.className = "github-links-more";
175
- more.textContent = "+ more";
176
- more.title = "Additional related GitHub references are omitted from this bounded dashboard view.";
177
- more.setAttribute("aria-label", more.title);
178
- const finalGroup = children.at(-1);
179
- if (finalGroup) finalGroup.append(more);
180
- else children.push(more);
181
- }
182
- container.hidden = children.length === 0;
183
- container.replaceChildren(...children);
184
- return container;
185
- }
186
-
187
- function renderProject(container, task) {
188
- const children = [document.createTextNode(task.project.name)];
189
- const repository = task.relatedGitHubRepository;
190
- if (repository) {
191
- const [owner, repositoryName] = repository.split("/");
192
- children.push(
193
- document.createTextNode(" "),
194
- referenceLink({
195
- label: repository,
196
- owner,
197
- provider: "github",
198
- repository: repositoryName,
199
- type: "repository",
200
- url: `https://github.com/${repository}`,
201
- }, { displayLabel: repositoryName }),
202
- );
203
- }
204
- container.replaceChildren(...children);
205
- }
206
-
207
- function timestampControl(value, { accessibleName, key, prefix = "" }) {
208
- if (!parsedTimestamp(value)) {
209
- const missing = document.createElement("span");
210
- missing.className = "timestamp-missing";
211
- missing.textContent = `${prefix}—`;
212
- return missing;
213
- }
214
- const control = document.createElement("button");
215
- control.type = "button";
216
- control.className = "timestamp-toggle";
217
- control.dataset.focusKey = `timestamp:${key}`;
218
- const time = document.createElement("time");
219
- control.append(time);
220
- relativeTimes.register(key, value, ({ exact, iso, label }) => {
221
- time.dateTime = iso;
222
- time.textContent = `${prefix}${label}`;
223
- const action = exact ? "Show relative time" : "Show exact date and time";
224
- control.title = action;
225
- control.setAttribute("aria-label", `${accessibleName}: ${label}. ${action}.`);
226
- }, { isActive: () => control.isConnected });
227
- control.addEventListener("click", () => relativeTimes.toggle(key));
228
- return control;
229
- }
230
-
231
- function codexIcon() {
232
- const picture = document.createElement("picture");
233
- picture.className = "codex-icon";
234
- picture.setAttribute("aria-hidden", "true");
235
- const dark = document.createElement("source");
236
- dark.srcset = "/assets/codex-app-dark.png";
237
- dark.media = "(prefers-color-scheme: dark)";
238
- const image = document.createElement("img");
239
- image.src = "/assets/codex-app-light.png";
240
- image.alt = "";
241
- image.width = 18;
242
- image.height = 18;
243
- picture.append(dark, image);
244
- return picture;
245
- }
246
-
247
- function configureOpenTaskControl(control, ariaLabel) {
248
- control.classList.add("task-action");
249
- control.setAttribute("aria-label", ariaLabel);
250
- const label = document.createElement("span");
251
- label.textContent = "Open task";
252
- control.replaceChildren(codexIcon(), label);
253
- }
254
-
255
- function setCopyTaskIdLabel(label) {
256
- elements.copyTaskId.textContent = label;
257
- elements.copyTaskId.setAttribute("aria-label", label);
258
- }
259
-
260
- function setConnection(connected) {
261
- elements.connectionDot.classList.toggle("connected", connected);
262
- elements.connectionLabel.textContent = connected ? "Live" : "Reconnecting…";
263
- }
264
-
265
- async function loadDashboardVersion() {
266
- try {
267
- const response = await fetch("/api/health");
268
- if (!response.ok) return;
269
- const identity = await response.json();
270
- if (typeof identity.taskchefVersion !== "string" || !identity.taskchefVersion) return;
271
- elements.taskchefVersion.textContent = `v${identity.taskchefVersion}`;
272
- elements.taskchefVersion.setAttribute(
273
- "aria-label",
274
- `TaskChef version ${identity.taskchefVersion}`,
275
- );
276
- } catch {
277
- // The live connection state already communicates dashboard availability.
278
- }
279
- }
280
-
281
- function replaceOptions(select, values, allLabel) {
282
- const selected = select.value;
283
- select.replaceChildren();
284
- const all = document.createElement("option");
285
- all.value = "";
286
- all.textContent = allLabel;
287
- select.append(all);
288
- for (const value of values) {
289
- const option = document.createElement("option");
290
- option.value = value;
291
- option.textContent = value;
292
- select.append(option);
293
- }
294
- if (values.includes(selected)) select.value = selected;
295
- }
296
-
297
- function notificationToast(notification) {
298
- const task = findCurrentTask(state.tasks, notification.taskId);
299
- const toast = document.createElement("div");
300
- toast.className = `toast${task ? "" : " toast-missing"}`;
301
- const text = document.createElement("button");
302
- text.type = "button";
303
- text.className = "toast-content";
304
- text.setAttribute("aria-label", notificationOpenLabel(notification, Boolean(task)));
305
- const describedBy = [];
306
- const title = document.createElement("strong");
307
- title.textContent = notificationTitle(notification);
308
- const description = document.createElement("span");
309
- description.textContent = notification.title;
310
- text.append(title, description);
311
- if (notification.summary) {
312
- const summary = document.createElement("span");
313
- summary.className = "toast-summary preserve-lines";
314
- summary.id = `notification-description-${notificationDescriptionSerial += 1}`;
315
- summary.textContent = notification.summary;
316
- text.append(summary);
317
- describedBy.push(summary.id);
318
- }
319
- const metadata = document.createElement("span");
320
- metadata.className = "toast-metadata";
321
- metadata.id = `notification-description-${notificationDescriptionSerial += 1}`;
322
- const timestamp = document.createElement("time");
323
- timestamp.dateTime = notification.timestamp ?? "";
324
- timestamp.textContent = formatRelativeTime(notification.timestamp);
325
- metadata.append(timestamp);
326
- if (!task) metadata.append(" · Task no longer available");
327
- text.append(metadata);
328
- describedBy.push(metadata.id);
329
- text.setAttribute("aria-describedby", describedBy.join(" "));
330
- text.addEventListener("click", async () => {
331
- const current = findCurrentTask(state.tasks, notification.taskId);
332
- state.notifications = dismissNotification(state.notifications, notification.id);
333
- renderNotifications();
334
- if (current) {
335
- await openDialog(current, {
336
- focus: true,
337
- highlightTurnRef: notification.turnRef ?? notification.turnId,
338
- });
339
- } else showMessage("This task is no longer present in the current snapshot.");
340
- });
341
- const dismiss = document.createElement("button");
342
- dismiss.type = "button";
343
- dismiss.className = "icon-button";
344
- dismiss.setAttribute("aria-label", notificationDismissLabel(notification));
345
- dismiss.setAttribute("aria-describedby", describedBy.join(" "));
346
- dismiss.textContent = "×";
347
- dismiss.addEventListener("click", () => {
348
- state.notifications = dismissNotification(state.notifications, notification.id);
349
- renderNotifications();
350
- });
351
- toast.append(text, dismiss);
352
- return toast;
353
- }
354
-
355
- function notificationAnnouncement(notification) {
356
- return [
357
- notificationTitle(notification),
358
- notification.title,
359
- notification.summary,
360
- notification.turnRef ? `Turn ref ${notification.turnRef}` : null,
361
- formatRelativeTime(notification.timestamp),
362
- ].filter(Boolean).join(". ");
363
- }
364
-
365
- function renderNotifications(additions = []) {
366
- elements.toastList.replaceChildren(
367
- ...state.notifications.map(notificationToast),
368
- );
369
- const notificationHost = elements.dialog.open ? elements.dialog : elements.notificationHost;
370
- const movingLayer = elements.notifications.parentNode !== notificationHost;
371
- if ((movingLayer || additions.length > 0) && state.notifications.length > 0) {
372
- try {
373
- elements.notifications.hidePopover?.();
374
- } catch {
375
- // The notification layer is not open yet.
376
- }
377
- }
378
- if (movingLayer) notificationHost.append(elements.notifications);
379
- if (state.notifications.length === 0) {
380
- try {
381
- elements.notifications.hidePopover?.();
382
- } catch {
383
- // The notification layer is already closed.
384
- }
385
- elements.notifications.hidden = true;
386
- } else {
387
- elements.notifications.hidden = false;
388
- try {
389
- elements.notifications.showPopover?.();
390
- } catch {
391
- // Re-rendering an already-open manual popover needs no further action.
392
- }
393
- }
394
- if (additions.length > 0) {
395
- elements.notificationAnnouncer.textContent = additions
396
- .map(notificationAnnouncement)
397
- .join(". ");
398
- }
399
- }
400
-
401
- export function showArchiveUpdate(task, message, { failed = false } = {}) {
402
- const notification = Object.freeze({
403
- id: `archive:${task.id}:${Date.now()}:${archiveUpdateSerial += 1}`,
404
- taskId: task.id,
405
- title: task.title,
406
- status: task.status,
407
- event: failed ? "archive_failed" : "archive_succeeded",
408
- turnRef: null,
409
- turnId: null,
410
- timestamp: new Date().toISOString(),
411
- summary: message,
412
- });
413
- state.notifications = [notification, ...state.notifications].slice(0, MAX_NOTIFICATIONS);
414
- renderNotifications([notification]);
415
- }
416
-
417
- function detailRow(term, value) {
418
- const dt = document.createElement("dt");
419
- dt.textContent = term;
420
- const dd = document.createElement("dd");
421
- if (value instanceof Node) dd.append(value);
422
- else dd.textContent = value ?? "—";
423
- return [dt, dd];
424
- }
425
-
426
- const tokenFormatter = new Intl.NumberFormat();
427
-
428
- function formatEstimatedCost(value) {
429
- if (typeof value !== "number") return "cost unavailable";
430
- if (value === 0) return "estimated $0.00";
431
- return `estimated $${value < 0.01 ? value.toFixed(4) : value.toFixed(2)}`;
432
- }
433
-
434
- function usageBreakdownText(usage) {
435
- return [
436
- `${tokenFormatter.format(usage.inputTokens)} input`,
437
- `${tokenFormatter.format(usage.cachedInputTokens)} cached input`,
438
- `${tokenFormatter.format(usage.outputTokens)} output`,
439
- `${tokenFormatter.format(usage.reasoningOutputTokens)} reasoning`,
440
- ].join(" · ");
441
- }
442
-
443
- export function renderTextShimmer(element, value) {
444
- element.textContent = String(value ?? "");
445
- return element;
446
- }
447
-
448
- export function usagePresentation(usage, { wholeTask = false } = {}) {
449
- const container = document.createElement("div");
450
- container.className = `usage-summary usage-${usage?.status ?? "unavailable"}`;
451
- if (usage?.status === "pending" || usage?.status === "calculating") {
452
- const text = document.createElement("span");
453
- text.className = "text-shimmer";
454
- renderTextShimmer(text, usage.status === "pending"
455
- ? "Tokens pending · available when turn finishes"
456
- : "Calculating token usage…");
457
- container.replaceChildren(text);
458
- return container;
459
- }
460
- if (usage?.status !== "available") {
461
- const headline = document.createElement("strong");
462
- headline.textContent = "Token usage unavailable";
463
- container.append(headline);
464
- if (usage?.reason) {
465
- const reason = document.createElement("span");
466
- reason.textContent = usage.reason;
467
- container.append(reason);
468
- }
469
- return container;
470
- }
471
- const headline = document.createElement("strong");
472
- headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}${usage.knownSoFar ? " · known so far" : ""}`;
473
- const breakdown = document.createElement("span");
474
- breakdown.textContent = usageBreakdownText(usage);
475
- container.append(headline, breakdown);
476
- const provenance = document.createElement("span");
477
- const version = usage.provenance?.version ? ` ${usage.provenance.version}` : "";
478
- const pricingMode = usage.provenance?.pricingMode
479
- ? ` · ${usage.provenance.pricingMode === "online"
480
- ? "online pricing requested"
481
- : usage.provenance.pricingMode === "standard" ? "standard API pricing" : "offline pricing fallback"}`
482
- : "";
483
- const freshness = usage.sourceUpdatedAt ?? usage.sampledAt;
484
- provenance.textContent = `Source: ${usage.provenance?.provider === "taskchef" ? "TaskChef" : "ccusage (historical)"}${version}${pricingMode}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
485
- container.append(provenance);
486
- return container;
487
- }
488
-
489
- function usageStillCalculating(task) {
490
- return task.usage?.status === "calculating"
491
- || (task.status !== "working" && Object.values(task.usage?.turns ?? {})
492
- .some((turn) => turn.status === "calculating"));
493
- }
494
-
495
- function turnTimeline(task, { highlightTurnRef = null } = {}) {
496
- if (task.turns.length === 0) {
497
- const empty = document.createElement("p");
498
- empty.className = "result-history-empty";
499
- empty.textContent = "No executor turn has been reported yet.";
500
- return [empty];
501
- }
502
- return [...task.turns].reverse().map((turn, index) => {
503
- const item = document.createElement("article");
504
- item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
505
- const header = document.createElement("div");
506
- header.className = "result-history-header";
507
- const status = document.createElement("span");
508
- const presentation = turnPresentation(turn);
509
- const turnStatus = presentation.status;
510
- status.className = `status status-${turnStatus}`;
511
- status.textContent = turnStatus.replaceAll("_", " ");
512
- const turnKey = turn.turnRef ?? turn.turnId ?? `no-turn:${index}`;
513
- item.dataset.turnRef = turnKey;
514
- if (highlightTurnRef !== null && turnKey === highlightTurnRef) {
515
- item.className += " result-history-notified";
516
- item.tabIndex = -1;
517
- }
518
- const timestamp = timestampControl(presentation.updatedAt, {
519
- accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
520
- key: `detail:${task.id}:turn:${turnKey}`,
521
- });
522
- header.append(status, timestamp);
523
- if (presentation.sourceLabel) {
524
- const source = document.createElement("span");
525
- source.className = "result-history-source";
526
- source.textContent = presentation.sourceLabel;
527
- header.insertBefore(source, timestamp);
528
- }
529
- const requestLabel = document.createElement("h4");
530
- requestLabel.textContent = "Request";
531
- const request = document.createElement("p");
532
- request.className = "preserve-lines";
533
- appendLinkedText(
534
- request,
535
- turn.requestSummary ?? "Request not recorded by this TaskChef version.",
536
- task,
537
- );
538
- const resultLabel = document.createElement("h4");
539
- resultLabel.textContent = "Result";
540
- const result = document.createElement("p");
541
- result.className = `preserve-lines${turnStatus === "working" ? " text-shimmer" : ""}`;
542
- if (turnStatus === "working") renderTextShimmer(result, presentation.summary);
543
- else appendLinkedText(result, presentation.summary, task);
544
- const turnMetadata = document.createElement("p");
545
- turnMetadata.className = "result-history-turn";
546
- turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
547
- const recordedUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId];
548
- const turnUsage = turn.result === null
549
- ? { status: "pending" }
550
- : recordedUsage ?? {
551
- status: "unavailable",
552
- reason: "No reliable turn boundary is available.",
553
- };
554
- item.append(
555
- header,
556
- requestLabel,
557
- request,
558
- resultLabel,
559
- result,
560
- usagePresentation(turnUsage),
561
- turnMetadata,
562
- );
563
- return item;
564
- });
565
- }
566
-
567
- function descendantPath(root, descendant) {
568
- const path = [];
569
- let current = descendant;
570
- while (current && current !== root) {
571
- const parent = current.parentNode;
572
- if (!parent) return null;
573
- const index = Array.prototype.indexOf.call(parent.children, current);
574
- if (index < 0) return null;
575
- path.unshift(index);
576
- current = parent;
577
- }
578
- return current === root ? path : null;
579
- }
580
-
581
- function descendantAtPath(root, path) {
582
- let current = root;
583
- for (const index of path ?? []) {
584
- current = current?.children?.[index];
585
- if (!current) return null;
586
- }
587
- return current;
588
- }
589
-
590
- function descendantWithFocusKey(root, focusKey) {
591
- if (!root || !focusKey) return null;
592
- if (root.dataset?.focusKey === focusKey) return root;
593
- for (const child of root.children ?? []) {
594
- const match = descendantWithFocusKey(child, focusKey);
595
- if (match) return match;
596
- }
597
- return null;
598
- }
599
-
600
- function manualTransitionPending() {
601
- return state.manualTransition?.stage === "pending";
602
- }
603
-
604
- function resetManualTransition({ focus = false } = {}) {
605
- state.manualTransition = null;
606
- elements.closeDialog.disabled = false;
607
- if (state.selectedTask) renderManualTransition(state.selectedTask);
608
- if (focus) elements.moreTaskActions.focus();
609
- }
610
-
611
- function replaceCurrentTask(task) {
612
- state.tasks = state.tasks.map((candidate) => candidate.id === task.id ? task : candidate);
613
- state.selectedTask = task;
614
- }
615
-
616
- function renderManualTransition(task) {
617
- const activeElement = document.activeElement;
618
- const focusWasInPanel = elements.manualTransitionPanel.contains?.(activeElement) ?? false;
619
- state.manualTransition = reconcileManualTransition(state.manualTransition, task);
620
- const pending = manualTransitionPending();
621
- const expanded = Boolean(state.manualTransition);
622
- elements.moreTaskActions.disabled = pending;
623
- elements.moreTaskActions.textContent = expanded ? "←" : "…";
624
- elements.moreTaskActions.setAttribute(
625
- "aria-label",
626
- expanded ? "Hide more task actions" : "More task actions",
627
- );
628
- elements.moreTaskActions.setAttribute("title", expanded ? "Hide more task actions" : "More task actions");
629
- elements.moreTaskActions.setAttribute("aria-expanded", String(expanded));
630
- elements.closeDialog.disabled = pending;
631
- elements.manualTransitionPanel.hidden = state.manualTransition === null;
632
- elements.manualTransitionPanel.setAttribute("aria-busy", String(pending));
633
- elements.copyTaskId.disabled = pending || !task.id;
634
- elements.markTaskCompleted.hidden = !canManuallyTransitionTask(task, "completed");
635
- elements.markTaskFailed.hidden = !canManuallyTransitionTask(task, "failed");
636
- elements.markTaskCompleted.disabled = pending;
637
- elements.markTaskFailed.disabled = pending;
638
- const archivePending = state.archivePendingThreadIds.has(task.threadId);
639
- const archived = state.archivedThreadIds.has(task.threadId);
640
- elements.archiveTask.disabled = pending || archivePending || archived;
641
- elements.archiveTask.textContent = archived ? "Archived" : archivePending ? "Archiving…" : "Archive chat";
642
- elements.archiveTask.setAttribute(
643
- "aria-label",
644
- archived
645
- ? `${task.title} is archived in Codex`
646
- : archivePending
647
- ? `Archiving ${task.title} in Codex`
648
- : `Archive ${task.title} in Codex`,
649
- );
650
- elements.manualTransitionStatus.hidden = !pending;
651
- elements.manualTransitionStatus.textContent = pending ? "Saving task state…" : "";
652
- const error = state.manualTransition?.error ?? "";
653
- elements.manualTransitionError.hidden = !error;
654
- elements.manualTransitionError.textContent = error;
655
- if (!state.manualTransition) {
656
- elements.closeDialog.disabled = false;
657
- if (focusWasInPanel) elements.moreTaskActions.focus();
658
- return;
659
- }
660
- if (pending) {
661
- focusManualTransitionStatus(elements.manualTransitionPanel);
662
- } else {
663
- restoreTaskActionMenuFocus(
664
- elements.manualTransitionPanel,
665
- activeElement,
666
- elements.moreTaskActions,
667
- );
668
- }
669
- }
670
-
671
- async function submitManualTransition(targetStatus, event) {
672
- const task = state.selectedTask;
673
- if (
674
- !task
675
- || !canManuallyTransitionTask(task, targetStatus)
676
- || manualTransitionPending()
677
- ) return;
678
- const previous = state.manualTransition;
679
- const actionId = previous?.targetStatus === targetStatus && previous.actionId
680
- ? previous.actionId
681
- : crypto.randomUUID();
682
- const attempt = {
683
- ...previous,
684
- taskId: task.id,
685
- stage: "pending",
686
- targetStatus,
687
- actionId,
688
- expected: previous?.expected ?? manualTransitionExpectedState(task),
689
- error: null,
690
- };
691
- state.manualTransition = attempt;
692
- renderManualTransition(task);
693
- const result = await manuallyTransitionTaskFromControl(
694
- event,
695
- { ...task, ...attempt.expected },
696
- targetStatus,
697
- attempt.actionId,
698
- );
699
- const current = reconcileManualTransitionResponse({
700
- requestTask: task,
701
- expected: attempt.expected,
702
- responseTask: result.task,
703
- selectedTask: state.selectedTask,
704
- });
705
- if (result.ok) {
706
- state.manualTransition = null;
707
- if (current === result.task) replaceCurrentTask(result.task);
708
- renderDialog(current);
709
- render();
710
- elements.dialogTitle.focus?.();
711
- return;
712
- }
713
- if (current === result.task) replaceCurrentTask(result.task);
714
- if (
715
- !canManuallyTransitionTask(current)
716
- || !taskMatchesManualTransitionExpected(current, attempt.expected)
717
- ) {
718
- state.manualTransition = null;
719
- renderDialog(current);
720
- render();
721
- showMessage("This task changed. Review its current state before trying again.");
722
- elements.dialogTitle.focus?.();
723
- return;
724
- }
725
- state.manualTransition = {
726
- ...attempt,
727
- stage: "choose",
728
- error: result.message,
729
- actionId: result.code === "stale_task" ? crypto.randomUUID() : attempt.actionId,
730
- };
731
- renderDialog(current);
732
- elements.manualTransitionError.focus();
733
- }
734
-
735
- function renderDialog(task, { highlightTurnRef = null } = {}) {
736
- if (state.selectedTask?.id !== task.id) {
737
- copyTaskIdGeneration += 1;
738
- clearTimeout(copyTaskIdTimer);
739
- copyTaskIdTimer = null;
740
- }
741
- const preservedResults = state.selectedTask?.id === task.id
742
- ? state.selectedTask.results
743
- : null;
744
- const preservedUsage = state.selectedTask?.id === task.id
745
- ? state.selectedTask.usage
746
- : null;
747
- const detailedTask = {
748
- ...task,
749
- turns: mergeProjectedTurns(task, state.selectedTask?.turns ?? []),
750
- results: task.results ?? preservedResults ?? [],
751
- usage: task.usage ?? preservedUsage ?? null,
752
- };
753
- const focusedElement = elements.dialogResults.contains?.(document.activeElement)
754
- ? document.activeElement
755
- : null;
756
- let focusedTurn = focusedElement;
757
- while (focusedTurn && focusedTurn !== elements.dialogResults && !focusedTurn.dataset?.turnRef) {
758
- focusedTurn = focusedTurn.parentNode;
759
- }
760
- const focusedTurnRef = focusedTurn?.dataset?.turnRef ?? null;
761
- const focusedDescendantKey = focusedElement?.dataset?.focusKey ?? null;
762
- const focusedDescendantPath = focusedTurn && focusedElement
763
- ? descendantPath(focusedTurn, focusedElement)
764
- : null;
765
- state.selectedTask = detailedTask;
766
- renderProject(elements.dialogProject, task);
767
- elements.dialogTitle.textContent = task.title;
768
- elements.dialogRelatedLinks.replaceChildren(...relatedGitHubLinks(detailedTask).children);
769
- elements.dialogRelatedLinks.setAttribute(
770
- "aria-label",
771
- `Related GitHub links for ${task.title}`,
772
- );
773
- elements.dialogRelatedLinks.hidden = (
774
- (task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
775
- );
776
- const timeline = turnTimeline(detailedTask, { highlightTurnRef });
777
- elements.dialogResults.replaceChildren(...timeline);
778
- const highlightedTurn = timeline.find((item) => item.dataset.turnRef === highlightTurnRef) ?? null;
779
- const replacedFocusedTurn = timeline.find((item) => item.dataset.turnRef === focusedTurnRef) ?? null;
780
- if (replacedFocusedTurn) {
781
- const pathMatchedElement = descendantAtPath(replacedFocusedTurn, focusedDescendantPath);
782
- const replacedFocusedElement = (
783
- pathMatchedElement?.dataset?.focusKey === focusedDescendantKey
784
- ? pathMatchedElement
785
- : null
786
- ) ?? descendantWithFocusKey(replacedFocusedTurn, focusedDescendantKey);
787
- if (replacedFocusedElement && replacedFocusedElement !== replacedFocusedTurn) {
788
- replacedFocusedElement.focus?.();
789
- } else {
790
- replacedFocusedTurn.tabIndex = -1;
791
- replacedFocusedTurn.focus?.();
792
- }
793
- }
794
- const currentTurnRef = task.turnRef ?? task.latestTurn?.turnRef ?? null;
795
- const preservedUsageIsPriorGeneration = task.usage == null
796
- && preservedUsage != null
797
- && currentTurnRef != null
798
- && preservedUsage.generationTurnRef !== currentTurnRef;
799
- const taskUsage = detailedTask.usage?.task
800
- && (detailedTask.usage.status === "available" || task.status === "working")
801
- && (!preservedUsageIsPriorGeneration || task.status === "working")
802
- ? {
803
- status: "available",
804
- ...detailedTask.usage.task,
805
- knownSoFar: task.status === "working",
806
- }
807
- : detailedTask.usage?.status === "calculating" || preservedUsageIsPriorGeneration
808
- ? { ...detailedTask.usage, status: task.status === "working" ? "pending" : "calculating" }
809
- : detailedTask.usage ?? {
810
- status: task.status === "working"
811
- ? "pending"
812
- : task.threadId
813
- ? "calculating"
814
- : "unavailable",
815
- };
816
- elements.dialogUsage.replaceChildren(usagePresentation(
817
- taskUsage,
818
- { wholeTask: true },
819
- ));
820
- elements.dialogInstruction.textContent = task.instruction;
821
- elements.copyTaskId.disabled = !task.id;
822
- setCopyTaskIdLabel("Copy Task ID");
823
- elements.dialogMetadata.replaceChildren(
824
- ...detailRow("Current status", taskStatusLabel(task)),
825
- ...detailRow("Current turn ref", task.turnRef),
826
- ...detailRow("Current Codex turn ID", task.turnId),
827
- ...detailRow("Last result status", task.lastResult?.status?.replaceAll("_", " ")),
828
- ...detailRow("Last result turn ref", task.lastResult?.turnRef),
829
- ...detailRow("Last result Codex turn ID", task.lastResult?.turnId),
830
- ...detailRow("Last result updated", timestampControl(task.lastResult?.updatedAt, {
831
- accessibleName: `Last result updated time for ${task.title}`,
832
- key: `detail:${task.id}:last-result-updated`,
833
- })),
834
- ...detailRow("Task ID", task.id),
835
- ...detailRow("Thread ID", task.threadId),
836
- ...detailRow("Project path", task.project.path),
837
- ...detailRow("Created", timestampControl(task.createdAt, {
838
- accessibleName: `Created time for ${task.title}`,
839
- key: `detail:${task.id}:created`,
840
- })),
841
- ...detailRow("Updated", timestampControl(
842
- task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
843
- {
844
- accessibleName: `Updated time for ${task.title}`,
845
- key: `detail:${task.id}:updated`,
846
- },
847
- )),
848
- ...detailRow("Updated by", task.updatedBy),
849
- );
850
- configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
851
- const canArchive = canArchiveTask(task);
852
- const archived = state.archivedThreadIds.has(task.threadId);
853
- const archivePending = state.archivePendingThreadIds.has(task.threadId);
854
- elements.archiveTask.hidden = !canArchive;
855
- elements.archiveTask.disabled = archived || archivePending;
856
- elements.archiveTask.textContent = archived ? "Archived" : archivePending ? "Archiving…" : "Archive chat";
857
- elements.archiveTask.setAttribute(
858
- "aria-label",
859
- archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
860
- );
861
- renderManualTransition(detailedTask);
862
- return highlightedTurn;
863
- }
864
-
865
- async function openDialog(task, {
866
- focus = false,
867
- highlightTurnRef = null,
868
- preserveNavigation = false,
869
- } = {}) {
870
- if (focus) {
871
- state.detailNavigation = {
872
- focusPending: true,
873
- highlightTurnRef,
874
- taskId: task.id,
875
- };
876
- } else if (!preserveNavigation) state.detailNavigation = null;
877
- const navigation = state.detailNavigation?.taskId === task.id
878
- ? state.detailNavigation
879
- : null;
880
- const activeHighlightTurnRef = navigation?.highlightTurnRef ?? highlightTurnRef;
881
- const requestGeneration = ++detailRequestGeneration;
882
- renderDialog(task, { highlightTurnRef: activeHighlightTurnRef });
883
- if (!elements.dialog.open) {
884
- elements.dialog.showModal();
885
- renderNotifications();
886
- }
887
- const load = async (attempt = 0) => {
888
- try {
889
- const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
890
- if (!response.ok) throw new Error("Task details are unavailable.");
891
- const detail = await response.json();
892
- if (
893
- requestGeneration === detailRequestGeneration
894
- && state.selectedTask?.id === task.id
895
- && elements.dialog.open
896
- ) {
897
- const activeNavigation = state.detailNavigation?.taskId === task.id
898
- ? state.detailNavigation
899
- : null;
900
- const highlightedTurn = renderDialog(detail.task, {
901
- highlightTurnRef: activeNavigation?.highlightTurnRef ?? activeHighlightTurnRef,
902
- });
903
- if (activeNavigation?.focusPending) {
904
- (highlightedTurn ?? elements.dialogTitle).focus?.();
905
- activeNavigation.focusPending = false;
906
- }
907
- if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
908
- setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
909
- }
910
- }
911
- } catch {
912
- if (
913
- requestGeneration === detailRequestGeneration
914
- && state.selectedTask?.id === task.id
915
- && elements.dialog.open
916
- ) {
917
- showMessage("Task activity timeline is temporarily unavailable.");
918
- const activeNavigation = state.detailNavigation?.taskId === task.id
919
- ? state.detailNavigation
920
- : null;
921
- if (activeNavigation?.focusPending) {
922
- elements.dialogTitle.focus?.();
923
- activeNavigation.focusPending = false;
924
- }
925
- }
926
- }
927
- };
928
- await load();
929
- }
930
-
931
- function taskCard(task) {
932
- const article = document.createElement("article");
933
- article.className = "task-card";
934
- const heading = document.createElement("div");
935
- heading.className = "task-heading";
936
- const title = document.createElement("button");
937
- title.type = "button";
938
- title.className = "task-title";
939
- title.textContent = task.title;
940
- title.addEventListener("click", () => openDialog(task));
941
- const badge = document.createElement("span");
942
- badge.className = `status status-${task.status ?? "unresolved"}`;
943
- badge.textContent = taskStatusLabel(task);
944
- heading.append(title, badge);
945
- const project = document.createElement("p");
946
- project.className = "task-project";
947
- renderProject(project, task);
948
- const summary = document.createElement("p");
949
- summary.className = "task-summary";
950
- const latest = latestTurnPresentation(task);
951
- const requestLabel = document.createElement("strong");
952
- requestLabel.textContent = "Request";
953
- const request = document.createElement("span");
954
- request.className = "preserve-lines";
955
- appendLinkedText(request, latest.requestSummary, task);
956
- const resultLabel = document.createElement("strong");
957
- resultLabel.textContent = "Result";
958
- const result = document.createElement("span");
959
- result.className = `preserve-lines${latest.resultStatus === "working" ? " text-shimmer" : ""}`;
960
- if (latest.resultStatus === "working") renderTextShimmer(result, latest.resultSummary);
961
- else appendLinkedText(result, latest.resultSummary, task);
962
- summary.replaceChildren(requestLabel, request, resultLabel, result);
963
- const relatedLinks = relatedGitHubLinks(task, { compact: true });
964
- const time = timestampControl(
965
- task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
966
- {
967
- accessibleName: `Updated time for ${task.title}`,
968
- key: `list:${task.id}:updated`,
969
- prefix: "Updated ",
970
- },
971
- );
972
- const footer = document.createElement("div");
973
- footer.className = "task-footer";
974
- const openTask = document.createElement("button");
975
- openTask.type = "button";
976
- openTask.className = "secondary-button task-open";
977
- configureOpenTaskControl(openTask, `Open ${task.title} in Codex`);
978
- openTask.addEventListener("click", (event) => openTaskFromControl(event, task.id, {
979
- showMessage,
980
- }));
981
- footer.append(time, openTask);
982
- article.append(heading, project, summary, relatedLinks, footer);
983
- return article;
984
- }
985
-
986
- function render() {
987
- const project = elements.projectFilter.value;
988
- const status = elements.statusFilter.querySelector("input:checked")?.value ?? "";
989
- const date = elements.dateFilter.value;
990
- const now = Date.now();
991
- const contextualTasks = filterTasks(state.tasks, { project, date, now });
992
- const visible = filterTasks(contextualTasks, { status, now });
993
- const counts = statusFilterCounts(state.tasks, { project, date, now });
994
- for (const label of elements.statusFilter.querySelectorAll("[data-status-label]")) {
995
- const value = label.dataset.statusLabel;
996
- label.textContent = statusFilterText(value, counts[value]);
997
- }
998
- elements.taskList.replaceChildren(...visible.map(taskCard));
999
- elements.emptyState.hidden = visible.length > 0;
1000
- elements.taskCount.textContent = `${visible.length} of ${state.tasks.length} task${state.tasks.length === 1 ? "" : "s"}`;
1001
- clearTimeout(dateRefreshTimer);
1002
- const refreshDelay = nextDateFilterRefreshDelay(contextualTasks, date, now);
1003
- dateRefreshTimer = refreshDelay === null ? null : setTimeout(render, refreshDelay);
1004
- }
1005
-
1006
- function selectStatusFromKeyboard(event) {
1007
- if (event.target?.name !== "status-filter") return;
1008
- const inputs = [...elements.statusFilter.querySelectorAll('input[name="status-filter"]')];
1009
- const currentIndex = inputs.indexOf(event.target);
1010
- if (currentIndex < 0) return;
1011
- let nextIndex = currentIndex;
1012
- if (["ArrowRight", "ArrowDown"].includes(event.key)) nextIndex = (currentIndex + 1) % inputs.length;
1013
- else if (["ArrowLeft", "ArrowUp"].includes(event.key)) {
1014
- nextIndex = (currentIndex - 1 + inputs.length) % inputs.length;
1015
- } else if (event.key === "Home") nextIndex = 0;
1016
- else if (event.key === "End") nextIndex = inputs.length - 1;
1017
- else if (![" ", "Enter"].includes(event.key)) return;
1018
- event.preventDefault();
1019
- for (const input of inputs) input.checked = false;
1020
- inputs[nextIndex].checked = true;
1021
- inputs[nextIndex].focus();
1022
- render();
1023
- }
1024
-
1025
- function applySnapshot(snapshot) {
1026
- const reconciled = reconcileNotifications({
1027
- initialized: state.initialized,
1028
- notifications: state.notifications,
1029
- seenIds: state.seenNotificationIds,
1030
- signatures: state.signatures,
1031
- }, snapshot.tasks);
1032
- state.tasks = snapshot.tasks;
1033
- state.signatures = reconciled.signatures;
1034
- state.notifications = reconciled.notifications;
1035
- state.seenNotificationIds = reconciled.seenIds;
1036
- state.initialized = true;
1037
- if (snapshot.healthy === false) {
1038
- showMessage("The task log is temporarily unavailable. Showing the last valid snapshot.");
1039
- } else {
1040
- elements.dashboardMessage.hidden = true;
1041
- }
1042
- replaceOptions(
1043
- elements.projectFilter,
1044
- [...new Set(state.tasks.map((task) => task.project.name))].sort(),
1045
- "All projects",
1046
- );
1047
- if (state.selectedTask) {
1048
- const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
1049
- if (updated && elements.dialog.open) openDialog(updated, { preserveNavigation: true });
1050
- }
1051
- renderNotifications(reconciled.additions);
1052
- render();
1053
- }
1054
-
1055
- function showMessage(message) {
1056
- elements.dashboardMessageText.textContent = message;
1057
- elements.dashboardMessage.hidden = false;
1058
- }
1059
-
1060
- void loadDashboardVersion();
1061
-
1062
- const events = new EventSource("/api/events");
1063
- events.addEventListener("open", () => setConnection(true));
1064
- events.addEventListener("error", () => setConnection(false));
1065
- events.addEventListener("snapshot", (event) => {
1066
- setConnection(true);
1067
- applySnapshot(JSON.parse(event.data));
1068
- });
1069
- events.addEventListener("dashboard-error", (event) => {
1070
- showMessage(JSON.parse(event.data).message);
1071
- });
1072
-
1073
- elements.projectFilter.addEventListener("change", render);
1074
- elements.statusFilter.addEventListener("change", render);
1075
- elements.statusFilter.addEventListener("keydown", selectStatusFromKeyboard);
1076
- elements.dateFilter.addEventListener("change", render);
1077
- elements.dismissDashboardMessage.addEventListener("click", () => {
1078
- elements.dashboardMessage.hidden = true;
1079
- });
1080
- elements.clearNotifications.addEventListener("click", () => {
1081
- state.notifications = clearNotifications();
1082
- renderNotifications();
1083
- });
1084
- elements.closeDialog.addEventListener("click", () => {
1085
- if (!manualTransitionPending()) elements.dialog.close();
1086
- });
1087
- elements.dialog.addEventListener("click", (event) => {
1088
- if (event.target === elements.dialog && !manualTransitionPending()) elements.dialog.close();
1089
- });
1090
- elements.dialog.addEventListener("keydown", (event) => {
1091
- handleManualTransitionEscape(event, {
1092
- active: Boolean(state.manualTransition),
1093
- pending: manualTransitionPending(),
1094
- cancel: () => resetManualTransition({ focus: true }),
1095
- });
1096
- });
1097
- elements.dialog.addEventListener("cancel", (event) => {
1098
- if (!state.manualTransition) return;
1099
- event.preventDefault();
1100
- if (!manualTransitionPending()) resetManualTransition({ focus: true });
1101
- });
1102
- elements.dialog.addEventListener("close", () => {
1103
- if (!manualTransitionPending()) state.manualTransition = null;
1104
- state.detailNavigation = null;
1105
- renderNotifications();
1106
- });
1107
- elements.moreTaskActions.addEventListener("click", () => {
1108
- const task = state.selectedTask;
1109
- if (!task || manualTransitionPending()) return;
1110
- if (state.manualTransition) {
1111
- resetManualTransition({ focus: true });
1112
- return;
1113
- }
1114
- state.manualTransition = {
1115
- taskId: task.id,
1116
- stage: "choose",
1117
- expected: manualTransitionExpectedState(task),
1118
- };
1119
- renderManualTransition(task);
1120
- elements.copyTaskId.focus();
1121
- });
1122
- elements.markTaskCompleted.addEventListener("click", (event) => {
1123
- return submitManualTransition("completed", event);
1124
- });
1125
- elements.markTaskFailed.addEventListener("click", (event) => {
1126
- return submitManualTransition("failed", event);
1127
- });
1128
- elements.copyTaskId.addEventListener("click", async () => {
1129
- const taskId = state.selectedTask?.id;
1130
- if (!taskId) return;
1131
- const copyGeneration = ++copyTaskIdGeneration;
1132
- clearTimeout(copyTaskIdTimer);
1133
- copyTaskIdTimer = null;
1134
- try {
1135
- await navigator.clipboard.writeText(taskId);
1136
- if (copyGeneration !== copyTaskIdGeneration || state.selectedTask?.id !== taskId) return;
1137
- setCopyTaskIdLabel("Task ID copied");
1138
- copyTaskIdTimer = setTimeout(() => {
1139
- if (copyGeneration === copyTaskIdGeneration && state.selectedTask?.id === taskId) {
1140
- setCopyTaskIdLabel("Copy Task ID");
1141
- }
1142
- copyTaskIdTimer = null;
1143
- }, 1_500);
1144
- } catch {
1145
- if (copyGeneration === copyTaskIdGeneration && state.selectedTask?.id === taskId) {
1146
- showMessage("Clipboard access is unavailable. Copy the Task ID from the metadata below.");
1147
- }
1148
- }
1149
- });
1150
- elements.openProject.addEventListener("click", async (event) => {
1151
- if (!state.selectedTask) return;
1152
- await openTaskFromControl(event, state.selectedTask.id, { showMessage });
1153
- });
1154
- elements.archiveTask.addEventListener("click", async (event) => {
1155
- const task = state.selectedTask;
1156
- if (
1157
- !task
1158
- || !canArchiveTask(task)
1159
- || state.archivePendingThreadIds.has(task.threadId)
1160
- ) return;
1161
- state.archivePendingThreadIds.add(task.threadId);
1162
- renderManualTransition(task);
1163
- try {
1164
- await archiveTaskFromControl(event, task, {
1165
- onArchived: (threadId) => state.archivedThreadIds.add(threadId),
1166
- showUpdate: (message, options) => showArchiveUpdate(task, message, options),
1167
- });
1168
- } finally {
1169
- state.archivePendingThreadIds.delete(task.threadId);
1170
- if (state.selectedTask?.id === task.id) renderDialog(state.selectedTask);
1171
- }
1172
- });