taskchef 7.22.5 → 7.23.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 +3 -3
- package/docs/dashboard-lifecycle.md +9 -0
- package/docs/images/dashboard-token-pending.jpg +0 -0
- package/package.json +2 -1
- package/src/dashboard/app.js +207 -32
- package/src/dashboard/index.html +10 -8
- package/src/dashboard/styles.css +20 -5
package/README.md
CHANGED
|
@@ -327,10 +327,10 @@ app-callable archive interface or guarantees CLI compatibility. The dormant
|
|
|
327
327
|
server path also rejects requests before discovering or invoking the CLI.
|
|
328
328
|
|
|
329
329
|
Task details also show whole-task and per-turn token usage when `ccusage` can
|
|
330
|
-
map the linked Codex thread. A completed turn briefly shows “
|
|
331
|
-
|
|
330
|
+
map the linked Codex thread. A completed turn briefly shows “Calculating token
|
|
331
|
+
usage…” while TaskChef performs bounded deferred reconciliation, because
|
|
332
332
|
the terminal lifecycle callback precedes Codex's final output write. Historical
|
|
333
|
-
tasks may show a trustworthy task total while older turns remain “
|
|
333
|
+
tasks may show a trustworthy task total while older turns remain “Token usage
|
|
334
334
|
unavailable” when no cumulative turn boundaries were recorded. Input, cached
|
|
335
335
|
input, output, reasoning, and total counts retain ccusage's categories. Dollar
|
|
336
336
|
figures are labeled API-equivalent estimates; provenance identifies the online
|
|
@@ -25,6 +25,15 @@ workspace resolution, and startup isolation, but none of those requires the
|
|
|
25
25
|
HTTP listener to share one MCP transport's lifetime. The session process keeps
|
|
26
26
|
those benefits without retaining the obsolete per-transport ownership.
|
|
27
27
|
|
|
28
|
+
## Token usage presentation
|
|
29
|
+
|
|
30
|
+
The detail view distinguishes an active turn whose final usage is pending from
|
|
31
|
+
a terminal turn whose usage is being calculated. Available usage keeps both
|
|
32
|
+
the token total and API-equivalent USD estimate; an available cumulative value
|
|
33
|
+
shown during a newer active turn is labeled as known so far.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
28
37
|
## Start and reuse
|
|
29
38
|
|
|
30
39
|
Unless `dashboard.autostart` is explicitly `false`, MCP activation runs the
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.23.0",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"docs/images/notification-event-snapshots.jpg",
|
|
33
33
|
"docs/images/result-history-dashboard.jpg",
|
|
34
34
|
"docs/images/interrupted-turn-recovery.jpg",
|
|
35
|
+
"docs/images/dashboard-token-pending.jpg",
|
|
35
36
|
"index.js",
|
|
36
37
|
"mcp",
|
|
37
38
|
"scripts/benchmark-dispatch-prepare.js",
|
package/src/dashboard/app.js
CHANGED
|
@@ -49,6 +49,7 @@ const state = {
|
|
|
49
49
|
notifications: [],
|
|
50
50
|
seenNotificationIds: new Set(),
|
|
51
51
|
initialized: false,
|
|
52
|
+
detailNavigation: null,
|
|
52
53
|
manualTransition: null,
|
|
53
54
|
selectedTask: null,
|
|
54
55
|
};
|
|
@@ -80,6 +81,7 @@ const elements = {
|
|
|
80
81
|
dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
|
|
81
82
|
emptyState: document.querySelector("#empty-state"),
|
|
82
83
|
notifications: document.querySelector("#notifications"),
|
|
84
|
+
notificationHost: document.querySelector("#notification-host"),
|
|
83
85
|
notificationAnnouncer: document.querySelector("#notification-announcer"),
|
|
84
86
|
manualTransitionPanel: document.querySelector("#manual-transition-panel"),
|
|
85
87
|
manualTransitionError: document.querySelector("#manual-transition-error"),
|
|
@@ -103,6 +105,7 @@ function referenceLink(link, { compact = false, displayLabel } = {}) {
|
|
|
103
105
|
anchor.href = link.url;
|
|
104
106
|
anchor.target = "_blank";
|
|
105
107
|
anchor.rel = "noopener noreferrer";
|
|
108
|
+
anchor.dataset.focusKey = `link:${link.url}`;
|
|
106
109
|
const label = link.label ?? link.text;
|
|
107
110
|
anchor.textContent = displayLabel ?? label;
|
|
108
111
|
const accessibleLabel = githubReferenceAccessibleLabel(link);
|
|
@@ -211,6 +214,7 @@ function timestampControl(value, { accessibleName, key, prefix = "" }) {
|
|
|
211
214
|
const control = document.createElement("button");
|
|
212
215
|
control.type = "button";
|
|
213
216
|
control.className = "timestamp-toggle";
|
|
217
|
+
control.dataset.focusKey = `timestamp:${key}`;
|
|
214
218
|
const time = document.createElement("time");
|
|
215
219
|
control.append(time);
|
|
216
220
|
relativeTimes.register(key, value, ({ exact, iso, label }) => {
|
|
@@ -323,10 +327,16 @@ function notificationToast(notification) {
|
|
|
323
327
|
text.append(metadata);
|
|
324
328
|
describedBy.push(metadata.id);
|
|
325
329
|
text.setAttribute("aria-describedby", describedBy.join(" "));
|
|
326
|
-
text.addEventListener("click", () => {
|
|
330
|
+
text.addEventListener("click", async () => {
|
|
327
331
|
const current = findCurrentTask(state.tasks, notification.taskId);
|
|
328
|
-
|
|
329
|
-
|
|
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.");
|
|
330
340
|
});
|
|
331
341
|
const dismiss = document.createElement("button");
|
|
332
342
|
dismiss.type = "button";
|
|
@@ -356,7 +366,31 @@ function renderNotifications(additions = []) {
|
|
|
356
366
|
elements.toastList.replaceChildren(
|
|
357
367
|
...state.notifications.map(notificationToast),
|
|
358
368
|
);
|
|
359
|
-
elements.
|
|
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
|
+
}
|
|
360
394
|
if (additions.length > 0) {
|
|
361
395
|
elements.notificationAnnouncer.textContent = additions
|
|
362
396
|
.map(notificationAnnouncement)
|
|
@@ -406,24 +440,31 @@ function usageBreakdownText(usage) {
|
|
|
406
440
|
].join(" · ");
|
|
407
441
|
}
|
|
408
442
|
|
|
409
|
-
function usagePresentation(usage, { wholeTask = false } = {}) {
|
|
443
|
+
export function usagePresentation(usage, { wholeTask = false } = {}) {
|
|
410
444
|
const container = document.createElement("div");
|
|
411
|
-
container.className = `usage-summary usage-${usage?.status ?? "
|
|
412
|
-
if (
|
|
413
|
-
const indicator = document.createElement("span");
|
|
414
|
-
indicator.className = "usage-spinner";
|
|
415
|
-
indicator.setAttribute("aria-hidden", "true");
|
|
445
|
+
container.className = `usage-summary usage-${usage?.status ?? "unavailable"}`;
|
|
446
|
+
if (usage?.status === "pending" || usage?.status === "calculating") {
|
|
416
447
|
const text = document.createElement("span");
|
|
417
|
-
text.
|
|
418
|
-
|
|
448
|
+
text.className = "usage-shimmer";
|
|
449
|
+
text.textContent = usage.status === "pending"
|
|
450
|
+
? "Tokens pending · available when turn finishes"
|
|
451
|
+
: "Calculating token usage…";
|
|
452
|
+
container.replaceChildren(text);
|
|
419
453
|
return container;
|
|
420
454
|
}
|
|
421
|
-
if (usage
|
|
422
|
-
|
|
455
|
+
if (usage?.status !== "available") {
|
|
456
|
+
const headline = document.createElement("strong");
|
|
457
|
+
headline.textContent = "Token usage unavailable";
|
|
458
|
+
container.append(headline);
|
|
459
|
+
if (usage?.reason) {
|
|
460
|
+
const reason = document.createElement("span");
|
|
461
|
+
reason.textContent = usage.reason;
|
|
462
|
+
container.append(reason);
|
|
463
|
+
}
|
|
423
464
|
return container;
|
|
424
465
|
}
|
|
425
466
|
const headline = document.createElement("strong");
|
|
426
|
-
headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
467
|
+
headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}${usage.knownSoFar ? " · known so far" : ""}`;
|
|
427
468
|
const breakdown = document.createElement("span");
|
|
428
469
|
breakdown.textContent = usageBreakdownText(usage);
|
|
429
470
|
container.append(headline, breakdown);
|
|
@@ -449,7 +490,7 @@ function usageStillCalculating(task) {
|
|
|
449
490
|
.some((turn) => turn.status === "calculating"));
|
|
450
491
|
}
|
|
451
492
|
|
|
452
|
-
function turnTimeline(task) {
|
|
493
|
+
function turnTimeline(task, { highlightTurnRef = null } = {}) {
|
|
453
494
|
if (task.turns.length === 0) {
|
|
454
495
|
const empty = document.createElement("p");
|
|
455
496
|
empty.className = "result-history-empty";
|
|
@@ -467,6 +508,11 @@ function turnTimeline(task) {
|
|
|
467
508
|
status.className = `status status-${turnStatus}`;
|
|
468
509
|
status.textContent = turnStatus.replaceAll("_", " ");
|
|
469
510
|
const turnKey = turn.turnRef ?? turn.turnId ?? `no-turn:${index}`;
|
|
511
|
+
item.dataset.turnRef = turnKey;
|
|
512
|
+
if (highlightTurnRef !== null && turnKey === highlightTurnRef) {
|
|
513
|
+
item.className += " result-history-notified";
|
|
514
|
+
item.tabIndex = -1;
|
|
515
|
+
}
|
|
470
516
|
const timestamp = timestampControl(presentation.updatedAt, {
|
|
471
517
|
accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
|
|
472
518
|
key: `detail:${task.id}:turn:${turnKey}`,
|
|
@@ -495,11 +541,13 @@ function turnTimeline(task) {
|
|
|
495
541
|
const turnMetadata = document.createElement("p");
|
|
496
542
|
turnMetadata.className = "result-history-turn";
|
|
497
543
|
turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
544
|
+
const recordedUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId];
|
|
545
|
+
const turnUsage = turn.result === null
|
|
546
|
+
? { status: "pending" }
|
|
547
|
+
: recordedUsage ?? {
|
|
548
|
+
status: "unavailable",
|
|
549
|
+
reason: "No reliable turn boundary is available.",
|
|
550
|
+
};
|
|
503
551
|
item.append(
|
|
504
552
|
header,
|
|
505
553
|
requestLabel,
|
|
@@ -513,6 +561,39 @@ function turnTimeline(task) {
|
|
|
513
561
|
});
|
|
514
562
|
}
|
|
515
563
|
|
|
564
|
+
function descendantPath(root, descendant) {
|
|
565
|
+
const path = [];
|
|
566
|
+
let current = descendant;
|
|
567
|
+
while (current && current !== root) {
|
|
568
|
+
const parent = current.parentNode;
|
|
569
|
+
if (!parent) return null;
|
|
570
|
+
const index = Array.prototype.indexOf.call(parent.children, current);
|
|
571
|
+
if (index < 0) return null;
|
|
572
|
+
path.unshift(index);
|
|
573
|
+
current = parent;
|
|
574
|
+
}
|
|
575
|
+
return current === root ? path : null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function descendantAtPath(root, path) {
|
|
579
|
+
let current = root;
|
|
580
|
+
for (const index of path ?? []) {
|
|
581
|
+
current = current?.children?.[index];
|
|
582
|
+
if (!current) return null;
|
|
583
|
+
}
|
|
584
|
+
return current;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function descendantWithFocusKey(root, focusKey) {
|
|
588
|
+
if (!root || !focusKey) return null;
|
|
589
|
+
if (root.dataset?.focusKey === focusKey) return root;
|
|
590
|
+
for (const child of root.children ?? []) {
|
|
591
|
+
const match = descendantWithFocusKey(child, focusKey);
|
|
592
|
+
if (match) return match;
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
516
597
|
function manualTransitionPending() {
|
|
517
598
|
return state.manualTransition?.stage === "pending";
|
|
518
599
|
}
|
|
@@ -648,7 +729,7 @@ async function submitManualTransition(targetStatus, event) {
|
|
|
648
729
|
elements.manualTransitionError.focus();
|
|
649
730
|
}
|
|
650
731
|
|
|
651
|
-
function renderDialog(task) {
|
|
732
|
+
function renderDialog(task, { highlightTurnRef = null } = {}) {
|
|
652
733
|
if (state.selectedTask?.id !== task.id) {
|
|
653
734
|
copyTaskIdGeneration += 1;
|
|
654
735
|
clearTimeout(copyTaskIdTimer);
|
|
@@ -657,11 +738,27 @@ function renderDialog(task) {
|
|
|
657
738
|
const preservedResults = state.selectedTask?.id === task.id
|
|
658
739
|
? state.selectedTask.results
|
|
659
740
|
: null;
|
|
741
|
+
const preservedUsage = state.selectedTask?.id === task.id
|
|
742
|
+
? state.selectedTask.usage
|
|
743
|
+
: null;
|
|
660
744
|
const detailedTask = {
|
|
661
745
|
...task,
|
|
662
746
|
turns: mergeProjectedTurns(task, state.selectedTask?.turns ?? []),
|
|
663
747
|
results: task.results ?? preservedResults ?? [],
|
|
748
|
+
usage: task.usage ?? preservedUsage ?? null,
|
|
664
749
|
};
|
|
750
|
+
const focusedElement = elements.dialogResults.contains?.(document.activeElement)
|
|
751
|
+
? document.activeElement
|
|
752
|
+
: null;
|
|
753
|
+
let focusedTurn = focusedElement;
|
|
754
|
+
while (focusedTurn && focusedTurn !== elements.dialogResults && !focusedTurn.dataset?.turnRef) {
|
|
755
|
+
focusedTurn = focusedTurn.parentNode;
|
|
756
|
+
}
|
|
757
|
+
const focusedTurnRef = focusedTurn?.dataset?.turnRef ?? null;
|
|
758
|
+
const focusedDescendantKey = focusedElement?.dataset?.focusKey ?? null;
|
|
759
|
+
const focusedDescendantPath = focusedTurn && focusedElement
|
|
760
|
+
? descendantPath(focusedTurn, focusedElement)
|
|
761
|
+
: null;
|
|
665
762
|
state.selectedTask = detailedTask;
|
|
666
763
|
renderProject(elements.dialogProject, task);
|
|
667
764
|
elements.dialogTitle.textContent = task.title;
|
|
@@ -673,11 +770,48 @@ function renderDialog(task) {
|
|
|
673
770
|
elements.dialogRelatedLinks.hidden = (
|
|
674
771
|
(task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
|
|
675
772
|
);
|
|
676
|
-
|
|
773
|
+
const timeline = turnTimeline(detailedTask, { highlightTurnRef });
|
|
774
|
+
elements.dialogResults.replaceChildren(...timeline);
|
|
775
|
+
const highlightedTurn = timeline.find((item) => item.dataset.turnRef === highlightTurnRef) ?? null;
|
|
776
|
+
const replacedFocusedTurn = timeline.find((item) => item.dataset.turnRef === focusedTurnRef) ?? null;
|
|
777
|
+
if (replacedFocusedTurn) {
|
|
778
|
+
const pathMatchedElement = descendantAtPath(replacedFocusedTurn, focusedDescendantPath);
|
|
779
|
+
const replacedFocusedElement = (
|
|
780
|
+
pathMatchedElement?.dataset?.focusKey === focusedDescendantKey
|
|
781
|
+
? pathMatchedElement
|
|
782
|
+
: null
|
|
783
|
+
) ?? descendantWithFocusKey(replacedFocusedTurn, focusedDescendantKey);
|
|
784
|
+
if (replacedFocusedElement && replacedFocusedElement !== replacedFocusedTurn) {
|
|
785
|
+
replacedFocusedElement.focus?.();
|
|
786
|
+
} else {
|
|
787
|
+
replacedFocusedTurn.tabIndex = -1;
|
|
788
|
+
replacedFocusedTurn.focus?.();
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const currentTurnRef = task.turnRef ?? task.latestTurn?.turnRef ?? null;
|
|
792
|
+
const preservedUsageIsPriorGeneration = task.usage == null
|
|
793
|
+
&& preservedUsage != null
|
|
794
|
+
&& currentTurnRef != null
|
|
795
|
+
&& preservedUsage.generationTurnRef !== currentTurnRef;
|
|
796
|
+
const taskUsage = detailedTask.usage?.task
|
|
797
|
+
&& (detailedTask.usage.status === "available" || task.status === "working")
|
|
798
|
+
&& (!preservedUsageIsPriorGeneration || task.status === "working")
|
|
799
|
+
? {
|
|
800
|
+
status: "available",
|
|
801
|
+
...detailedTask.usage.task,
|
|
802
|
+
knownSoFar: task.status === "working",
|
|
803
|
+
}
|
|
804
|
+
: detailedTask.usage?.status === "calculating" || preservedUsageIsPriorGeneration
|
|
805
|
+
? { ...detailedTask.usage, status: task.status === "working" ? "pending" : "calculating" }
|
|
806
|
+
: detailedTask.usage ?? {
|
|
807
|
+
status: task.status === "working"
|
|
808
|
+
? "pending"
|
|
809
|
+
: task.threadId
|
|
810
|
+
? "calculating"
|
|
811
|
+
: "unavailable",
|
|
812
|
+
};
|
|
677
813
|
elements.dialogUsage.replaceChildren(usagePresentation(
|
|
678
|
-
|
|
679
|
-
? { status: "available", ...task.usage.task }
|
|
680
|
-
: task.usage ?? { status: task.threadId ? "calculating" : "unavailable" },
|
|
814
|
+
taskUsage,
|
|
681
815
|
{ wholeTask: true },
|
|
682
816
|
));
|
|
683
817
|
elements.dialogInstruction.textContent = task.instruction;
|
|
@@ -722,12 +856,31 @@ function renderDialog(task) {
|
|
|
722
856
|
archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
|
|
723
857
|
);
|
|
724
858
|
renderManualTransition(detailedTask);
|
|
859
|
+
return highlightedTurn;
|
|
725
860
|
}
|
|
726
861
|
|
|
727
|
-
async function openDialog(task
|
|
862
|
+
async function openDialog(task, {
|
|
863
|
+
focus = false,
|
|
864
|
+
highlightTurnRef = null,
|
|
865
|
+
preserveNavigation = false,
|
|
866
|
+
} = {}) {
|
|
867
|
+
if (focus) {
|
|
868
|
+
state.detailNavigation = {
|
|
869
|
+
focusPending: true,
|
|
870
|
+
highlightTurnRef,
|
|
871
|
+
taskId: task.id,
|
|
872
|
+
};
|
|
873
|
+
} else if (!preserveNavigation) state.detailNavigation = null;
|
|
874
|
+
const navigation = state.detailNavigation?.taskId === task.id
|
|
875
|
+
? state.detailNavigation
|
|
876
|
+
: null;
|
|
877
|
+
const activeHighlightTurnRef = navigation?.highlightTurnRef ?? highlightTurnRef;
|
|
728
878
|
const requestGeneration = ++detailRequestGeneration;
|
|
729
|
-
renderDialog(task);
|
|
730
|
-
if (!elements.dialog.open)
|
|
879
|
+
renderDialog(task, { highlightTurnRef: activeHighlightTurnRef });
|
|
880
|
+
if (!elements.dialog.open) {
|
|
881
|
+
elements.dialog.showModal();
|
|
882
|
+
renderNotifications();
|
|
883
|
+
}
|
|
731
884
|
const load = async (attempt = 0) => {
|
|
732
885
|
try {
|
|
733
886
|
const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
|
|
@@ -738,14 +891,34 @@ async function openDialog(task) {
|
|
|
738
891
|
&& state.selectedTask?.id === task.id
|
|
739
892
|
&& elements.dialog.open
|
|
740
893
|
) {
|
|
741
|
-
|
|
894
|
+
const activeNavigation = state.detailNavigation?.taskId === task.id
|
|
895
|
+
? state.detailNavigation
|
|
896
|
+
: null;
|
|
897
|
+
const highlightedTurn = renderDialog(detail.task, {
|
|
898
|
+
highlightTurnRef: activeNavigation?.highlightTurnRef ?? activeHighlightTurnRef,
|
|
899
|
+
});
|
|
900
|
+
if (activeNavigation?.focusPending) {
|
|
901
|
+
(highlightedTurn ?? elements.dialogTitle).focus?.();
|
|
902
|
+
activeNavigation.focusPending = false;
|
|
903
|
+
}
|
|
742
904
|
if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
|
|
743
905
|
setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
|
|
744
906
|
}
|
|
745
907
|
}
|
|
746
908
|
} catch {
|
|
747
|
-
if (
|
|
909
|
+
if (
|
|
910
|
+
requestGeneration === detailRequestGeneration
|
|
911
|
+
&& state.selectedTask?.id === task.id
|
|
912
|
+
&& elements.dialog.open
|
|
913
|
+
) {
|
|
748
914
|
showMessage("Task activity timeline is temporarily unavailable.");
|
|
915
|
+
const activeNavigation = state.detailNavigation?.taskId === task.id
|
|
916
|
+
? state.detailNavigation
|
|
917
|
+
: null;
|
|
918
|
+
if (activeNavigation?.focusPending) {
|
|
919
|
+
elements.dialogTitle.focus?.();
|
|
920
|
+
activeNavigation.focusPending = false;
|
|
921
|
+
}
|
|
749
922
|
}
|
|
750
923
|
}
|
|
751
924
|
};
|
|
@@ -869,7 +1042,7 @@ function applySnapshot(snapshot) {
|
|
|
869
1042
|
);
|
|
870
1043
|
if (state.selectedTask) {
|
|
871
1044
|
const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
|
|
872
|
-
if (updated && elements.dialog.open) openDialog(updated);
|
|
1045
|
+
if (updated && elements.dialog.open) openDialog(updated, { preserveNavigation: true });
|
|
873
1046
|
}
|
|
874
1047
|
renderNotifications(reconciled.additions);
|
|
875
1048
|
render();
|
|
@@ -924,6 +1097,8 @@ elements.dialog.addEventListener("cancel", (event) => {
|
|
|
924
1097
|
});
|
|
925
1098
|
elements.dialog.addEventListener("close", () => {
|
|
926
1099
|
if (!manualTransitionPending()) state.manualTransition = null;
|
|
1100
|
+
state.detailNavigation = null;
|
|
1101
|
+
renderNotifications();
|
|
927
1102
|
});
|
|
928
1103
|
elements.moreTaskActions.addEventListener("click", () => {
|
|
929
1104
|
const task = state.selectedTask;
|
package/src/dashboard/index.html
CHANGED
|
@@ -87,14 +87,16 @@
|
|
|
87
87
|
</section>
|
|
88
88
|
</main>
|
|
89
89
|
|
|
90
|
-
<
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
<div id="notification-host">
|
|
91
|
+
<aside id="notifications" class="notifications" aria-label="Task notifications" popover="manual">
|
|
92
|
+
<div class="notifications-heading">
|
|
93
|
+
<strong>Updates</strong>
|
|
94
|
+
<button id="clear-notifications" class="text-button" type="button">Clear all</button>
|
|
95
|
+
</div>
|
|
96
|
+
<p id="notification-announcer" class="visually-hidden" role="status" aria-live="polite"></p>
|
|
97
|
+
<div id="toast-list" class="toast-list"></div>
|
|
98
|
+
</aside>
|
|
99
|
+
</div>
|
|
98
100
|
|
|
99
101
|
<dialog id="task-dialog" aria-labelledby="dialog-title">
|
|
100
102
|
<div class="dialog-header">
|
package/src/dashboard/styles.css
CHANGED
|
@@ -134,7 +134,7 @@ time, .timestamp-missing { font-size: 0.75rem; }
|
|
|
134
134
|
.dashboard-message span { flex: 1; }
|
|
135
135
|
.dashboard-message .icon-button { flex: none; padding: 4px 7px; color: inherit; }
|
|
136
136
|
|
|
137
|
-
.notifications { position: fixed;
|
|
137
|
+
.notifications { position: fixed; inset: auto 20px 20px auto; width: min(380px, calc(100vw - 40px)); margin: 0; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
|
|
138
138
|
.notifications-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
139
139
|
.toast-list { display: grid; gap: 7px; max-height: 50vh; overflow: auto; }
|
|
140
140
|
.toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
|
|
@@ -170,6 +170,7 @@ dialog section + section { margin-top: 24px; }
|
|
|
170
170
|
.result-history { display: grid; gap: 10px; }
|
|
171
171
|
.result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
|
|
172
172
|
.result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
|
|
173
|
+
.result-history-notified { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); outline-offset: 2px; }
|
|
173
174
|
.result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
|
|
174
175
|
.result-history-header .timestamp-toggle { margin-left: auto; }
|
|
175
176
|
.result-history-source { color: var(--muted); font-size: 0.72rem; font-weight: 700; }
|
|
@@ -181,10 +182,24 @@ dialog section + section { margin-top: 24px; }
|
|
|
181
182
|
.usage-panel { margin-top: 8px; }
|
|
182
183
|
.usage-summary { display: grid; gap: 3px; margin: 10px 0 4px; color: var(--muted); font-size: 0.8rem; }
|
|
183
184
|
.usage-summary strong { color: var(--text); font-size: 0.9rem; }
|
|
184
|
-
.usage-calculating { display: flex; align-items: center;
|
|
185
|
-
.usage-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
.usage-pending, .usage-calculating { display: flex; align-items: center; }
|
|
186
|
+
.usage-shimmer {
|
|
187
|
+
color: var(--muted);
|
|
188
|
+
background: linear-gradient(100deg, var(--muted) 35%, var(--text) 50%, var(--muted) 65%);
|
|
189
|
+
background-size: 250% 100%;
|
|
190
|
+
background-position: 100% 0;
|
|
191
|
+
background-clip: text;
|
|
192
|
+
-webkit-background-clip: text;
|
|
193
|
+
-webkit-text-fill-color: transparent;
|
|
194
|
+
animation: usage-shimmer 2.8s ease-in-out infinite;
|
|
195
|
+
}
|
|
196
|
+
@keyframes usage-shimmer {
|
|
197
|
+
0% { background-position: 100% 0; }
|
|
198
|
+
72%, 100% { background-position: -100% 0; }
|
|
199
|
+
}
|
|
200
|
+
@media (prefers-reduced-motion: reduce) {
|
|
201
|
+
.usage-shimmer { background: none; animation: none; -webkit-text-fill-color: currentcolor; }
|
|
202
|
+
}
|
|
188
203
|
pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
189
204
|
.metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
|
|
190
205
|
.metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|