@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/ui/tabs/tasks.js
ADDED
|
@@ -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
|
+
}
|