taskchef 5.8.0 → 5.10.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.8.0",
3
+ "version": "5.10.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -162,6 +162,66 @@ when its metadata is newer than the callback; missing callbacks and other
162
162
  anomalies also receive at most one targeted read. Reporting writes nothing and
163
163
  never polls.
164
164
 
165
+ ### Watch the local dashboard
166
+
167
+ Run a local dashboard when you want the task history to remain visible while
168
+ executors report results:
169
+
170
+ ```sh
171
+ taskchef dashboard
172
+ ```
173
+
174
+ Open the printed capability URL, which starts with `http://127.0.0.1:3210` and
175
+ contains a one-time launch token. Do not share it: the browser exchanges it for
176
+ a local session cookie before loading task data, then removes the token from the
177
+ address bar. Use `--port <number>` to choose another port and `--workspace
178
+ <path>` to override the normal workspace resolution. Press Ctrl+C in the
179
+ terminal to stop the server.
180
+
181
+ The dashboard:
182
+
183
+ - orders tasks by their latest semantic or identity update;
184
+ - filters by project, status, and latest update window (24 hours, 7 days, or
185
+ all time);
186
+ - shows dismissible notifications when tasks are added or changed;
187
+ - reveals the original instruction, latest semantic result, and task metadata;
188
+ - opens the recorded task directly in Codex when it has a supported UUID thread
189
+ ID, with a project-opening fallback for unresolved or legacy identities.
190
+
191
+ The server binds only to the numeric IPv4 loopback interface, requires the
192
+ unguessable launch capability before serving instructions or results, validates
193
+ the same task schema as the CLI, retains its last valid snapshot if the log
194
+ becomes invalid, and never writes dispatcher-workspace files. It limits event
195
+ streams and disconnects slow clients instead of buffering snapshots without
196
+ bound. It watches the workspace directory
197
+ so TaskChef's atomic log replacement remains visible, uses a low-frequency file
198
+ metadata check to recover from missed watcher events, and streams snapshots to
199
+ the browser with automatic reconnect. To keep untrusted history from exhausting
200
+ the server or browser, the dashboard rejects logs above 16 MiB, histories above
201
+ 2,000 tasks, and unusually large display fields while retaining the last valid
202
+ snapshot. The data CLI remains unaffected by these display limits.
203
+
204
+ Date windows advance while the page is open, even when the task file is idle.
205
+ They use persisted semantic/identity timestamps and identity changes observed by
206
+ the current server. Legacy schema-v1 identity resolutions did not record an
207
+ update timestamp, so after a dashboard restart those rare historical records
208
+ fall back to their creation time.
209
+
210
+ The dashboard uses Codex's registered `codex://threads/<thread-id>` desktop
211
+ route for direct navigation. A task-level refresh would require native Codex
212
+ metadata tools that are available to the report skill, not to a standalone
213
+ browser page. TaskChef also does not submit replies from the dashboard: `codex
214
+ resume <session> [prompt]` starts an interactive CLI session and may execute the
215
+ prompt immediately, rather than opening a reviewed draft in the desktop app.
216
+ Refresh and reply integrations remain deferred until Codex exposes supported
217
+ browser-facing contracts for those actions.
218
+
219
+ TaskChef task records do not contain model token usage, and the supported Codex
220
+ task metadata surface does not expose it to this local server. The dashboard
221
+ therefore does not estimate tokens or inspect private Codex session logs. Token
222
+ usage can be added later if Codex exposes a supported per-task usage field or
223
+ TaskChef executors begin reporting a structured usage value.
224
+
165
225
  ### Manage configured projects
166
226
 
167
227
  Use `$taskchef-bootstrap` to scan local Codex projects and refresh the managed
