@sergeychuvayev/claude-fleet 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +304 -39
- package/public/ask.js +22 -6
- package/public/blocks.js +4 -1
- package/public/control.js +30 -7
- package/public/styles.css +2584 -501
- package/public/teams.js +8 -5
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
+
// An isolated scope, the way teams.js and blocks.js already do it. Everything in
|
|
3
|
+
// here used to sit in the page's shared global scope alongside control.js, blocks.js
|
|
4
|
+
// and ask.js, where a name chosen twice in two files is a SyntaxError that takes the
|
|
5
|
+
// whole dashboard down before a line of it runs. What the other files legitimately
|
|
6
|
+
// need is window.Fleet, published partway down; nothing else escapes.
|
|
7
|
+
// The leading semicolon is load-bearing: without it the directive above and the
|
|
8
|
+
// parenthesis below join into a call on the string "use strict".
|
|
9
|
+
;(() => {
|
|
2
10
|
const $ = id => document.getElementById(id)
|
|
11
|
+
// The one thing the core borrows back from control.js, which owns the control
|
|
12
|
+
// token every write is authenticated with. Resolved per call, not at load: app.js
|
|
13
|
+
// runs first, and every caller here is an event handler that fires long after.
|
|
14
|
+
const api = (...args) => window.FleetControl.api(...args)
|
|
3
15
|
const STATES = ['busy', 'idle', 'stale', 'dead']
|
|
4
16
|
const LABELS = { busy: 'Working', idle: 'Waiting', stale: 'Stale', dead: 'Offline' }
|
|
5
17
|
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
|
@@ -12,6 +24,15 @@ const age = timestamp => {
|
|
|
12
24
|
const secs = Math.max(0, Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000))
|
|
13
25
|
return secs < 60 ? `${secs}s` : secs < 3600 ? `${Math.floor(secs/60)}m` : secs < 86400 ? `${Math.floor(secs/3600)}h` : `${Math.floor(secs/86400)}d`
|
|
14
26
|
}
|
|
27
|
+
// Everything Fleet remembers between visits goes through here. It is declared before
|
|
28
|
+
// its first reader on purpose: a `const` used above its declaration throws a
|
|
29
|
+
// ReferenceError that the callers' own try/catch would quietly absorb, leaving every
|
|
30
|
+
// remembered preference silently reset on each load.
|
|
31
|
+
const store = {
|
|
32
|
+
get(key) { try { return localStorage.getItem(key) } catch { return null } },
|
|
33
|
+
set(key, value) { try { localStorage.setItem(key, value) } catch {} },
|
|
34
|
+
clear(key) { try { localStorage.removeItem(key) } catch {} },
|
|
35
|
+
}
|
|
15
36
|
let snapshot = null, filter = 'all', selected = null, pending = false, toastTimer
|
|
16
37
|
// A delegation row nested under a team session. Keyed on the delegation id, never on
|
|
17
38
|
// its position or status, so a sub-agent finishing does not move the operator's focus.
|
|
@@ -23,7 +44,10 @@ const formatModel = m => m ? String(m).replace('claude-', '') : 'Model pending'
|
|
|
23
44
|
// A child row shares its data-session with the parent that owns it, so the session
|
|
24
45
|
// id alone is not a unique row key: folding in data-delegation is what tells a
|
|
25
46
|
// delegation row apart from its parent when the list redraws underneath focus.
|
|
26
|
-
|
|
47
|
+
// The fold header shares no data-session with anything, so it needs a key of its own:
|
|
48
|
+
// without one it reads as unkeyed and the poll two seconds after a click would drop
|
|
49
|
+
// the focus ring off the control the operator just used.
|
|
50
|
+
const rowFocusKey = b => b.dataset.foldSession ? `fold::${b.dataset.foldSession}` : b.dataset.delegation ? `${b.dataset.session}::${b.dataset.delegation}` : b.dataset.session || b.dataset.filter
|
|
27
51
|
function update(id, html) {
|
|
28
52
|
const el = $(id)
|
|
29
53
|
if (!el || el.innerHTML === html) return
|
|
@@ -95,10 +119,44 @@ function childRowHtml(s, d) {
|
|
|
95
119
|
const label = DELEGATION_LABEL[d.status] || d.status
|
|
96
120
|
return `<button class="session session-child" data-session="${esc(key(s))}" data-delegation="${esc(d.id)}" aria-pressed="${selectedChild === d.id}" aria-controls="detail"><span><span class="session-top"><span class="badge ${cls}"><span class="dot"></span>${esc(label)}</span><span class="session-name">⑂ ${esc(d.role)}</span></span><span class="session-title">${esc(formatModel(d.model))}</span></span><span class="session-context"></span></button>`
|
|
97
121
|
}
|
|
122
|
+
// A session's sub-agents fold away behind a header of their own. An initiative can sit
|
|
123
|
+
// between two sessions with twenty delegation rows wedged in the gap, which buries the
|
|
124
|
+
// list those rows belong to. Folding is per session and remembered, so putting one
|
|
125
|
+
// team's sub-agents away leaves every other team's on screen.
|
|
126
|
+
const COLLAPSED_KEY = 'fleet:children-collapsed'
|
|
127
|
+
let collapsedChildren = new Set()
|
|
128
|
+
try { collapsedChildren = new Set(JSON.parse(store.get(COLLAPSED_KEY) || '[]')) } catch { collapsedChildren = new Set() }
|
|
129
|
+
const childGroupId = k => `children-${String(k).replace(/[^\w-]/g, '_')}`
|
|
130
|
+
function setChildrenCollapsed(k, collapsed) {
|
|
131
|
+
if (collapsed) collapsedChildren.add(k)
|
|
132
|
+
else collapsedChildren.delete(k)
|
|
133
|
+
// Sessions come and go; only the folds still worth honouring are worth storing.
|
|
134
|
+
store.set(COLLAPSED_KEY, JSON.stringify([...collapsedChildren].slice(-200)))
|
|
135
|
+
}
|
|
136
|
+
// The header names the group and carries the fold. It is a sibling of the session
|
|
137
|
+
// row rather than part of it because that row is itself a button, and a button
|
|
138
|
+
// cannot hold another one.
|
|
139
|
+
function childToggleHtml(s, collapsed) {
|
|
140
|
+
const all = s.delegations || []
|
|
141
|
+
const running = all.filter(d => d.status === 'running').length
|
|
142
|
+
const failed = all.filter(d => d.status === 'failed').length
|
|
143
|
+
const counts = [`${all.length} sub-agent${all.length === 1 ? '' : 's'}`, running ? `${running} working` : '', failed ? `${failed} failed` : ''].filter(Boolean).join(' · ')
|
|
144
|
+
return `<button class="session-children-toggle${collapsed ? ' is-collapsed' : ''}" data-fold-session="${esc(key(s))}" aria-expanded="${!collapsed}" aria-controls="${esc(childGroupId(key(s)))}"><span class="children-chevron" aria-hidden="true">›</span><span class="children-count">⑂ ${esc(counts)}</span></button>`
|
|
145
|
+
}
|
|
98
146
|
// An initiative that runs long enough accumulates delegations without bound; the
|
|
99
147
|
// row list stays a list, not a scrollbar of its own, by showing only the tail.
|
|
100
148
|
const CHILD_ROW_LIMIT = 20
|
|
101
149
|
const childRowsHtml = s => {
|
|
150
|
+
const all = s.delegations || []
|
|
151
|
+
if (!all.length) return ''
|
|
152
|
+
// A fold never hides the one row the list reads as selected: clicking the header
|
|
153
|
+
// hands the selection back to the session first, and a group still holding the
|
|
154
|
+
// selected delegation by any other route draws open however it was left.
|
|
155
|
+
const collapsed = collapsedChildren.has(key(s)) && !all.some(d => d.id === selectedChild)
|
|
156
|
+
const group = `<div class="session-children" id="${esc(childGroupId(key(s)))}"${collapsed ? ' hidden' : ''}>${collapsed ? '' : childTailHtml(s)}</div>`
|
|
157
|
+
return childToggleHtml(s, collapsed) + group
|
|
158
|
+
}
|
|
159
|
+
const childTailHtml = s => {
|
|
102
160
|
const all = s.delegations || []
|
|
103
161
|
const recent = all.length > CHILD_ROW_LIMIT ? all.slice(-CHILD_ROW_LIMIT) : all
|
|
104
162
|
// The parent row has already handed its aria-pressed to session-ancestor, so a
|
|
@@ -177,6 +235,61 @@ function renderStatusbar(usage, sessions) {
|
|
|
177
235
|
if (usage?.blocked) banner.textContent = `Rate limited until ${clockAt(usage.blocked.resetsAt)} (${untilReset(usage.blocked.resetsAt, now)}). A new agent will not get past its first message until this window resets.`
|
|
178
236
|
}
|
|
179
237
|
}
|
|
238
|
+
|
|
239
|
+
// ── The session row ─────────────────────────────────────────────────────────
|
|
240
|
+
// One row is four lines: what the agent is and how it is doing, what it was asked,
|
|
241
|
+
// where it is working, and the story of its latest turn. Each line is built by its
|
|
242
|
+
// own function, so changing the look of one does not mean reading the other three.
|
|
243
|
+
|
|
244
|
+
// Line one, after the status badge: the qualifiers that say this row is not an
|
|
245
|
+
// ordinary foreground session you started yourself.
|
|
246
|
+
function rowTags(s, spawnCounts) {
|
|
247
|
+
const spawned = spawnCounts.get(s.pid)
|
|
248
|
+
return [
|
|
249
|
+
spawned ? `<span class="spawn-badge" title="Running ${spawned} background session(s)">⑂ ${spawned}</span>` : '',
|
|
250
|
+
s.background ? `<span class="spawn-owner" title="Started by ${esc(s.spawnedByName || 'a program')}, not from a terminal">via ${esc(s.spawnedByName || 'a program')}</span>` : '',
|
|
251
|
+
s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : '',
|
|
252
|
+
].join('')
|
|
253
|
+
}
|
|
254
|
+
// A team session says which team is running it and how far through its tasks it is.
|
|
255
|
+
function initiativeTag(s) {
|
|
256
|
+
if (s.kind !== 'initiative') return ''
|
|
257
|
+
const p = s.taskProgress
|
|
258
|
+
const progress = p ? ` · ${p.verified}/${p.total} verified${p.blocked ? ` · ${p.blocked} need attention` : ''}` : ''
|
|
259
|
+
return `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}${progress}</span>`
|
|
260
|
+
}
|
|
261
|
+
// Where the work is happening, and what it has cost.
|
|
262
|
+
function rowMeta(s) {
|
|
263
|
+
const project = s.cwd?.split('/').filter(Boolean).pop() || 'No project'
|
|
264
|
+
const spend = money(s.costUsd)
|
|
265
|
+
return `<span class="session-meta"><span>${esc(project)}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${spend ? `<span class="session-cost" title="What this conversation has cost so far">${esc(spend)}</span>` : ''}</span>`
|
|
266
|
+
}
|
|
267
|
+
// The right-hand column: how full the context window is, and how long ago the
|
|
268
|
+
// agent last did anything.
|
|
269
|
+
function contextCell(s) {
|
|
270
|
+
const p = percent(s)
|
|
271
|
+
return `<span class="session-context ${heat(p)}">${p === null ? '—' : Math.round(p) + '%'}<span class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></span><small>${age(s.lastActivity)} ago</small></span>`
|
|
272
|
+
}
|
|
273
|
+
function sessionRowHtml(s, spawnCounts) {
|
|
274
|
+
// A row holding the selected sub-agent is an ancestor of the selection, not the
|
|
275
|
+
// selection itself, so it gives up aria-pressed to the child row below it.
|
|
276
|
+
const childSelectedHere = !!selectedChild && (s.delegations || []).some(d => d.id === selectedChild)
|
|
277
|
+
const name = (s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session')
|
|
278
|
+
const top = `<span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc(name)}</span>${rowTags(s, spawnCounts)}</span>`
|
|
279
|
+
const body = `${top}${initiativeTag(s)}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span>${rowMeta(s)}${turnRow(s)}`
|
|
280
|
+
return `<button class="session${childSelectedHere ? ' session-ancestor' : ''}" data-session="${esc(key(s))}" aria-pressed="${selected === key(s) && !childSelectedHere}" aria-controls="detail" title="${hasUnseen(s) ? 'New output since you last opened this' : ''}"><span>${body}</span>${contextCell(s)}</button>${childRowsHtml(s)}`
|
|
281
|
+
}
|
|
282
|
+
const filterBarHtml = (counts, foreground, background, archived) => [
|
|
283
|
+
['all', 'All sessions', foreground],
|
|
284
|
+
...STATES.map(s => [s, LABELS[s], counts[s] || 0]),
|
|
285
|
+
...(background ? [['background', 'Background', background]] : []),
|
|
286
|
+
...(archived ? [['archived', 'Archived', archived]] : []),
|
|
287
|
+
].map(([s, label, n]) => `<button class="filter" data-filter="${s}" aria-pressed="${filter === s}">${label}<span>${n}</span></button>`).join('')
|
|
288
|
+
const emptyListHtml = total =>
|
|
289
|
+
filter === 'background' ? 'No background sessions right now.'
|
|
290
|
+
: total ? 'No sessions match your filters.<br>Try another search or select All sessions.'
|
|
291
|
+
: 'Your fleet is quiet.<br>Start a Claude Code session and it will appear here automatically.'
|
|
292
|
+
|
|
180
293
|
function render() {
|
|
181
294
|
if (!snapshot) return
|
|
182
295
|
const {sessions, total} = snapshot
|
|
@@ -200,13 +313,11 @@ function render() {
|
|
|
200
313
|
if (!shown.some(s => key(s) === selected)) selected = shown[0] ? key(shown[0]) : null
|
|
201
314
|
$('shown-count').textContent = shown.length
|
|
202
315
|
renderStatusbar(snapshot.usage, live)
|
|
203
|
-
update('filters',
|
|
316
|
+
update('filters', filterBarHtml(visibleCounts, foreground.length, background.length, archived.length))
|
|
204
317
|
renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
|
|
205
|
-
update('session-list', shown.length
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return `<button class="session${childSelectedHere ? ' session-ancestor' : ''}" data-session="${esc(key(s))}" aria-pressed="${selected === key(s) && !childSelectedHere}" aria-controls="detail" title="${hasUnseen(s) ? 'New output since you last opened this' : ''}"><span><span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc((s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session'))}</span>${spawnCounts.get(s.pid) ? `<span class="spawn-badge" title="Running ${spawnCounts.get(s.pid)} background session(s)">⑂ ${spawnCounts.get(s.pid)}</span>` : ''}${s.background ? `<span class="spawn-owner" title="Started by ${esc(s.spawnedByName || 'a program')}, not from a terminal">via ${esc(s.spawnedByName || 'a program')}</span>` : ''}${s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : ''}</span>${s.kind === 'initiative' ? `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}${s.taskProgress ? ` · ${s.taskProgress.verified}/${s.taskProgress.total} verified${s.taskProgress.blocked ? ` · ${s.taskProgress.blocked} need attention` : ''}` : ''}</span>` : ''}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span><span class="session-meta"><span>${esc(s.cwd?.split('/').filter(Boolean).pop() || 'No project')}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${money(s.costUsd) ? `<span class="session-cost" title="What this conversation has cost so far">${esc(money(s.costUsd))}</span>` : ''}</span>${turnRow(s)}</span><span class="session-context ${heat(p)}">${p === null ? '—' : Math.round(p)+'%'}<span class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></span><small>${age(s.lastActivity)} ago</small></span></button>${childRowsHtml(s)}`
|
|
209
|
-
}).join('') : `<div class="empty">${filter === 'background' ? 'No background sessions right now.' : total ? 'No sessions match your filters.<br>Try another search or select All sessions.' : 'Your fleet is quiet.<br>Start a Claude Code session and it will appear here automatically.'}</div>`)
|
|
318
|
+
update('session-list', shown.length
|
|
319
|
+
? shown.map(s => sessionRowHtml(s, spawnCounts)).join('')
|
|
320
|
+
: `<div class="empty">${emptyListHtml(total)}</div>`)
|
|
210
321
|
const current = shown.find(s => key(s) === selected)
|
|
211
322
|
if (current) markSeen(key(current), current.lastActivity)
|
|
212
323
|
// A delegation belongs to whichever session is actually current; switching sessions,
|
|
@@ -224,10 +335,10 @@ function render() {
|
|
|
224
335
|
renderChildDetail(current, childId)
|
|
225
336
|
// A sub-agent is not addressable: clearing the control panel drops its composer
|
|
226
337
|
// and conversation from the DOM entirely, not merely hiding them.
|
|
227
|
-
|
|
338
|
+
window.FleetControl?.selectControl(null)
|
|
228
339
|
} else {
|
|
229
340
|
renderDetail(current)
|
|
230
|
-
|
|
341
|
+
window.FleetControl?.selectControl(current)
|
|
231
342
|
}
|
|
232
343
|
syncDetails()
|
|
233
344
|
}
|
|
@@ -380,11 +491,32 @@ document.addEventListener('click', event => {
|
|
|
380
491
|
if (event.target === $(openModalId) || event.target.closest('[data-close-modal]')) closeModal()
|
|
381
492
|
})
|
|
382
493
|
|
|
383
|
-
|
|
494
|
+
// Reached from every error path, including ones that fire before the page has
|
|
495
|
+
// finished wiring itself up, so a missing toast element costs the message and
|
|
496
|
+
// nothing more.
|
|
497
|
+
function toast(message) {
|
|
498
|
+
const box = $('toast')
|
|
499
|
+
if (!box) return
|
|
500
|
+
box.textContent = message
|
|
501
|
+
box.hidden = false
|
|
502
|
+
clearTimeout(toastTimer)
|
|
503
|
+
toastTimer = setTimeout(() => { box.hidden = true }, 3000)
|
|
504
|
+
}
|
|
384
505
|
document.addEventListener('click', async event => {
|
|
385
506
|
const b = event.target.closest('button')
|
|
386
507
|
if (!b) return
|
|
387
508
|
if (b.dataset.filter) { filter = b.dataset.filter; render() }
|
|
509
|
+
if (b.dataset.foldSession) {
|
|
510
|
+
const k = b.dataset.foldSession, collapsed = !collapsedChildren.has(k)
|
|
511
|
+
setChildrenCollapsed(k, collapsed)
|
|
512
|
+
// Folding the group away takes the selection back up to the session that owns it,
|
|
513
|
+
// so the list never hides the one row reading as selected.
|
|
514
|
+
if (collapsed && selectedChild && snapshot?.sessions.find(s => key(s) === k)?.delegations?.some(d => d.id === selectedChild)) {
|
|
515
|
+
selected = k
|
|
516
|
+
selectedChild = null
|
|
517
|
+
}
|
|
518
|
+
return render()
|
|
519
|
+
}
|
|
388
520
|
if (b.dataset.delegation) {
|
|
389
521
|
selected = b.dataset.session; selectedChild = b.dataset.delegation; render()
|
|
390
522
|
if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
|
|
@@ -405,9 +537,23 @@ document.addEventListener('click', async event => {
|
|
|
405
537
|
catch { toast('Clipboard unavailable. The command is shown below.'); const code = document.createElement('pre'); code.className = 'response'; code.textContent = s.resumeCmd; $('detail').append(code) }
|
|
406
538
|
}
|
|
407
539
|
})
|
|
540
|
+
const setBusy = busy => { if ($('refresh')) $('refresh').disabled = busy }
|
|
541
|
+
const setConnection = (text, state) => {
|
|
542
|
+
if ($('connection')) $('connection').textContent = text
|
|
543
|
+
if ($('connection-dot')) $('connection-dot').className = `dot ${state}`
|
|
544
|
+
}
|
|
545
|
+
const showError = message => {
|
|
546
|
+
const box = $('error')
|
|
547
|
+
if (!box) return
|
|
548
|
+
box.hidden = !message
|
|
549
|
+
if (message) box.textContent = message
|
|
550
|
+
}
|
|
408
551
|
async function tick() {
|
|
409
552
|
if (pending) return
|
|
410
|
-
|
|
553
|
+
// The poll runs on a timer and in the catch below, so it never assumes the
|
|
554
|
+
// chrome it writes into is mounted.
|
|
555
|
+
pending = true
|
|
556
|
+
setBusy(true)
|
|
411
557
|
try {
|
|
412
558
|
const r = await fetch('/api/sessions', {cache:'no-store',signal:AbortSignal.timeout(8000)})
|
|
413
559
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
@@ -422,31 +568,63 @@ async function tick() {
|
|
|
422
568
|
}
|
|
423
569
|
// Other panels (the ask results) re-read the snapshot to refresh "open now" state.
|
|
424
570
|
document.dispatchEvent(new CustomEvent('fleet-snapshot'))
|
|
425
|
-
|
|
426
|
-
$('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
|
|
427
|
-
|
|
428
|
-
if (data.storageError) $('error').textContent = data.storageError
|
|
571
|
+
setConnection('Live connection', 'busy')
|
|
572
|
+
if ($('updated')) $('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
|
|
573
|
+
showError(data.storageError)
|
|
429
574
|
} catch {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
$('error').hidden = false
|
|
575
|
+
setConnection('Disconnected', 'stale')
|
|
576
|
+
showError(snapshot ? 'Connection lost. Showing the last successful snapshot; retrying automatically.' : 'Unable to connect to the local server. Retrying automatically.')
|
|
433
577
|
if (!snapshot) { update('session-list','<div class="empty">Waiting for the local server…</div>'); renderDetail(null) }
|
|
434
|
-
} finally { pending = false;
|
|
578
|
+
} finally { pending = false; setBusy(false) }
|
|
579
|
+
}
|
|
580
|
+
// ── What the rest of the page may use ───────────────────────────────────────
|
|
581
|
+
// Published here, before the boot sequence below runs, and not as the value the
|
|
582
|
+
// wrapper returns: control.js, teams.js and ask.js destructure this the moment they
|
|
583
|
+
// load, so an element missing from the wiring that follows must not take the whole
|
|
584
|
+
// page down with it. State is handed out through functions rather than as live
|
|
585
|
+
// bindings, so a caller cannot take a copy of `snapshot` and read a stale one after
|
|
586
|
+
// the next poll.
|
|
587
|
+
window.Fleet = {
|
|
588
|
+
// DOM and formatting helpers the other files share.
|
|
589
|
+
$, esc, update, key, age, tokens, money, status, store,
|
|
590
|
+
// The account's plan windows, rendered from a usage reading.
|
|
591
|
+
usageHtml,
|
|
592
|
+
// Current state.
|
|
593
|
+
snapshot: () => snapshot,
|
|
594
|
+
// Actions. Each one renders, so a caller never has to remember to.
|
|
595
|
+
render,
|
|
596
|
+
setSnapshot(next) { snapshot = next; render() },
|
|
597
|
+
setFilter(next) { filter = next; render() },
|
|
598
|
+
select(sessionKey, delegationId = null) { selected = sessionKey; selectedChild = delegationId; render() },
|
|
599
|
+
setChildrenCollapsed(sessionKey, collapsed) { setChildrenCollapsed(sessionKey, collapsed); render() },
|
|
600
|
+
// What loadChildDetail's completion does: hand over the delegation the session
|
|
601
|
+
// route returned, then redraw. Select the delegation first; a detail handed over
|
|
602
|
+
// for one that is not selected has nowhere to be drawn.
|
|
603
|
+
setChildDetail(delegationId, detail, error = null) {
|
|
604
|
+
childDetail = detail
|
|
605
|
+
childDetailFor = delegationId
|
|
606
|
+
childDetailError = error
|
|
607
|
+
render()
|
|
608
|
+
},
|
|
609
|
+
toast,
|
|
610
|
+
// Modals.
|
|
611
|
+
modalIsOpen, openModal, closeModal,
|
|
612
|
+
// Layout, formerly window.FleetLayout.
|
|
613
|
+
watchConversation, syncDetails,
|
|
614
|
+
// Control-panel rendering, for tests and for the update poller.
|
|
615
|
+
renderUpdate: (...args) => window.FleetControl.renderUpdate(...args),
|
|
435
616
|
}
|
|
436
|
-
|
|
617
|
+
|
|
618
|
+
// ── Boot ────────────────────────────────────────────────────────────────────
|
|
619
|
+
$('refresh')?.addEventListener('click',tick)
|
|
437
620
|
tick()
|
|
438
621
|
setInterval(() => { if (!document.hidden) tick() },2000)
|
|
439
622
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) tick() })
|
|
440
623
|
|
|
441
624
|
// Layout the operator controls: a draggable split between the session list and the
|
|
442
|
-
// inspector, and
|
|
625
|
+
// inspector, and resizable session sections. Sizes are remembered per browser; a storage
|
|
443
626
|
// failure (private window, blocked site data) only costs the remembered size.
|
|
444
|
-
const LAYOUT = { split: 'fleet:split'
|
|
445
|
-
const store = {
|
|
446
|
-
get(key) { try { return localStorage.getItem(key) } catch { return null } },
|
|
447
|
-
set(key, value) { try { localStorage.setItem(key, value) } catch {} },
|
|
448
|
-
clear(key) { try { localStorage.removeItem(key) } catch {} },
|
|
449
|
-
}
|
|
627
|
+
const LAYOUT = { split: 'fleet:split' }
|
|
450
628
|
const SPLIT_DEFAULT = 58, LIST_MIN = 300, DETAIL_MIN = 380
|
|
451
629
|
|
|
452
630
|
function applySplit(percent, { save = true } = {}) {
|
|
@@ -512,20 +690,107 @@ function initSplitter() {
|
|
|
512
690
|
})
|
|
513
691
|
}
|
|
514
692
|
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
let
|
|
693
|
+
// Vertical sections share the same interaction as the session-list divider.
|
|
694
|
+
// Observe the container so saved sizes yield when the viewport or controls change.
|
|
695
|
+
let panelLayoutCleanup = () => {}
|
|
518
696
|
function watchConversation(element) {
|
|
697
|
+
panelLayoutCleanup()
|
|
519
698
|
if (!element) return
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
699
|
+
const container = element.parentElement
|
|
700
|
+
const disposers = []
|
|
701
|
+
const addPanel = (panel, { key, label, min, initial, before = false }) => {
|
|
702
|
+
const divider = document.createElement('div')
|
|
703
|
+
divider.className = 'panel-splitter'
|
|
704
|
+
divider.tabIndex = 0
|
|
705
|
+
divider.setAttribute('role', 'separator')
|
|
706
|
+
divider.setAttribute('aria-orientation', 'horizontal')
|
|
707
|
+
divider.setAttribute('aria-label', label)
|
|
708
|
+
divider.setAttribute('aria-controls', panel.id)
|
|
709
|
+
divider.title = 'Drag to resize · arrow keys to adjust · double-click to reset'
|
|
710
|
+
before ? panel.before(divider) : panel.after(divider)
|
|
711
|
+
const saved = Number(store.get(key))
|
|
712
|
+
let preferred = Number.isFinite(saved) && saved >= min ? saved : initial
|
|
713
|
+
let drag = null
|
|
714
|
+
const height = () => panel.getBoundingClientRect().height
|
|
715
|
+
const maximum = () => Math.max(min, Math.min(container.clientHeight * .45,
|
|
716
|
+
height() + element.clientHeight - 120))
|
|
717
|
+
const apply = () => {
|
|
718
|
+
const mobile = matchMedia('(max-width:720px)').matches
|
|
719
|
+
const collapsed = panel.tagName === 'DETAILS' && !panel.open
|
|
720
|
+
divider.hidden = mobile || collapsed
|
|
721
|
+
if (mobile || collapsed) { panel.style.removeProperty('height'); return }
|
|
722
|
+
const max = Math.floor(maximum())
|
|
723
|
+
const value = Math.round(Math.max(min, Math.min(preferred, max)))
|
|
724
|
+
panel.style.height = `${value}px`
|
|
725
|
+
divider.setAttribute('aria-valuemin', String(min))
|
|
726
|
+
divider.setAttribute('aria-valuemax', String(max))
|
|
727
|
+
divider.setAttribute('aria-valuenow', String(value))
|
|
728
|
+
divider.setAttribute('aria-valuetext', `${value} pixels`)
|
|
729
|
+
}
|
|
730
|
+
const set = value => {
|
|
731
|
+
preferred = Math.max(min, Math.min(value, maximum()))
|
|
732
|
+
store.set(key, String(Math.round(preferred)))
|
|
733
|
+
apply()
|
|
734
|
+
}
|
|
735
|
+
const reset = () => { preferred = initial; store.clear(key); apply() }
|
|
736
|
+
const stop = () => {
|
|
737
|
+
if (!drag) return
|
|
738
|
+
const id = drag.id
|
|
739
|
+
drag = null
|
|
740
|
+
divider.removeAttribute('data-dragging')
|
|
741
|
+
document.body.removeAttribute('data-panel-resizing')
|
|
742
|
+
if (divider.hasPointerCapture?.(id)) divider.releasePointerCapture(id)
|
|
743
|
+
}
|
|
744
|
+
divider.addEventListener('pointerdown', event => {
|
|
745
|
+
if (event.button) return
|
|
746
|
+
event.preventDefault()
|
|
747
|
+
divider.focus({ preventScroll: true })
|
|
748
|
+
drag = { y: event.clientY, height: height(), id: event.pointerId }
|
|
749
|
+
divider.setPointerCapture(event.pointerId)
|
|
750
|
+
divider.setAttribute('data-dragging', '')
|
|
751
|
+
document.body.setAttribute('data-panel-resizing', '')
|
|
752
|
+
})
|
|
753
|
+
divider.addEventListener('pointermove', event => {
|
|
754
|
+
if (drag && event.pointerId === drag.id) set(drag.height + (event.clientY - drag.y) * (before ? -1 : 1))
|
|
755
|
+
})
|
|
756
|
+
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) divider.addEventListener(event, stop)
|
|
757
|
+
divider.addEventListener('dblclick', reset)
|
|
758
|
+
divider.addEventListener('keydown', event => {
|
|
759
|
+
const delta = { ArrowUp: -10, ArrowDown: 10 }[event.key]
|
|
760
|
+
if (delta !== undefined) { event.preventDefault(); set(height() + delta * (before ? -1 : 1)) }
|
|
761
|
+
else if (['Home', 'End', 'Enter', ' '].includes(event.key)) {
|
|
762
|
+
event.preventDefault()
|
|
763
|
+
if (event.key === 'Home') set(min)
|
|
764
|
+
else if (event.key === 'End') set(maximum())
|
|
765
|
+
else reset()
|
|
766
|
+
}
|
|
767
|
+
})
|
|
768
|
+
panel.addEventListener('toggle', apply)
|
|
769
|
+
const observer = new ResizeObserver(apply)
|
|
770
|
+
observer.observe(container)
|
|
771
|
+
addEventListener('resize', apply)
|
|
772
|
+
const dispose = () => { stop(); observer.disconnect(); removeEventListener('resize', apply); panel.removeEventListener('toggle', apply); divider.remove() }
|
|
773
|
+
disposers.push(dispose)
|
|
774
|
+
apply()
|
|
775
|
+
return dispose
|
|
776
|
+
}
|
|
777
|
+
const composer = $('composer')
|
|
778
|
+
if (composer) addPanel(composer, { key: 'fleet:composer-height', label: 'Resize message composer', min: 130, initial: 170, before: true })
|
|
779
|
+
let board = null, disposeBoard = null
|
|
780
|
+
const syncBoard = () => {
|
|
781
|
+
const next = $('initiative-board')
|
|
782
|
+
if (next === board) return
|
|
783
|
+
disposeBoard?.()
|
|
784
|
+
board = next
|
|
785
|
+
if (board) {
|
|
786
|
+
disposeBoard = addPanel(board, { key: 'fleet:overview-height', label: 'Resize team overview', min: 90, initial: 220 })
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const mutation = new MutationObserver(syncBoard)
|
|
790
|
+
mutation.observe(container, { childList: true })
|
|
791
|
+
syncBoard()
|
|
792
|
+
panelLayoutCleanup = () => { mutation.disconnect(); disposers.forEach(dispose => dispose()) }
|
|
527
793
|
}
|
|
528
|
-
window.FleetLayout = { watchConversation }
|
|
529
794
|
initSplitter()
|
|
530
795
|
|
|
531
796
|
// The session details sit behind a disclosure: with a console on screen the terminal
|
|
@@ -545,4 +810,4 @@ $('details-toggle')?.addEventListener('click', () => {
|
|
|
545
810
|
store.set(DETAILS_KEY, open ? '1' : '0')
|
|
546
811
|
syncDetails()
|
|
547
812
|
})
|
|
548
|
-
|
|
813
|
+
})()
|
package/public/ask.js
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
+
// An isolated scope, matching teams.js and blocks.js.
|
|
3
|
+
;(() => {
|
|
4
|
+
const { $, esc, key, age, update, status, render, toast, modalIsOpen, openModal, closeModal } = window.Fleet
|
|
5
|
+
const api = (...args) => window.FleetControl.api(...args)
|
|
2
6
|
// Ask panel: one question, searched across every transcript on this machine.
|
|
3
7
|
// Keyword matches render the moment the server has them; the written answer
|
|
4
8
|
// arrives a few seconds later and reorders the cards by what Claude found relevant.
|
|
5
9
|
let askJob = null, askPoll = null, askRequest = 0
|
|
6
10
|
const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
|
|
7
|
-
$('ask-shortcut').textContent = isMac ? '⌘K' : 'Ctrl K'
|
|
8
11
|
|
|
9
12
|
const openAsk = () => { renderAsk(); openModal('ask-backdrop', '#ask-input') }
|
|
10
|
-
|
|
13
|
+
|
|
14
|
+
// ── What the rest of the page may use ───────────────────────────────────────
|
|
15
|
+
// Published before the boot wiring below, as app.js and control.js do. Nothing
|
|
16
|
+
// consumes this yet; it exists so opening the panel is a call rather than a
|
|
17
|
+
// synthesised click, and so this file leaks one name like every other.
|
|
18
|
+
window.FleetAsk = { openAsk }
|
|
19
|
+
|
|
20
|
+
// ── Boot ────────────────────────────────────────────────────────────────────
|
|
21
|
+
if ($('ask-shortcut')) $('ask-shortcut').textContent = isMac ? '⌘K' : 'Ctrl K'
|
|
22
|
+
$('ask-welcome')?.addEventListener('click', event => {
|
|
11
23
|
const suggestion = event.target.closest('[data-question]')
|
|
12
24
|
if (!suggestion) return
|
|
13
25
|
$('ask-input').value = suggestion.dataset.question
|
|
14
26
|
$('ask-input').focus()
|
|
15
27
|
})
|
|
16
|
-
$('ask-sessions')
|
|
28
|
+
$('ask-sessions')?.addEventListener('click', () => modalIsOpen('ask-backdrop') ? closeModal() : openAsk())
|
|
17
29
|
document.addEventListener('keydown', event => {
|
|
18
30
|
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') { event.preventDefault(); openAsk() }
|
|
19
31
|
})
|
|
20
32
|
|
|
21
|
-
$('ask-form')
|
|
33
|
+
$('ask-form')?.addEventListener('submit', async event => {
|
|
22
34
|
event.preventDefault()
|
|
23
35
|
const question = $('ask-input').value.trim()
|
|
24
36
|
if (!question) return
|
|
@@ -55,7 +67,9 @@ async function pollAsk() {
|
|
|
55
67
|
|
|
56
68
|
const REL_WORD = { high: 'strong match', medium: 'related', low: 'loosely related' }
|
|
57
69
|
const dateOf = ms => ms ? new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' }) : ''
|
|
58
|
-
|
|
70
|
+
// Read through the accessor, never a captured binding: the poll replaces the
|
|
71
|
+
// snapshot object every couple of seconds.
|
|
72
|
+
const liveSession = id => window.Fleet.snapshot()?.sessions.find(s => s.sessionId === id) || null
|
|
59
73
|
|
|
60
74
|
function hitCard(hit, match) {
|
|
61
75
|
const live = liveSession(hit.sessionId)
|
|
@@ -97,7 +111,7 @@ function renderAsk() {
|
|
|
97
111
|
update('ask-results', `<div class="ask-head"><span class="ask-question">“${esc(job.question)}”</span>${stats}</div>${status}${answer}${cards}`)
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
$('ask-results')
|
|
114
|
+
$('ask-results')?.addEventListener('click', async event => {
|
|
101
115
|
const button = event.target.closest('button')
|
|
102
116
|
if (!button) return
|
|
103
117
|
if (button.dataset.openSession) {
|
|
@@ -117,3 +131,5 @@ $('ask-results').addEventListener('click', async event => {
|
|
|
117
131
|
// Live sessions may appear or vanish while the results are on screen; refresh the
|
|
118
132
|
// "open now" state and the Open button from the latest snapshot.
|
|
119
133
|
document.addEventListener('fleet-snapshot', () => { if (askJob && modalIsOpen('ask-backdrop')) renderAsk() })
|
|
134
|
+
|
|
135
|
+
})()
|
package/public/blocks.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
+
// An isolated scope, matching teams.js. This file borrows nothing from the others.
|
|
3
|
+
window.FleetBlocks = (() => {
|
|
2
4
|
// Warp-style conversation blocks. Every message, tool call and result is its own
|
|
3
5
|
// block with a sticky header, a copy action and collapse. Rendering is incremental:
|
|
4
6
|
// a block is only rebuilt when its content signature changes, so a streaming turn
|
|
@@ -190,4 +192,5 @@ function renderBlocks(container, messages, { streamingId = null, onCopy = () =>
|
|
|
190
192
|
for (const element of [...container.children]) if (!seen.has(element.dataset?.block)) element.remove()
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
|
|
195
|
+
return { renderBlocks, proseHtml, codeHtml, highlight }
|
|
196
|
+
})()
|
package/public/control.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
+
// An isolated scope, matching teams.js and blocks.js. What this file borrows from
|
|
3
|
+
// app.js is destructured once, here, instead of being picked out of a global scope
|
|
4
|
+
// the two files happened to share.
|
|
5
|
+
;(() => {
|
|
6
|
+
const { $, esc, update, toast, modalIsOpen, openModal, closeModal } = window.Fleet
|
|
2
7
|
let controlToken=null, controlSession=null, controlId=null, controlFetch=null, controlVersion=0
|
|
3
8
|
const drafts=new Map()
|
|
4
9
|
const inFlight=new Set()
|
|
@@ -60,7 +65,23 @@ function openLaunch(source=null) {
|
|
|
60
65
|
$('launch-cwd').readOnly=!!source
|
|
61
66
|
openModal('launch-backdrop', '[name=prompt]')
|
|
62
67
|
}
|
|
63
|
-
|
|
68
|
+
// ── What the rest of the page may use ───────────────────────────────────────
|
|
69
|
+
// Published before the boot wiring below, for the same reason app.js does it there:
|
|
70
|
+
// teams.js destructures this at load, and an element missing from the wiring must
|
|
71
|
+
// not cost it the whole namespace. The two values teams.js has to change are handed
|
|
72
|
+
// out as setters rather than as variables it reaches in and assigns.
|
|
73
|
+
window.FleetControl = {
|
|
74
|
+
selectControl, isWorking, updateLaunchTeam, renderUpdate,
|
|
75
|
+
// teams.js posts to the same endpoints through the same helper, and app.js and
|
|
76
|
+
// ask.js borrow it back: this file owns the token every write is signed with.
|
|
77
|
+
api,
|
|
78
|
+
launchTeams: () => launchTeams,
|
|
79
|
+
setLaunchTeams(teams) { launchTeams = teams },
|
|
80
|
+
setLaunchRequestId(id) { launchRequestId = id },
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Boot ────────────────────────────────────────────────────────────────────
|
|
84
|
+
$('new-session')?.addEventListener('click',()=>modalIsOpen('launch-backdrop') ? closeModal() : openLaunch())
|
|
64
85
|
document.addEventListener('keydown', event => {
|
|
65
86
|
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'n') {
|
|
66
87
|
event.preventDefault()
|
|
@@ -68,8 +89,8 @@ document.addEventListener('keydown', event => {
|
|
|
68
89
|
else openLaunch()
|
|
69
90
|
}
|
|
70
91
|
})
|
|
71
|
-
$('launch-form')
|
|
72
|
-
$('launch-form')
|
|
92
|
+
$('launch-form')?.addEventListener('input',()=>{launchRequestId=null})
|
|
93
|
+
$('launch-form')?.addEventListener('submit',async event=>{
|
|
73
94
|
event.preventDefault()
|
|
74
95
|
if(window.FleetTeams?.isEditing()){window.FleetTeams.save();return}
|
|
75
96
|
const button=$('launch-submit'); if(button.disabled)return
|
|
@@ -91,12 +112,13 @@ function selectControl(session) {
|
|
|
91
112
|
const next=session?.managedId || null
|
|
92
113
|
if(next===controlId && next) return
|
|
93
114
|
controlId=next;controlSession=null;controlVersion++
|
|
115
|
+
window.Fleet.watchConversation(null)
|
|
94
116
|
$('control-panel').innerHTML=''
|
|
95
117
|
if(next){
|
|
96
118
|
$('control-panel').innerHTML=`<div class="conversation-header"><h3 id="conversation-title">Conversation</h3><button type="button" id="close-agent" class="button close-agent" title="Remove this conversation from Fleet">Close</button><label class="mode-picker"><span class="sr-only">Model for this agent</span><select id="model-choice" title="Applies from your next message"></select></label><label class="mode-picker"><span class="sr-only">Approvals for this agent</span><select id="approval-mode"><option value="auto">Auto approvals</option><option value="ask">Ask every time</option><option value="all">Approve everything</option></select></label><span id="agent-context" class="subtle context-chip"></span><span id="agent-state" class="subtle">Connecting…</span></div><div id="conversation" class="conversation" role="log" aria-label="Agent conversation" aria-live="off"><p class="note">Loading conversation…</p></div><div id="agent-error" class="form-error" role="status" hidden></div><div id="approvals"></div><form id="composer" class="composer"><label class="sr-only" for="message-input">Message this agent</label><ul id="slash-picker" class="slash-picker" role="listbox" aria-label="Commands and skills" hidden></ul><div id="attach-tray" class="attach-tray" hidden></div><textarea id="message-input" rows="3" maxlength="16000" placeholder="What should this agent do next? · press / for commands · paste an image" role="combobox" aria-expanded="false" aria-controls="slash-picker" aria-autocomplete="list"></textarea><div class="composer-footer"><span id="composer-hint" class="note">Enter to send · Shift + Enter for a new line</span><button id="stop-agent" type="button" class="button stop" hidden>■ Stop</button><button id="send-message" class="button resume" type="submit">Send ↗</button></div><p id="send-error" class="form-error" role="alert" hidden></p></form>`
|
|
97
|
-
window.
|
|
119
|
+
window.Fleet.watchConversation($('conversation'))
|
|
98
120
|
catalog=[];catalogFor=null;closePicker();renderTray()
|
|
99
|
-
window.
|
|
121
|
+
window.Fleet.syncDetails()
|
|
100
122
|
$('message-input').value=drafts.get(next)?.text || ''
|
|
101
123
|
$('message-input').addEventListener('input',()=>drafts.set(next,{text:$('message-input').value,requestId:crypto.randomUUID()}))
|
|
102
124
|
$('message-input').addEventListener('keydown',event=>{
|
|
@@ -129,7 +151,7 @@ function selectControl(session) {
|
|
|
129
151
|
}else if(session){
|
|
130
152
|
$('control-panel').innerHTML=`<div class="external-note"><strong>Opened outside Fleet</strong><p>${session.alive ? 'This session is running in a terminal. Use its terminal to send messages, or launch a new Fleet-managed agent.' : 'This process has stopped. Continue its saved conversation here with a new message.'}</p>${!session.alive && session.sessionId && session.cwd ? '<button id="resume-in-fleet" class="button">Continue in Fleet ↗</button>' : ''}</div>`
|
|
131
153
|
$('resume-in-fleet')?.addEventListener('click',()=>openLaunch(session))
|
|
132
|
-
window.
|
|
154
|
+
window.Fleet.syncDetails()
|
|
133
155
|
}
|
|
134
156
|
}
|
|
135
157
|
async function refreshControl() {
|
|
@@ -439,7 +461,7 @@ async function waitForRestart(deadline = Date.now() + 60000) {
|
|
|
439
461
|
renderUpdate()
|
|
440
462
|
toast('Fleet installed the update but did not come back. Start it again.')
|
|
441
463
|
}
|
|
442
|
-
$('update-pill')
|
|
464
|
+
$('update-pill')?.addEventListener('click', async () => {
|
|
443
465
|
if (fleetUpdateBusy || !fleetUpdate || !fleetUpdate.canInstall) return
|
|
444
466
|
fleetUpdateBusy = true
|
|
445
467
|
renderUpdate()
|
|
@@ -463,3 +485,4 @@ pollUpdate()
|
|
|
463
485
|
// time to answer, then settle into a slow poll for long-lived windows.
|
|
464
486
|
setTimeout(pollUpdate, 9000)
|
|
465
487
|
setInterval(pollUpdate, 60 * 60 * 1000)
|
|
488
|
+
})()
|