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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +45 -11
- package/docs/images/ccusage-token-consumption.png +0 -0
- package/docs/spec.md +100 -15
- package/docs/workflows.md +13 -9
- package/index.js +1 -0
- package/package.json +2 -1
- package/src/dashboard/actions.js +81 -0
- package/src/dashboard/app.js +317 -29
- package/src/dashboard/index.html +17 -4
- package/src/dashboard/state.js +60 -3
- package/src/dashboard/styles.css +24 -4
- package/src/dashboard.js +129 -1
- package/src/mcp.js +23 -1
- package/src/usage-tracker.js +480 -0
- package/src/usage.js +388 -0
- package/src/workspace.js +368 -20
package/src/dashboard/app.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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", () =>
|
|
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 (
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
});
|
package/src/dashboard/index.html
CHANGED
|
@@ -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="
|
|
116
|
-
<
|
|
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>
|
package/src/dashboard/state.js
CHANGED
|
@@ -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))
|
|
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
|
-
},
|
|
317
|
+
}, event),
|
|
261
318
|
taskId: task.id,
|
|
262
319
|
title: task.title,
|
|
263
320
|
status: result.status,
|
|
264
|
-
event
|
|
321
|
+
event,
|
|
265
322
|
turnRef: result.turnRef ?? result.turnId ?? null,
|
|
266
323
|
turnId: result.turnId ?? null,
|
|
267
324
|
timestamp: result.updatedAt,
|