@virtengine/openfleet 0.25.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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,1385 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Tab: Tasks — board, search, filters, task CRUD
3
+ * ────────────────────────────────────────────────────────────── */
4
+ import { h } from "preact";
5
+ import {
6
+ useState,
7
+ useEffect,
8
+ useRef,
9
+ useCallback,
10
+ } from "preact/hooks";
11
+ import htm from "htm";
12
+
13
+ const html = htm.bind(h);
14
+
15
+ import { haptic, showConfirm } from "../modules/telegram.js";
16
+ import { apiFetch, sendCommandToChat } from "../modules/api.js";
17
+ import { signal } from "@preact/signals";
18
+ import {
19
+ tasksData,
20
+ tasksLoaded,
21
+ tasksPage,
22
+ tasksPageSize,
23
+ tasksFilter,
24
+ tasksPriority,
25
+ tasksSearch,
26
+ tasksSort,
27
+ tasksTotalPages,
28
+ executorData,
29
+ showToast,
30
+ refreshTab,
31
+ runOptimistic,
32
+ scheduleRefresh,
33
+ loadTasks,
34
+ } from "../modules/state.js";
35
+ import { ICONS } from "../modules/icons.js";
36
+ import {
37
+ cloneValue,
38
+ formatRelative,
39
+ truncate,
40
+ debounce,
41
+ exportAsCSV,
42
+ exportAsJSON,
43
+ } from "../modules/utils.js";
44
+ import {
45
+ Card,
46
+ Badge,
47
+ StatCard,
48
+ SkeletonCard,
49
+ Modal,
50
+ EmptyState,
51
+ ListItem,
52
+ } from "../components/shared.js";
53
+ import { SegmentedControl, SearchInput, Toggle } from "../components/forms.js";
54
+ import { KanbanBoard } from "../components/kanban-board.js";
55
+
56
+ /* ─── View mode toggle ─── */
57
+ const viewMode = signal("kanban");
58
+
59
+ /* ─── Export dropdown icon (inline SVG) ─── */
60
+ const DOWNLOAD_ICON = html`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
61
+
62
+ /* ─── Status chip definitions ─── */
63
+ const STATUS_CHIPS = [
64
+ { value: "all", label: "All" },
65
+ { value: "draft", label: "Draft" },
66
+ { value: "todo", label: "Todo" },
67
+ { value: "inprogress", label: "Active" },
68
+ { value: "inreview", label: "Review" },
69
+ { value: "done", label: "Done" },
70
+ { value: "error", label: "Error" },
71
+ ];
72
+
73
+ const PRIORITY_CHIPS = [
74
+ { value: "", label: "Any" },
75
+ { value: "low", label: "Low" },
76
+ { value: "medium", label: "Medium" },
77
+ { value: "high", label: "High" },
78
+ { value: "critical", label: "Critical" },
79
+ ];
80
+
81
+ const SORT_OPTIONS = [
82
+ { value: "updated", label: "Updated" },
83
+ { value: "created", label: "Created" },
84
+ { value: "priority", label: "Priority" },
85
+ { value: "title", label: "Title" },
86
+ ];
87
+
88
+ const SYSTEM_TAGS = new Set([
89
+ "draft",
90
+ "todo",
91
+ "inprogress",
92
+ "inreview",
93
+ "done",
94
+ "cancelled",
95
+ "error",
96
+ "blocked",
97
+ "critical",
98
+ "high",
99
+ "medium",
100
+ "low",
101
+ "codex:ignore",
102
+ "codex:claimed",
103
+ "codex:working",
104
+ "codex:stale",
105
+ "openfleet",
106
+ "codex-mointor",
107
+ ]);
108
+
109
+ function normalizeTagInput(input) {
110
+ if (!input) return [];
111
+ const values = Array.isArray(input)
112
+ ? input
113
+ : String(input || "")
114
+ .split(",")
115
+ .map((entry) => entry.trim())
116
+ .filter(Boolean);
117
+ const seen = new Set();
118
+ const tags = [];
119
+ for (const value of values) {
120
+ const normalized = String(value || "")
121
+ .trim()
122
+ .toLowerCase();
123
+ if (!normalized || seen.has(normalized) || SYSTEM_TAGS.has(normalized)) continue;
124
+ if (/^(?:upstream|base|target)(?:_branch)?[:=]/i.test(normalized)) continue;
125
+ seen.add(normalized);
126
+ tags.push(normalized);
127
+ }
128
+ return tags;
129
+ }
130
+
131
+ function getTaskTags(task) {
132
+ if (!task) return [];
133
+ const direct = normalizeTagInput(task.tags || []);
134
+ if (direct.length) return direct;
135
+ const metaTags = normalizeTagInput(task?.meta?.tags || []);
136
+ if (metaTags.length) return metaTags;
137
+ const metaLabels = Array.isArray(task?.meta?.labels)
138
+ ? task.meta.labels.map((label) =>
139
+ typeof label === "string" ? label : label?.name || "",
140
+ )
141
+ : [];
142
+ return normalizeTagInput(metaLabels);
143
+ }
144
+
145
+ function getTaskBaseBranch(task) {
146
+ if (!task) return "";
147
+ return (
148
+ task.baseBranch ||
149
+ task.base_branch ||
150
+ task.meta?.baseBranch ||
151
+ task.meta?.base_branch ||
152
+ ""
153
+ );
154
+ }
155
+
156
+ export function StartTaskModal({
157
+ task,
158
+ defaultSdk = "auto",
159
+ allowTaskIdInput = false,
160
+ onClose,
161
+ onStart,
162
+ }) {
163
+ const [sdk, setSdk] = useState(defaultSdk || "auto");
164
+ const [model, setModel] = useState("");
165
+ const [taskIdInput, setTaskIdInput] = useState(task?.id || "");
166
+ const [starting, setStarting] = useState(false);
167
+
168
+ useEffect(() => {
169
+ setSdk(defaultSdk || "auto");
170
+ }, [defaultSdk]);
171
+
172
+ useEffect(() => {
173
+ setTaskIdInput(task?.id || "");
174
+ }, [task?.id]);
175
+
176
+ const canModel = sdk && sdk !== "auto";
177
+ const resolvedTaskId = (task?.id || taskIdInput || "").trim();
178
+
179
+ const handleStart = async () => {
180
+ if (starting) return;
181
+ if (!resolvedTaskId) {
182
+ showToast("Task ID is required", "error");
183
+ return;
184
+ }
185
+ setStarting(true);
186
+ try {
187
+ await onStart?.({
188
+ taskId: resolvedTaskId,
189
+ sdk: sdk && sdk !== "auto" ? sdk : undefined,
190
+ model: model.trim() ? model.trim() : undefined,
191
+ });
192
+ onClose();
193
+ } catch {
194
+ /* toast via apiFetch */
195
+ }
196
+ setStarting(false);
197
+ };
198
+
199
+ return html`
200
+ <${Modal} title="Start Task" onClose=${onClose}>
201
+ <div class="meta-text mb-sm">
202
+ ${task?.title || "(untitled)"} · ${task?.id || "—"}
203
+ </div>
204
+ <div class="flex-col gap-md">
205
+ ${(allowTaskIdInput || !task?.id) &&
206
+ html`
207
+ <div class="card-subtitle">Task ID</div>
208
+ <input
209
+ class="input"
210
+ placeholder="e.g. task-123"
211
+ value=${taskIdInput}
212
+ onInput=${(e) => setTaskIdInput(e.target.value)}
213
+ />
214
+ `}
215
+ <div class="card-subtitle">Executor SDK</div>
216
+ <select class="input" value=${sdk} onChange=${(e) => setSdk(e.target.value)}>
217
+ ${["auto", "codex", "copilot", "claude"].map(
218
+ (opt) => html`<option value=${opt}>${opt}</option>`,
219
+ )}
220
+ </select>
221
+ <div class="card-subtitle">Model Override (optional)</div>
222
+ <input
223
+ class="input"
224
+ placeholder=${canModel ? "e.g. gpt-5.3-codex" : "Select SDK to enable"}
225
+ value=${model}
226
+ disabled=${!canModel}
227
+ onInput=${(e) => setModel(e.target.value)}
228
+ />
229
+ <button
230
+ class="btn btn-primary"
231
+ onClick=${handleStart}
232
+ disabled=${starting || !resolvedTaskId}
233
+ >
234
+ ${starting ? "Starting…" : "▶ Start Task"}
235
+ </button>
236
+ </div>
237
+ <//>
238
+ `;
239
+ }
240
+
241
+ /* ─── TaskDetailModal ─── */
242
+ export function TaskDetailModal({ task, onClose, onStart }) {
243
+ const [title, setTitle] = useState(task?.title || "");
244
+ const [description, setDescription] = useState(task?.description || "");
245
+ const [baseBranch, setBaseBranch] = useState(getTaskBaseBranch(task));
246
+ const [status, setStatus] = useState(task?.status || "todo");
247
+ const [priority, setPriority] = useState(task?.priority || "");
248
+ const [tagsInput, setTagsInput] = useState(
249
+ getTaskTags(task).join(", "),
250
+ );
251
+ const [draft, setDraft] = useState(
252
+ Boolean(task?.draft || task?.status === "draft"),
253
+ );
254
+ const [saving, setSaving] = useState(false);
255
+
256
+ useEffect(() => {
257
+ setTitle(task?.title || "");
258
+ setDescription(task?.description || "");
259
+ setBaseBranch(getTaskBaseBranch(task));
260
+ setStatus(task?.status || "todo");
261
+ setPriority(task?.priority || "");
262
+ setTagsInput(getTaskTags(task).join(", "));
263
+ setDraft(Boolean(task?.draft || task?.status === "draft"));
264
+ }, [task?.id]);
265
+
266
+ const handleSave = async () => {
267
+ setSaving(true);
268
+ haptic("medium");
269
+ const prev = cloneValue(tasksData.value);
270
+ const tags = normalizeTagInput(tagsInput);
271
+ const wantsDraft = draft || status === "draft";
272
+ const nextStatus = wantsDraft ? "draft" : status;
273
+ try {
274
+ await runOptimistic(
275
+ () => {
276
+ tasksData.value = tasksData.value.map((t) =>
277
+ t.id === task.id
278
+ ? {
279
+ ...t,
280
+ title,
281
+ description,
282
+ baseBranch,
283
+ status: nextStatus,
284
+ priority: priority || null,
285
+ tags,
286
+ draft: wantsDraft,
287
+ }
288
+ : t,
289
+ );
290
+ },
291
+ async () => {
292
+ const res = await apiFetch("/api/tasks/edit", {
293
+ method: "POST",
294
+ body: JSON.stringify({
295
+ taskId: task.id,
296
+ title,
297
+ description,
298
+ baseBranch,
299
+ status: nextStatus,
300
+ priority,
301
+ tags,
302
+ draft: wantsDraft,
303
+ }),
304
+ });
305
+ if (res?.data)
306
+ tasksData.value = tasksData.value.map((t) =>
307
+ t.id === task.id ? { ...t, ...res.data } : t,
308
+ );
309
+ return res;
310
+ },
311
+ () => {
312
+ tasksData.value = prev;
313
+ },
314
+ );
315
+ showToast("Task saved", "success");
316
+ onClose();
317
+ } catch {
318
+ /* toast via apiFetch */
319
+ }
320
+ setSaving(false);
321
+ };
322
+
323
+ const handleStatusUpdate = async (newStatus) => {
324
+ haptic("medium");
325
+ const prev = cloneValue(tasksData.value);
326
+ const wantsDraft = newStatus === "draft";
327
+ try {
328
+ await runOptimistic(
329
+ () => {
330
+ tasksData.value = tasksData.value.map((t) =>
331
+ t.id === task.id
332
+ ? { ...t, status: newStatus, draft: wantsDraft }
333
+ : t,
334
+ );
335
+ },
336
+ async () => {
337
+ const res = await apiFetch("/api/tasks/update", {
338
+ method: "POST",
339
+ body: JSON.stringify({
340
+ taskId: task.id,
341
+ status: newStatus,
342
+ draft: wantsDraft,
343
+ }),
344
+ });
345
+ if (res?.data)
346
+ tasksData.value = tasksData.value.map((t) =>
347
+ t.id === task.id ? { ...t, ...res.data } : t,
348
+ );
349
+ return res;
350
+ },
351
+ () => {
352
+ tasksData.value = prev;
353
+ },
354
+ );
355
+ if (newStatus === "done" || newStatus === "cancelled") onClose();
356
+ else {
357
+ setStatus(newStatus);
358
+ setDraft(wantsDraft);
359
+ }
360
+ } catch {
361
+ /* toast */
362
+ }
363
+ };
364
+
365
+ const handleStart = () => {
366
+ if (onStart) onStart(task);
367
+ };
368
+
369
+ const handleRetry = async () => {
370
+ haptic("medium");
371
+ try {
372
+ await apiFetch("/api/tasks/retry", {
373
+ method: "POST",
374
+ body: JSON.stringify({ taskId: task.id }),
375
+ });
376
+ showToast("Task retried", "success");
377
+ onClose();
378
+ scheduleRefresh(150);
379
+ } catch {
380
+ /* toast */
381
+ }
382
+ };
383
+
384
+ const handleCancel = async () => {
385
+ const ok = await showConfirm("Cancel this task?");
386
+ if (!ok) return;
387
+ await handleStatusUpdate("cancelled");
388
+ };
389
+
390
+ return html`
391
+ <${Modal} title=${task?.title || "Task Detail"} onClose=${onClose}>
392
+ <div class="meta-text mb-sm" style="user-select:all">ID: ${task?.id}</div>
393
+ <div class="flex-row gap-sm mb-md">
394
+ <${Badge} status=${task?.status} text=${task?.status} />
395
+ ${task?.priority &&
396
+ html`<${Badge} status=${task.priority} text=${task.priority} />`}
397
+ </div>
398
+
399
+ <div class="flex-col gap-md">
400
+ <input
401
+ class="input"
402
+ placeholder="Title"
403
+ value=${title}
404
+ onInput=${(e) => setTitle(e.target.value)}
405
+ />
406
+ <textarea
407
+ class="input"
408
+ rows="5"
409
+ placeholder="Description"
410
+ value=${description}
411
+ onInput=${(e) => setDescription(e.target.value)}
412
+ ></textarea>
413
+ <input
414
+ class="input"
415
+ placeholder="Base branch (optional, e.g. feature/xyz)"
416
+ value=${baseBranch}
417
+ onInput=${(e) => setBaseBranch(e.target.value)}
418
+ />
419
+ <input
420
+ class="input"
421
+ placeholder="Tags (comma-separated)"
422
+ value=${tagsInput}
423
+ onInput=${(e) => setTagsInput(e.target.value)}
424
+ />
425
+ ${normalizeTagInput(tagsInput).length > 0 &&
426
+ html`
427
+ <div class="tag-row">
428
+ ${normalizeTagInput(tagsInput).map(
429
+ (tag) => html`<span class="tag-chip">#${tag}</span>`,
430
+ )}
431
+ </div>
432
+ `}
433
+
434
+ <div class="input-row">
435
+ <select
436
+ class="input"
437
+ value=${status}
438
+ onChange=${(e) => {
439
+ const next = e.target.value;
440
+ setStatus(next);
441
+ if (next === "draft") setDraft(true);
442
+ else if (draft) setDraft(false);
443
+ }}
444
+ >
445
+ ${["draft", "todo", "inprogress", "inreview", "done", "cancelled"].map(
446
+ (s) => html`<option value=${s}>${s}</option>`,
447
+ )}
448
+ </select>
449
+ <select
450
+ class="input"
451
+ value=${priority}
452
+ onChange=${(e) => setPriority(e.target.value)}
453
+ >
454
+ <option value="">No priority</option>
455
+ ${["low", "medium", "high", "critical"].map(
456
+ (p) => html`<option value=${p}>${p}</option>`,
457
+ )}
458
+ </select>
459
+ </div>
460
+ <${Toggle}
461
+ label="Draft (keep in backlog)"
462
+ checked=${draft}
463
+ onChange=${(next) => {
464
+ setDraft(next);
465
+ if (next) setStatus("draft");
466
+ else if (status === "draft") setStatus("todo");
467
+ }}
468
+ />
469
+
470
+ <!-- Metadata -->
471
+ ${task?.created_at &&
472
+ html`
473
+ <div class="meta-text">
474
+ Created: ${new Date(task.created_at).toLocaleString()}
475
+ </div>
476
+ `}
477
+ ${task?.updated_at &&
478
+ html`
479
+ <div class="meta-text">
480
+ Updated: ${formatRelative(task.updated_at)}
481
+ </div>
482
+ `}
483
+ ${task?.assignee &&
484
+ html` <div class="meta-text">Assignee: ${task.assignee}</div> `}
485
+ ${task?.branch &&
486
+ html`
487
+ <div class="meta-text" style="user-select:all">
488
+ Branch: ${task.branch}
489
+ </div>
490
+ `}
491
+
492
+ <!-- Action buttons -->
493
+ <div class="btn-row">
494
+ ${task?.status === "todo" &&
495
+ onStart &&
496
+ html`
497
+ <button class="btn btn-primary btn-sm" onClick=${handleStart}>
498
+ ▶ Start
499
+ </button>
500
+ `}
501
+ ${(task?.status === "error" || task?.status === "cancelled") &&
502
+ html`
503
+ <button class="btn btn-primary btn-sm" onClick=${handleRetry}>
504
+ ↻ Retry
505
+ </button>
506
+ `}
507
+ <button
508
+ class="btn btn-secondary btn-sm"
509
+ onClick=${handleSave}
510
+ disabled=${saving}
511
+ >
512
+ ${saving ? "Saving…" : "💾 Save"}
513
+ </button>
514
+ <button
515
+ class="btn btn-ghost btn-sm"
516
+ onClick=${() => handleStatusUpdate("inreview")}
517
+ >
518
+ → Review
519
+ </button>
520
+ <button
521
+ class="btn btn-ghost btn-sm"
522
+ onClick=${() => handleStatusUpdate("done")}
523
+ >
524
+ ✓ Done
525
+ </button>
526
+ ${task?.status !== "cancelled" &&
527
+ html`
528
+ <button
529
+ class="btn btn-ghost btn-sm"
530
+ style="color:var(--color-error)"
531
+ onClick=${handleCancel}
532
+ >
533
+ ✕ Cancel
534
+ </button>
535
+ `}
536
+ </div>
537
+
538
+ <!-- Agent log link -->
539
+ ${task?.id &&
540
+ html`
541
+ <button
542
+ class="btn btn-ghost btn-sm"
543
+ onClick=${() => {
544
+ haptic();
545
+ sendCommandToChat("/logs " + task.id);
546
+ }}
547
+ >
548
+ 📄 View Agent Logs
549
+ </button>
550
+ `}
551
+ </div>
552
+ <//>
553
+ `;
554
+ }
555
+
556
+ /* ─── TasksTab ─── */
557
+ export function TasksTab() {
558
+ const [showCreate, setShowCreate] = useState(false);
559
+ const [detailTask, setDetailTask] = useState(null);
560
+ const [startTarget, setStartTarget] = useState(null);
561
+ const [batchMode, setBatchMode] = useState(false);
562
+ const [selectedIds, setSelectedIds] = useState(new Set());
563
+ const [isSearching, setIsSearching] = useState(false);
564
+ const [exportOpen, setExportOpen] = useState(false);
565
+ const [exporting, setExporting] = useState(false);
566
+ const searchRef = useRef(null);
567
+
568
+ /* Detect desktop for keyboard shortcut hint */
569
+ const [showKbdHint] = useState(() => {
570
+ try { return globalThis.matchMedia?.("(hover: hover)")?.matches ?? false; }
571
+ catch { return false; }
572
+ });
573
+ const isMac = typeof navigator !== "undefined" &&
574
+ /Mac|iPod|iPhone|iPad/.test(navigator.platform || "");
575
+
576
+ const tasks = tasksData.value || [];
577
+ const filterVal = tasksFilter?.value ?? "todo";
578
+ const priorityVal = tasksPriority?.value ?? "";
579
+ const searchVal = tasksSearch?.value ?? "";
580
+ const sortVal = tasksSort?.value ?? "updated";
581
+ const page = tasksPage?.value ?? 0;
582
+ const pageSize = tasksPageSize?.value ?? 8;
583
+ const totalPages = tasksTotalPages?.value ?? 1;
584
+ const defaultSdk = executorData.value?.data?.sdk || "auto";
585
+ const activeSlots = executorData.value?.data?.slots || [];
586
+ const hasActiveSlots = activeSlots.length > 0;
587
+ const completedOnly = filterVal === "done";
588
+ const trimmedSearch = searchVal.trim();
589
+ const statusLabel =
590
+ STATUS_CHIPS.find((s) => s.value === filterVal)?.label || "All";
591
+ const priorityLabel =
592
+ PRIORITY_CHIPS.find((p) => p.value === priorityVal)?.label || "Any";
593
+ const sortLabel =
594
+ SORT_OPTIONS.find((o) => o.value === sortVal)?.label || "Updated";
595
+ const hasSearch = Boolean(trimmedSearch);
596
+ const hasStatusFilter = filterVal && filterVal !== "all";
597
+ const hasPriorityFilter = Boolean(priorityVal);
598
+ const hasSortFilter = sortVal && sortVal !== "updated";
599
+ const hasActiveFilters =
600
+ hasSearch || hasStatusFilter || hasPriorityFilter || hasSortFilter;
601
+ const filterSummaryParts = [];
602
+ if (hasSearch)
603
+ filterSummaryParts.push(`Search: "${truncate(trimmedSearch, 24)}"`);
604
+ if (hasStatusFilter) filterSummaryParts.push(`Status: ${statusLabel}`);
605
+ if (hasPriorityFilter) filterSummaryParts.push(`Priority: ${priorityLabel}`);
606
+ if (hasSortFilter) filterSummaryParts.push(`Sort: ${sortLabel}`);
607
+ const filterSummary = filterSummaryParts.join(" · ");
608
+ const lastNonCompletedRef = useRef(
609
+ filterVal && filterVal !== "done" ? filterVal : "all",
610
+ );
611
+
612
+ useEffect(() => {
613
+ if (filterVal && filterVal !== "done") {
614
+ lastNonCompletedRef.current = filterVal;
615
+ }
616
+ }, [filterVal]);
617
+
618
+ /* Search (local fuzzy filter on already-loaded data) */
619
+ const searchLower = trimmedSearch.toLowerCase();
620
+ const visible = searchLower
621
+ ? tasks.filter((t) =>
622
+ `${t.title || ""} ${t.description || ""} ${t.id || ""} ${getTaskBaseBranch(t)} ${getTaskTags(t).join(" ")}`
623
+ .toLowerCase()
624
+ .includes(searchLower),
625
+ )
626
+ : tasks;
627
+
628
+ /* ── Handlers ── */
629
+ const handleFilter = async (s) => {
630
+ haptic();
631
+ if (tasksFilter) tasksFilter.value = s;
632
+ if (tasksPage) tasksPage.value = 0;
633
+ await refreshTab("tasks");
634
+ };
635
+
636
+ const toggleCompletedFilter = async () => {
637
+ const next = completedOnly
638
+ ? lastNonCompletedRef.current || "all"
639
+ : "done";
640
+ await handleFilter(next);
641
+ };
642
+
643
+ const handlePriorityFilter = async (p) => {
644
+ haptic();
645
+ if (tasksPriority) tasksPriority.value = p;
646
+ if (tasksPage) tasksPage.value = 0;
647
+ await refreshTab("tasks");
648
+ };
649
+
650
+ const handleSort = async (e) => {
651
+ haptic();
652
+ if (tasksSort) tasksSort.value = e.target.value;
653
+ if (tasksPage) tasksPage.value = 0;
654
+ await refreshTab("tasks");
655
+ };
656
+
657
+ /* Server-side search: debounce 300ms then reload from server */
658
+ const triggerServerSearch = useCallback(
659
+ debounce(async () => {
660
+ if (tasksPage) tasksPage.value = 0;
661
+ setIsSearching(true);
662
+ try { await loadTasks(); } finally { setIsSearching(false); }
663
+ }, 300),
664
+ [],
665
+ );
666
+
667
+ const handleSearch = useCallback(
668
+ (val) => {
669
+ if (tasksSearch) tasksSearch.value = val;
670
+ triggerServerSearch();
671
+ },
672
+ [triggerServerSearch],
673
+ );
674
+
675
+ const handleClearSearch = useCallback(() => {
676
+ if (tasksSearch) tasksSearch.value = "";
677
+ triggerServerSearch.cancel();
678
+ if (tasksPage) tasksPage.value = 0;
679
+ setIsSearching(false);
680
+ loadTasks();
681
+ }, [triggerServerSearch]);
682
+
683
+ const handleClearFilters = useCallback(async () => {
684
+ haptic();
685
+ if (tasksFilter) tasksFilter.value = "all";
686
+ if (tasksPriority) tasksPriority.value = "";
687
+ if (tasksSort) tasksSort.value = "updated";
688
+ if (tasksSearch) tasksSearch.value = "";
689
+ if (tasksPage) tasksPage.value = 0;
690
+ triggerServerSearch.cancel();
691
+ setIsSearching(false);
692
+ await refreshTab("tasks");
693
+ }, [triggerServerSearch]);
694
+
695
+ /* Keyboard shortcuts (mount/unmount) */
696
+ useEffect(() => {
697
+ const onKeyDown = (e) => {
698
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
699
+ e.preventDefault();
700
+ searchRef.current?.focus?.();
701
+ }
702
+ if (e.key === "Escape" && searchRef.current &&
703
+ document.activeElement === searchRef.current) {
704
+ handleClearSearch();
705
+ searchRef.current.blur();
706
+ }
707
+ };
708
+ document.addEventListener("keydown", onKeyDown);
709
+ return () => document.removeEventListener("keydown", onKeyDown);
710
+ }, [handleClearSearch]);
711
+
712
+ const handlePrev = async () => {
713
+ if (tasksPage) tasksPage.value = Math.max(0, page - 1);
714
+ await refreshTab("tasks");
715
+ };
716
+
717
+ const handleNext = async () => {
718
+ if (tasksPage) tasksPage.value = page + 1;
719
+ await refreshTab("tasks");
720
+ };
721
+
722
+ const handleStatusUpdate = async (taskId, newStatus) => {
723
+ haptic("medium");
724
+ const prev = cloneValue(tasks);
725
+ const wantsDraft = newStatus === "draft";
726
+ await runOptimistic(
727
+ () => {
728
+ tasksData.value = tasksData.value.map((t) =>
729
+ t.id === taskId
730
+ ? { ...t, status: newStatus, draft: wantsDraft }
731
+ : t,
732
+ );
733
+ },
734
+ async () => {
735
+ const res = await apiFetch("/api/tasks/update", {
736
+ method: "POST",
737
+ body: JSON.stringify({
738
+ taskId,
739
+ status: newStatus,
740
+ draft: wantsDraft,
741
+ }),
742
+ });
743
+ if (res?.data)
744
+ tasksData.value = tasksData.value.map((t) =>
745
+ t.id === taskId ? { ...t, ...res.data } : t,
746
+ );
747
+ },
748
+ () => {
749
+ tasksData.value = prev;
750
+ },
751
+ ).catch(() => {});
752
+ };
753
+
754
+ const startTask = async ({ taskId, sdk, model }) => {
755
+ haptic("medium");
756
+ const prev = cloneValue(tasks);
757
+ await runOptimistic(
758
+ () => {
759
+ tasksData.value = tasksData.value.map((t) =>
760
+ t.id === taskId ? { ...t, status: "inprogress" } : t,
761
+ );
762
+ },
763
+ () =>
764
+ apiFetch("/api/tasks/start", {
765
+ method: "POST",
766
+ body: JSON.stringify({
767
+ taskId,
768
+ ...(sdk ? { sdk } : {}),
769
+ ...(model ? { model } : {}),
770
+ }),
771
+ }),
772
+ () => {
773
+ tasksData.value = prev;
774
+ },
775
+ ).catch(() => {});
776
+ scheduleRefresh(150);
777
+ };
778
+
779
+ const openStartModal = (task) => {
780
+ haptic("medium");
781
+ setStartTarget(task);
782
+ };
783
+
784
+ const openDetail = async (taskId) => {
785
+ haptic();
786
+ const local = tasks.find((t) => t.id === taskId);
787
+ const result = await apiFetch(
788
+ `/api/tasks/detail?taskId=${encodeURIComponent(taskId)}`,
789
+ { _silent: true },
790
+ ).catch(() => ({ data: local }));
791
+ setDetailTask(result.data || local);
792
+ };
793
+
794
+ /* ── Batch operations ── */
795
+ const toggleSelect = (id) => {
796
+ setSelectedIds((prev) => {
797
+ const next = new Set(prev);
798
+ next.has(id) ? next.delete(id) : next.add(id);
799
+ return next;
800
+ });
801
+ };
802
+
803
+ const handleBatchDone = async () => {
804
+ if (!selectedIds.size) return;
805
+ const ok = await showConfirm(`Mark ${selectedIds.size} tasks as done?`);
806
+ if (!ok) return;
807
+ haptic("medium");
808
+ for (const id of selectedIds) {
809
+ await handleStatusUpdate(id, "done");
810
+ }
811
+ setSelectedIds(new Set());
812
+ setBatchMode(false);
813
+ scheduleRefresh(150);
814
+ };
815
+
816
+ const handleBatchCancel = async () => {
817
+ if (!selectedIds.size) return;
818
+ const ok = await showConfirm(`Cancel ${selectedIds.size} tasks?`);
819
+ if (!ok) return;
820
+ haptic("medium");
821
+ for (const id of selectedIds) {
822
+ await handleStatusUpdate(id, "cancelled");
823
+ }
824
+ setSelectedIds(new Set());
825
+ setBatchMode(false);
826
+ scheduleRefresh(150);
827
+ };
828
+
829
+ /* ── Export handlers ── */
830
+ const handleExportCSV = async () => {
831
+ setExporting(true);
832
+ setExportOpen(false);
833
+ haptic("medium");
834
+ try {
835
+ const res = await apiFetch("/api/tasks?limit=1000", { _silent: true });
836
+ const allTasks = res?.data || res?.tasks || tasks;
837
+ const headers = [
838
+ "ID",
839
+ "Title",
840
+ "Status",
841
+ "Priority",
842
+ "Base Branch",
843
+ "Tags",
844
+ "Draft",
845
+ "Created",
846
+ "Updated",
847
+ "Description",
848
+ ];
849
+ const rows = allTasks.map((t) => [
850
+ t.id || "",
851
+ t.title || "",
852
+ t.status || "",
853
+ t.priority || "",
854
+ getTaskBaseBranch(t),
855
+ getTaskTags(t).join(", "),
856
+ t.draft || t.status === "draft" ? "true" : "false",
857
+ t.created_at || "",
858
+ t.updated_at || "",
859
+ truncate(t.description || "", 200),
860
+ ]);
861
+ const date = new Date().toISOString().slice(0, 10);
862
+ exportAsCSV(headers, rows, `tasks-${date}.csv`);
863
+ showToast(`Exported ${allTasks.length} tasks`, "success");
864
+ } catch {
865
+ showToast("Export failed", "error");
866
+ }
867
+ setExporting(false);
868
+ };
869
+
870
+ const handleExportJSON = async () => {
871
+ setExporting(true);
872
+ setExportOpen(false);
873
+ haptic("medium");
874
+ try {
875
+ const res = await apiFetch("/api/tasks?limit=1000", { _silent: true });
876
+ const allTasks = res?.data || res?.tasks || tasks;
877
+ const date = new Date().toISOString().slice(0, 10);
878
+ exportAsJSON(allTasks, `tasks-${date}.json`);
879
+ showToast(`Exported ${allTasks.length} tasks`, "success");
880
+ } catch {
881
+ showToast("Export failed", "error");
882
+ }
883
+ setExporting(false);
884
+ };
885
+
886
+ /* ── Render ── */
887
+ const isKanban = viewMode.value === "kanban";
888
+ const showBatchBar = !isKanban && batchMode && selectedIds.size > 0;
889
+
890
+ if (!tasksLoaded.value && !tasks.length && !searchVal)
891
+ return html`<${Card} title="Loading Tasks…"><${SkeletonCard} /><//>`;
892
+
893
+ if (tasksLoaded.value && !tasks.length && !searchVal)
894
+ return html`
895
+ <div class="flex-between mb-sm" style="padding:0 4px">
896
+ <div class="view-toggle">
897
+ <button class="view-toggle-btn ${!isKanban ? 'active' : ''}" onClick=${() => { viewMode.value = 'list'; haptic(); }}>☰ List</button>
898
+ <button class="view-toggle-btn ${isKanban ? 'active' : ''}" onClick=${() => { viewMode.value = 'kanban'; haptic(); }}>▦ Board</button>
899
+ </div>
900
+ <button
901
+ class="btn btn-ghost btn-sm"
902
+ onClick=${toggleCompletedFilter}
903
+ >
904
+ ${completedOnly ? "Show All" : "Show Completed"}
905
+ </button>
906
+ </div>
907
+ ${hasActiveSlots &&
908
+ html`
909
+ <${Card} title="Active Slots">
910
+ ${activeSlots.map(
911
+ (slot) => html`
912
+ <div key=${slot.taskId} class="list-item">
913
+ <div class="list-item-content">
914
+ <div class="list-item-title">
915
+ ${truncate(slot.taskTitle || "(untitled)", 50)}
916
+ </div>
917
+ <div class="meta-text">
918
+ ${slot.taskId} · ${slot.branch || "no branch"}
919
+ </div>
920
+ </div>
921
+ <${Badge} status="inprogress" text="running" />
922
+ </div>
923
+ `,
924
+ )}
925
+ <//>
926
+ `}
927
+ ${!hasActiveSlots &&
928
+ html`
929
+ <${EmptyState}
930
+ message="No tasks yet"
931
+ description="Create a task to start orchestrating agents."
932
+ icon="\u{1F4CB}"
933
+ action=${{
934
+ label: "Create Task",
935
+ onClick: () => {
936
+ haptic();
937
+ setShowCreate(true);
938
+ },
939
+ }}
940
+ />
941
+ `}
942
+ <button class="fab" onClick=${() => { haptic(); setShowCreate(true); }}>${ICONS.plus}</button>
943
+ ${showCreate && html`<${CreateTaskModalInline} onClose=${() => setShowCreate(false)} />`}
944
+ `;
945
+
946
+ return html`
947
+ <!-- Sticky search bar + view toggle -->
948
+ <div class="sticky-search">
949
+ <div class="sticky-search-row">
950
+ <div class="sticky-search-main">
951
+ <${SearchInput}
952
+ inputRef=${searchRef}
953
+ placeholder="Search title, ID, or tag…"
954
+ value=${searchVal}
955
+ onInput=${(e) => handleSearch(e.target.value)}
956
+ onClear=${handleClearSearch}
957
+ />
958
+ ${showKbdHint && !searchVal && html`<span class="pill" style="font-size:10px;padding:2px 7px;opacity:0.55;white-space:nowrap;pointer-events:none">${isMac ? "⌘K" : "Ctrl+K"}</span>`}
959
+ ${isSearching && html`<span class="pill" style="font-size:10px;padding:2px 7px;color:var(--accent);white-space:nowrap">Searching…</span>`}
960
+ ${!isSearching && searchVal && html`<span class="pill" style="font-size:10px;padding:2px 7px;white-space:nowrap">${visible.length} result${visible.length !== 1 ? "s" : ""}</span>`}
961
+ </div>
962
+ <div class="view-toggle">
963
+ <button class="view-toggle-btn ${!isKanban ? 'active' : ''}" onClick=${() => { viewMode.value = 'list'; haptic(); }}>☰ List</button>
964
+ <button class="view-toggle-btn ${isKanban ? 'active' : ''}" onClick=${() => { viewMode.value = 'kanban'; haptic(); }}>▦ Board</button>
965
+ </div>
966
+ <button
967
+ class="btn btn-ghost btn-sm"
968
+ onClick=${toggleCompletedFilter}
969
+ >
970
+ ${completedOnly ? "Show All" : "Show Completed"}
971
+ </button>
972
+ <div class="export-wrap">
973
+ <button
974
+ class="btn btn-secondary btn-sm export-btn"
975
+ disabled=${exporting}
976
+ onClick=${() => { setExportOpen(!exportOpen); haptic(); }}
977
+ >
978
+ ${DOWNLOAD_ICON} ${exporting ? "…" : "Export"}
979
+ </button>
980
+ ${exportOpen && html`
981
+ <div class="export-dropdown">
982
+ <button class="export-dropdown-item" onClick=${handleExportCSV}>📊 Export as CSV</button>
983
+ <button class="export-dropdown-item" onClick=${handleExportJSON}>📋 Export as JSON</button>
984
+ </div>
985
+ `}
986
+ </div>
987
+ </div>
988
+ ${hasActiveFilters && html`
989
+ <div class="filter-summary">
990
+ <div class="filter-summary-text">
991
+ <span class="pill">Filters</span>
992
+ <span>${filterSummary}</span>
993
+ </div>
994
+ <div class="filter-summary-actions">
995
+ <button class="btn btn-ghost btn-sm" onClick=${handleClearFilters}>
996
+ Clear Filters
997
+ </button>
998
+ </div>
999
+ </div>
1000
+ `}
1001
+ ${showBatchBar &&
1002
+ html`
1003
+ <div class="btn-row batch-action-bar">
1004
+ <span class="pill">${selectedIds.size} selected</span>
1005
+ <button class="btn btn-primary btn-sm" onClick=${handleBatchDone}>
1006
+ ✓ Done All
1007
+ </button>
1008
+ <button class="btn btn-danger btn-sm" onClick=${handleBatchCancel}>
1009
+ ✕ Cancel All
1010
+ </button>
1011
+ <button
1012
+ class="btn btn-ghost btn-sm"
1013
+ onClick=${() => {
1014
+ setSelectedIds(new Set());
1015
+ haptic();
1016
+ }}
1017
+ >
1018
+ Clear
1019
+ </button>
1020
+ </div>
1021
+ `}
1022
+ </div>
1023
+
1024
+ <style>
1025
+ .export-btn { display:inline-flex; align-items:center; gap:4px; }
1026
+ .export-dropdown {
1027
+ position:absolute; right:0; top:100%; margin-top:4px; z-index:100;
1028
+ background:var(--card-bg, #1e1e2e); border:1px solid var(--border, #333);
1029
+ border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.3); overflow:hidden;
1030
+ min-width:160px;
1031
+ }
1032
+ .export-dropdown-item {
1033
+ display:block; width:100%; padding:10px 14px; border:none;
1034
+ background:none; color:inherit; text-align:left; font-size:13px;
1035
+ cursor:pointer;
1036
+ }
1037
+ .export-dropdown-item:hover { background:var(--hover-bg, rgba(255,255,255,.08)); }
1038
+ </style>
1039
+
1040
+ <!-- Kanban board view -->
1041
+ ${isKanban && html`<${KanbanBoard} onOpenTask=${openDetail} />`}
1042
+
1043
+ <!-- List view filters -->
1044
+ ${!isKanban && html`<${Card} title="Task Board">
1045
+ <div class="chip-group mb-sm">
1046
+ ${STATUS_CHIPS.map(
1047
+ (s) => html`
1048
+ <button
1049
+ key=${s.value}
1050
+ class="chip ${filterVal === s.value ? "active" : ""}"
1051
+ onClick=${() => handleFilter(s.value)}
1052
+ >
1053
+ ${s.label}
1054
+ </button>
1055
+ `,
1056
+ )}
1057
+ </div>
1058
+ <div class="chip-group mb-sm">
1059
+ ${PRIORITY_CHIPS.map(
1060
+ (p) => html`
1061
+ <button
1062
+ key=${p.value}
1063
+ class="chip chip-outline ${priorityVal === p.value
1064
+ ? "active"
1065
+ : ""}"
1066
+ onClick=${() => handlePriorityFilter(p.value)}
1067
+ >
1068
+ ${p.label}
1069
+ </button>
1070
+ `,
1071
+ )}
1072
+ </div>
1073
+ <div class="flex-between mb-sm">
1074
+ <select
1075
+ class="input input-sm"
1076
+ value=${sortVal}
1077
+ onChange=${handleSort}
1078
+ style="max-width:140px"
1079
+ >
1080
+ ${SORT_OPTIONS.map(
1081
+ (o) =>
1082
+ html`<option key=${o.value} value=${o.value}>${o.label}</option>`,
1083
+ )}
1084
+ </select>
1085
+ <span class="pill">${visible.length} shown</span>
1086
+ </div>
1087
+
1088
+ <!-- Batch mode toggle -->
1089
+ <div class="flex-between mb-sm">
1090
+ <label
1091
+ class="meta-text toggle-label"
1092
+ onClick=${() => {
1093
+ setBatchMode(!batchMode);
1094
+ haptic();
1095
+ setSelectedIds(new Set());
1096
+ }}
1097
+ >
1098
+ <input
1099
+ type="checkbox"
1100
+ checked=${batchMode}
1101
+ style="accent-color:var(--accent)"
1102
+ />
1103
+ Batch Select
1104
+ </label>
1105
+ </div>
1106
+
1107
+ <//>
1108
+
1109
+ <!-- Task list -->
1110
+ ${visible.map(
1111
+ (task) => html`
1112
+ <div
1113
+ key=${task.id}
1114
+ class="task-card ${batchMode && selectedIds.has(task.id)
1115
+ ? "task-card-selected"
1116
+ : ""} task-card-enter"
1117
+ onClick=${() =>
1118
+ batchMode ? toggleSelect(task.id) : openDetail(task.id)}
1119
+ >
1120
+ ${batchMode &&
1121
+ html`
1122
+ <input
1123
+ type="checkbox"
1124
+ checked=${selectedIds.has(task.id)}
1125
+ class="task-checkbox"
1126
+ onClick=${(e) => {
1127
+ e.stopPropagation();
1128
+ toggleSelect(task.id);
1129
+ }}
1130
+ style="accent-color:var(--accent)"
1131
+ />
1132
+ `}
1133
+ <div class="task-card-header">
1134
+ <div>
1135
+ <div class="task-card-title">${task.title || "(untitled)"}</div>
1136
+ <div class="task-card-meta">
1137
+ ${task.id}${task.priority
1138
+ ? html` ·
1139
+ <${Badge}
1140
+ status=${task.priority}
1141
+ text=${task.priority}
1142
+ />`
1143
+ : ""}
1144
+ ${task.updated_at
1145
+ ? html` · ${formatRelative(task.updated_at)}`
1146
+ : ""}
1147
+ </div>
1148
+ </div>
1149
+ <${Badge} status=${task.status} text=${task.status} />
1150
+ </div>
1151
+ <div class="meta-text">
1152
+ ${task.description
1153
+ ? truncate(task.description, 120)
1154
+ : "No description."}
1155
+ </div>
1156
+ ${getTaskBaseBranch(task) &&
1157
+ html`
1158
+ <div class="meta-text">
1159
+ Base: <code>${getTaskBaseBranch(task)}</code>
1160
+ </div>
1161
+ `}
1162
+ ${getTaskTags(task).length > 0 &&
1163
+ html`
1164
+ <div class="tag-row">
1165
+ ${getTaskTags(task).map(
1166
+ (tag) => html`<span class="tag-chip">#${tag}</span>`,
1167
+ )}
1168
+ </div>
1169
+ `}
1170
+ ${!batchMode &&
1171
+ html`
1172
+ <div class="btn-row mt-sm" onClick=${(e) => e.stopPropagation()}>
1173
+ ${task.status === "todo" &&
1174
+ html`
1175
+ <button
1176
+ class="btn btn-primary btn-sm"
1177
+ onClick=${() => openStartModal(task)}
1178
+ >
1179
+ ▶ Start
1180
+ </button>
1181
+ `}
1182
+ <button
1183
+ class="btn btn-secondary btn-sm"
1184
+ onClick=${() => handleStatusUpdate(task.id, "inreview")}
1185
+ >
1186
+ → Review
1187
+ </button>
1188
+ <button
1189
+ class="btn btn-ghost btn-sm"
1190
+ onClick=${() => handleStatusUpdate(task.id, "done")}
1191
+ >
1192
+ ✓ Done
1193
+ </button>
1194
+ </div>
1195
+ `}
1196
+ </div>
1197
+ `,
1198
+ )}
1199
+ ${!visible.length &&
1200
+ html`
1201
+ <${EmptyState}
1202
+ message="No tasks match those filters"
1203
+ description="Try clearing filters or searching by ID, title, or tag."
1204
+ action=${hasActiveFilters
1205
+ ? { label: "Clear Filters", onClick: handleClearFilters }
1206
+ : null}
1207
+ />
1208
+ `}
1209
+
1210
+ <!-- Pagination -->
1211
+ <div class="pager">
1212
+ <button
1213
+ class="btn btn-secondary btn-sm"
1214
+ onClick=${handlePrev}
1215
+ disabled=${page <= 0}
1216
+ >
1217
+ ← Prev
1218
+ </button>
1219
+ <span class="pager-info">Page ${page + 1} / ${totalPages}</span>
1220
+ <button
1221
+ class="btn btn-secondary btn-sm"
1222
+ onClick=${handleNext}
1223
+ disabled=${page + 1 >= totalPages}
1224
+ >
1225
+ Next →
1226
+ </button>
1227
+ </div>
1228
+ `}
1229
+
1230
+ <!-- FAB -->
1231
+ <button
1232
+ class="fab"
1233
+ onClick=${() => {
1234
+ haptic();
1235
+ setShowCreate(true);
1236
+ }}
1237
+ >
1238
+ ${ICONS.plus}
1239
+ </button>
1240
+
1241
+ <!-- Modals -->
1242
+ ${showCreate &&
1243
+ html`
1244
+ <!-- re-use CreateTaskModal from dashboard.js -->
1245
+ <${CreateTaskModalInline} onClose=${() => setShowCreate(false)} />
1246
+ `}
1247
+ ${detailTask &&
1248
+ html`
1249
+ <${TaskDetailModal}
1250
+ task=${detailTask}
1251
+ onClose=${() => setDetailTask(null)}
1252
+ onStart=${(task) => openStartModal(task)}
1253
+ />
1254
+ `}
1255
+ ${startTarget &&
1256
+ html`
1257
+ <${StartTaskModal}
1258
+ task=${startTarget}
1259
+ defaultSdk=${defaultSdk}
1260
+ allowTaskIdInput=${false}
1261
+ onClose=${() => setStartTarget(null)}
1262
+ onStart=${startTask}
1263
+ />
1264
+ `}
1265
+ `;
1266
+ }
1267
+
1268
+ /* ── Inline CreateTask (duplicated here to keep tasks.js self-contained) ── */
1269
+ function CreateTaskModalInline({ onClose }) {
1270
+ const [title, setTitle] = useState("");
1271
+ const [description, setDescription] = useState("");
1272
+ const [baseBranch, setBaseBranch] = useState("");
1273
+ const [priority, setPriority] = useState("medium");
1274
+ const [tagsInput, setTagsInput] = useState("");
1275
+ const [draft, setDraft] = useState(false);
1276
+ const [submitting, setSubmitting] = useState(false);
1277
+
1278
+ const handleSubmit = async () => {
1279
+ if (!title.trim()) {
1280
+ showToast("Title is required", "error");
1281
+ return;
1282
+ }
1283
+ setSubmitting(true);
1284
+ haptic("medium");
1285
+ const tags = normalizeTagInput(tagsInput);
1286
+ try {
1287
+ await apiFetch("/api/tasks/create", {
1288
+ method: "POST",
1289
+ body: JSON.stringify({
1290
+ title: title.trim(),
1291
+ description: description.trim(),
1292
+ baseBranch: baseBranch.trim() || undefined,
1293
+ priority,
1294
+ tags,
1295
+ draft,
1296
+ status: draft ? "draft" : "todo",
1297
+ }),
1298
+ });
1299
+ showToast("Task created", "success");
1300
+ onClose();
1301
+ await loadTasks();
1302
+ } catch {
1303
+ /* toast */
1304
+ }
1305
+ setSubmitting(false);
1306
+ };
1307
+
1308
+ useEffect(() => {
1309
+ const tg = globalThis.Telegram?.WebApp;
1310
+ if (tg?.MainButton) {
1311
+ tg.MainButton.setText("Create Task");
1312
+ tg.MainButton.show();
1313
+ tg.MainButton.onClick(handleSubmit);
1314
+ return () => {
1315
+ tg.MainButton.hide();
1316
+ tg.MainButton.offClick(handleSubmit);
1317
+ };
1318
+ }
1319
+ }, [title, description, baseBranch, priority, tagsInput, draft]);
1320
+
1321
+ return html`
1322
+ <${Modal} title="New Task" onClose=${onClose}>
1323
+ <div class="flex-col gap-md">
1324
+ <input
1325
+ class="input"
1326
+ placeholder="Task title"
1327
+ value=${title}
1328
+ onInput=${(e) => setTitle(e.target.value)}
1329
+ />
1330
+ <textarea
1331
+ class="input"
1332
+ rows="4"
1333
+ placeholder="Description"
1334
+ value=${description}
1335
+ onInput=${(e) => setDescription(e.target.value)}
1336
+ ></textarea>
1337
+ <input
1338
+ class="input"
1339
+ placeholder="Base branch (optional, e.g. feature/xyz)"
1340
+ value=${baseBranch}
1341
+ onInput=${(e) => setBaseBranch(e.target.value)}
1342
+ />
1343
+ <input
1344
+ class="input"
1345
+ placeholder="Tags (comma-separated)"
1346
+ value=${tagsInput}
1347
+ onInput=${(e) => setTagsInput(e.target.value)}
1348
+ />
1349
+ ${normalizeTagInput(tagsInput).length > 0 &&
1350
+ html`
1351
+ <div class="tag-row">
1352
+ ${normalizeTagInput(tagsInput).map(
1353
+ (tag) => html`<span class="tag-chip">#${tag}</span>`,
1354
+ )}
1355
+ </div>
1356
+ `}
1357
+ <${Toggle}
1358
+ label="Draft (keep in backlog)"
1359
+ checked=${draft}
1360
+ onChange=${(next) => setDraft(next)}
1361
+ />
1362
+ <${SegmentedControl}
1363
+ options=${[
1364
+ { value: "low", label: "Low" },
1365
+ { value: "medium", label: "Medium" },
1366
+ { value: "high", label: "High" },
1367
+ { value: "critical", label: "Critical" },
1368
+ ]}
1369
+ value=${priority}
1370
+ onChange=${(v) => {
1371
+ haptic();
1372
+ setPriority(v);
1373
+ }}
1374
+ />
1375
+ <button
1376
+ class="btn btn-primary"
1377
+ onClick=${handleSubmit}
1378
+ disabled=${submitting}
1379
+ >
1380
+ ${submitting ? "Creating…" : "Create Task"}
1381
+ </button>
1382
+ </div>
1383
+ <//>
1384
+ `;
1385
+ }