@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.
- package/LICENSE +8 -0
- package/dist/agent.js +214 -0
- package/dist/cli.js +172 -0
- package/dist/git.js +291 -0
- package/dist/index.js +1250 -0
- package/dist/profile.js +79 -0
- package/dist/projects.js +337 -0
- package/dist/queue.js +868 -0
- package/dist/services.js +194 -0
- package/dist/store.js +612 -0
- package/dist/views/agent-progress.js +242 -0
- package/dist/views/branches.js +191 -0
- package/dist/views/chat.js +386 -0
- package/dist/views/diff.js +321 -0
- package/dist/views/history.js +124 -0
- package/dist/views/layout.js +121 -0
- package/dist/views/run.js +92 -0
- package/dist/views/services.js +230 -0
- package/dist/views/spec.js +18 -0
- package/dist/views/tasks.js +898 -0
- package/dist/views/verify.js +209 -0
- package/package.json +36 -0
- package/public/style.css +2088 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { html as diff2htmlHtml } from "diff2html";
|
|
2
|
+
import { ColorSchemeType } from "diff2html/lib/types.js";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { layout } from "./layout.js";
|
|
5
|
+
export function diffPage(diffId, diffContent, task, taskId, chatMessages = [], chatPending = false, acceptanceCriteria, agentSummary, checks) {
|
|
6
|
+
const diffHtml = diff2htmlHtml(diffContent, {
|
|
7
|
+
drawFileList: true,
|
|
8
|
+
matching: "lines",
|
|
9
|
+
outputFormat: "line-by-line",
|
|
10
|
+
colorScheme: ColorSchemeType.DARK,
|
|
11
|
+
});
|
|
12
|
+
const verifyBtn = taskId
|
|
13
|
+
? `<a href="/task/${taskId}/verify?diffId=${diffId}" class="btn btn-verify">Verify</a>`
|
|
14
|
+
: "";
|
|
15
|
+
// Chat section — only for task diffs
|
|
16
|
+
const chatHtml = taskId
|
|
17
|
+
? `
|
|
18
|
+
<div class="diff-chat-section">
|
|
19
|
+
<div class="task-chat">
|
|
20
|
+
<div class="messages" id="diff-messages">
|
|
21
|
+
${chatMessages.map(renderMessage).join("\n")}
|
|
22
|
+
${chatPending ? `<div class="message message-assistant" id="thinking-indicator">
|
|
23
|
+
<div class="message-role">Claude</div>
|
|
24
|
+
<div class="message-content"><em>Thinking...</em></div>
|
|
25
|
+
</div>` : ""}
|
|
26
|
+
</div>
|
|
27
|
+
<div class="chat-input-area">
|
|
28
|
+
<form class="chat-input" id="diff-chat-form">
|
|
29
|
+
<textarea
|
|
30
|
+
name="message"
|
|
31
|
+
id="diff-message-input"
|
|
32
|
+
placeholder="Describe what needs fixing..."
|
|
33
|
+
rows="2"
|
|
34
|
+
></textarea>
|
|
35
|
+
<div class="task-chat-buttons">
|
|
36
|
+
<button type="button" id="diff-fix-btn" class="btn btn-primary">Fix</button>
|
|
37
|
+
</div>
|
|
38
|
+
</form>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>`
|
|
42
|
+
: "";
|
|
43
|
+
// Acceptance criteria section
|
|
44
|
+
const criteriaHtml = acceptanceCriteria
|
|
45
|
+
? `<details class="diff-criteria">
|
|
46
|
+
<summary>Acceptance Criteria</summary>
|
|
47
|
+
<div class="diff-criteria-content spec-content">${marked.parse(acceptanceCriteria, { async: false })}</div>
|
|
48
|
+
</details>`
|
|
49
|
+
: "";
|
|
50
|
+
// Change summary section
|
|
51
|
+
const summaryHtml = agentSummary
|
|
52
|
+
? `<div class="diff-summary">
|
|
53
|
+
<div class="diff-summary-text"><strong>${escapeHtml(agentSummary.summary)}</strong></div>
|
|
54
|
+
${agentSummary.filesChanged.length > 0
|
|
55
|
+
? `<div class="diff-summary-files">${agentSummary.filesChanged.map((f) => `<span class="diff-summary-file">${escapeHtml(f)}</span>`).join("")}</div>`
|
|
56
|
+
: ""}
|
|
57
|
+
${agentSummary.actions.length > 0
|
|
58
|
+
? `<ul class="diff-summary-actions">${agentSummary.actions.map((a) => `<li>${escapeHtml(a)}</li>`).join("")}</ul>`
|
|
59
|
+
: ""}
|
|
60
|
+
</div>`
|
|
61
|
+
: "";
|
|
62
|
+
// Checks section
|
|
63
|
+
let checksHtml = "";
|
|
64
|
+
if (checks && (checks.testsRun || (checks.todoItems && checks.todoItems.length > 0))) {
|
|
65
|
+
const testStatusHtml = checks.testsRun
|
|
66
|
+
? `<div class="diff-check-item">
|
|
67
|
+
<span class="diff-check-indicator ${checks.testsPassed ? "check-pass" : "check-fail"}">${checks.testsPassed ? "PASS" : "FAIL"}</span>
|
|
68
|
+
<span>Tests</span>
|
|
69
|
+
</div>`
|
|
70
|
+
: "";
|
|
71
|
+
const todoHtml = checks.todoItems && checks.todoItems.length > 0
|
|
72
|
+
? `<div class="diff-check-todos">${checks.todoItems.map((t) => `<div class="diff-check-todo ${t.status === "completed" ? "todo-done" : ""}">
|
|
73
|
+
<span class="todo-check">${t.status === "completed" ? "✓" : "◯"}</span>
|
|
74
|
+
<span>${escapeHtml(t.content)}</span>
|
|
75
|
+
</div>`).join("")}</div>`
|
|
76
|
+
: "";
|
|
77
|
+
checksHtml = `<details class="diff-checks" open>
|
|
78
|
+
<summary>Checks</summary>
|
|
79
|
+
<div class="diff-checks-content">
|
|
80
|
+
${testStatusHtml}
|
|
81
|
+
${todoHtml}
|
|
82
|
+
</div>
|
|
83
|
+
</details>`;
|
|
84
|
+
}
|
|
85
|
+
const body = `
|
|
86
|
+
<div class="diff-container">
|
|
87
|
+
<div class="diff-header">
|
|
88
|
+
<h1>Review Changes</h1>
|
|
89
|
+
<p class="diff-task">${escapeHtml(task)}</p>
|
|
90
|
+
</div>
|
|
91
|
+
${summaryHtml}
|
|
92
|
+
<div class="diff-actions diff-actions-top">
|
|
93
|
+
${verifyBtn}
|
|
94
|
+
<button class="btn btn-approve" onclick="reviewDiff('${diffId}', 'approve')">Approve</button>
|
|
95
|
+
<button class="btn btn-reject" onclick="reviewDiff('${diffId}', 'reject')">Reject</button>
|
|
96
|
+
</div>
|
|
97
|
+
${criteriaHtml}
|
|
98
|
+
${checksHtml}
|
|
99
|
+
${chatHtml}
|
|
100
|
+
<div class="diff-content">
|
|
101
|
+
${diffHtml}
|
|
102
|
+
</div>
|
|
103
|
+
<div class="diff-actions diff-actions-bottom">
|
|
104
|
+
${verifyBtn}
|
|
105
|
+
<button class="btn btn-approve" onclick="reviewDiff('${diffId}', 'approve')">Approve</button>
|
|
106
|
+
<button class="btn btn-reject" onclick="reviewDiff('${diffId}', 'reject')">Reject</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<script>
|
|
110
|
+
async function reviewDiff(id, action) {
|
|
111
|
+
const btns = document.querySelectorAll('.diff-actions .btn');
|
|
112
|
+
btns.forEach(b => { b.disabled = true; });
|
|
113
|
+
|
|
114
|
+
// Hide action bars and chat section
|
|
115
|
+
const actionBars = document.querySelectorAll('.diff-actions');
|
|
116
|
+
const chatSection = document.querySelector('.diff-chat' + '-section');
|
|
117
|
+
actionBars.forEach(el => { el.style.display = 'none'; });
|
|
118
|
+
if (chatSection) chatSection.style.display = 'none';
|
|
119
|
+
|
|
120
|
+
// Show status banner
|
|
121
|
+
const statusBanner = document.createElement('div');
|
|
122
|
+
statusBanner.className = 'agent-done';
|
|
123
|
+
statusBanner.id = 'review-status-banner';
|
|
124
|
+
statusBanner.style.cssText = 'text-align:center;padding:16px;margin:16px';
|
|
125
|
+
statusBanner.textContent = action === 'approve' ? 'Approving...' : 'Rejecting...';
|
|
126
|
+
document.querySelector('.diff-container').prepend(statusBanner);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch('/diff/' + id + '/' + action, { method: 'POST' });
|
|
130
|
+
const data = await res.json();
|
|
131
|
+
if (data.ok) {
|
|
132
|
+
if (data.specUpdate) {
|
|
133
|
+
statusBanner.textContent = 'Approved \\u2014 updating spec...';
|
|
134
|
+
setTimeout(function() { window.location.href = '/tasks'; }, 1500);
|
|
135
|
+
} else {
|
|
136
|
+
window.location.href = '/chat';
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Restore everything on error
|
|
140
|
+
statusBanner.remove();
|
|
141
|
+
actionBars.forEach(el => { el.style.display = ''; });
|
|
142
|
+
if (chatSection) chatSection.style.display = '';
|
|
143
|
+
btns.forEach(b => { b.disabled = false; });
|
|
144
|
+
alert('Error: ' + (data.error || 'unknown'));
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// Restore everything on network error
|
|
148
|
+
statusBanner.remove();
|
|
149
|
+
actionBars.forEach(el => { el.style.display = ''; });
|
|
150
|
+
if (chatSection) chatSection.style.display = '';
|
|
151
|
+
btns.forEach(b => { b.disabled = false; });
|
|
152
|
+
alert('Error: network failure');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
${taskId ? diffChatScript(taskId, diffId, chatMessages.length, chatPending) : ""}
|
|
157
|
+
</script>`;
|
|
158
|
+
return layout("Diff", "chat", body, `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css">`);
|
|
159
|
+
}
|
|
160
|
+
function diffChatScript(taskId, diffId, messageCount, chatPending) {
|
|
161
|
+
return `
|
|
162
|
+
(function() {
|
|
163
|
+
const taskId = ${taskId};
|
|
164
|
+
const diffId = '${diffId}';
|
|
165
|
+
const form = document.getElementById('diff-chat-form');
|
|
166
|
+
const input = document.getElementById('diff-message-input');
|
|
167
|
+
const messagesEl = document.getElementById('diff-messages');
|
|
168
|
+
const fixBtn = document.getElementById('diff-fix-btn');
|
|
169
|
+
let messageCount = ${messageCount};
|
|
170
|
+
let polling = ${chatPending ? "true" : "false"};
|
|
171
|
+
|
|
172
|
+
function scrollToBottom() {
|
|
173
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
174
|
+
}
|
|
175
|
+
scrollToBottom();
|
|
176
|
+
|
|
177
|
+
function escapeHtml(str) {
|
|
178
|
+
const div = document.createElement('div');
|
|
179
|
+
div.textContent = str;
|
|
180
|
+
return div.innerHTML;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
input.addEventListener('input', () => {
|
|
184
|
+
input.style.height = 'auto';
|
|
185
|
+
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
input.addEventListener('keydown', (e) => {
|
|
189
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
fixBtn.click();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
function showThinking() {
|
|
196
|
+
let indicator = document.getElementById('thinking-indicator');
|
|
197
|
+
if (!indicator) {
|
|
198
|
+
indicator = document.createElement('div');
|
|
199
|
+
indicator.id = 'thinking-indicator';
|
|
200
|
+
indicator.className = 'message message-assistant';
|
|
201
|
+
indicator.innerHTML = '<div class="message-role">Claude</div><div class="message-content"><em>Thinking...</em></div>';
|
|
202
|
+
messagesEl.appendChild(indicator);
|
|
203
|
+
}
|
|
204
|
+
scrollToBottom();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function removeThinking() {
|
|
208
|
+
const indicator = document.getElementById('thinking-indicator');
|
|
209
|
+
if (indicator) indicator.remove();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function pollForResponse() {
|
|
213
|
+
if (polling) return;
|
|
214
|
+
polling = true;
|
|
215
|
+
showThinking();
|
|
216
|
+
|
|
217
|
+
const poll = async () => {
|
|
218
|
+
try {
|
|
219
|
+
const resp = await fetch('/api/tasks/' + taskId + '/chat?after=' + messageCount);
|
|
220
|
+
const data = await resp.json();
|
|
221
|
+
|
|
222
|
+
if (data.messages.length > 0) {
|
|
223
|
+
removeThinking();
|
|
224
|
+
for (const msg of data.messages) {
|
|
225
|
+
const div = document.createElement('div');
|
|
226
|
+
div.className = 'message ' + (msg.role === 'user' ? 'message-user' : 'message-assistant');
|
|
227
|
+
div.innerHTML = '<div class="message-role">' + (msg.role === 'user' ? 'You' : 'Claude') + '</div><div class="message-content">' + (msg.html || msg.content) + '</div>';
|
|
228
|
+
messagesEl.appendChild(div);
|
|
229
|
+
}
|
|
230
|
+
messageCount = data.total;
|
|
231
|
+
scrollToBottom();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (data.pending) {
|
|
235
|
+
showThinking();
|
|
236
|
+
setTimeout(poll, 2000);
|
|
237
|
+
} else {
|
|
238
|
+
removeThinking();
|
|
239
|
+
polling = false;
|
|
240
|
+
fixBtn.disabled = false;
|
|
241
|
+
fixBtn.textContent = 'Fix';
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
polling = false;
|
|
245
|
+
removeThinking();
|
|
246
|
+
fixBtn.disabled = false;
|
|
247
|
+
fixBtn.textContent = 'Fix';
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
setTimeout(poll, 2000);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// If already pending on page load, start polling
|
|
255
|
+
if (polling) {
|
|
256
|
+
polling = false;
|
|
257
|
+
pollForResponse();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fixBtn.addEventListener('click', async () => {
|
|
261
|
+
const message = input.value.trim();
|
|
262
|
+
if (!message || polling) return;
|
|
263
|
+
|
|
264
|
+
// Add user message to DOM
|
|
265
|
+
const userDiv = document.createElement('div');
|
|
266
|
+
userDiv.className = 'message message-user';
|
|
267
|
+
userDiv.innerHTML = '<div class="message-role">You</div><div class="message-content">[Fix] ' + escapeHtml(message) + '</div>';
|
|
268
|
+
messagesEl.appendChild(userDiv);
|
|
269
|
+
|
|
270
|
+
input.value = '';
|
|
271
|
+
input.style.height = 'auto';
|
|
272
|
+
fixBtn.disabled = true;
|
|
273
|
+
fixBtn.textContent = 'Starting...';
|
|
274
|
+
scrollToBottom();
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Save message to task conversation first
|
|
278
|
+
await fetch('/api/tasks/' + taskId + '/chat', {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify({ message: '[Fix] ' + message }),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Enqueue fix via the fix endpoint
|
|
285
|
+
const resp = await fetch('/api/tasks/' + taskId + '/fix', {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
headers: { 'Content-Type': 'application/json' },
|
|
288
|
+
body: JSON.stringify({ message, diffId }),
|
|
289
|
+
});
|
|
290
|
+
const data = await resp.json();
|
|
291
|
+
if (data.runId) {
|
|
292
|
+
window.location.href = '/agent/' + data.runId;
|
|
293
|
+
} else if (data.ok) {
|
|
294
|
+
// Queued — go to tasks to see progress
|
|
295
|
+
window.location.href = '/tasks';
|
|
296
|
+
} else {
|
|
297
|
+
fixBtn.disabled = false;
|
|
298
|
+
fixBtn.textContent = 'Fix';
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
fixBtn.disabled = false;
|
|
302
|
+
fixBtn.textContent = 'Fix';
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
})();`;
|
|
306
|
+
}
|
|
307
|
+
function renderMessage(msg) {
|
|
308
|
+
const isUser = msg.role === "user";
|
|
309
|
+
const rendered = marked.parse(msg.content, { async: false });
|
|
310
|
+
return `<div class="message ${isUser ? "message-user" : "message-assistant"}">
|
|
311
|
+
<div class="message-role">${isUser ? "You" : "Claude"}</div>
|
|
312
|
+
<div class="message-content">${rendered}</div>
|
|
313
|
+
</div>`;
|
|
314
|
+
}
|
|
315
|
+
function escapeHtml(str) {
|
|
316
|
+
return str
|
|
317
|
+
.replace(/&/g, "&")
|
|
318
|
+
.replace(/</g, "<")
|
|
319
|
+
.replace(/>/g, ">")
|
|
320
|
+
.replace(/"/g, """);
|
|
321
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { layout } from "./layout.js";
|
|
2
|
+
const typeIcons = {
|
|
3
|
+
spec_update: "S",
|
|
4
|
+
agent_spawn: "A",
|
|
5
|
+
agent_complete: "C",
|
|
6
|
+
checkpoint: "OK",
|
|
7
|
+
rejection: "X",
|
|
8
|
+
action: "i",
|
|
9
|
+
task_created: "T",
|
|
10
|
+
task_complete: "T",
|
|
11
|
+
};
|
|
12
|
+
const typeColors = {
|
|
13
|
+
spec_update: "type-spec",
|
|
14
|
+
agent_spawn: "type-agent",
|
|
15
|
+
agent_complete: "type-agent",
|
|
16
|
+
checkpoint: "type-checkpoint",
|
|
17
|
+
rejection: "type-rejection",
|
|
18
|
+
action: "type-action",
|
|
19
|
+
task_created: "type-spec",
|
|
20
|
+
task_complete: "type-checkpoint",
|
|
21
|
+
};
|
|
22
|
+
function extractRunId(details) {
|
|
23
|
+
if (!details)
|
|
24
|
+
return null;
|
|
25
|
+
const match = details.match(/Run:\s*(\S+)/);
|
|
26
|
+
return match ? match[1] : null;
|
|
27
|
+
}
|
|
28
|
+
function renderEntry(entry) {
|
|
29
|
+
const time = new Date(entry.timestamp).toLocaleString();
|
|
30
|
+
const icon = typeIcons[entry.type] || "?";
|
|
31
|
+
const colorClass = typeColors[entry.type] || "";
|
|
32
|
+
const runId = extractRunId(entry.details);
|
|
33
|
+
return `<div class="history-entry">
|
|
34
|
+
<div class="history-marker ${colorClass}">${icon}</div>
|
|
35
|
+
<div class="history-body">
|
|
36
|
+
<div class="history-summary">${escapeHtml(entry.summary)}</div>
|
|
37
|
+
<div class="history-time">${time}</div>
|
|
38
|
+
${entry.details ? `<div class="history-details">${escapeHtml(entry.details)}</div>` : ""}
|
|
39
|
+
${runId ? `<a href="/run/${encodeURIComponent(runId)}" class="history-run-link">View run log →</a>` : ""}
|
|
40
|
+
</div>
|
|
41
|
+
</div>`;
|
|
42
|
+
}
|
|
43
|
+
export function historyPage(entries) {
|
|
44
|
+
const reversed = [...entries].reverse();
|
|
45
|
+
const entriesHtml = reversed.length
|
|
46
|
+
? reversed.map(renderEntry).join("\n")
|
|
47
|
+
: '<p class="empty-state">No history yet.</p>';
|
|
48
|
+
const body = `
|
|
49
|
+
<div class="history-container">
|
|
50
|
+
<div class="history-header">
|
|
51
|
+
<h1>History</h1>
|
|
52
|
+
<button class="btn btn-small" onclick="location.reload()">Refresh</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="history-timeline" id="history-timeline">
|
|
55
|
+
${entriesHtml}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<script>
|
|
59
|
+
let historyTotal = ${entries.length};
|
|
60
|
+
|
|
61
|
+
const typeIcons = ${JSON.stringify(typeIcons)};
|
|
62
|
+
const typeColors = ${JSON.stringify(typeColors)};
|
|
63
|
+
|
|
64
|
+
function escapeHtmlClient(str) {
|
|
65
|
+
const div = document.createElement('div');
|
|
66
|
+
div.textContent = str;
|
|
67
|
+
return div.innerHTML;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractRunIdClient(details) {
|
|
71
|
+
if (!details) return null;
|
|
72
|
+
const match = details.match(/Run:\\s*(\\S+)/);
|
|
73
|
+
return match ? match[1] : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderEntryClient(entry) {
|
|
77
|
+
const time = new Date(entry.timestamp).toLocaleString();
|
|
78
|
+
const icon = typeIcons[entry.type] || '?';
|
|
79
|
+
const colorClass = typeColors[entry.type] || '';
|
|
80
|
+
const runId = extractRunIdClient(entry.details);
|
|
81
|
+
|
|
82
|
+
return '<div class="history-entry">' +
|
|
83
|
+
'<div class="history-marker ' + colorClass + '">' + icon + '</div>' +
|
|
84
|
+
'<div class="history-body">' +
|
|
85
|
+
'<div class="history-summary">' + escapeHtmlClient(entry.summary) + '</div>' +
|
|
86
|
+
'<div class="history-time">' + time + '</div>' +
|
|
87
|
+
(entry.details ? '<div class="history-details">' + escapeHtmlClient(entry.details) + '</div>' : '') +
|
|
88
|
+
(runId ? '<a href="/run/' + encodeURIComponent(runId) + '" class="history-run-link">View run log \\u2192</a>' : '') +
|
|
89
|
+
'</div>' +
|
|
90
|
+
'</div>';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function pollHistory() {
|
|
94
|
+
try {
|
|
95
|
+
const resp = await fetch('/api/history');
|
|
96
|
+
if (!resp.ok) return;
|
|
97
|
+
const data = await resp.json();
|
|
98
|
+
|
|
99
|
+
if (data.total !== historyTotal) {
|
|
100
|
+
historyTotal = data.total;
|
|
101
|
+
const timeline = document.getElementById('history-timeline');
|
|
102
|
+
if (timeline) {
|
|
103
|
+
const reversed = [...data.entries].reverse();
|
|
104
|
+
if (reversed.length) {
|
|
105
|
+
timeline.innerHTML = reversed.map(renderEntryClient).join('');
|
|
106
|
+
} else {
|
|
107
|
+
timeline.innerHTML = '<p class="empty-state">No history yet.</p>';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setInterval(pollHistory, 5000);
|
|
115
|
+
</script>`;
|
|
116
|
+
return layout("History", "history", body);
|
|
117
|
+
}
|
|
118
|
+
function escapeHtml(str) {
|
|
119
|
+
return str
|
|
120
|
+
.replace(/&/g, "&")
|
|
121
|
+
.replace(/</g, "<")
|
|
122
|
+
.replace(/>/g, ">")
|
|
123
|
+
.replace(/"/g, """);
|
|
124
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as projects from "../projects.js";
|
|
5
|
+
function getCurrentBranchSync() {
|
|
6
|
+
try {
|
|
7
|
+
const cwd = projects.getProjectRoot();
|
|
8
|
+
if (!existsSync(join(cwd, ".git")))
|
|
9
|
+
return "";
|
|
10
|
+
const result = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
11
|
+
cwd,
|
|
12
|
+
encoding: "utf-8",
|
|
13
|
+
timeout: 2000,
|
|
14
|
+
});
|
|
15
|
+
return result.trim();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function layout(title, activeTab, body, extraHead) {
|
|
22
|
+
const isMulti = projects.isMultiProject();
|
|
23
|
+
const currentProject = projects.getCurrentProjectName();
|
|
24
|
+
const allProjects = isMulti ? projects.readProjects() : [];
|
|
25
|
+
const currentBranch = getCurrentBranchSync();
|
|
26
|
+
const projectSelector = isMulti
|
|
27
|
+
? `<div class="project-selector">
|
|
28
|
+
<button class="project-selector-btn" onclick="document.getElementById('project-dropdown').classList.toggle('open')">${escapeHtml(currentProject || "No project")}</button>
|
|
29
|
+
<div id="project-dropdown" class="project-dropdown">
|
|
30
|
+
${allProjects.map((p) => `<button class="project-option${p.name === currentProject ? " active" : ""}" onclick="switchProject('${escapeAttr(p.name)}')">${escapeHtml(p.name)}</button>`).join("")}
|
|
31
|
+
<div class="project-dropdown-divider"></div>
|
|
32
|
+
<button class="project-option project-option-action" onclick="showNewProjectForm()">+ New Project</button>
|
|
33
|
+
<button class="project-option project-option-action" onclick="showAdoptForm()">+ Adopt Existing</button>
|
|
34
|
+
<div id="new-project-form" class="project-inline-form" style="display:none">
|
|
35
|
+
<input type="text" id="new-project-name" placeholder="project-name" class="project-inline-input" />
|
|
36
|
+
<button onclick="createProject()" class="btn-small btn-primary">Create</button>
|
|
37
|
+
</div>
|
|
38
|
+
<div id="adopt-form" class="project-inline-form" style="display:none">
|
|
39
|
+
<div id="adopt-list" class="adopt-list">Loading...</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<script>
|
|
44
|
+
function switchProject(name) {
|
|
45
|
+
fetch('/api/projects/current', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})
|
|
46
|
+
.then(r => { if(r.ok) location.reload(); });
|
|
47
|
+
}
|
|
48
|
+
function showNewProjectForm() {
|
|
49
|
+
document.getElementById('new-project-form').style.display = 'flex';
|
|
50
|
+
document.getElementById('adopt-form').style.display = 'none';
|
|
51
|
+
document.getElementById('new-project-name').focus();
|
|
52
|
+
}
|
|
53
|
+
function createProject() {
|
|
54
|
+
const name = document.getElementById('new-project-name').value.trim();
|
|
55
|
+
if (!name) return;
|
|
56
|
+
fetch('/api/projects', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})
|
|
57
|
+
.then(r => { if(r.ok) location.reload(); else r.json().then(d => alert(d.error)); });
|
|
58
|
+
}
|
|
59
|
+
function showAdoptForm() {
|
|
60
|
+
document.getElementById('adopt-form').style.display = 'block';
|
|
61
|
+
document.getElementById('new-project-form').style.display = 'none';
|
|
62
|
+
fetch('/api/projects/available').then(r=>r.json()).then(d => {
|
|
63
|
+
const list = document.getElementById('adopt-list');
|
|
64
|
+
if (!d.available || d.available.length === 0) {
|
|
65
|
+
list.textContent = 'No available subdirectories';
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
list.innerHTML = d.available.map(name =>
|
|
69
|
+
'<button class="project-option" onclick="adoptProject(\\'' + name + '\\')">' + name + '</button>'
|
|
70
|
+
).join('');
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function adoptProject(name) {
|
|
74
|
+
fetch('/api/projects/adopt', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})
|
|
75
|
+
.then(r => { if(r.ok) location.reload(); else r.json().then(d => alert(d.error)); });
|
|
76
|
+
}
|
|
77
|
+
document.addEventListener('click', function(e) {
|
|
78
|
+
const dd = document.getElementById('project-dropdown');
|
|
79
|
+
const btn = document.querySelector('.project-selector-btn');
|
|
80
|
+
if (dd && !dd.contains(e.target) && e.target !== btn) {
|
|
81
|
+
dd.classList.remove('open');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
</script>`
|
|
85
|
+
: "";
|
|
86
|
+
const isDefaultBranch = !currentBranch || currentBranch === "master" || currentBranch === "main";
|
|
87
|
+
const branchIndicator = currentBranch
|
|
88
|
+
? `<a href="/branches" class="branch-indicator${isDefaultBranch ? "" : " branch-indicator-feature"}">${escapeHtml(currentBranch)}</a>`
|
|
89
|
+
: "";
|
|
90
|
+
return `<!DOCTYPE html>
|
|
91
|
+
<html lang="en">
|
|
92
|
+
<head>
|
|
93
|
+
<meta charset="UTF-8">
|
|
94
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
95
|
+
<title>${title} - longshot</title>
|
|
96
|
+
${extraHead || ""}
|
|
97
|
+
<link rel="stylesheet" href="/style.css">
|
|
98
|
+
<script src="https://unpkg.com/marked@17.0.2/marked.min.js"></script>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<nav class="nav">
|
|
102
|
+
${projectSelector}
|
|
103
|
+
<a href="/chat" class="nav-tab${activeTab === "chat" ? " active" : ""}">Chat</a>
|
|
104
|
+
<a href="/tasks" class="nav-tab${activeTab === "tasks" ? " active" : ""}">Tasks</a>
|
|
105
|
+
<a href="/services" class="nav-tab${activeTab === "services" ? " active" : ""}">Svc</a>
|
|
106
|
+
<a href="/spec" class="nav-tab${activeTab === "spec" ? " active" : ""}">Spec</a>
|
|
107
|
+
<a href="/history" class="nav-tab${activeTab === "history" ? " active" : ""}">History</a>
|
|
108
|
+
</nav>
|
|
109
|
+
${branchIndicator}
|
|
110
|
+
<main class="main${branchIndicator ? " main-with-branch" : ""}">
|
|
111
|
+
${body}
|
|
112
|
+
</main>
|
|
113
|
+
</body>
|
|
114
|
+
</html>`;
|
|
115
|
+
}
|
|
116
|
+
function escapeHtml(s) {
|
|
117
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
118
|
+
}
|
|
119
|
+
function escapeAttr(s) {
|
|
120
|
+
return s.replace(/'/g, "\\'").replace(/"/g, """);
|
|
121
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { layout } from "./layout.js";
|
|
2
|
+
function escapeHtml(str) {
|
|
3
|
+
return str
|
|
4
|
+
.replace(/&/g, "&")
|
|
5
|
+
.replace(/</g, "<")
|
|
6
|
+
.replace(/>/g, ">")
|
|
7
|
+
.replace(/"/g, """);
|
|
8
|
+
}
|
|
9
|
+
function truncate(str, max) {
|
|
10
|
+
if (str.length <= max)
|
|
11
|
+
return str;
|
|
12
|
+
return str.slice(0, max) + "…";
|
|
13
|
+
}
|
|
14
|
+
function renderEvent(event) {
|
|
15
|
+
const time = new Date(event.ts).toLocaleTimeString();
|
|
16
|
+
switch (event.type) {
|
|
17
|
+
case "user_message":
|
|
18
|
+
return `<div class="run-event run-event-user">
|
|
19
|
+
<div class="run-event-marker">U</div>
|
|
20
|
+
<div class="run-event-body">
|
|
21
|
+
<div class="run-event-label">User message <span class="run-event-time">${time}</span></div>
|
|
22
|
+
<div class="run-event-content">${escapeHtml(truncate(event.content || "", 500))}</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>`;
|
|
25
|
+
case "text":
|
|
26
|
+
return `<div class="run-event run-event-text">
|
|
27
|
+
<div class="run-event-marker">T</div>
|
|
28
|
+
<div class="run-event-body">
|
|
29
|
+
<div class="run-event-label">Text <span class="run-event-time">${time}</span></div>
|
|
30
|
+
<div class="run-event-content">${escapeHtml(truncate(event.content || "", 500))}</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>`;
|
|
33
|
+
case "tool_use":
|
|
34
|
+
return `<div class="run-event run-event-tool-use">
|
|
35
|
+
<div class="run-event-marker">⚙</div>
|
|
36
|
+
<div class="run-event-body">
|
|
37
|
+
<div class="run-event-label">Tool: ${escapeHtml(event.name || "unknown")} <span class="run-event-time">${time}</span></div>
|
|
38
|
+
${event.input ? `<details class="run-event-details"><summary>Input</summary><pre>${escapeHtml(JSON.stringify(event.input, null, 2).slice(0, 2000))}</pre></details>` : ""}
|
|
39
|
+
</div>
|
|
40
|
+
</div>`;
|
|
41
|
+
case "tool_result":
|
|
42
|
+
return `<div class="run-event run-event-tool-result">
|
|
43
|
+
<div class="run-event-marker">✓</div>
|
|
44
|
+
<div class="run-event-body">
|
|
45
|
+
<div class="run-event-label">Result <span class="run-event-time">${time}</span></div>
|
|
46
|
+
${event.content ? `<details class="run-event-details"><summary>Output</summary><pre>${escapeHtml(truncate(event.content, 2000))}</pre></details>` : ""}
|
|
47
|
+
</div>
|
|
48
|
+
</div>`;
|
|
49
|
+
case "error":
|
|
50
|
+
return `<div class="run-event run-event-error">
|
|
51
|
+
<div class="run-event-marker">!</div>
|
|
52
|
+
<div class="run-event-body">
|
|
53
|
+
<div class="run-event-label">Error <span class="run-event-time">${time}</span></div>
|
|
54
|
+
<div class="run-event-content">${escapeHtml(event.message || "")}</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>`;
|
|
57
|
+
case "done":
|
|
58
|
+
return `<div class="run-event run-event-done">
|
|
59
|
+
<div class="run-event-marker">●</div>
|
|
60
|
+
<div class="run-event-body">
|
|
61
|
+
<div class="run-event-label">Done <span class="run-event-time">${time}</span></div>
|
|
62
|
+
<div class="run-event-content">Exit code: ${event.exitCode ?? "unknown"}</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>`;
|
|
65
|
+
default:
|
|
66
|
+
return `<div class="run-event">
|
|
67
|
+
<div class="run-event-marker">?</div>
|
|
68
|
+
<div class="run-event-body">
|
|
69
|
+
<div class="run-event-label">${escapeHtml(event.type)} <span class="run-event-time">${time}</span></div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function runPage(runId, events) {
|
|
75
|
+
const eventsHtml = events.length
|
|
76
|
+
? events.map(renderEvent).join("\n")
|
|
77
|
+
: '<p class="empty-state">No events recorded.</p>';
|
|
78
|
+
const body = `
|
|
79
|
+
<div class="run-container">
|
|
80
|
+
<div class="run-header">
|
|
81
|
+
<h1>Run Log</h1>
|
|
82
|
+
<div class="run-id">${escapeHtml(runId)}</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="run-timeline">
|
|
85
|
+
${eventsHtml}
|
|
86
|
+
</div>
|
|
87
|
+
<div class="run-footer">
|
|
88
|
+
<a href="/history" class="btn btn-small">← History</a>
|
|
89
|
+
</div>
|
|
90
|
+
</div>`;
|
|
91
|
+
return layout("Run", "history", body);
|
|
92
|
+
}
|