package/index.js CHANGED
@@ -56,9 +56,18 @@ export {
56
56
 
57
57
  export {
58
58
  discoverCodexCli,
59
+ isCodexThreadDeepLinkId,
60
+ openThreadInCodex,
59
61
  openWorkspaceInCodex,
60
62
  } from "./src/codex-app.js";
61
63
 
64
+ export {
65
+ DashboardMonitor,
66
+ createDashboardServer,
67
+ dashboardAuthority,
68
+ sortTasksByMeaningfulUpdate,
69
+ } from "./src/dashboard.js";
70
+
62
71
  export { createTaskChefMcpServer } from "./src/mcp.js";
63
72
 
64
73
  export { handleInitialPromptHook } from "./src/hook.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.8.0",
3
+ "version": "5.10.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -45,7 +45,7 @@
45
45
  "scripts": {
46
46
  "benchmark:dispatch": "node scripts/benchmark-dispatch-prepare.js",
47
47
  "benchmark:e2e": "node scripts/e2e-benchmark.js",
48
- "test": "node --test tests/taskchef.test.js"
48
+ "test": "node --test tests/*.test.js"
49
49
  },
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "^1.30.0",
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@ import { access, readFile, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { openWorkspaceInCodex } from "./codex-app.js";
5
+ import { createDashboardServer } from "./dashboard.js";
5
6
  import { resolveWorkspacePath } from "./workspace-path.js";
6
7
 
7
8
  import {
@@ -409,11 +410,51 @@ async function taskSummary(args) {
409
410
  return 0;
410
411
  }
411
412
 
413
+ function dashboardPort(args) {
414
+ const value = option(args, "--port", "3210");
415
+ if (!/^\d+$/.test(value)) throw new Error("--port must be an integer from 0 to 65535");
416
+ const port = Number(value);
417
+ if (port > 65_535) throw new Error("--port must be an integer from 0 to 65535");
418
+ return port;
419
+ }
420
+
421
+ async function dashboard(args) {
422
+ validateCommandArgs(args, 1, {
423
+ values: ["--port", "--workspace"],
424
+ switches: ["--json"],
425
+ });
426
+ const server = await createDashboardServer({
427
+ workspace: workspaceRoot(args),
428
+ port: dashboardPort(args),
429
+ });
430
+ print({
431
+ schemaVersion: 1,
432
+ url: server.url,
433
+ workspace: server.monitor.workspace,
434
+ }, args, (value) => [
435
+ `TaskChef dashboard: ${value.url}`,
436
+ `Workspace: ${value.workspace}`,
437
+ "Press Ctrl+C to stop.",
438
+ ].join("\n"));
439
+
440
+ let stop;
441
+ await new Promise((resolve) => {
442
+ stop = resolve;
443
+ process.once("SIGINT", stop);
444
+ process.once("SIGTERM", stop);
445
+ });
446
+ process.off("SIGINT", stop);
447
+ process.off("SIGTERM", stop);
448
+ await server.close();
449
+ return 0;
450
+ }
451
+
412
452
  function usage() {
413
453
  process.stdout.write(`TaskChef workspace utility
414
454
 
415
455
  Usage:
416
456
  taskchef help
457
+ taskchef dashboard [--port <number>] [--json] [--workspace <path>]
417
458
  taskchef doctor [--json] [--workspace <path>]
418
459
  taskchef workspace path [--json] [--workspace <path>]
419
460
  taskchef workspace init [--register-codex] [--codex-cli <path>] [--json] [--workspace <path>]
@@ -435,6 +476,8 @@ Project import reads a JSON
435
476
  array from a file, or from standard input when the source is '-' or omitted.
436
477
  Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
437
478
  ~/.agents/taskchef.
479
+ The dashboard binds to 127.0.0.1 and reads the canonical task log without
480
+ modifying dispatcher-workspace files.
438
481
  `);
439
482
  }
440
483
 
@@ -443,6 +486,7 @@ export async function runCli(args) {
443
486
  usage();
444
487
  return 0;
445
488
  }
489
+ if (args[0] === "dashboard") return dashboard(args);
446
490
  if (args[0] === "doctor") return doctor(args);
447
491
  if (args[0] === "workspace" && args[1] === "path") return workspacePath(args);
448
492
  if (args[0] === "workspace" && args[1] === "init") return initialize(args);
package/src/codex-app.js CHANGED
@@ -5,6 +5,7 @@ import { promisify } from "node:util";
5
5
 
6
6
  const execFile = promisify(execFileCallback);
7
7
  const CODEX_COMMAND_TIMEOUT_MS = 10_000;
8
+ const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
8
9
 
9
10
  function runCodex(run, filePath, args) {
10
11
  return run(filePath, args, { timeout: CODEX_COMMAND_TIMEOUT_MS, killSignal: "SIGKILL" });
@@ -26,6 +27,10 @@ function pathCandidates(env) {
26
27
  .map((directory) => path.join(directory, process.platform === "win32" ? "codex.exe" : "codex"));
27
28
  }
28
29
 
30
+ function isDesktopBundleCandidate(filePath) {
31
+ return filePath.includes(`${path.sep}Contents${path.sep}Resources${path.sep}`);
32
+ }
33
+
29
34
  async function supportsAppCommand(filePath, run) {
30
35
  try {
31
36
  const { stdout, stderr } = await runCodex(run, filePath, ["app", "--help"]);
@@ -50,8 +55,16 @@ export async function discoverCodexCli({
50
55
  return { path: candidate, source: explicit !== null ? "explicit" : "environment" };
51
56
  }
52
57
 
58
+ const candidates = pathCandidates(env);
59
+ for (const pathCandidate of candidates.filter(isDesktopBundleCandidate)) {
60
+ const candidate = await executable(pathCandidate);
61
+ if (candidate && await supportsAppCommand(candidate, run)) {
62
+ return { path: candidate, source: "desktop-path" };
63
+ }
64
+ }
65
+
53
66
  let candidate = null;
54
- for (const pathCandidate of pathCandidates(env)) {
67
+ for (const pathCandidate of candidates) {
55
68
  candidate = await executable(pathCandidate);
56
69
  if (candidate) break;
57
70
  }
@@ -59,8 +72,7 @@ export async function discoverCodexCli({
59
72
  if (!(await supportsAppCommand(candidate, run))) {
60
73
  throw new Error(`Codex CLI does not support the app command: ${candidate}`);
61
74
  }
62
- const bundled = candidate.includes(`${path.sep}Contents${path.sep}Resources${path.sep}`);
63
- return { path: candidate, source: bundled ? "desktop-path" : "path" };
75
+ return { path: candidate, source: "path" };
64
76
  }
65
77
 
66
78
  export async function openWorkspaceInCodex(workspace, options = {}) {
@@ -75,3 +87,24 @@ export async function openWorkspaceInCodex(workspace, options = {}) {
75
87
  workspace,
76
88
  };
77
89
  }
90
+
91
+ export function isCodexThreadDeepLinkId(threadId) {
92
+ return typeof threadId === "string" && CODEX_THREAD_ID_PATTERN.test(threadId);
93
+ }
94
+
95
+ export async function openThreadInCodex(threadId, options = {}) {
96
+ if (!isCodexThreadDeepLinkId(threadId)) {
97
+ throw new Error("Codex thread ID is not supported by the desktop deep link");
98
+ }
99
+ const run = options.run ?? execFile;
100
+ const platform = options.platform ?? process.platform;
101
+ const url = `codex://threads/${encodeURIComponent(threadId)}`;
102
+ if (platform === "darwin") {
103
+ await runCodex(run, "/usr/bin/open", [url]);
104
+ } else if (platform === "win32") {
105
+ await runCodex(run, "rundll32.exe", ["url.dll,FileProtocolHandler", url]);
106
+ } else {
107
+ await runCodex(run, "xdg-open", [url]);
108
+ }
109
+ return { status: "requested", mechanism: "codex-deep-link", threadId, url };
110
+ }
@@ -0,0 +1,265 @@
1
+ import {
2
+ findCurrentTask,
3
+ KNOWN_TASK_STATUSES,
4
+ nextDateFilterRefreshDelay,
5
+ reconcileNotifications,
6
+ taskStatusLabel,
7
+ taskWithinDateFilter,
8
+ } from "./state.js";
9
+
10
+ const state = {
11
+ tasks: [],
12
+ signatures: new Map(),
13
+ notifications: [],
14
+ initialized: false,
15
+ selectedTask: null,
16
+ };
17
+ let dateRefreshTimer = null;
18
+
19
+ const elements = {
20
+ clearNotifications: document.querySelector("#clear-notifications"),
21
+ closeDialog: document.querySelector("#close-dialog"),
22
+ connectionDot: document.querySelector("#connection-dot"),
23
+ connectionLabel: document.querySelector("#connection-label"),
24
+ copyThreadId: document.querySelector("#copy-thread-id"),
25
+ dashboardMessage: document.querySelector("#dashboard-message"),
26
+ dateFilter: document.querySelector("#date-filter"),
27
+ dialog: document.querySelector("#task-dialog"),
28
+ dialogInstruction: document.querySelector("#dialog-instruction"),
29
+ dialogMetadata: document.querySelector("#dialog-metadata"),
30
+ dialogProject: document.querySelector("#dialog-project"),
31
+ dialogSummary: document.querySelector("#dialog-summary"),
32
+ dialogTitle: document.querySelector("#dialog-title"),
33
+ emptyState: document.querySelector("#empty-state"),
34
+ notifications: document.querySelector("#notifications"),
35
+ openProject: document.querySelector("#open-codex"),
36
+ projectFilter: document.querySelector("#project-filter"),
37
+ statusFilter: document.querySelector("#status-filter"),
38
+ taskCount: document.querySelector("#task-count"),
39
+ taskList: document.querySelector("#task-list"),
40
+ toastList: document.querySelector("#toast-list"),
41
+ };
42
+
43
+ function formatTime(value) {
44
+ if (!value) return "—";
45
+ return new Intl.DateTimeFormat(undefined, {
46
+ dateStyle: "medium",
47
+ timeStyle: "short",
48
+ }).format(new Date(value));
49
+ }
50
+
51
+ function setConnection(connected) {
52
+ elements.connectionDot.classList.toggle("connected", connected);
53
+ elements.connectionLabel.textContent = connected ? "Live" : "Reconnecting…";
54
+ }
55
+
56
+ function replaceOptions(select, values, allLabel) {
57
+ const selected = select.value;
58
+ select.replaceChildren();
59
+ const all = document.createElement("option");
60
+ all.value = "";
61
+ all.textContent = allLabel;
62
+ select.append(all);
63
+ for (const value of values) {
64
+ const option = document.createElement("option");
65
+ option.value = value;
66
+ option.textContent = value;
67
+ select.append(option);
68
+ }
69
+ if (values.includes(selected)) select.value = selected;
70
+ }
71
+
72
+ function notificationToast(notification) {
73
+ const task = findCurrentTask(state.tasks, notification.taskId);
74
+ if (!task) return null;
75
+ const toast = document.createElement("div");
76
+ toast.className = "toast";
77
+ const text = document.createElement("button");
78
+ text.type = "button";
79
+ text.className = "toast-content";
80
+ const title = document.createElement("strong");
81
+ title.textContent = notification.kind === "new" ? "New task" : "Task updated";
82
+ const description = document.createElement("span");
83
+ description.textContent = `${task.title} · ${taskStatusLabel(task)}`;
84
+ text.append(title, description);
85
+ text.addEventListener("click", () => {
86
+ const current = findCurrentTask(state.tasks, notification.taskId);
87
+ if (current) openDialog(current);
88
+ else showMessage("This task is no longer present in the current snapshot.");
89
+ });
90
+ const dismiss = document.createElement("button");
91
+ dismiss.type = "button";
92
+ dismiss.className = "icon-button";
93
+ dismiss.setAttribute("aria-label", `Dismiss notification for ${task.title}`);
94
+ dismiss.textContent = "×";
95
+ dismiss.addEventListener("click", () => {
96
+ state.notifications = state.notifications.filter(({ id }) => id !== notification.id);
97
+ renderNotifications();
98
+ });
99
+ toast.append(text, dismiss);
100
+ return toast;
101
+ }
102
+
103
+ function renderNotifications() {
104
+ elements.toastList.replaceChildren(
105
+ ...state.notifications.map(notificationToast).filter(Boolean),
106
+ );
107
+ elements.notifications.hidden = state.notifications.length === 0;
108
+ }
109
+
110
+ function detailRow(term, value) {
111
+ const dt = document.createElement("dt");
112
+ dt.textContent = term;
113
+ const dd = document.createElement("dd");
114
+ dd.textContent = value ?? "—";
115
+ return [dt, dd];
116
+ }
117
+
118
+ function openDialog(task) {
119
+ state.selectedTask = task;
120
+ elements.dialogProject.textContent = task.project.name;
121
+ elements.dialogTitle.textContent = task.title;
122
+ elements.dialogSummary.textContent = task.summary ?? "No semantic result has been reported yet.";
123
+ elements.dialogInstruction.textContent = task.instruction;
124
+ elements.copyThreadId.disabled = !task.threadId;
125
+ elements.dialogMetadata.replaceChildren(
126
+ ...detailRow("Status", taskStatusLabel(task)),
127
+ ...detailRow("Task ID", task.id),
128
+ ...detailRow("Thread ID", task.threadId),
129
+ ...detailRow("Turn ID", task.turnId),
130
+ ...detailRow("Project path", task.project.path),
131
+ ...detailRow("Created", formatTime(task.createdAt)),
132
+ ...detailRow("Updated", formatTime(
133
+ task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
134
+ )),
135
+ ...detailRow("Updated by", task.updatedBy),
136
+ );
137
+ if (!elements.dialog.open) elements.dialog.showModal();
138
+ }
139
+
140
+ function taskCard(task) {
141
+ const article = document.createElement("article");
142
+ article.className = "task-card";
143
+ const heading = document.createElement("div");
144
+ heading.className = "task-heading";
145
+ const title = document.createElement("button");
146
+ title.type = "button";
147
+ title.className = "task-title";
148
+ title.textContent = task.title;
149
+ title.addEventListener("click", () => openDialog(task));
150
+ const badge = document.createElement("span");
151
+ badge.className = `status status-${task.status ?? "unresolved"}`;
152
+ badge.textContent = taskStatusLabel(task);
153
+ heading.append(title, badge);
154
+ const project = document.createElement("p");
155
+ project.className = "task-project";
156
+ project.textContent = task.project.name;
157
+ const summary = document.createElement("p");
158
+ summary.className = "task-summary";
159
+ summary.textContent = task.summary ?? "No semantic result reported yet.";
160
+ const time = document.createElement("time");
161
+ time.dateTime = task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt;
162
+ time.textContent = `Updated ${formatTime(time.dateTime)}`;
163
+ article.append(heading, project, summary, time);
164
+ return article;
165
+ }
166
+
167
+ function render() {
168
+ const project = elements.projectFilter.value;
169
+ const status = elements.statusFilter.value;
170
+ const date = elements.dateFilter.value;
171
+ const visible = state.tasks.filter((task) =>
172
+ (!project || task.project.name === project)
173
+ && (!status || taskStatusLabel(task) === status)
174
+ && taskWithinDateFilter(task, date));
175
+ elements.taskList.replaceChildren(...visible.map(taskCard));
176
+ elements.emptyState.hidden = visible.length > 0;
177
+ elements.taskCount.textContent = `${visible.length} of ${state.tasks.length} task${state.tasks.length === 1 ? "" : "s"}`;
178
+ clearTimeout(dateRefreshTimer);
179
+ const refreshDelay = nextDateFilterRefreshDelay(visible, date);
180
+ dateRefreshTimer = refreshDelay === null ? null : setTimeout(render, refreshDelay);
181
+ }
182
+
183
+ function applySnapshot(snapshot) {
184
+ const reconciled = reconcileNotifications({
185
+ initialized: state.initialized,
186
+ notifications: state.notifications,
187
+ signatures: state.signatures,
188
+ }, snapshot.tasks, snapshot.revision);
189
+ state.tasks = snapshot.tasks;
190
+ state.signatures = reconciled.signatures;
191
+ state.notifications = reconciled.notifications;
192
+ state.initialized = true;
193
+ if (snapshot.healthy === false) {
194
+ showMessage("The task log is temporarily unavailable. Showing the last valid snapshot.");
195
+ } else {
196
+ elements.dashboardMessage.hidden = true;
197
+ }
198
+ replaceOptions(
199
+ elements.projectFilter,
200
+ [...new Set(state.tasks.map((task) => task.project.name))].sort(),
201
+ "All projects",
202
+ );
203
+ replaceOptions(
204
+ elements.statusFilter,
205
+ [...new Set([...KNOWN_TASK_STATUSES, ...state.tasks.map(taskStatusLabel)])],
206
+ "All statuses",
207
+ );
208
+ if (state.selectedTask) {
209
+ const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
210
+ if (updated && elements.dialog.open) openDialog(updated);
211
+ }
212
+ renderNotifications();
213
+ render();
214
+ }
215
+
216
+ function showMessage(message) {
217
+ elements.dashboardMessage.textContent = message;
218
+ elements.dashboardMessage.hidden = false;
219
+ }
220
+
221
+ const events = new EventSource("/api/events");
222
+ events.addEventListener("open", () => setConnection(true));
223
+ events.addEventListener("error", () => setConnection(false));
224
+ events.addEventListener("snapshot", (event) => {
225
+ setConnection(true);
226
+ applySnapshot(JSON.parse(event.data));
227
+ });
228
+ events.addEventListener("dashboard-error", (event) => {
229
+ showMessage(JSON.parse(event.data).message);
230
+ });
231
+
232
+ elements.projectFilter.addEventListener("change", render);
233
+ elements.statusFilter.addEventListener("change", render);
234
+ elements.dateFilter.addEventListener("change", render);
235
+ elements.clearNotifications.addEventListener("click", () => {
236
+ state.notifications = [];
237
+ renderNotifications();
238
+ });
239
+ elements.closeDialog.addEventListener("click", () => elements.dialog.close());
240
+ elements.dialog.addEventListener("click", (event) => {
241
+ if (event.target === elements.dialog) elements.dialog.close();
242
+ });
243
+ elements.copyThreadId.addEventListener("click", async () => {
244
+ if (!state.selectedTask?.threadId) return;
245
+ try {
246
+ await navigator.clipboard.writeText(state.selectedTask.threadId);
247
+ elements.copyThreadId.textContent = "Copied";
248
+ setTimeout(() => { elements.copyThreadId.textContent = "Copy thread ID"; }, 1_500);
249
+ } catch {
250
+ showMessage("Clipboard access is unavailable. Copy the thread ID from the metadata below.");
251
+ }
252
+ });
253
+ elements.openProject.addEventListener("click", async () => {
254
+ if (!state.selectedTask) return;
255
+ elements.openProject.disabled = true;
256
+ try {
257
+ const response = await fetch(`/api/tasks/${encodeURIComponent(state.selectedTask.id)}/open-codex`, {
258
+ method: "POST",
259
+ });
260
+ const result = await response.json();
261
+ showMessage(result.message);
262
+ } finally {
263
+ elements.openProject.disabled = false;
264
+ }
265
+ });
@@ -0,0 +1,94 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>TaskChef dashboard</title>
8
+ <link rel="stylesheet" href="/styles.css">
9
+ <script src="/app.js" type="module"></script>
10
+ </head>
11
+ <body>
12
+ <header class="site-header">
13
+ <div>
14
+ <p class="eyebrow">TaskChef</p>
15
+ <h1>Task dashboard</h1>
16
+ <p class="subtitle">Latest delegated work, updated as TaskChef reports change.</p>
17
+ </div>
18
+ <div class="connection" role="status" aria-live="polite">
19
+ <span id="connection-dot" class="connection-dot"></span>
20
+ <span id="connection-label">Connecting…</span>
21
+ </div>
22
+ </header>
23
+
24
+ <main>
25
+ <section class="toolbar" aria-label="Task filters">
26
+ <label>
27
+ Project
28
+ <select id="project-filter">
29
+ <option value="">All projects</option>
30
+ </select>
31
+ </label>
32
+ <label>
33
+ Status
34
+ <select id="status-filter">
35
+ <option value="">All statuses</option>
36
+ </select>
37
+ </label>
38
+ <label>
39
+ Updated
40
+ <select id="date-filter">
41
+ <option value="24h">Latest 24 hours</option>
42
+ <option value="7d">Latest 7 days</option>
43
+ <option value="all" selected>All time</option>
44
+ </select>
45
+ </label>
46
+ <p id="task-count" class="task-count" aria-live="polite"></p>
47
+ </section>
48
+
49
+ <div id="dashboard-message" class="dashboard-message" role="status" hidden></div>
50
+ <section aria-labelledby="tasks-heading">
51
+ <h2 id="tasks-heading" class="visually-hidden">Tasks</h2>
52
+ <div id="task-list" class="task-list"></div>
53
+ <div id="empty-state" class="empty-state" hidden>
54
+ <h2>No tasks match these filters</h2>
55
+ <p>Choose a different project or status to see more work.</p>
56
+ </div>
57
+ </section>
58
+ </main>
59
+
60
+ <aside id="notifications" class="notifications" aria-label="Task notifications" hidden>
61
+ <div class="notifications-heading">
62
+ <strong>Updates</strong>
63
+ <button id="clear-notifications" class="text-button" type="button">Clear all</button>
64
+ </div>
65
+ <div id="toast-list" class="toast-list" aria-live="polite"></div>
66
+ </aside>
67
+
68
+ <dialog id="task-dialog" aria-labelledby="dialog-title">
69
+ <div class="dialog-header">
70
+ <div>
71
+ <p id="dialog-project" class="eyebrow"></p>
72
+ <h2 id="dialog-title"></h2>
73
+ </div>
74
+ <button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
75
+ </div>
76
+ <div class="dialog-actions">
77
+ <button id="open-codex" class="primary-button" type="button">Open task in Codex</button>
78
+ <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
79
+ </div>
80
+ <section>
81
+ <h3>Result</h3>
82
+ <p id="dialog-summary" class="preserve-lines"></p>
83
+ </section>
84
+ <section>
85
+ <h3>Original instruction</h3>
86
+ <pre id="dialog-instruction"></pre>
87
+ </section>
88
+ <section>
89
+ <h3>Metadata</h3>
90
+ <dl id="dialog-metadata" class="metadata"></dl>
91
+ </section>
92
+ </dialog>
93
+ </body>
94
+ </html>