taskchef 7.5.0 → 7.7.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 +12 -0
- package/assets/codex.svg +3 -0
- package/docs/images/notification-event-snapshots.jpg +0 -0
- package/docs/images/result-history-dashboard.jpg +0 -0
- package/docs/spec.md +28 -0
- package/docs/workflows.md +22 -0
- package/package.json +2 -1
- package/src/dashboard/app.js +126 -31
- package/src/dashboard/index.html +6 -2
- package/src/dashboard/state.js +135 -23
- package/src/dashboard/styles.css +11 -4
- package/src/dashboard/time.js +146 -0
- package/src/dashboard.js +2 -0
package/README.md
CHANGED
|
@@ -176,6 +176,16 @@ taskchef dashboard --port 3211
|
|
|
176
176
|
The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
|
|
177
177
|
linked Codex tasks. List snapshots and SSE events carry only the latest-result
|
|
178
178
|
projection; opening task details fetches the full newest-first result history.
|
|
179
|
+
Task and result times are relative through 29 days (with minute detail for the
|
|
180
|
+
first six hours), then use a locale-aware calendar date. Each time is a keyboard-
|
|
181
|
+
accessible toggle for its full locale-aware date and time, and one shared
|
|
182
|
+
30-second timer keeps relative labels current without reloading the page.
|
|
183
|
+
The Updates panel captures immutable event-time lifecycle notices. It identifies
|
|
184
|
+
them by task, turn when available, and lifecycle event rather than dashboard
|
|
185
|
+
revision, so reconnects and non-semantic rewrites do not replay a notice and a
|
|
186
|
+
later task state cannot rewrite an older notice. A notice remains readable if
|
|
187
|
+
its task disappears; selecting it then explains that current details are no
|
|
188
|
+
longer available.
|
|
179
189
|
It does not mutate TaskChef data and prints its local URL.
|
|
180
190
|
When a compatible foreground dashboard already owns port 3210,
|
|
181
191
|
`ensure_dashboard` reuses it but does not take ownership. If an unknown,
|
|
@@ -187,6 +197,8 @@ The health endpoint contains only a fixed service marker, health schema,
|
|
|
187
197
|
TaskChef version, dashboard-server version, and canonical workspace. It exposes
|
|
188
198
|
no task data, credentials, environment variables, process control, or secrets.
|
|
189
199
|
|
|
200
|
+

|
|
201
|
+
|
|
190
202
|

