@longshot/cli 0.0.1

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.
@@ -0,0 +1,898 @@
1
+ import { marked } from "marked";
2
+ import { hasRealSpec } from "../store.js";
3
+ import { layout } from "./layout.js";
4
+ const statusColors = {
5
+ drafting: "status-drafting",
6
+ ready: "status-ready",
7
+ in_progress: "status-progress",
8
+ complete: "status-complete",
9
+ rejected: "status-rejected",
10
+ queued: "status-queued",
11
+ approved: "status-approved",
12
+ refining: "status-refining",
13
+ fixing: "status-fixing",
14
+ conflict: "status-conflict",
15
+ };
16
+ const statusLabels = {
17
+ drafting: "Drafting",
18
+ ready: "Ready",
19
+ in_progress: "In Progress",
20
+ complete: "Complete",
21
+ rejected: "Rejected",
22
+ queued: "Queued",
23
+ approved: "Approved",
24
+ refining: "Refining",
25
+ fixing: "Fixing",
26
+ conflict: "Conflict",
27
+ };
28
+ // Sort groups: agent-active tasks first, then other active, then terminal
29
+ const statusGroup = {
30
+ in_progress: 0,
31
+ queued: 0,
32
+ approved: 0,
33
+ refining: 0,
34
+ fixing: 0,
35
+ conflict: 0,
36
+ ready: 1,
37
+ drafting: 1,
38
+ complete: 2,
39
+ rejected: 2,
40
+ };
41
+ function taskSortKey(t) {
42
+ const group = statusGroup[t.status] ?? 2;
43
+ return [group, t.id];
44
+ }
45
+ function renderTaskCard(task, ctx) {
46
+ const colorClass = statusColors[task.status] || "";
47
+ const label = statusLabels[task.status] || task.status;
48
+ // Build action buttons
49
+ const actions = [];
50
+ if (task.status === "drafting" && ctx.pendingQueueTaskIds.has(task.id) && ctx.agentTaskId !== task.id) {
51
+ actions.push(`<span class="btn btn-task-action btn-queued">Drafting...</span>`);
52
+ }
53
+ if (task.status === "ready") {
54
+ actions.push(`<button class="btn btn-task-action btn-primary" onclick="event.preventDefault(); startWork(${task.id})">Start Work</button>`);
55
+ }
56
+ if (task.status === "in_progress" && ctx.pendingDiffId) {
57
+ actions.push(`<a href="/diff/${ctx.pendingDiffId}" class="btn btn-task-action btn-approve" onclick="event.stopPropagation()">Review Diff</a>`);
58
+ }
59
+ if ((task.status === "in_progress" || task.status === "drafting" || task.status === "refining" || task.status === "approved" || task.status === "fixing" || task.status === "conflict") && ctx.agentTaskId === task.id && ctx.currentRunId) {
60
+ actions.push(`<a href="/agent/${ctx.currentRunId}" class="btn btn-task-action btn-task-progress" onclick="event.stopPropagation()">View Progress</a>`);
61
+ }
62
+ const actionsHtml = actions.length
63
+ ? `<div class="task-card-actions">${actions.join("\n")}</div>`
64
+ : "";
65
+ return `<a href="/tasks/${task.id}" class="task-card ${colorClass}">
66
+ <div class="task-card-top">
67
+ <span class="task-status-badge ${colorClass}">${label}</span>
68
+ <span class="task-id">#${task.id}</span>
69
+ </div>
70
+ <div class="task-title">${escapeHtml(task.title)}</div>
71
+ ${actionsHtml}
72
+ </a>`;
73
+ }
74
+ export function tasksListPage(tasks, taskDiffs = new Map(), agentRunning = false, currentRunId = null, agentTaskId = null, pendingQueueTaskIds = new Set()) {
75
+ const ctx = { agentRunning, currentRunId, agentTaskId, pendingQueueTaskIds };
76
+ const active = tasks
77
+ .filter((t) => t.status !== "complete" && t.status !== "rejected")
78
+ .sort((a, b) => {
79
+ const [aGroup, aId] = taskSortKey(a);
80
+ const [bGroup, bId] = taskSortKey(b);
81
+ if (aGroup !== bGroup)
82
+ return aGroup - bGroup;
83
+ // Group 0 (agent-active): ID ascending (oldest first)
84
+ // Group 1 (pending): ID descending (newest first)
85
+ return aGroup === 0 ? Number(aId) - Number(bId) : Number(bId) - Number(aId);
86
+ });
87
+ const done = tasks
88
+ .filter((t) => t.status === "complete" || t.status === "rejected")
89
+ .sort((a, b) => Number(b.id) - Number(a.id));
90
+ const body = `
91
+ <div class="tasks-container">
92
+ <div class="tasks-header">
93
+ <h1>Tasks</h1>
94
+ </div>
95
+ <div id="git-status-bar"></div>
96
+ <div id="task-list-container">
97
+ ${active.length ? `<div class="tasks-section">
98
+ <h2>Active</h2>
99
+ <div class="task-list">${active.map((t) => renderTaskCard(t, { ...ctx, pendingDiffId: taskDiffs.get(t.id) })).join("\n")}</div>
100
+ </div>` : ""}
101
+ ${done.length ? `<div class="tasks-section">
102
+ <h2>Done</h2>
103
+ <div class="task-list">${done.map((t) => renderTaskCard(t, { ...ctx, pendingDiffId: taskDiffs.get(t.id) })).join("\n")}</div>
104
+ </div>` : ""}
105
+ ${!tasks.length ? '<p class="empty-state">No tasks yet. Chat to create one.</p>' : ""}
106
+ </div>
107
+ </div>
108
+ <script>
109
+ const statusColors = ${JSON.stringify(statusColors)};
110
+ const statusLabels = ${JSON.stringify(statusLabels)};
111
+ const statusGroup = ${JSON.stringify(statusGroup)};
112
+
113
+ function escapeHtmlClient(str) {
114
+ const div = document.createElement('div');
115
+ div.textContent = str;
116
+ return div.innerHTML;
117
+ }
118
+
119
+ function renderTaskCardClient(task, taskDiffs, agentRunning, currentRunId, agentTaskId, pendingQueueTaskIds) {
120
+ const colorClass = statusColors[task.status] || '';
121
+ const label = statusLabels[task.status] || task.status;
122
+ const pendingDiffId = taskDiffs[task.id];
123
+ const pendingQueue = pendingQueueTaskIds && pendingQueueTaskIds.indexOf(task.id) >= 0;
124
+ const actions = [];
125
+
126
+ if (task.status === 'drafting' && pendingQueue && agentTaskId !== task.id) {
127
+ actions.push('<span class="btn btn-task-action btn-queued">Drafting...</span>');
128
+ }
129
+ if (task.status === 'ready') {
130
+ actions.push('<button class="btn btn-task-action btn-primary" onclick="event.preventDefault(); startWork(' + task.id + ')">Start Work</button>');
131
+ }
132
+ if (task.status === 'in_progress' && pendingDiffId) {
133
+ actions.push('<a href="/diff/' + pendingDiffId + '" class="btn btn-task-action btn-approve" onclick="event.stopPropagation()">Review Diff</a>');
134
+ }
135
+ if ((task.status === 'in_progress' || task.status === 'drafting' || task.status === 'refining' || task.status === 'approved' || task.status === 'fixing' || task.status === 'conflict') && agentTaskId === task.id && currentRunId) {
136
+ actions.push('<a href="/agent/' + currentRunId + '" class="btn btn-task-action btn-task-progress" onclick="event.stopPropagation()">View Progress</a>');
137
+ }
138
+
139
+ const actionsHtml = actions.length ? '<div class="task-card-actions">' + actions.join('') + '</div>' : '';
140
+
141
+ return '<a href="/tasks/' + task.id + '" class="task-card ' + colorClass + '">' +
142
+ '<div class="task-card-top">' +
143
+ '<span class="task-status-badge ' + colorClass + '">' + label + '</span>' +
144
+ '<span class="task-id">#' + task.id + '</span>' +
145
+ '</div>' +
146
+ '<div class="task-title">' + escapeHtmlClient(task.title) + '</div>' +
147
+ actionsHtml +
148
+ '</a>';
149
+ }
150
+
151
+ function renderTaskList(tasks, taskDiffs, agentRunning, currentRunId, agentTaskId, pendingQueueTaskIds) {
152
+ const active = tasks
153
+ .filter(t => t.status !== 'complete' && t.status !== 'rejected')
154
+ .sort((a, b) => {
155
+ const aGroup = a.status in statusGroup ? statusGroup[a.status] : 2;
156
+ const bGroup = b.status in statusGroup ? statusGroup[b.status] : 2;
157
+ if (aGroup !== bGroup) return aGroup - bGroup;
158
+ return aGroup === 0 ? a.id - b.id : b.id - a.id;
159
+ });
160
+ const done = tasks
161
+ .filter(t => t.status === 'complete' || t.status === 'rejected')
162
+ .sort((a, b) => b.id - a.id);
163
+
164
+ let html = '';
165
+ if (active.length) {
166
+ html += '<div class="tasks-section"><h2>Active</h2><div class="task-list">' +
167
+ active.map(t => renderTaskCardClient(t, taskDiffs, agentRunning, currentRunId, agentTaskId, pendingQueueTaskIds)).join('') +
168
+ '</div></div>';
169
+ }
170
+ if (done.length) {
171
+ html += '<div class="tasks-section"><h2>Done</h2><div class="task-list">' +
172
+ done.map(t => renderTaskCardClient(t, taskDiffs, agentRunning, currentRunId, agentTaskId, pendingQueueTaskIds)).join('') +
173
+ '</div></div>';
174
+ }
175
+ if (!tasks.length) {
176
+ html = '<p class="empty-state">No tasks yet. Chat to create one.</p>';
177
+ }
178
+ return html;
179
+ }
180
+
181
+ async function pollTasks() {
182
+ try {
183
+ const resp = await fetch('/api/tasks');
184
+ if (!resp.ok) return;
185
+ const data = await resp.json();
186
+ const container = document.getElementById('task-list-container');
187
+ if (container) {
188
+ container.innerHTML = renderTaskList(data.tasks, data.taskDiffs, data.agentRunning, data.currentRunId, data.agentTaskId, data.pendingQueueTaskIds || []);
189
+ }
190
+ } catch {}
191
+ }
192
+
193
+ setInterval(pollTasks, 3000);
194
+
195
+ async function startWork(id) {
196
+ const btn = event.target;
197
+ btn.disabled = true;
198
+ btn.textContent = 'Queuing...';
199
+ try {
200
+ const resp = await fetch('/tasks/' + id + '/status', {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ status: 'in_progress' }),
204
+ });
205
+ if (resp.ok) {
206
+ pollTasks();
207
+ } else {
208
+ btn.disabled = false;
209
+ btn.textContent = 'Start Work';
210
+ }
211
+ } catch {
212
+ btn.disabled = false;
213
+ btn.textContent = 'Start Work';
214
+ }
215
+ }
216
+
217
+ // --- Git sync ---
218
+ let gitPullFailed = false;
219
+ let gitCommitsExpanded = false;
220
+
221
+ function renderGitStatus(data) {
222
+ const bar = document.getElementById('git-status-bar');
223
+ if (!bar) return;
224
+
225
+ if (!data.remote) {
226
+ bar.innerHTML = '';
227
+ return;
228
+ }
229
+
230
+ if (data.ahead === 0 && data.behind === 0 && !gitPullFailed) {
231
+ bar.innerHTML = '';
232
+ gitCommitsExpanded = false;
233
+ return;
234
+ }
235
+
236
+ let html = '<div class="git-sync-bar">';
237
+
238
+ if (gitPullFailed) {
239
+ html += '<div class="git-sync-error" id="git-error">' + escapeHtmlClient(gitPullFailed) + '</div>';
240
+ html += '<div class="git-sync-actions">';
241
+ html += '<button class="btn btn-small git-btn" onclick="gitPullRebase()">Pull (rebase)</button>';
242
+ html += '<button class="btn btn-small git-btn" onclick="gitDismissError()">Dismiss</button>';
243
+ html += '</div>';
244
+ } else {
245
+ html += '<div class="git-sync-info">';
246
+ if (data.ahead > 0) {
247
+ html += '<span class="git-ahead git-ahead-toggle" onclick="toggleCommitList()">' + data.ahead + ' commit' + (data.ahead === 1 ? '' : 's') + ' to push</span>';
248
+ }
249
+ if (data.behind > 0) {
250
+ html += '<span class="git-behind">' + data.behind + ' behind</span>';
251
+ }
252
+ html += '</div>';
253
+ html += '<div class="git-sync-actions">';
254
+ if (data.behind > 0) {
255
+ html += '<button class="btn btn-small git-btn git-pull-btn" onclick="gitPull()">Pull</button>';
256
+ }
257
+ html += '</div>';
258
+ }
259
+
260
+ html += '</div>';
261
+
262
+ // Expandable commit list
263
+ if (data.ahead > 0 && data.outgoing && data.outgoing.length > 0) {
264
+ html += '<div class="git-commit-list" id="git-commit-list" style="display:' + (gitCommitsExpanded ? 'block' : 'none') + '">';
265
+ html += '<div class="git-commit-entries">';
266
+ for (var i = 0; i < data.outgoing.length; i++) {
267
+ var c = data.outgoing[i];
268
+ html += '<div class="git-commit-entry"><span class="git-commit-hash">' + escapeHtmlClient(c.hash) + '</span> <span class="git-commit-subject">' + escapeHtmlClient(c.subject) + '</span></div>';
269
+ }
270
+ html += '</div>';
271
+ html += '<div class="git-commit-list-actions"><button class="btn btn-small git-btn git-push-btn" onclick="gitPush()">Push</button></div>';
272
+ html += '</div>';
273
+ }
274
+
275
+ bar.innerHTML = html;
276
+ }
277
+
278
+ function toggleCommitList() {
279
+ gitCommitsExpanded = !gitCommitsExpanded;
280
+ var el = document.getElementById('git-commit-list');
281
+ if (el) {
282
+ el.style.display = gitCommitsExpanded ? 'block' : 'none';
283
+ }
284
+ }
285
+
286
+ async function pollGitStatus() {
287
+ try {
288
+ const resp = await fetch('/api/git/status');
289
+ if (!resp.ok) return;
290
+ const data = await resp.json();
291
+ renderGitStatus(data);
292
+ } catch {}
293
+ }
294
+
295
+ async function gitPush() {
296
+ const btns = document.querySelectorAll('.git-btn');
297
+ btns.forEach(b => { b.disabled = true; });
298
+ try {
299
+ const resp = await fetch('/api/git/push', { method: 'POST' });
300
+ const data = await resp.json();
301
+ if (!data.success) {
302
+ alert('Push failed: ' + (data.error || 'Unknown error'));
303
+ }
304
+ pollGitStatus();
305
+ } catch {
306
+ alert('Push failed');
307
+ }
308
+ btns.forEach(b => { b.disabled = false; });
309
+ }
310
+
311
+ async function gitPull() {
312
+ const btns = document.querySelectorAll('.git-btn');
313
+ btns.forEach(b => { b.disabled = true; });
314
+ try {
315
+ const resp = await fetch('/api/git/pull', {
316
+ method: 'POST',
317
+ headers: { 'Content-Type': 'application/json' },
318
+ body: JSON.stringify({}),
319
+ });
320
+ const data = await resp.json();
321
+ if (!data.success) {
322
+ gitPullFailed = data.error || 'Pull failed';
323
+ pollGitStatus();
324
+ } else {
325
+ gitPullFailed = false;
326
+ pollGitStatus();
327
+ }
328
+ } catch {
329
+ alert('Pull failed');
330
+ }
331
+ btns.forEach(b => { b.disabled = false; });
332
+ }
333
+
334
+ async function gitPullRebase() {
335
+ const btns = document.querySelectorAll('.git-btn');
336
+ btns.forEach(b => { b.disabled = true; });
337
+ try {
338
+ const resp = await fetch('/api/git/pull', {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json' },
341
+ body: JSON.stringify({ rebase: true }),
342
+ });
343
+ const data = await resp.json();
344
+ if (!data.success) {
345
+ gitPullFailed = data.error || 'Pull (rebase) failed';
346
+ } else {
347
+ gitPullFailed = false;
348
+ }
349
+ pollGitStatus();
350
+ } catch {
351
+ alert('Pull (rebase) failed');
352
+ }
353
+ btns.forEach(b => { b.disabled = false; });
354
+ }
355
+
356
+ function gitDismissError() {
357
+ gitPullFailed = false;
358
+ pollGitStatus();
359
+ }
360
+
361
+ pollGitStatus();
362
+ setInterval(pollGitStatus, 10000);
363
+ </script>`;
364
+ return layout("Tasks", "tasks", body);
365
+ }
366
+ function renderMessage(msg) {
367
+ const isUser = msg.role === "user";
368
+ const rendered = marked.parse(msg.content, { async: false });
369
+ return `<div class="message ${isUser ? "message-user" : "message-assistant"}">
370
+ <div class="message-role">${isUser ? "You" : "Claude"}</div>
371
+ <div class="message-content">${rendered}</div>
372
+ </div>`;
373
+ }
374
+ export function taskDetailPage(task, spec, agentRunning = false, currentRunId = null, chatMessages = [], chatPending = false, report = null, agentTaskId = null, pendingDiffId, hasPendingQueueItem = false, conflictError) {
375
+ const rendered = spec
376
+ ? marked.parse(spec, { async: false })
377
+ : '<p class="empty-state">No task spec written yet.</p>';
378
+ const colorClass = statusColors[task.status] || "";
379
+ const isDrafting = task.status === "drafting" || task.status === "refining";
380
+ const isConflict = task.status === "conflict";
381
+ const agentWorkingOnThis = agentTaskId === task.id && agentRunning;
382
+ const isComplete = task.status === "complete";
383
+ const draftPending = hasPendingQueueItem && !agentWorkingOnThis;
384
+ const actions = [];
385
+ // Drafting (no agent, no pending queue item): Mark Ready (only if spec has real content)
386
+ if (isDrafting && !agentWorkingOnThis && !draftPending && hasRealSpec(spec)) {
387
+ actions.push(`<button class="btn btn-approve" onclick="updateTask(${task.id}, 'ready')">Mark Ready</button>`);
388
+ }
389
+ // Show "Drafting..." indicator when queue item is pending
390
+ if (isDrafting && draftPending) {
391
+ actions.push(`<span class="btn btn-queued">Drafting...</span>`);
392
+ }
393
+ // Ready: Start Work
394
+ if (task.status === "ready") {
395
+ actions.push(`<button class="btn btn-primary" id="start-work-btn">Start Work</button>`);
396
+ }
397
+ // In Progress with pending diff: Review Diff
398
+ if (task.status === "in_progress" && pendingDiffId) {
399
+ actions.push(`<a href="/diff/${pendingDiffId}" class="btn btn-task-action btn-approve">Review Diff</a>`);
400
+ }
401
+ // In Progress with no diff and no agent: Done — Review Diff (after manual conflict resolution)
402
+ if (task.status === "in_progress" && !pendingDiffId && !agentWorkingOnThis) {
403
+ actions.push(`<button class="btn btn-approve" onclick="conflictDone()">Done — Review Diff</button>`);
404
+ }
405
+ // Agent working on this task: View Progress
406
+ if (agentWorkingOnThis && currentRunId) {
407
+ actions.push(`<a href="/agent/${currentRunId}" class="btn btn-task-action btn-task-progress">View Progress</a>`);
408
+ }
409
+ const bannerHtml = agentWorkingOnThis && currentRunId
410
+ ? `<a href="/agent/${currentRunId}" class="agent-banner">Agent working... tap to view progress</a>`
411
+ : "";
412
+ // Completion report for completed tasks
413
+ const reportHtml = isComplete && report
414
+ ? `<div class="task-report">
415
+ <h2>Completion Report</h2>
416
+ <div class="report-content">${marked.parse(report, { async: false })}</div>
417
+ </div>`
418
+ : "";
419
+ // Chat UI for drafting tasks — history always visible, input hidden while agent is running
420
+ const chatHistoryHtml = isDrafting ? `
421
+ <div class="task-chat">
422
+ <div class="messages" id="task-messages">
423
+ ${chatMessages.map(renderMessage).join("\n")}
424
+ ${chatPending ? `<div class="message message-assistant" id="thinking-indicator">
425
+ <div class="message-role">Claude</div>
426
+ <div class="message-content"><em>Thinking...</em></div>
427
+ </div>` : ""}
428
+ </div>
429
+ ${!agentWorkingOnThis && !draftPending ? `<div class="chat-input-area">
430
+ <form class="chat-input" id="task-chat-form">
431
+ <textarea
432
+ name="message"
433
+ id="task-message-input"
434
+ placeholder="Describe changes to the spec..."
435
+ rows="2"
436
+ autofocus
437
+ ></textarea>
438
+ <div class="task-chat-buttons">
439
+ <button type="button" id="task-refine-btn" class="btn btn-primary">Refine</button>
440
+ </div>
441
+ </form>
442
+ </div>` : `<div class="chat-busy">
443
+ <p>Claude is working on this task...${currentRunId ? ` <a href="/agent/${currentRunId}">View progress</a>` : ""}</p>
444
+ </div>`}
445
+ </div>` : "";
446
+ const chatHtml = chatHistoryHtml;
447
+ const body = `
448
+ <div class="task-detail">
449
+ <div id="task-banner">${bannerHtml}</div>
450
+ <div class="task-detail-header">
451
+ <a href="/tasks" class="back-link">Tasks</a>
452
+ <h1>${escapeHtml(task.title)}</h1>
453
+ <div class="task-meta">
454
+ <span class="task-status-badge ${colorClass}" id="task-status-badge">${statusLabels[task.status] || task.status}</span>
455
+ <span class="task-id">#${task.id}</span>
456
+ </div>
457
+ </div>
458
+ <div id="task-primary-actions">${actions.length ? `<div class="task-actions">${actions.join("\n")}</div>` : ""}</div>
459
+ ${task.status !== "complete" && task.status !== "rejected" ? `<div class="task-actions" style="margin-top: 8px;">
460
+ <button class="btn btn-reject" style="opacity: 0.7; font-size: 0.85em;" id="cancel-task-btn" onclick="cancelTask(this, ${task.id})">Cancel Task</button>
461
+ </div>` : ""}
462
+ ${isConflict ? `<div class="conflict-section">
463
+ <div class="conflict-error">
464
+ <strong>Merge Conflict</strong>
465
+ <p>${escapeHtml(conflictError || "Stash apply failed due to merge conflicts.")}</p>
466
+ </div>
467
+ <div class="conflict-actions">
468
+ <button class="btn btn-primary" onclick="resolveConflict('restart')">Restart Task</button>
469
+ <button class="btn btn-approve" onclick="resolveConflict('agent-resolve')">Agent Resolve</button>
470
+ <button class="btn btn-task-action" onclick="resolveConflict('manual')">Manual Resolve</button>
471
+ </div>
472
+ </div>` : ""}
473
+ <details class="spec-collapsible" ${isDrafting ? "" : "open"}>
474
+ <summary>Task Spec</summary>
475
+ <div class="spec-content">
476
+ ${rendered}
477
+ </div>
478
+ </details>
479
+ ${reportHtml}
480
+ ${chatHtml}
481
+ </div>
482
+ <script>
483
+ const statusColors = ${JSON.stringify(statusColors)};
484
+ const statusLabels = ${JSON.stringify(statusLabels)};
485
+ const agentBusy = ${agentRunning};
486
+ const taskId = ${task.id};
487
+ const isDrafting = ${isDrafting};
488
+ let currentStatus = '${task.status}';
489
+ let lastPendingQueueItem = ${draftPending};
490
+
491
+ async function updateTask(id, status) {
492
+ const btns = document.querySelectorAll('.task-actions .btn');
493
+ btns.forEach(b => { b.disabled = true; });
494
+
495
+ try {
496
+ const resp = await fetch('/tasks/' + id + '/status', {
497
+ method: 'POST',
498
+ headers: { 'Content-Type': 'application/json' },
499
+ body: JSON.stringify({ status }),
500
+ });
501
+ if (!resp.ok) {
502
+ const data = await resp.json();
503
+ alert(data.error || 'Failed to update task');
504
+ btns.forEach(b => { b.disabled = false; });
505
+ return;
506
+ }
507
+ location.reload();
508
+ } catch {
509
+ btns.forEach(b => { b.disabled = false; });
510
+ }
511
+ }
512
+
513
+ async function resolveConflict(action) {
514
+ const btns = document.querySelectorAll('.conflict-actions .btn');
515
+ btns.forEach(b => { b.disabled = true; });
516
+
517
+ try {
518
+ const resp = await fetch('/api/tasks/' + taskId + '/conflict/' + action, { method: 'POST' });
519
+ if (!resp.ok) {
520
+ const data = await resp.json();
521
+ alert(data.error || 'Failed to resolve conflict');
522
+ btns.forEach(b => { b.disabled = false; });
523
+ return;
524
+ }
525
+ const data = await resp.json();
526
+ if (action === 'manual' && data.conflicts && data.conflicts.length > 0) {
527
+ alert('Conflict markers written to disk in: ' + data.conflicts.join(', ') + '\\n\\nResolve the conflicts locally, then come back and click "Done — Review Diff".');
528
+ }
529
+ location.reload();
530
+ } catch {
531
+ btns.forEach(b => { b.disabled = false; });
532
+ }
533
+ }
534
+
535
+ async function conflictDone() {
536
+ try {
537
+ const resp = await fetch('/api/tasks/' + taskId + '/conflict/done', { method: 'POST' });
538
+ if (!resp.ok) {
539
+ const data = await resp.json();
540
+ alert(data.error || 'No changes to review');
541
+ return;
542
+ }
543
+ const data = await resp.json();
544
+ if (data.diffId) {
545
+ location.href = '/diff/' + data.diffId;
546
+ } else {
547
+ location.reload();
548
+ }
549
+ } catch {
550
+ alert('Failed to create diff');
551
+ }
552
+ }
553
+
554
+ async function cancelTask(btn, id) {
555
+ if (!confirm('Cancel this task?')) return;
556
+ const origText = btn.textContent;
557
+ btn.disabled = true;
558
+ btn.textContent = 'Cancelling...';
559
+ try {
560
+ const resp = await fetch('/tasks/' + id + '/status', {
561
+ method: 'POST',
562
+ headers: { 'Content-Type': 'application/json' },
563
+ body: JSON.stringify({ status: 'rejected' }),
564
+ });
565
+ if (resp.ok) {
566
+ location.href = '/tasks';
567
+ } else {
568
+ btn.disabled = false;
569
+ btn.textContent = origText;
570
+ }
571
+ } catch {
572
+ btn.disabled = false;
573
+ btn.textContent = origText;
574
+ }
575
+ }
576
+
577
+ // Poll for task status changes
578
+ async function pollTaskStatus() {
579
+ try {
580
+ const resp = await fetch('/api/tasks/' + taskId + '/status');
581
+ if (!resp.ok) return;
582
+ const data = await resp.json();
583
+
584
+ // Update status badge
585
+ const badge = document.getElementById('task-status-badge');
586
+ if (badge) {
587
+ const newColorClass = statusColors[data.status] || '';
588
+ const newLabel = statusLabels[data.status] || data.status;
589
+ badge.className = 'task-status-badge ' + newColorClass;
590
+ badge.textContent = newLabel;
591
+ }
592
+
593
+ const agentWorkingOnThis = data.agentTaskId === taskId && data.agentRunning;
594
+
595
+ // Update banner
596
+ const bannerEl = document.getElementById('task-banner');
597
+ if (bannerEl) {
598
+ if (agentWorkingOnThis && data.currentRunId) {
599
+ bannerEl.innerHTML = '<a href="/agent/' + data.currentRunId + '" class="agent-banner">Agent working... tap to view progress</a>';
600
+ } else {
601
+ bannerEl.innerHTML = '';
602
+ }
603
+ }
604
+
605
+ // Update action buttons
606
+ const actionsEl = document.getElementById('task-primary-actions');
607
+ if (actionsEl) {
608
+ const actions = [];
609
+ const taskIsDrafting = data.status === 'drafting' || data.status === 'refining';
610
+ const draftPending = data.hasPendingQueueItem && !agentWorkingOnThis;
611
+ if (taskIsDrafting && !agentWorkingOnThis && !draftPending && data.hasSpec) {
612
+ actions.push('<button class="btn btn-approve" onclick="updateTask(' + taskId + ', \\'ready\\')">Mark Ready</button>');
613
+ }
614
+ if (taskIsDrafting && draftPending) {
615
+ actions.push('<span class="btn btn-queued">Drafting...</span>');
616
+ }
617
+ if (data.status === 'ready') {
618
+ actions.push('<button class="btn btn-primary" id="start-work-btn" onclick="updateTask(' + taskId + ', \\'in_progress\\')">Start Work</button>');
619
+ }
620
+ if (data.status === 'in_progress' && data.pendingDiffId) {
621
+ actions.push('<a href="/diff/' + data.pendingDiffId + '" class="btn btn-task-action btn-approve">Review Diff</a>');
622
+ }
623
+ if (agentWorkingOnThis && data.currentRunId) {
624
+ actions.push('<a href="/agent/' + data.currentRunId + '" class="btn btn-task-action btn-task-progress">View Progress</a>');
625
+ }
626
+ actionsEl.innerHTML = actions.length ? '<div class="task-actions">' + actions.join('') + '</div>' : '';
627
+ }
628
+
629
+ // Update spec content in-place (preserves <details> open state)
630
+ if (data.specHtml) {
631
+ const specContent = document.querySelector('.spec-content');
632
+ if (specContent) specContent.innerHTML = data.specHtml;
633
+ }
634
+
635
+ // Check for state changes
636
+ const pendingQueueChanged = !!data.hasPendingQueueItem !== lastPendingQueueItem;
637
+ if (pendingQueueChanged) lastPendingQueueItem = !!data.hasPendingQueueItem;
638
+
639
+ if (data.status !== currentStatus || pendingQueueChanged) {
640
+ const staysDraftingOrRefining =
641
+ (data.status === 'drafting' || data.status === 'refining') &&
642
+ (currentStatus === 'drafting' || currentStatus === 'refining');
643
+
644
+ if (staysDraftingOrRefining) {
645
+ // Handle refining <-> drafting transitions in-place
646
+ const wasRefining = currentStatus === 'refining';
647
+ const nowRefining = data.status === 'refining';
648
+ currentStatus = data.status;
649
+
650
+ if (wasRefining && !nowRefining) {
651
+ // refining → drafting: show chat input, hide busy message, refresh chat messages
652
+ const busyEl = document.querySelector('.chat-busy');
653
+ if (busyEl) {
654
+ const inputArea = document.createElement('div');
655
+ inputArea.className = 'chat-input-area';
656
+ inputArea.innerHTML = '<form class="chat-input" id="task-chat-form">' +
657
+ '<textarea name="message" id="task-message-input" placeholder="Describe changes to the spec..." rows="2" autofocus></textarea>' +
658
+ '<div class="task-chat-buttons"><button type="button" id="task-refine-btn" class="btn btn-primary">Refine</button></div>' +
659
+ '</form>';
660
+ busyEl.replaceWith(inputArea);
661
+ // Re-bind chat input handlers
662
+ rebindChatInput();
663
+ }
664
+ // Fetch any new chat messages added during refining
665
+ refreshChatMessages();
666
+ } else if (!wasRefining && nowRefining) {
667
+ // drafting → refining: hide chat input, show busy message
668
+ const inputArea = document.querySelector('.chat-input-area');
669
+ if (inputArea) {
670
+ const busyEl = document.createElement('div');
671
+ busyEl.className = 'chat-busy';
672
+ busyEl.innerHTML = '<p>Claude is working on this task...' +
673
+ (data.currentRunId ? ' <a href="/agent/' + data.currentRunId + '">View progress</a>' : '') + '</p>';
674
+ inputArea.replaceWith(busyEl);
675
+ }
676
+ }
677
+ // pendingQueueChanged within drafting/refining — no reload needed
678
+ } else {
679
+ // For non-drafting/refining transitions, reload as before
680
+ const needsReload = (data.status === 'complete') ||
681
+ (data.status === 'conflict') ||
682
+ (currentStatus === 'conflict') ||
683
+ (data.status === 'drafting' && currentStatus !== 'drafting' && currentStatus !== 'refining') ||
684
+ (currentStatus === 'drafting' && data.status !== 'drafting' && data.status !== 'refining') ||
685
+ pendingQueueChanged;
686
+ currentStatus = data.status;
687
+ if (needsReload) {
688
+ location.reload();
689
+ }
690
+ }
691
+ }
692
+ } catch {}
693
+ }
694
+
695
+ setInterval(pollTaskStatus, 3000);
696
+
697
+ // Start Work button
698
+ const startBtn = document.getElementById('start-work-btn');
699
+ if (startBtn) {
700
+ startBtn.addEventListener('click', () => {
701
+ updateTask(${task.id}, 'in_progress');
702
+ });
703
+ }
704
+
705
+ // Task chat logic (drafting/refining — history always visible, input only when not refining)
706
+ var messagesEl = document.getElementById('task-messages');
707
+ var chatMessageCount = ${chatMessages.length};
708
+ var chatPolling = ${chatPending ? "true" : "false"};
709
+
710
+ function scrollToBottom() {
711
+ if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
712
+ }
713
+
714
+ function escapeHtml(str) {
715
+ const div = document.createElement('div');
716
+ div.textContent = str;
717
+ return div.innerHTML;
718
+ }
719
+
720
+ function showThinking() {
721
+ if (!messagesEl) return;
722
+ let indicator = document.getElementById('thinking-indicator');
723
+ if (!indicator) {
724
+ indicator = document.createElement('div');
725
+ indicator.id = 'thinking-indicator';
726
+ indicator.className = 'message message-assistant';
727
+ indicator.innerHTML = '<div class="message-role">Claude</div><div class="message-content"><em>Thinking...</em></div>';
728
+ messagesEl.appendChild(indicator);
729
+ }
730
+ scrollToBottom();
731
+ }
732
+
733
+ function removeThinking() {
734
+ const indicator = document.getElementById('thinking-indicator');
735
+ if (indicator) indicator.remove();
736
+ }
737
+
738
+ async function pollForResponse() {
739
+ if (chatPolling) return;
740
+ chatPolling = true;
741
+ showThinking();
742
+
743
+ const poll = async () => {
744
+ try {
745
+ const resp = await fetch('/api/tasks/' + taskId + '/chat?after=' + chatMessageCount);
746
+ const data = await resp.json();
747
+
748
+ if (data.messages.length > 0) {
749
+ removeThinking();
750
+ for (const msg of data.messages) {
751
+ const div = document.createElement('div');
752
+ div.className = 'message ' + (msg.role === 'user' ? 'message-user' : 'message-assistant');
753
+ div.innerHTML = '<div class="message-role">' + (msg.role === 'user' ? 'You' : 'Claude') + '</div><div class="message-content">' + (msg.html || msg.content) + '</div>';
754
+ messagesEl.appendChild(div);
755
+ }
756
+ chatMessageCount = data.total;
757
+ scrollToBottom();
758
+ }
759
+
760
+ if (data.pending) {
761
+ showThinking();
762
+ setTimeout(poll, 2000);
763
+ } else {
764
+ removeThinking();
765
+ chatPolling = false;
766
+ const btn = document.getElementById('task-refine-btn');
767
+ if (btn) {
768
+ btn.disabled = false;
769
+ btn.textContent = 'Refine';
770
+ }
771
+ }
772
+ } catch {
773
+ chatPolling = false;
774
+ removeThinking();
775
+ const btn = document.getElementById('task-refine-btn');
776
+ if (btn) {
777
+ btn.disabled = false;
778
+ btn.textContent = 'Refine';
779
+ }
780
+ }
781
+ };
782
+
783
+ setTimeout(poll, 2000);
784
+ }
785
+
786
+ async function refreshChatMessages() {
787
+ try {
788
+ const resp = await fetch('/api/tasks/' + taskId + '/chat?after=' + chatMessageCount);
789
+ const data = await resp.json();
790
+ if (data.messages.length > 0 && messagesEl) {
791
+ removeThinking();
792
+ for (const msg of data.messages) {
793
+ const div = document.createElement('div');
794
+ div.className = 'message ' + (msg.role === 'user' ? 'message-user' : 'message-assistant');
795
+ div.innerHTML = '<div class="message-role">' + (msg.role === 'user' ? 'You' : 'Claude') + '</div><div class="message-content">' + (msg.html || msg.content) + '</div>';
796
+ messagesEl.appendChild(div);
797
+ }
798
+ chatMessageCount = data.total;
799
+ scrollToBottom();
800
+ }
801
+ } catch {}
802
+ }
803
+
804
+ function rebindChatInput() {
805
+ const input = document.getElementById('task-message-input');
806
+ const refineBtn = document.getElementById('task-refine-btn');
807
+
808
+ if (input) {
809
+ input.addEventListener('input', () => {
810
+ input.style.height = 'auto';
811
+ input.style.height = Math.min(input.scrollHeight, 120) + 'px';
812
+ });
813
+
814
+ input.addEventListener('keydown', (e) => {
815
+ if (e.key === 'Enter' && !e.shiftKey) {
816
+ e.preventDefault();
817
+ if (refineBtn) refineBtn.click();
818
+ }
819
+ });
820
+ }
821
+
822
+ if (refineBtn) {
823
+ refineBtn.addEventListener('click', async () => {
824
+ const inp = document.getElementById('task-message-input');
825
+ const message = inp ? inp.value.trim() : '';
826
+ if (!message || chatPolling) return;
827
+
828
+ // Add user message to DOM
829
+ const userDiv = document.createElement('div');
830
+ userDiv.className = 'message message-user';
831
+ userDiv.innerHTML = '<div class="message-role">You</div><div class="message-content">' + escapeHtml(message) + '</div>';
832
+ if (messagesEl) messagesEl.appendChild(userDiv);
833
+
834
+ inp.value = '';
835
+ inp.style.height = 'auto';
836
+ refineBtn.disabled = true;
837
+ refineBtn.textContent = 'Starting...';
838
+ scrollToBottom();
839
+
840
+ try {
841
+ // Save message to task conversation first
842
+ await fetch('/api/tasks/' + taskId + '/chat', {
843
+ method: 'POST',
844
+ headers: { 'Content-Type': 'application/json' },
845
+ body: JSON.stringify({ message }),
846
+ });
847
+
848
+ // Then enqueue refine via the agent route
849
+ const resp = await fetch('/agent/refine-task/' + taskId, {
850
+ method: 'POST',
851
+ headers: { 'Content-Type': 'application/json' },
852
+ body: JSON.stringify({ message }),
853
+ });
854
+ if (resp.ok) {
855
+ // Transition will be handled by polling — just update state
856
+ currentStatus = 'refining';
857
+ lastPendingQueueItem = true;
858
+ // Hide input, show busy
859
+ const inputArea = document.querySelector('.chat-input-area');
860
+ if (inputArea) {
861
+ const busyEl = document.createElement('div');
862
+ busyEl.className = 'chat-busy';
863
+ busyEl.innerHTML = '<p>Claude is working on this task...</p>';
864
+ inputArea.replaceWith(busyEl);
865
+ }
866
+ } else {
867
+ refineBtn.disabled = false;
868
+ refineBtn.textContent = 'Refine';
869
+ }
870
+ } catch {
871
+ refineBtn.disabled = false;
872
+ refineBtn.textContent = 'Refine';
873
+ }
874
+ });
875
+ }
876
+ }
877
+
878
+ if (isDrafting) {
879
+ scrollToBottom();
880
+
881
+ // If already pending on page load, start polling
882
+ if (chatPolling) {
883
+ chatPolling = false;
884
+ pollForResponse();
885
+ }
886
+
887
+ rebindChatInput();
888
+ }
889
+ </script>`;
890
+ return layout("Task #" + task.id, "tasks", body);
891
+ }
892
+ function escapeHtml(str) {
893
+ return str
894
+ .replace(/&/g, "&amp;")
895
+ .replace(/</g, "&lt;")
896
+ .replace(/>/g, "&gt;")
897
+ .replace(/"/g, "&quot;");
898
+ }