taskchef 7.22.4 → 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 +43 -31
- package/docs/dashboard-lifecycle.md +177 -0
- package/docs/images/dashboard-token-pending.jpg +0 -0
- package/docs/spec.md +117 -61
- package/docs/workflows.md +30 -18
- package/mcp/dashboard-session.js +51 -0
- package/package.json +3 -1
- package/src/dashboard/app.js +207 -32
- package/src/dashboard/index.html +10 -8
- package/src/dashboard/styles.css +20 -5
- package/src/dashboard-manager.js +633 -157
- package/src/dashboard-ownership.js +124 -13
- package/src/dashboard-session-process.js +179 -0
- package/src/dashboard-session.js +91 -0
- package/src/dashboard.js +288 -8
- package/src/mcp.js +2 -2
- package/src/version.js +1 -1
package/docs/workflows.md
CHANGED
|
@@ -4,7 +4,8 @@ This developer and advanced-agent guide explains how the current implementation
|
|
|
4
4
|
moves data through TaskChef. The [specification](spec.md) is normative; the
|
|
5
5
|
[README](../README.md) owns user operation. The
|
|
6
6
|
[FirstMate comparison](firstmate-taskchef-comparison.md) is non-normative
|
|
7
|
-
research.
|
|
7
|
+
research. The [dashboard lifecycle](dashboard-lifecycle.md) owns the focused
|
|
8
|
+
start, upgrade, shutdown, and security rationale.
|
|
8
9
|
|
|
9
10
|
## Implementation map
|
|
10
11
|
|
|
@@ -15,12 +16,14 @@ research.
|
|
|
15
16
|
| `skills/taskchef-bootstrap/SKILL.md` | Initialize the workspace and maintain the Codex project index. |
|
|
16
17
|
| `skills/taskchef-dashboard/SKILL.md` | Ensure or recover the canonical dashboard and return its URL. |
|
|
17
18
|
| `skills/taskchef-copilot/SKILL.md` | Explain normalized cached briefs and coordinate safe next actions. |
|
|
18
|
-
| `src/mcp.js` | Dashboard ensure, four primary lifecycle tools, one deprecated alias,
|
|
19
|
+
| `src/mcp.js` | Dashboard ensure, four primary lifecycle tools, one deprecated alias, MCP cleanup, and annotations. |
|
|
19
20
|
| `src/delegation.js` | UUID marker, concise executor-skill invocation shape, and creation-failure handling. |
|
|
20
21
|
| `src/workspace.js` | Current schemas, validation, locking, atomic JSONL writes, linking, and result freshness. |
|
|
21
22
|
| `src/cli.js` | Administration, normalized cached briefs, inspection, diagnostics, and dashboard startup. |
|
|
22
23
|
| `src/dashboard.js` | Versioned health identity, validated compact snapshots, SSE fan-out, on-demand details, and bounded open actions. |
|
|
23
|
-
| `src/dashboard-manager.js` | Concurrent
|
|
24
|
+
| `src/dashboard-manager.js` | Concurrent session ensure, authenticated reuse and upgrade handoff, and safe conflicts. |
|
|
25
|
+
| `src/dashboard-session-process.js` | Independent loopback server, private ownership publication, signals, and Codex-session lease. |
|
|
26
|
+
| `src/dashboard-session.js` | Exact registered-PID liveness checks and grace-period expiry. |
|
|
24
27
|
| `src/usage.js` | Optional bounded ccusage execution, exact primary-thread mapping, normalized aggregation, and the private usage cache. |
|
|
25
28
|
| `src/usage-tracker.js` | Deferred sampling, cumulative boundaries, historical availability, and per-turn deltas. |
|
|
26
29
|
|
|
@@ -40,19 +43,25 @@ sequenceDiagram
|
|
|
40
43
|
participant D as Dispatcher
|
|
41
44
|
participant M as TaskChef MCP
|
|
42
45
|
participant H as Loopback health
|
|
43
|
-
participant S as Dashboard
|
|
46
|
+
participant S as Dashboard session process
|
|
44
47
|
M->>M: Read dashboard.autostart (absent means true)
|
|
45
48
|
M->>M: Best-effort ensure before MCP transport connects
|
|
46
49
|
D->>M: ensure_dashboard()
|
|
47
50
|
M->>M: Serialize concurrent ensure calls
|
|
48
51
|
M->>H: GET 127.0.0.1:3210/api/health
|
|
49
|
-
alt Exact service, versions, canonical workspace, and
|
|
52
|
+
alt Exact service, versions, canonical workspace, and session launcher
|
|
50
53
|
H-->>M: Bounded compatible identity
|
|
54
|
+
M->>S: Authenticate owner and register Codex parent PID
|
|
51
55
|
M-->>D: reused, URL, workspace, versions
|
|
52
56
|
else No listener
|
|
53
57
|
H--xM: Connection refused
|
|
54
|
-
M->>S:
|
|
55
|
-
S-->>M:
|
|
58
|
+
M->>S: Launch session process on 127.0.0.1:3210
|
|
59
|
+
S-->>M: Private owner metadata and bounded identity
|
|
60
|
+
M-->>D: started, URL, workspace, versions
|
|
61
|
+
else Verified older TaskChef listener for this workspace
|
|
62
|
+
M->>S: Authenticated prepare, lease join, and commit
|
|
63
|
+
S-->>M: Signed final leases and bounded retry before shutdown
|
|
64
|
+
M->>S: Launch installed session version
|
|
56
65
|
M-->>D: started, URL, workspace, versions
|
|
57
66
|
else Standalone, unknown, stale, or different workspace
|
|
58
67
|
H-->>M: Missing or incompatible identity
|
|
@@ -63,14 +72,15 @@ sequenceDiagram
|
|
|
63
72
|
Note over D: Created-thread directive, when any, precedes final dashboard link
|
|
64
73
|
```
|
|
65
74
|
|
|
66
|
-
When
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
When one MCP transport or plugin process closes, the dashboard remains while a
|
|
76
|
+
registered Codex session PID is alive. After every registered PID disappears
|
|
77
|
+
for the grace period, the dashboard closes and exits. A foreground
|
|
78
|
+
`taskchef dashboard` listener identifies itself as standalone and is never
|
|
79
|
+
reused as the canonical session dashboard. No TaskChef path terminates an
|
|
80
|
+
incompatible listener or installs OS persistence.
|
|
71
81
|
|
|
72
|
-
Autostart and explicit ensures share the same manager promise,
|
|
73
|
-
|
|
82
|
+
Autostart and explicit ensures share the same manager promise, while
|
|
83
|
+
cross-process port races converge through authenticated reuse. An
|
|
74
84
|
explicit `dashboard.autostart: false` skips only activation-time ensure. Any
|
|
75
85
|
failure is reduced to a fixed stderr diagnostic; tool registration and MCP
|
|
76
86
|
availability continue. Activation never opens a browser.
|
|
@@ -82,13 +92,15 @@ The practical release handoff ends in this order:
|
|
|
82
92
|
1. Install the released plugin.
|
|
83
93
|
2. Activate or reload its new TaskChef MCP process.
|
|
84
94
|
3. Run `$taskchef-dashboard` or call `ensure_dashboard`.
|
|
85
|
-
4. Verify the expected TaskChef version, protocol `serverVersion`, `
|
|
95
|
+
4. Verify the expected TaskChef version, protocol `serverVersion`, `session`
|
|
86
96
|
launcher, canonical workspace, and canonical URL returned by the dashboard identity.
|
|
87
97
|
|
|
88
98
|
Replacing plugin files alone cannot execute autostart because old code remains
|
|
89
|
-
in
|
|
90
|
-
Codex.
|
|
91
|
-
listener
|
|
99
|
+
in an already-running MCP process. Installation does not necessarily reload
|
|
100
|
+
Codex. Activation of the installed MCP authenticates and retires a verified
|
|
101
|
+
older TaskChef listener before starting the installed session version. An
|
|
102
|
+
exact-compatible session dashboard may be reused; a newer, standalone, or
|
|
103
|
+
unknown listener is never terminated or replaced.
|
|
92
104
|
|
|
93
105
|
## Normal delegation and self-linking
|
|
94
106
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { runDashboardSessionProcess } from "../src/dashboard-session-process.js";
|
|
4
|
+
|
|
5
|
+
let cancelled = false;
|
|
6
|
+
let runtime = null;
|
|
7
|
+
let readySent = false;
|
|
8
|
+
const startupController = new AbortController();
|
|
9
|
+
process.on("message", (message) => {
|
|
10
|
+
if (message?.type !== "cancel") return;
|
|
11
|
+
cancelled = true;
|
|
12
|
+
startupController.abort();
|
|
13
|
+
if (runtime) void runtime.close().finally(() => { process.exitCode = 1; });
|
|
14
|
+
});
|
|
15
|
+
process.on("disconnect", () => {
|
|
16
|
+
if (readySent) return;
|
|
17
|
+
cancelled = true;
|
|
18
|
+
startupController.abort();
|
|
19
|
+
if (runtime) void runtime.close().finally(() => { process.exitCode = 1; });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
runtime = await runDashboardSessionProcess({ signal: startupController.signal });
|
|
24
|
+
if (cancelled) {
|
|
25
|
+
await runtime.close();
|
|
26
|
+
throw Object.assign(new Error("dashboard session startup was cancelled"), {
|
|
27
|
+
code: "TASKCHEF_DASHBOARD_START_TIMEOUT",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (process.send && process.connected) {
|
|
31
|
+
readySent = true;
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
process.send({ type: "ready", port: runtime.server.port }, (error) =>
|
|
34
|
+
error ? reject(error) : resolve());
|
|
35
|
+
});
|
|
36
|
+
if (process.connected) process.disconnect();
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
readySent = false;
|
|
40
|
+
if (runtime) await runtime.close().catch(() => {});
|
|
41
|
+
if (process.send && process.connected) {
|
|
42
|
+
await new Promise((resolve) => {
|
|
43
|
+
process.send({
|
|
44
|
+
type: "error",
|
|
45
|
+
code: typeof error?.code === "string" ? error.code : "TASKCHEF_DASHBOARD_START_FAILED",
|
|
46
|
+
}, () => resolve());
|
|
47
|
+
}).catch(() => {});
|
|
48
|
+
if (process.connected) process.disconnect();
|
|
49
|
+
}
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
}
|
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",
|
|
@@ -25,12 +25,14 @@
|
|
|
25
25
|
"bin",
|
|
26
26
|
"docs/spec.md",
|
|
27
27
|
"docs/workflows.md",
|
|
28
|
+
"docs/dashboard-lifecycle.md",
|
|
28
29
|
"docs/firstmate-taskchef-comparison.md",
|
|
29
30
|
"docs/images/dashboard-identity.jpg",
|
|
30
31
|
"docs/images/ccusage-token-consumption.png",
|
|
31
32
|
"docs/images/notification-event-snapshots.jpg",
|
|
32
33
|
"docs/images/result-history-dashboard.jpg",
|
|
33
34
|
"docs/images/interrupted-turn-recovery.jpg",
|
|
35
|
+
"docs/images/dashboard-token-pending.jpg",
|
|
34
36
|
"index.js",
|
|
35
37
|
"mcp",
|
|
36
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; }
|