|
|
191
203
|
|
|
192
204
|
## Common recovery
|
package/assets/codex.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
|
2
|
+
<path fill="currentColor" d="M3 1.5h10A1.5 1.5 0 0 1 14.5 3v10a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 13V3A1.5 1.5 0 0 1 3 1.5Zm0 1a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5H3Zm1.15 3.1.7-.7L7.95 8l-3.1 3.1-.7-.7L6.55 8l-2.4-2.4ZM8 10h4v1H8v-1Z"/>
|
|
3
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
package/docs/spec.md
CHANGED
|
@@ -327,6 +327,34 @@ current configuration before use.
|
|
|
327
327
|
Snapshot and SSE list payloads MUST omit full `results` history and include the
|
|
328
328
|
derived latest-result projection. The bounded per-task detail endpoint MAY
|
|
329
329
|
return the full validated task so the dialog can render history newest first.
|
|
330
|
+
Dashboard notifications MUST capture an immutable event-time projection of the
|
|
331
|
+
task title, lifecycle state and event, turn ID when present, event timestamp,
|
|
332
|
+
and relevant concise summary. Rendering MUST NOT resolve historical notice text
|
|
333
|
+
from the task's later current state. Notice identity and deduplication MUST use
|
|
334
|
+
task ID, turn ID, and lifecycle event; a creation notice without a turn ID MUST
|
|
335
|
+
fall back to task ID plus the immutable creation timestamp. Dashboard revision
|
|
336
|
+
MUST NOT be the sole identity. Identical snapshots, SSE reconnects, idempotent
|
|
337
|
+
reports, schema normalization, and non-semantic rewrites MUST NOT add notices.
|
|
338
|
+
Distinct working and semantic-result events for one turn MAY each be retained.
|
|
339
|
+
When one compact snapshot first exposes both a latest semantic result and a
|
|
340
|
+
newer working turn, the browser MUST reconcile both events. Temporary task
|
|
341
|
+
absence MUST NOT discard the prior semantic signature or turn a later
|
|
342
|
+
reappearance into another creation event. The first page snapshot MUST establish
|
|
343
|
+
a quiet baseline rather than replay existing history. After that baseline, a
|
|
344
|
+
new task first observed with a turn or semantic result MUST retain its creation
|
|
345
|
+
event and each lifecycle event observable in that compact projection.
|
|
346
|
+
|
|
347
|
+
Working with no prior result SHOULD be labeled as a task start. Working on a
|
|
348
|
+
newer turn while a prior result remains projected SHOULD be labeled as a
|
|
349
|
+
follow-up start. Creation, completion, input-needed, and failure labels MUST be
|
|
350
|
+
distinct. The Updates panel MUST remain bounded and support individual dismiss
|
|
351
|
+
and clear-all without resetting replay protection. A retained notice whose task
|
|
352
|
+
is absent from the current list MUST remain readable; selecting it MUST NOT
|
|
353
|
+
navigate or mutate data and SHOULD explain that current details are unavailable.
|
|
354
|
+
The retained notice list MUST NOT be a live region that re-announces old notices
|
|
355
|
+
when it rerenders. A separate polite status region MUST announce only newly
|
|
356
|
+
reconciled events. Each notice control's accessible description MUST include
|
|
357
|
+
its displayed summary when present, event time, and missing-task state.
|
|
330
358
|
|
|
331
359
|
## Task-log migration
|
|
332
360
|
|
package/docs/workflows.md
CHANGED
|
@@ -284,6 +284,28 @@ unnecessary history. A read-only per-task endpoint returns full history only
|
|
|
284
284
|
when the dialog opens. Historical project paths are untrusted until matched
|
|
285
285
|
against current configuration.
|
|
286
286
|
|
|
287
|
+
The browser derives one immutable Updates-panel snapshot when it first observes
|
|
288
|
+
a semantic lifecycle transition. The snapshot keeps its event-time title,
|
|
289
|
+
state/event, turn ID, timestamp, and relevant summary. Its identity combines
|
|
290
|
+
task ID, turn ID, and event, with creation time as the fallback when creation
|
|
291
|
+
has no turn. A separate seen-identity set outlives the bounded visible notices,
|
|
292
|
+
individual dismissals, and Clear all, preventing replay after reconnect,
|
|
293
|
+
normalization, or disappearance and reappearance. Per-task semantic signatures
|
|
294
|
+
also remain as tombstones while a task is absent. When one compact snapshot
|
|
295
|
+
first reveals a latest semantic result plus a newer working turn, the browser
|
|
296
|
+
captures both. The first page snapshot is a quiet baseline so opening or
|
|
297
|
+
refreshing the dashboard does not replay existing history. If a task is first
|
|
298
|
+
observed in a progressed state after that baseline, creation is retained
|
|
299
|
+
alongside the observable result and working events. The current task list is
|
|
300
|
+
used only as the navigation target: if the task is gone, the notice stays
|
|
301
|
+
readable and selection reports that current details are unavailable.
|
|
302
|
+
|
|
303
|
+
The retained toast list is not live. Reconciliation passes only its newly added
|
|
304
|
+
snapshots to a separate polite status region, so reconnect, dismissal, Clear
|
|
305
|
+
all, and ordinary rerendering do not re-announce retained history. Toast action
|
|
306
|
+
labels remain concise while `aria-describedby` connects the visible summary,
|
|
307
|
+
event time, and missing-task explanation for assistive technology.
|
|
308
|
+
|
|
287
309
|
## Schema 4/5 migration
|
|
288
310
|
|
|
289
311
|
`taskchef workspace migrate` acquires the same workspace lock as lifecycle
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.0",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"docs/spec.md",
|
|
27
27
|
"docs/workflows.md",
|
|
28
28
|
"docs/firstmate-taskchef-comparison.md",
|
|
29
|
+
"docs/images/notification-event-snapshots.jpg",
|
|
29
30
|
"docs/images/result-history-dashboard.jpg",
|
|
30
31
|
"index.js",
|
|
31
32
|
"mcp",
|
package/src/dashboard/app.js
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
import {
|
|
2
|
+
clearNotifications,
|
|
3
|
+
dismissNotification,
|
|
2
4
|
findCurrentTask,
|
|
3
5
|
KNOWN_TASK_STATUSES,
|
|
4
6
|
nextDateFilterRefreshDelay,
|
|
7
|
+
notificationDismissLabel,
|
|
8
|
+
notificationOpenLabel,
|
|
5
9
|
notificationTitle,
|
|
6
10
|
reconcileNotifications,
|
|
7
11
|
taskStatusLabel,
|
|
8
12
|
taskWithinDateFilter,
|
|
9
13
|
} from "./state.js";
|
|
10
14
|
import { openTaskFromControl } from "./actions.js";
|
|
15
|
+
import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
|
|
11
16
|
|
|
12
17
|
const state = {
|
|
13
18
|
tasks: [],
|
|
14
19
|
signatures: new Map(),
|
|
15
20
|
notifications: [],
|
|
21
|
+
seenNotificationIds: new Set(),
|
|
16
22
|
initialized: false,
|
|
17
23
|
selectedTask: null,
|
|
18
24
|
};
|
|
19
25
|
let dateRefreshTimer = null;
|
|
20
26
|
let detailRequestGeneration = 0;
|
|
27
|
+
const relativeTimes = new RelativeTimeController();
|
|
21
28
|
|
|
22
29
|
const elements = {
|
|
23
30
|
clearNotifications: document.querySelector("#clear-notifications"),
|
|
@@ -37,6 +44,7 @@ const elements = {
|
|
|
37
44
|
dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
|
|
38
45
|
emptyState: document.querySelector("#empty-state"),
|
|
39
46
|
notifications: document.querySelector("#notifications"),
|
|
47
|
+
notificationAnnouncer: document.querySelector("#notification-announcer"),
|
|
40
48
|
openProject: document.querySelector("#open-codex"),
|
|
41
49
|
projectFilter: document.querySelector("#project-filter"),
|
|
42
50
|
statusFilter: document.querySelector("#status-filter"),
|
|
@@ -44,13 +52,44 @@ const elements = {
|
|
|
44
52
|
taskList: document.querySelector("#task-list"),
|
|
45
53
|
toastList: document.querySelector("#toast-list"),
|
|
46
54
|
};
|
|
55
|
+
let notificationDescriptionSerial = 0;
|
|
47
56
|
|
|
48
|
-
function
|
|
49
|
-
if (!value)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
function timestampControl(value, { accessibleName, key, prefix = "" }) {
|
|
58
|
+
if (!parsedTimestamp(value)) {
|
|
59
|
+
const missing = document.createElement("span");
|
|
60
|
+
missing.className = "timestamp-missing";
|
|
61
|
+
missing.textContent = `${prefix}—`;
|
|
62
|
+
return missing;
|
|
63
|
+
}
|
|
64
|
+
const control = document.createElement("button");
|
|
65
|
+
control.type = "button";
|
|
66
|
+
control.className = "timestamp-toggle";
|
|
67
|
+
const time = document.createElement("time");
|
|
68
|
+
control.append(time);
|
|
69
|
+
relativeTimes.register(key, value, ({ exact, iso, label }) => {
|
|
70
|
+
time.dateTime = iso;
|
|
71
|
+
time.textContent = `${prefix}${label}`;
|
|
72
|
+
const action = exact ? "Show relative time" : "Show exact date and time";
|
|
73
|
+
control.title = action;
|
|
74
|
+
control.setAttribute("aria-label", `${accessibleName}: ${label}. ${action}.`);
|
|
75
|
+
}, { isActive: () => control.isConnected });
|
|
76
|
+
control.addEventListener("click", () => relativeTimes.toggle(key));
|
|
77
|
+
return control;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function codexIcon() {
|
|
81
|
+
const icon = document.createElement("span");
|
|
82
|
+
icon.className = "codex-icon";
|
|
83
|
+
icon.setAttribute("aria-hidden", "true");
|
|
84
|
+
return icon;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function configureOpenTaskControl(control, ariaLabel) {
|
|
88
|
+
control.classList.add("task-action");
|
|
89
|
+
control.setAttribute("aria-label", ariaLabel);
|
|
90
|
+
const label = document.createElement("span");
|
|
91
|
+
label.textContent = "Open task";
|
|
92
|
+
control.replaceChildren(codexIcon(), label);
|
|
54
93
|
}
|
|
55
94
|
|
|
56
95
|
function setConnection(connected) {
|
|
@@ -76,17 +115,37 @@ function replaceOptions(select, values, allLabel) {
|
|
|
76
115
|
|
|
77
116
|
function notificationToast(notification) {
|
|
78
117
|
const task = findCurrentTask(state.tasks, notification.taskId);
|
|
79
|
-
if (!task) return null;
|
|
80
118
|
const toast = document.createElement("div");
|
|
81
|
-
toast.className = "toast"
|
|
119
|
+
toast.className = `toast${task ? "" : " toast-missing"}`;
|
|
82
120
|
const text = document.createElement("button");
|
|
83
121
|
text.type = "button";
|
|
84
122
|
text.className = "toast-content";
|
|
123
|
+
text.setAttribute("aria-label", notificationOpenLabel(notification, Boolean(task)));
|
|
124
|
+
const describedBy = [];
|
|
85
125
|
const title = document.createElement("strong");
|
|
86
|
-
title.textContent = notificationTitle(
|
|
126
|
+
title.textContent = notificationTitle(notification);
|
|
87
127
|
const description = document.createElement("span");
|
|
88
|
-
description.textContent =
|
|
128
|
+
description.textContent = notification.title;
|
|
89
129
|
text.append(title, description);
|
|
130
|
+
if (notification.summary) {
|
|
131
|
+
const summary = document.createElement("span");
|
|
132
|
+
summary.className = "toast-summary preserve-lines";
|
|
133
|
+
summary.id = `notification-description-${notificationDescriptionSerial += 1}`;
|
|
134
|
+
summary.textContent = notification.summary;
|
|
135
|
+
text.append(summary);
|
|
136
|
+
describedBy.push(summary.id);
|
|
137
|
+
}
|
|
138
|
+
const metadata = document.createElement("span");
|
|
139
|
+
metadata.className = "toast-metadata";
|
|
140
|
+
metadata.id = `notification-description-${notificationDescriptionSerial += 1}`;
|
|
141
|
+
const timestamp = document.createElement("time");
|
|
142
|
+
timestamp.dateTime = notification.timestamp ?? "";
|
|
143
|
+
timestamp.textContent = formatRelativeTime(notification.timestamp);
|
|
144
|
+
metadata.append(timestamp);
|
|
145
|
+
if (!task) metadata.append(" · Task no longer available");
|
|
146
|
+
text.append(metadata);
|
|
147
|
+
describedBy.push(metadata.id);
|
|
148
|
+
text.setAttribute("aria-describedby", describedBy.join(" "));
|
|
90
149
|
text.addEventListener("click", () => {
|
|
91
150
|
const current = findCurrentTask(state.tasks, notification.taskId);
|
|
92
151
|
if (current) openDialog(current);
|
|
@@ -95,32 +154,49 @@ function notificationToast(notification) {
|
|
|
95
154
|
const dismiss = document.createElement("button");
|
|
96
155
|
dismiss.type = "button";
|
|
97
156
|
dismiss.className = "icon-button";
|
|
98
|
-
dismiss.setAttribute("aria-label",
|
|
157
|
+
dismiss.setAttribute("aria-label", notificationDismissLabel(notification));
|
|
158
|
+
dismiss.setAttribute("aria-describedby", describedBy.join(" "));
|
|
99
159
|
dismiss.textContent = "×";
|
|
100
160
|
dismiss.addEventListener("click", () => {
|
|
101
|
-
state.notifications = state.notifications
|
|
161
|
+
state.notifications = dismissNotification(state.notifications, notification.id);
|
|
102
162
|
renderNotifications();
|
|
103
163
|
});
|
|
104
164
|
toast.append(text, dismiss);
|
|
105
165
|
return toast;
|
|
106
166
|
}
|
|
107
167
|
|
|
108
|
-
function
|
|
168
|
+
function notificationAnnouncement(notification) {
|
|
169
|
+
return [
|
|
170
|
+
notificationTitle(notification),
|
|
171
|
+
notification.title,
|
|
172
|
+
notification.summary,
|
|
173
|
+
notification.turnId ? `Turn ${notification.turnId}` : null,
|
|
174
|
+
formatRelativeTime(notification.timestamp),
|
|
175
|
+
].filter(Boolean).join(". ");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderNotifications(additions = []) {
|
|
109
179
|
elements.toastList.replaceChildren(
|
|
110
|
-
...state.notifications.map(notificationToast)
|
|
180
|
+
...state.notifications.map(notificationToast),
|
|
111
181
|
);
|
|
112
182
|
elements.notifications.hidden = state.notifications.length === 0;
|
|
183
|
+
if (additions.length > 0) {
|
|
184
|
+
elements.notificationAnnouncer.textContent = additions
|
|
185
|
+
.map(notificationAnnouncement)
|
|
186
|
+
.join(". ");
|
|
187
|
+
}
|
|
113
188
|
}
|
|
114
189
|
|
|
115
190
|
function detailRow(term, value) {
|
|
116
191
|
const dt = document.createElement("dt");
|
|
117
192
|
dt.textContent = term;
|
|
118
193
|
const dd = document.createElement("dd");
|
|
119
|
-
|
|
194
|
+
if (value instanceof Node) dd.append(value);
|
|
195
|
+
else dd.textContent = value ?? "—";
|
|
120
196
|
return [dt, dd];
|
|
121
197
|
}
|
|
122
198
|
|
|
123
|
-
function resultHistory(results) {
|
|
199
|
+
function resultHistory(taskId, results) {
|
|
124
200
|
if (results.length === 0) {
|
|
125
201
|
const empty = document.createElement("p");
|
|
126
202
|
empty.className = "result-history-empty";
|
|
@@ -135,9 +211,11 @@ function resultHistory(results) {
|
|
|
135
211
|
const status = document.createElement("span");
|
|
136
212
|
status.className = `status status-${result.status}`;
|
|
137
213
|
status.textContent = result.status.replaceAll("_", " ");
|
|
138
|
-
const
|
|
139
|
-
timestamp
|
|
140
|
-
|
|
214
|
+
const resultKey = result.turnId ?? `${result.status}:${index}`;
|
|
215
|
+
const timestamp = timestampControl(result.updatedAt, {
|
|
216
|
+
accessibleName: `Result updated time for ${result.status.replaceAll("_", " ")}`,
|
|
217
|
+
key: `detail:${taskId}:result:${resultKey}`,
|
|
218
|
+
});
|
|
141
219
|
header.append(status, timestamp);
|
|
142
220
|
const summary = document.createElement("p");
|
|
143
221
|
summary.className = "preserve-lines";
|
|
@@ -161,7 +239,7 @@ function renderDialog(task) {
|
|
|
161
239
|
state.selectedTask = detailedTask;
|
|
162
240
|
elements.dialogProject.textContent = task.project.name;
|
|
163
241
|
elements.dialogTitle.textContent = task.title;
|
|
164
|
-
elements.dialogResults.replaceChildren(...resultHistory(detailedTask.results));
|
|
242
|
+
elements.dialogResults.replaceChildren(...resultHistory(task.id, detailedTask.results));
|
|
165
243
|
elements.dialogInstruction.textContent = task.instruction;
|
|
166
244
|
elements.copyThreadId.disabled = !task.threadId;
|
|
167
245
|
elements.dialogMetadata.replaceChildren(
|
|
@@ -169,16 +247,27 @@ function renderDialog(task) {
|
|
|
169
247
|
...detailRow("Current turn ID", task.turnId),
|
|
170
248
|
...detailRow("Last result status", task.lastResult?.status?.replaceAll("_", " ")),
|
|
171
249
|
...detailRow("Last result turn ID", task.lastResult?.turnId),
|
|
172
|
-
...detailRow("Last result updated",
|
|
250
|
+
...detailRow("Last result updated", timestampControl(task.lastResult?.updatedAt, {
|
|
251
|
+
accessibleName: `Last result updated time for ${task.title}`,
|
|
252
|
+
key: `detail:${task.id}:last-result-updated`,
|
|
253
|
+
})),
|
|
173
254
|
...detailRow("Task ID", task.id),
|
|
174
255
|
...detailRow("Thread ID", task.threadId),
|
|
175
256
|
...detailRow("Project path", task.project.path),
|
|
176
|
-
...detailRow("Created",
|
|
177
|
-
|
|
257
|
+
...detailRow("Created", timestampControl(task.createdAt, {
|
|
258
|
+
accessibleName: `Created time for ${task.title}`,
|
|
259
|
+
key: `detail:${task.id}:created`,
|
|
260
|
+
})),
|
|
261
|
+
...detailRow("Updated", timestampControl(
|
|
178
262
|
task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
|
|
263
|
+
{
|
|
264
|
+
accessibleName: `Updated time for ${task.title}`,
|
|
265
|
+
key: `detail:${task.id}:updated`,
|
|
266
|
+
},
|
|
179
267
|
)),
|
|
180
268
|
...detailRow("Updated by", task.updatedBy),
|
|
181
269
|
);
|
|
270
|
+
configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
|
|
182
271
|
}
|
|
183
272
|
|
|
184
273
|
async function openDialog(task) {
|
|
@@ -223,16 +312,20 @@ function taskCard(task) {
|
|
|
223
312
|
const summary = document.createElement("p");
|
|
224
313
|
summary.className = "task-summary";
|
|
225
314
|
summary.textContent = task.lastResult?.summary ?? "No semantic result reported yet.";
|
|
226
|
-
const time =
|
|
227
|
-
|
|
228
|
-
|
|
315
|
+
const time = timestampControl(
|
|
316
|
+
task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
|
|
317
|
+
{
|
|
318
|
+
accessibleName: `Updated time for ${task.title}`,
|
|
319
|
+
key: `list:${task.id}:updated`,
|
|
320
|
+
prefix: "Updated ",
|
|
321
|
+
},
|
|
322
|
+
);
|
|
229
323
|
const footer = document.createElement("div");
|
|
230
324
|
footer.className = "task-footer";
|
|
231
325
|
const openTask = document.createElement("button");
|
|
232
326
|
openTask.type = "button";
|
|
233
327
|
openTask.className = "secondary-button task-open";
|
|
234
|
-
openTask
|
|
235
|
-
openTask.setAttribute("aria-label", `Open ${task.title} in Codex`);
|
|
328
|
+
configureOpenTaskControl(openTask, `Open ${task.title} in Codex`);
|
|
236
329
|
openTask.addEventListener("click", (event) => openTaskFromControl(event, task.id, {
|
|
237
330
|
showMessage,
|
|
238
331
|
}));
|
|
@@ -261,11 +354,13 @@ function applySnapshot(snapshot) {
|
|
|
261
354
|
const reconciled = reconcileNotifications({
|
|
262
355
|
initialized: state.initialized,
|
|
263
356
|
notifications: state.notifications,
|
|
357
|
+
seenIds: state.seenNotificationIds,
|
|
264
358
|
signatures: state.signatures,
|
|
265
|
-
}, snapshot.tasks
|
|
359
|
+
}, snapshot.tasks);
|
|
266
360
|
state.tasks = snapshot.tasks;
|
|
267
361
|
state.signatures = reconciled.signatures;
|
|
268
362
|
state.notifications = reconciled.notifications;
|
|
363
|
+
state.seenNotificationIds = reconciled.seenIds;
|
|
269
364
|
state.initialized = true;
|
|
270
365
|
if (snapshot.healthy === false) {
|
|
271
366
|
showMessage("The task log is temporarily unavailable. Showing the last valid snapshot.");
|
|
@@ -286,7 +381,7 @@ function applySnapshot(snapshot) {
|
|
|
286
381
|
const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
|
|
287
382
|
if (updated && elements.dialog.open) openDialog(updated);
|
|
288
383
|
}
|
|
289
|
-
renderNotifications();
|
|
384
|
+
renderNotifications(reconciled.additions);
|
|
290
385
|
render();
|
|
291
386
|
}
|
|
292
387
|
|
|
@@ -313,7 +408,7 @@ elements.dismissDashboardMessage.addEventListener("click", () => {
|
|
|
313
408
|
elements.dashboardMessage.hidden = true;
|
|
314
409
|
});
|
|
315
410
|
elements.clearNotifications.addEventListener("click", () => {
|
|
316
|
-
state.notifications =
|
|
411
|
+
state.notifications = clearNotifications();
|
|
317
412
|
renderNotifications();
|
|
318
413
|
});
|
|
319
414
|
elements.closeDialog.addEventListener("click", () => elements.dialog.close());
|
package/src/dashboard/index.html
CHANGED
|
@@ -71,7 +71,8 @@
|
|
|
71
71
|
<strong>Updates</strong>
|
|
72
72
|
<button id="clear-notifications" class="text-button" type="button">Clear all</button>
|
|
73
73
|
</div>
|
|
74
|
-
<
|
|
74
|
+
<p id="notification-announcer" class="visually-hidden" role="status" aria-live="polite"></p>
|
|
75
|
+
<div id="toast-list" class="toast-list"></div>
|
|
75
76
|
</aside>
|
|
76
77
|
|
|
77
78
|
<dialog id="task-dialog" aria-labelledby="dialog-title">
|
|
@@ -83,7 +84,10 @@
|
|
|
83
84
|
<button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
|
|
84
85
|
</div>
|
|
85
86
|
<div class="dialog-actions">
|
|
86
|
-
<button id="open-codex" class="primary-button" type="button"
|
|
87
|
+
<button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
|
|
88
|
+
<span class="codex-icon" aria-hidden="true"></span>
|
|
89
|
+
<span>Open task</span>
|
|
90
|
+
</button>
|
|
87
91
|
<button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
|
|
88
92
|
</div>
|
|
89
93
|
<section>
|
package/src/dashboard/state.js
CHANGED
|
@@ -7,6 +7,16 @@ export const KNOWN_TASK_STATUSES = [
|
|
|
7
7
|
"unresolved",
|
|
8
8
|
];
|
|
9
9
|
|
|
10
|
+
const NOTIFICATION_TITLES = new Map([
|
|
11
|
+
["created", "Task created"],
|
|
12
|
+
["task_started", "Task started"],
|
|
13
|
+
["follow_up_started", "Follow-up started"],
|
|
14
|
+
["completed", "Task completed"],
|
|
15
|
+
["needs_input", "Task needs input"],
|
|
16
|
+
["failed", "Task failed"],
|
|
17
|
+
["unresolved", "Task updated"],
|
|
18
|
+
]);
|
|
19
|
+
|
|
10
20
|
const DATE_WINDOWS_MS = new Map([
|
|
11
21
|
["24h", 24 * 60 * 60 * 1_000],
|
|
12
22
|
["7d", 7 * 24 * 60 * 60 * 1_000],
|
|
@@ -21,8 +31,8 @@ export function taskStatusLabel(task) {
|
|
|
21
31
|
return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
export function notificationTitle(
|
|
25
|
-
return
|
|
34
|
+
export function notificationTitle(notification) {
|
|
35
|
+
return NOTIFICATION_TITLES.get(notification.event) ?? "Task updated";
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
export function taskWithinDateFilter(task, filter, now = Date.now()) {
|
|
@@ -44,41 +54,143 @@ export function nextDateFilterRefreshDelay(tasks, filter, now = Date.now()) {
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export function taskSignature(task) {
|
|
47
|
-
return JSON.stringify([
|
|
48
|
-
task.threadId,
|
|
49
|
-
task.status,
|
|
50
|
-
task.summary,
|
|
51
|
-
task.turnId,
|
|
52
|
-
task.updatedAt,
|
|
53
|
-
task.updatedBy,
|
|
54
|
-
task.lastResult,
|
|
55
|
-
]);
|
|
57
|
+
return JSON.stringify([task.id, task.turnId ?? null, task.status ?? "unresolved"]);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export function findCurrentTask(tasks, taskId) {
|
|
59
61
|
return tasks.find((task) => task.id === taskId) ?? null;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
function lifecycleEvent(task) {
|
|
65
|
+
if (task.status === "working") {
|
|
66
|
+
return task.lastResult ? "follow_up_started" : "task_started";
|
|
67
|
+
}
|
|
68
|
+
return task.status ?? "unresolved";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function notificationIdentity(task, event) {
|
|
72
|
+
if (event === "created") {
|
|
73
|
+
return JSON.stringify([task.id, null, event, task.createdAt ?? null]);
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify([task.id, task.turnId ?? null, event]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function eventTimestamp(task, event) {
|
|
79
|
+
if (event === "created") return task.createdAt ?? task.updatedAt ?? null;
|
|
80
|
+
if (
|
|
81
|
+
task.lastResult?.status === task.status
|
|
82
|
+
&& task.lastResult?.turnId === task.turnId
|
|
83
|
+
) {
|
|
84
|
+
return task.lastResult.updatedAt;
|
|
85
|
+
}
|
|
86
|
+
return task.updatedAt ?? task.createdAt ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function eventSummary(task, event) {
|
|
90
|
+
if (!["completed", "needs_input", "failed"].includes(event)) return null;
|
|
91
|
+
if (
|
|
92
|
+
task.lastResult?.status === task.status
|
|
93
|
+
&& task.lastResult?.turnId === task.turnId
|
|
94
|
+
) {
|
|
95
|
+
return task.lastResult.summary;
|
|
96
|
+
}
|
|
97
|
+
return task.summary ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function notificationSnapshot(task, event = lifecycleEvent(task)) {
|
|
101
|
+
const created = event === "created";
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
id: notificationIdentity(task, event),
|
|
104
|
+
taskId: task.id,
|
|
105
|
+
title: task.title,
|
|
106
|
+
status: created ? "working" : task.status,
|
|
107
|
+
event,
|
|
108
|
+
turnId: created ? null : task.turnId ?? null,
|
|
109
|
+
timestamp: eventTimestamp(task, event),
|
|
110
|
+
summary: eventSummary(task, event),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resultNotificationSnapshot(task) {
|
|
115
|
+
const result = task.lastResult;
|
|
116
|
+
if (!result) return null;
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
id: notificationIdentity({ ...task, turnId: result.turnId }, result.status),
|
|
119
|
+
taskId: task.id,
|
|
120
|
+
title: task.title,
|
|
121
|
+
status: result.status,
|
|
122
|
+
event: result.status,
|
|
123
|
+
turnId: result.turnId ?? null,
|
|
124
|
+
timestamp: result.updatedAt,
|
|
125
|
+
summary: result.summary,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function notificationOpenLabel(notification, available) {
|
|
130
|
+
const action = available ? "Open current task details" : "Show task availability";
|
|
131
|
+
return `${action} for ${notification.title}: ${notificationTitle(notification)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function notificationDismissLabel(notification) {
|
|
135
|
+
return `Dismiss ${notificationTitle(notification)} notification for ${notification.title}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function dismissNotification(notifications, notificationId) {
|
|
139
|
+
return notifications.filter(({ id }) => id !== notificationId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function clearNotifications() {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
62
146
|
export function reconcileNotifications(
|
|
63
|
-
{ initialized, notifications, signatures },
|
|
147
|
+
{ initialized, notifications, seenIds = new Set(), signatures },
|
|
64
148
|
tasks,
|
|
65
|
-
revision,
|
|
66
149
|
) {
|
|
67
|
-
const nextSignatures = new Map(
|
|
68
|
-
|
|
150
|
+
const nextSignatures = new Map(signatures);
|
|
151
|
+
for (const task of tasks) nextSignatures.set(task.id, taskSignature(task));
|
|
152
|
+
const nextSeenIds = new Set(seenIds);
|
|
153
|
+
if (!initialized) {
|
|
154
|
+
for (const task of tasks) {
|
|
155
|
+
nextSeenIds.add(notificationSnapshot(task, "created").id);
|
|
156
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
157
|
+
if (resultNotification) nextSeenIds.add(resultNotification.id);
|
|
158
|
+
nextSeenIds.add(notificationSnapshot(task).id);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
additions: [],
|
|
162
|
+
notifications,
|
|
163
|
+
seenIds: nextSeenIds,
|
|
164
|
+
signatures: nextSignatures,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
69
167
|
const additions = [];
|
|
70
168
|
for (const task of tasks) {
|
|
71
|
-
|
|
72
|
-
if (!signatures.has(task.id))
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
169
|
+
const candidates = [];
|
|
170
|
+
if (!signatures.has(task.id)) {
|
|
171
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
172
|
+
if (task.turnId || task.status !== "working" || resultNotification) {
|
|
173
|
+
candidates.push(notificationSnapshot(task));
|
|
174
|
+
if (resultNotification) candidates.push(resultNotification);
|
|
175
|
+
}
|
|
176
|
+
candidates.push(notificationSnapshot(task, "created"));
|
|
177
|
+
} else if (signatures.get(task.id) !== nextSignatures.get(task.id)) {
|
|
178
|
+
candidates.push(notificationSnapshot(task));
|
|
179
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
180
|
+
if (resultNotification) candidates.push(resultNotification);
|
|
181
|
+
}
|
|
182
|
+
for (const notification of candidates) {
|
|
183
|
+
if (!nextSeenIds.has(notification.id)) additions.push(notification);
|
|
184
|
+
nextSeenIds.add(notification.id);
|
|
185
|
+
}
|
|
186
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
187
|
+
if (resultNotification) nextSeenIds.add(resultNotification.id);
|
|
188
|
+
nextSeenIds.add(notificationSnapshot(task).id);
|
|
79
189
|
}
|
|
80
190
|
return {
|
|
191
|
+
additions,
|
|
81
192
|
notifications: [...additions, ...notifications].slice(0, MAX_NOTIFICATIONS),
|
|
193
|
+
seenIds: nextSeenIds,
|
|
82
194
|
signatures: nextSignatures,
|
|
83
195
|
};
|
|
84
196
|
}
|
package/src/dashboard/styles.css
CHANGED
|
@@ -51,7 +51,7 @@ h3 { margin-bottom: 8px; font-size: 0.84rem; letter-spacing: 0.08em; text-transf
|
|
|
51
51
|
.dashboard-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
52
52
|
|
|
53
53
|
.eyebrow { margin-bottom: 10px; color: var(--accent); font-size: 0.78rem; font-weight: 750; letter-spacing: 0.15em; text-transform: uppercase; }
|
|
54
|
-
.subtitle, .task-project, time { color: var(--muted); }
|
|
54
|
+
.subtitle, .task-project, time, .timestamp-missing { color: var(--muted); }
|
|
55
55
|
.subtitle { margin-bottom: 0; }
|
|
56
56
|
|
|
57
57
|
.connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.9rem; }
|
|
@@ -78,9 +78,13 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
|
|
|
78
78
|
.task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
|
79
79
|
.task-project { margin: 3px 0 12px; font-size: 0.86rem; }
|
|
80
80
|
.task-summary { max-width: 78ch; margin-bottom: 14px; }
|
|
81
|
-
time { font-size: 0.78rem; }
|
|
81
|
+
time, .timestamp-missing { font-size: 0.78rem; }
|
|
82
|
+
.timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
|
|
83
|
+
.timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
|
|
82
84
|
.task-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
83
85
|
.task-open { flex: none; }
|
|
86
|
+
.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 5px 9px; font-size: 0.82rem; line-height: 1.2; white-space: nowrap; }
|
|
87
|
+
.codex-icon { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; mask: url("/assets/codex.svg") center / contain no-repeat; }
|
|
84
88
|
|
|
85
89
|
.status { flex: none; padding: 4px 9px; border-radius: 999px; background: var(--surface-muted); color: var(--muted); font-size: 0.72rem; font-weight: 750; text-transform: capitalize; }
|
|
86
90
|
.status-completed { background: var(--accent-soft); color: var(--accent); }
|
|
@@ -101,6 +105,9 @@ time { font-size: 0.78rem; }
|
|
|
101
105
|
.toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
|
|
102
106
|
.toast-content { display: grid; flex: 1; gap: 2px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; }
|
|
103
107
|
.toast-content span { color: var(--muted); font-size: 0.85rem; }
|
|
108
|
+
.toast-content .toast-summary { color: var(--text); }
|
|
109
|
+
.toast-content .toast-metadata { font-size: 0.75rem; }
|
|
110
|
+
.toast-missing { border: 1px dashed var(--border); }
|
|
104
111
|
|
|
105
112
|
.text-button, .icon-button { border: 0; background: none; cursor: pointer; }
|
|
106
113
|
.text-button { color: var(--accent); font-size: 0.82rem; font-weight: 700; }
|
|
@@ -142,8 +149,8 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
|
|
|
142
149
|
.task-count { margin: 0; }
|
|
143
150
|
.task-heading { align-items: flex-start; }
|
|
144
151
|
.task-footer { align-items: flex-start; flex-direction: column; }
|
|
145
|
-
.task-open {
|
|
146
|
-
.dialog-actions { align-items:
|
|
152
|
+
.task-open { align-self: flex-start; }
|
|
153
|
+
.dialog-actions { align-items: center; flex-flow: row wrap; }
|
|
147
154
|
.metadata { grid-template-columns: 1fr; }
|
|
148
155
|
.metadata dt { padding-bottom: 0; border-bottom: 0; }
|
|
149
156
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export const RELATIVE_TIME_REFRESH_MS = 30_000;
|
|
2
|
+
export const RELATIVE_DATE_LIMIT_DAYS = 30;
|
|
3
|
+
|
|
4
|
+
const MINUTE_MS = 60_000;
|
|
5
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
6
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
7
|
+
|
|
8
|
+
export function parsedTimestamp(value) {
|
|
9
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
10
|
+
const milliseconds = Date.parse(value);
|
|
11
|
+
if (!Number.isFinite(milliseconds)) return null;
|
|
12
|
+
return {
|
|
13
|
+
date: new Date(milliseconds),
|
|
14
|
+
iso: new Date(milliseconds).toISOString(),
|
|
15
|
+
milliseconds,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatExactTime(value, { locale, timeZone } = {}) {
|
|
20
|
+
const parsed = parsedTimestamp(value);
|
|
21
|
+
if (!parsed) return "—";
|
|
22
|
+
return new Intl.DateTimeFormat(locale, {
|
|
23
|
+
dateStyle: "medium",
|
|
24
|
+
timeStyle: "short",
|
|
25
|
+
timeZone,
|
|
26
|
+
}).format(parsed.date);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hourPhrase(milliseconds, direction) {
|
|
30
|
+
const hours = Math.floor(milliseconds / HOUR_MS);
|
|
31
|
+
const minutes = Math.floor((milliseconds % HOUR_MS) / MINUTE_MS);
|
|
32
|
+
const hourText = hours === 1 ? "1 hour" : `${hours} hours`;
|
|
33
|
+
const detail = hours < 6 && minutes > 0 ? ` ${minutes} minutes` : "";
|
|
34
|
+
return direction === "future"
|
|
35
|
+
? `in ${hourText}${detail}`
|
|
36
|
+
: `${hourText}${detail} ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatRelativeTime(value, {
|
|
40
|
+
now = Date.now(),
|
|
41
|
+
locale,
|
|
42
|
+
timeZone,
|
|
43
|
+
} = {}) {
|
|
44
|
+
const parsed = parsedTimestamp(value);
|
|
45
|
+
if (!parsed) return "—";
|
|
46
|
+
const difference = now - parsed.milliseconds;
|
|
47
|
+
const future = difference < 0;
|
|
48
|
+
const elapsed = Math.abs(difference);
|
|
49
|
+
if (elapsed < MINUTE_MS) return "just now";
|
|
50
|
+
if (elapsed < 2 * MINUTE_MS) return future ? "in 1 minute" : "1 minute ago";
|
|
51
|
+
if (elapsed < HOUR_MS) {
|
|
52
|
+
const minutes = future
|
|
53
|
+
? Math.ceil(elapsed / MINUTE_MS)
|
|
54
|
+
: Math.floor(elapsed / MINUTE_MS);
|
|
55
|
+
return future ? `in ${minutes} minutes` : `${minutes} minutes ago`;
|
|
56
|
+
}
|
|
57
|
+
if (elapsed < 2 * HOUR_MS) return future ? "in 1 hour" : "1 hour ago";
|
|
58
|
+
if (elapsed < DAY_MS) return hourPhrase(elapsed, future ? "future" : "past");
|
|
59
|
+
if (elapsed < 2 * DAY_MS) return future ? "in 1 day" : "1 day ago";
|
|
60
|
+
if (elapsed < RELATIVE_DATE_LIMIT_DAYS * DAY_MS) {
|
|
61
|
+
const days = future
|
|
62
|
+
? Math.ceil(elapsed / DAY_MS)
|
|
63
|
+
: Math.floor(elapsed / DAY_MS);
|
|
64
|
+
return future ? `in ${days} days` : `${days} days ago`;
|
|
65
|
+
}
|
|
66
|
+
return new Intl.DateTimeFormat(locale, {
|
|
67
|
+
dateStyle: "medium",
|
|
68
|
+
timeZone,
|
|
69
|
+
}).format(parsed.date);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function timestampPresentation(value, {
|
|
73
|
+
exact = false,
|
|
74
|
+
now = Date.now(),
|
|
75
|
+
locale,
|
|
76
|
+
timeZone,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const parsed = parsedTimestamp(value);
|
|
79
|
+
if (!parsed) return { valid: false, label: "—", iso: null };
|
|
80
|
+
return {
|
|
81
|
+
exact,
|
|
82
|
+
iso: parsed.iso,
|
|
83
|
+
label: exact
|
|
84
|
+
? formatExactTime(value, { locale, timeZone })
|
|
85
|
+
: formatRelativeTime(value, { now, locale, timeZone }),
|
|
86
|
+
valid: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class RelativeTimeController {
|
|
91
|
+
constructor({
|
|
92
|
+
now = () => Date.now(),
|
|
93
|
+
setIntervalFn = (...args) => globalThis.setInterval(...args),
|
|
94
|
+
clearIntervalFn = (...args) => globalThis.clearInterval(...args),
|
|
95
|
+
refreshEveryMs = RELATIVE_TIME_REFRESH_MS,
|
|
96
|
+
} = {}) {
|
|
97
|
+
this.now = now;
|
|
98
|
+
this.setIntervalFn = setIntervalFn;
|
|
99
|
+
this.clearIntervalFn = clearIntervalFn;
|
|
100
|
+
this.refreshEveryMs = refreshEveryMs;
|
|
101
|
+
this.entries = new Map();
|
|
102
|
+
this.exactKeys = new Set();
|
|
103
|
+
this.timer = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
register(key, value, render, { isActive = () => true } = {}) {
|
|
107
|
+
const token = Symbol(key);
|
|
108
|
+
this.entries.set(token, { isActive, key, render, value });
|
|
109
|
+
this.renderEntry(this.entries.get(token));
|
|
110
|
+
if (this.timer === null) {
|
|
111
|
+
this.timer = this.setIntervalFn(() => this.refresh(), this.refreshEveryMs);
|
|
112
|
+
}
|
|
113
|
+
return () => this.entries.delete(token);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
renderEntry(entry) {
|
|
117
|
+
entry.render(timestampPresentation(entry.value, {
|
|
118
|
+
exact: this.exactKeys.has(entry.key),
|
|
119
|
+
now: this.now(),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
toggle(key) {
|
|
124
|
+
if (this.exactKeys.has(key)) this.exactKeys.delete(key);
|
|
125
|
+
else this.exactKeys.add(key);
|
|
126
|
+
for (const entry of this.entries.values()) {
|
|
127
|
+
if (entry.key === key) this.renderEntry(entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
refresh() {
|
|
132
|
+
for (const [token, entry] of this.entries) {
|
|
133
|
+
if (!entry.isActive()) {
|
|
134
|
+
this.entries.delete(token);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!this.exactKeys.has(entry.key)) this.renderEntry(entry);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
stop() {
|
|
142
|
+
if (this.timer !== null) this.clearIntervalFn(this.timer);
|
|
143
|
+
this.timer = null;
|
|
144
|
+
this.entries.clear();
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/dashboard.js
CHANGED
|
@@ -43,10 +43,12 @@ const STATIC_FILES = new Map([
|
|
|
43
43
|
["/", [path.join(STATIC_ROOT, "index.html"), "text/html; charset=utf-8"]],
|
|
44
44
|
["/actions.js", [path.join(STATIC_ROOT, "actions.js"), "text/javascript; charset=utf-8"]],
|
|
45
45
|
["/app.js", [path.join(STATIC_ROOT, "app.js"), "text/javascript; charset=utf-8"]],
|
|
46
|
+
["/time.js", [path.join(STATIC_ROOT, "time.js"), "text/javascript; charset=utf-8"]],
|
|
46
47
|
["/state.js", [path.join(STATIC_ROOT, "state.js"), "text/javascript; charset=utf-8"]],
|
|
47
48
|
["/styles.css", [path.join(STATIC_ROOT, "styles.css"), "text/css; charset=utf-8"]],
|
|
48
49
|
["/assets/taskchef-dark.svg", [path.join(ASSET_ROOT, "taskchef-dark.svg"), "image/svg+xml"]],
|
|
49
50
|
["/assets/taskchef.svg", [path.join(ASSET_ROOT, "taskchef.svg"), "image/svg+xml"]],
|
|
51
|
+
["/assets/codex.svg", [path.join(ASSET_ROOT, "codex.svg"), "image/svg+xml"]],
|
|
50
52
|
]);
|
|
51
53
|
|
|
52
54
|
export function dashboardAuthority(host, port) {
|