@sergeychuvayev/claude-fleet 0.1.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/LICENSE +21 -0
- package/README.md +377 -0
- package/archive.js +96 -0
- package/bin/claude-fleet.js +102 -0
- package/build/make-app.sh +110 -0
- package/catalog.js +117 -0
- package/fleet.js +450 -0
- package/managed.js +456 -0
- package/package.json +62 -0
- package/paths.js +64 -0
- package/permissions.js +73 -0
- package/public/app.js +369 -0
- package/public/ask.js +119 -0
- package/public/blocks.js +180 -0
- package/public/control.js +426 -0
- package/public/icons/fleet-192.png +0 -0
- package/public/icons/fleet-512.png +0 -0
- package/public/index.html +48 -0
- package/public/styles.css +454 -0
- package/public/vendor/libs.js +75 -0
- package/search.js +425 -0
- package/server.js +255 -0
- package/theme.js +89 -0
- package/update.js +183 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const $ = id => document.getElementById(id)
|
|
3
|
+
const STATES = ['busy', 'idle', 'stale', 'dead']
|
|
4
|
+
const LABELS = { busy: 'Working', idle: 'Waiting', stale: 'Stale', dead: 'Offline' }
|
|
5
|
+
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
|
6
|
+
const key = s => s.managedId || s.sessionId || `session:${s.pid}`
|
|
7
|
+
const percent = s => s.contextTokens == null ? null : Math.min(100, Math.max(0, s.contextTokens / s.contextLimit * 100))
|
|
8
|
+
const heat = p => p >= 90 ? 'hot' : p >= 75 ? 'warn' : ''
|
|
9
|
+
const tokens = n => n >= 1000000 ? `${(n / 1000000).toFixed(1)}m` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
|
|
10
|
+
const age = timestamp => {
|
|
11
|
+
if (!timestamp) return '—'
|
|
12
|
+
const secs = Math.max(0, Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000))
|
|
13
|
+
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
|
+
}
|
|
15
|
+
let snapshot = null, filter = 'all', selected = null, pending = false, toastTimer
|
|
16
|
+
function update(id, html) {
|
|
17
|
+
const el = $(id)
|
|
18
|
+
if (!el || el.innerHTML === html) return
|
|
19
|
+
const active = document.activeElement
|
|
20
|
+
const focusKey = el.contains(active) ? active.dataset.session || active.dataset.filter : null
|
|
21
|
+
const top = el.scrollTop
|
|
22
|
+
const responseTop = el.querySelector('.response')?.scrollTop || 0
|
|
23
|
+
el.innerHTML = html
|
|
24
|
+
el.scrollTop = top
|
|
25
|
+
if (el.querySelector('.response')) el.querySelector('.response').scrollTop = responseTop
|
|
26
|
+
if (focusKey) [...el.querySelectorAll('button')].find(b => b.dataset.session === focusKey || b.dataset.filter === focusKey)?.focus({ preventScroll: true })
|
|
27
|
+
}
|
|
28
|
+
function status(s) {
|
|
29
|
+
// A Fleet conversation resumed in a terminal is driven there, whatever Fleet last recorded.
|
|
30
|
+
if (s.managed && s.openElsewhere) {
|
|
31
|
+
const where = s.openElsewhere.entrypoint === 'cli' ? 'In terminal' : 'Elsewhere'
|
|
32
|
+
const title = `Open ${s.openElsewhere.entrypoint === 'cli' ? 'in a terminal' : 'in another program'}${s.openElsewhere.name ? ' (' + s.openElsewhere.name + ')' : ''}${s.openElsewhere.state === 'busy' ? ', working' : ', idle'}`
|
|
33
|
+
return `<span class="badge elsewhere ${s.openElsewhere.state === 'busy' ? 'busy' : ''}" title="${esc(title)}"><span class="dot"></span>${where}</span>`
|
|
34
|
+
}
|
|
35
|
+
const label = s.managed ? ({starting:'Starting',running:'Working',approval:'Needs approval',stopping:'Stopping',stopped:'Stopped',error:'Error',idle:'Ready'})[s.managedStatus] : LABELS[s.state]
|
|
36
|
+
return `<span class="badge ${s.managedStatus === 'approval' ? 'stale' : s.managedStatus === 'error' ? 'hot' : s.state}"><span class="dot"></span>${label}</span>`
|
|
37
|
+
}
|
|
38
|
+
// The row's third line is the story of the latest turn, the way a CI job row shows
|
|
39
|
+
// its steps: one segment per tool call coloured by the family of work, red where it
|
|
40
|
+
// failed; then the step in progress (or the last one), then how long the turn has run.
|
|
41
|
+
const isWorkingRow = s => s.openElsewhere ? s.openElsewhere.state === 'busy' : s.managed ? ['starting','running','stopping'].includes(s.managedStatus) : s.state === 'busy'
|
|
42
|
+
const STEP_ICON = { inspect: '▤', change: '✎', run: '⚡', delegate: '✳', ask: '?', other: '▸' }
|
|
43
|
+
const STEP_WORD = { inspect: 'inspecting', change: 'changing files', run: 'running commands', delegate: 'delegating', ask: 'asking', other: 'other' }
|
|
44
|
+
const stepCategory = name => ['Read','Grep','Glob','LS','WebFetch','WebSearch'].includes(name) ? 'inspect' : ['Edit','Write','MultiEdit','NotebookEdit'].includes(name) ? 'change' : ['Bash','BashOutput','KillShell'].includes(name) ? 'run' : ['Task','Skill','Agent'].includes(name) ? 'delegate' : ['AskUserQuestion','ExitPlanMode','EnterPlanMode'].includes(name) ? 'ask' : 'other'
|
|
45
|
+
const elapsed = ms => ms < 60000 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600000 ? `${Math.floor(ms / 60000)}m ${String(Math.round(ms % 60000 / 1000)).padStart(2, '0')}s` : `${Math.floor(ms / 3600000)}h ${Math.floor(ms % 3600000 / 60000)}m`
|
|
46
|
+
function turnRow(s) {
|
|
47
|
+
const t = s.turn
|
|
48
|
+
if (!t || (!t.steps.length && !t.current && !t.last)) return ''
|
|
49
|
+
const working = isWorkingRow(s)
|
|
50
|
+
const failed = t.steps.filter(st => !st.ok).length
|
|
51
|
+
const tally = {}
|
|
52
|
+
for (const st of t.steps) tally[st.k] = (tally[st.k] || 0) + 1
|
|
53
|
+
const spoken = Object.entries(tally).map(([k, n]) => `${n} ${STEP_WORD[k] || k}`).join(', ') + (failed ? `, ${failed} failed` : '')
|
|
54
|
+
const bar = t.steps.map(st => `<i class="${st.ok ? 'k-' + st.k : 'k-failed'}" title="${esc(st.t)}${st.target ? ' · ' + esc(st.target) : ''}${st.ok ? '' : ' · failed'}"></i>`).join('')
|
|
55
|
+
const step = t.current || t.last
|
|
56
|
+
const icon = step ? STEP_ICON[stepCategory(step.t)] || '▸' : ''
|
|
57
|
+
const label = step ? `${esc(step.t)}${step.target ? ` <span class="step-target">${esc(step.target)}</span>` : ''}` : ''
|
|
58
|
+
const when = working && t.turnStartedAt ? elapsed(Date.now() - t.turnStartedAt) : step && step.at ? age(step.at) + ' ago' : ''
|
|
59
|
+
const aria = `This turn: ${spoken || 'no tool steps'}${step ? '. ' + (working ? 'Now' : 'Last') + ': ' + step.t + (step.target ? ' ' + step.target : '') : ''}`
|
|
60
|
+
return `<span class="turn" aria-label="${esc(aria)}"><span class="steps ${failed ? 'has-failed' : ''}" aria-hidden="true">${bar}</span>${step ? `<span class="step-now ${working ? 'is-live' : ''}"><span class="step-icon" aria-hidden="true">${icon}</span>${label}</span>` : ''}${when ? `<span class="step-when">${when}</span>` : ''}</span>`
|
|
61
|
+
}
|
|
62
|
+
// "There are answers you have not looked at": the row's newest activity is later
|
|
63
|
+
// than the last time it was open. Selecting a session marks it read.
|
|
64
|
+
let seen = {}
|
|
65
|
+
try { seen = JSON.parse(store.get('fleet:seen') || '{}') } catch { seen = {} }
|
|
66
|
+
function markSeen(k, at) {
|
|
67
|
+
if (!k || !at || seen[k] === at) return
|
|
68
|
+
seen[k] = at
|
|
69
|
+
// Keep the map from growing without bound as sessions come and go.
|
|
70
|
+
const entries = Object.entries(seen).sort((a, b) => b[1] - a[1]).slice(0, 200)
|
|
71
|
+
seen = Object.fromEntries(entries)
|
|
72
|
+
store.set('fleet:seen', JSON.stringify(seen))
|
|
73
|
+
}
|
|
74
|
+
const hasUnseen = s => !!s.lastActivity && key(s) !== selected && (seen[key(s)] || 0) < s.lastActivity
|
|
75
|
+
|
|
76
|
+
function render() {
|
|
77
|
+
if (!snapshot) return
|
|
78
|
+
const {sessions, total} = snapshot
|
|
79
|
+
// Archived sessions are put away, not deleted: they leave every count and every
|
|
80
|
+
// filter but their own, and the transcript behind them is untouched.
|
|
81
|
+
const archived = sessions.filter(s => s.archived)
|
|
82
|
+
const live = sessions.filter(s => !s.archived)
|
|
83
|
+
const background = live.filter(s => s.background)
|
|
84
|
+
// How many spawned sessions each visible session is running, for its row badge.
|
|
85
|
+
const spawnCounts = new Map()
|
|
86
|
+
for (const s of background) if (s.spawnedByPid) spawnCounts.set(s.spawnedByPid, (spawnCounts.get(s.spawnedByPid) || 0) + 1)
|
|
87
|
+
const foreground = live.filter(s => !s.background)
|
|
88
|
+
const visibleCounts = { busy:0, idle:0, stale:0, dead:0 }
|
|
89
|
+
for (const s of foreground) visibleCounts[s.state] = (visibleCounts[s.state] || 0) + 1
|
|
90
|
+
// Restoring the last archived session should not strand you on an empty filter.
|
|
91
|
+
if (filter === 'archived' && !archived.length) filter = 'all'
|
|
92
|
+
const pool = filter === 'archived' ? archived : filter === 'background' ? background : foreground
|
|
93
|
+
// Ordering comes from the server (approval, then busy, then most recent) and
|
|
94
|
+
// finding a specific session is what the Ask modal is for.
|
|
95
|
+
const shown = pool.filter(s => filter === 'all' || filter === 'background' || filter === 'archived' || s.state === filter)
|
|
96
|
+
if (!shown.some(s => key(s) === selected)) selected = shown[0] ? key(shown[0]) : null
|
|
97
|
+
$('shown-count').textContent = shown.length
|
|
98
|
+
update('filters', [['all','All sessions',foreground.length],...STATES.map(s => [s,LABELS[s],(visibleCounts[s] || 0)]),...(background.length ? [['background','Background',background.length]] : []),...(archived.length ? [['archived','Archived',archived.length]] : [])].map(([s,label,n]) => `<button class="filter" data-filter="${s}" aria-pressed="${filter === s}">${label}<span>${n}</span></button>`).join(''))
|
|
99
|
+
renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
|
|
100
|
+
update('session-list', shown.length ? shown.map(s => {
|
|
101
|
+
const p = percent(s)
|
|
102
|
+
return `<button class="session" data-session="${esc(key(s))}" aria-pressed="${selected === key(s)}" 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><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>` : ''}</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>`
|
|
103
|
+
}).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>`)
|
|
104
|
+
const current = shown.find(s => key(s) === selected)
|
|
105
|
+
if (current) markSeen(key(current), current.lastActivity)
|
|
106
|
+
renderDetail(current)
|
|
107
|
+
if (typeof selectControl === 'function') selectControl(current)
|
|
108
|
+
}
|
|
109
|
+
// ── The archive ──────────────────────────────────────────────────────────────
|
|
110
|
+
// Putting a session away hides its row and nothing else: the transcript stays in
|
|
111
|
+
// ~/.claude, `claude --resume` still reaches it, and Ask still finds it. One age
|
|
112
|
+
// threshold serves both the one-off sweep and the standing rule, so the button and
|
|
113
|
+
// the checkbox can never disagree about what "old" means.
|
|
114
|
+
const DAY_MS = 86400000
|
|
115
|
+
const SWEEP_DAYS = [3, 7, 14, 30]
|
|
116
|
+
const archiveRule = () => (snapshot && snapshot.archiveRule) || { enabled: false, days: 14 }
|
|
117
|
+
const sweepTargets = () => {
|
|
118
|
+
const cutoff = Date.now() - archiveRule().days * DAY_MS
|
|
119
|
+
return (snapshot ? snapshot.sessions : []).filter(s => !s.archived && !s.managed && s.state === 'dead' && s.sessionId && s.lastActivity && s.lastActivity < cutoff)
|
|
120
|
+
}
|
|
121
|
+
// Boolean attributes are written the way the browser serialises them, so an
|
|
122
|
+
// unchanged bar compares equal and a refresh never closes an open dropdown.
|
|
123
|
+
function renderArchiveBar(offline, archivedCount) {
|
|
124
|
+
const bar = $('archive-bar')
|
|
125
|
+
if (!bar) return
|
|
126
|
+
if (filter !== 'dead' && filter !== 'archived') { bar.hidden = true; return }
|
|
127
|
+
bar.hidden = false
|
|
128
|
+
if (filter === 'archived') return update('archive-bar', `<span class="archive-text">${archivedCount} session${archivedCount === 1 ? '' : 's'} put away. Each one still resumes in a terminal and still answers an Ask.</span><button class="button" id="archive-restore-all">Restore all</button>`)
|
|
129
|
+
const rule = archiveRule()
|
|
130
|
+
const stale = offline.filter(s => s.lastActivity && Date.now() - s.lastActivity > rule.days * DAY_MS).length
|
|
131
|
+
// A stored threshold that is not one of the presets is still offered, so the
|
|
132
|
+
// dropdown can never show a different number than the rule is actually using.
|
|
133
|
+
const days = [...new Set([...SWEEP_DAYS, rule.days])].sort((a, b) => a - b)
|
|
134
|
+
update('archive-bar', `<span class="archive-text">Archive offline sessions untouched for over</span><select id="archive-days" class="archive-days" aria-label="Age after which an offline session counts as old">${days.map(d => `<option value="${d}"${d === rule.days ? ' selected=""' : ''}>${d} days</option>`).join('')}</select>${stale ? `<button class="button" id="archive-sweep">Archive ${stale}</button>` : '<span class="archive-none">Nothing that old</span>'}<label class="archive-auto"><input type="checkbox" id="archive-rule"${rule.enabled ? ' checked=""' : ''}> Keep tidying automatically</label>`)
|
|
135
|
+
}
|
|
136
|
+
async function setArchived(ids, archived) {
|
|
137
|
+
if (!ids.length) return
|
|
138
|
+
try {
|
|
139
|
+
await api('/api/archive', { ids, archived })
|
|
140
|
+
toast(`${ids.length} session${ids.length === 1 ? '' : 's'} ${archived ? 'archived' : 'restored'}`)
|
|
141
|
+
await tick()
|
|
142
|
+
} catch (error) { toast(error.message || 'Could not update the archive.') }
|
|
143
|
+
}
|
|
144
|
+
document.addEventListener('change', async event => {
|
|
145
|
+
const target = event.target
|
|
146
|
+
if (target.id !== 'archive-days' && target.id !== 'archive-rule') return
|
|
147
|
+
const rule = archiveRule()
|
|
148
|
+
try {
|
|
149
|
+
await api('/api/archive/rule', target.id === 'archive-days'
|
|
150
|
+
? { enabled: rule.enabled, days: Number(target.value) }
|
|
151
|
+
: { enabled: target.checked, days: rule.days })
|
|
152
|
+
await tick()
|
|
153
|
+
} catch (error) { toast(error.message || 'Could not save the archive rule.') }
|
|
154
|
+
})
|
|
155
|
+
function renderDetail(s) {
|
|
156
|
+
if (!s) { update('detail-content','<div class="detail-empty"><span class="empty-symbol">⌘</span><h2>The full picture.</h2><p>Select a session to inspect it.</p></div>'); return }
|
|
157
|
+
const p = percent(s)
|
|
158
|
+
const links = (s.links || []).filter(l => /^https:\/\/(github\.com|linear\.app)\//.test(l.url))
|
|
159
|
+
const facts = [['Project',s.cwdShort],['Branch',s.branch],['Model',s.model?.replace('claude-','')],['Permissions',s.permissionMode || 'Default'],['Control',s.managed ? 'Fleet-managed' : s.alive ? 'Terminal · monitor only' : 'Saved · ready to continue'],['Session',s.sessionId]]
|
|
160
|
+
update('detail-content', `<div class="detail-top"><span class="eyebrow">SESSION INSPECTOR</span>${status(s)}</div><h2>${esc(s.title || s.name || 'Untitled session')}</h2><div class="detail-name">${esc(s.name || s.shortId)} · Active ${age(s.lastActivity)} ago</div><div class="context-label"><span>Context window</span><span class="${heat(p)}">${p === null ? 'Not available' : `${tokens(s.contextTokens)} / ${tokens(s.contextLimit)} · ${Math.round(p)}%`}</span></div><div class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></div>${p >= 75 ? `<p class="note ${heat(p)}">${p >= 90 ? 'Context nearly full. Compaction may happen soon.' : 'Context is getting full.'}</p>` : ''}${s.managed ? '' : `<section class="detail-section"><h3>Latest response <span>${s.latestResponseAt ? age(s.latestResponseAt)+' ago' : ''}</span></h3><div class="response ${s.latestResponse ? '' : 'missing'}">${esc(s.latestResponse || 'No assistant response recorded yet.')}</div></section>`}${s.lastPrompt && !s.managed ? `<section class="detail-section"><h3>Latest request</h3><div class="response">${esc(s.lastPrompt)}</div></section>` : ''}<section class="detail-section"><h3>Linked work <span>From transcript</span></h3>${links.length ? `<div class="links">${links.map(l => `<a class="work-link" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer" title="${esc(l.url)}">${l.kind === 'pr' ? '⑂' : '◩'} ${esc(l.label)} ↗</a>`).join('')}</div><p class="note" style="margin-top:9px">Recorded references, not live status.</p>` : '<p class="note">GitHub PR and Linear issue URLs appear here when mentioned in the conversation.</p>'}</section><section class="detail-section"><h3>Environment</h3><dl class="facts">${facts.map(([label,value]) => `<dt>${label}</dt><dd>${esc(value ?? '—')}</dd>`).join('')}</dl></section>${s.transcriptTruncated ? '<p class="note">Showing the most recent 6 MB of this transcript. Earlier responses and links may be absent.</p>' : ''}${s.archived ? '<p class="note archived-note">Archived. Hidden from your fleet, still on disk, still resumable and still searchable.</p>' : ''}<div class="detail-actions"><span class="subtle">${s.messages} recorded messages</span><span class="detail-buttons">${s.managed || !s.sessionId ? '' : `<button class="button" id="toggle-archive">${s.archived ? 'Restore' : 'Archive'}</button>`}${s.resumeCmd && !s.managed ? '<button class="button resume" id="copy-resume">Copy resume command ↗</button>' : ''}</span></div>`)
|
|
161
|
+
}
|
|
162
|
+
// ── Modals ───────────────────────────────────────────────────────────────────
|
|
163
|
+
// Ask and New agent are overlays, not panels that push the workspace down. One at
|
|
164
|
+
// a time, Escape and backdrop close them, Tab stays inside, and focus returns to
|
|
165
|
+
// whatever opened it.
|
|
166
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
|
167
|
+
let openModalId = null, modalReturnFocus = null
|
|
168
|
+
const modalIsOpen = id => openModalId === id
|
|
169
|
+
function openModal(id, focusSelector) {
|
|
170
|
+
const backdrop = $(id)
|
|
171
|
+
if (!backdrop) return
|
|
172
|
+
if (openModalId && openModalId !== id) closeModal()
|
|
173
|
+
if (openModalId !== id) {
|
|
174
|
+
modalReturnFocus = document.activeElement
|
|
175
|
+
backdrop.hidden = false
|
|
176
|
+
openModalId = id
|
|
177
|
+
document.body.setAttribute('data-modal', '')
|
|
178
|
+
document.querySelectorAll('body > .topbar, body > main').forEach(el => { el.inert = true })
|
|
179
|
+
document.querySelector(`[aria-controls="${id}"]`)?.setAttribute('aria-expanded', 'true')
|
|
180
|
+
}
|
|
181
|
+
const target = (focusSelector && backdrop.querySelector(focusSelector)) || backdrop.querySelector(FOCUSABLE)
|
|
182
|
+
target?.focus()
|
|
183
|
+
if (target && target.select) target.select()
|
|
184
|
+
}
|
|
185
|
+
function closeModal() {
|
|
186
|
+
if (!openModalId) return
|
|
187
|
+
const backdrop = $(openModalId)
|
|
188
|
+
if (backdrop) backdrop.hidden = true
|
|
189
|
+
document.querySelector(`[aria-controls="${openModalId}"]`)?.setAttribute('aria-expanded', 'false')
|
|
190
|
+
openModalId = null
|
|
191
|
+
document.body.removeAttribute('data-modal')
|
|
192
|
+
document.querySelectorAll('body > .topbar, body > main').forEach(el => { el.inert = false })
|
|
193
|
+
const back = modalReturnFocus
|
|
194
|
+
modalReturnFocus = null
|
|
195
|
+
if (back && back.isConnected) back.focus()
|
|
196
|
+
}
|
|
197
|
+
document.addEventListener('keydown', event => {
|
|
198
|
+
if (!openModalId) return
|
|
199
|
+
if (event.key === 'Escape') { event.preventDefault(); return closeModal() }
|
|
200
|
+
if (event.key !== 'Tab') return
|
|
201
|
+
const items = [...$(openModalId).querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null || el === document.activeElement)
|
|
202
|
+
if (!items.length) return
|
|
203
|
+
const first = items[0], last = items[items.length - 1]
|
|
204
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
|
205
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
|
206
|
+
})
|
|
207
|
+
document.addEventListener('click', event => {
|
|
208
|
+
if (!openModalId) return
|
|
209
|
+
// The backdrop itself, or an explicit close button. Never a click inside the dialog.
|
|
210
|
+
if (event.target === $(openModalId) || event.target.closest('[data-close-modal]')) closeModal()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
function toast(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').hidden = true, 3000) }
|
|
214
|
+
document.addEventListener('click', async event => {
|
|
215
|
+
const b = event.target.closest('button')
|
|
216
|
+
if (!b) return
|
|
217
|
+
if (b.dataset.filter) { filter = b.dataset.filter; render() }
|
|
218
|
+
if (b.dataset.session) {
|
|
219
|
+
selected = b.dataset.session; render()
|
|
220
|
+
if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
|
|
221
|
+
}
|
|
222
|
+
if (b.id === 'archive-sweep') return setArchived(sweepTargets().map(s => s.sessionId), true)
|
|
223
|
+
if (b.id === 'archive-restore-all') return setArchived(snapshot.sessions.filter(s => s.archived && s.sessionId).map(s => s.sessionId), false)
|
|
224
|
+
if (b.id === 'toggle-archive') {
|
|
225
|
+
const s = snapshot?.sessions.find(s => key(s) === selected)
|
|
226
|
+
if (s?.sessionId) return setArchived([s.sessionId], !s.archived)
|
|
227
|
+
}
|
|
228
|
+
if (b.id === 'copy-resume') {
|
|
229
|
+
const s = snapshot?.sessions.find(s => key(s) === selected)
|
|
230
|
+
if (!s?.resumeCmd) return
|
|
231
|
+
try { await navigator.clipboard.writeText(s.resumeCmd); toast('Resume command copied') }
|
|
232
|
+
catch { toast('Clipboard unavailable. The command is shown below.'); const code = document.createElement('pre'); code.className = 'response'; code.textContent = s.resumeCmd; $('detail').append(code) }
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
async function tick() {
|
|
236
|
+
if (pending) return
|
|
237
|
+
pending = true; $('refresh').disabled = true
|
|
238
|
+
try {
|
|
239
|
+
const r = await fetch('/api/sessions', {cache:'no-store',signal:AbortSignal.timeout(8000)})
|
|
240
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
241
|
+
const data = await r.json()
|
|
242
|
+
if (!Array.isArray(data.sessions) || !data.counts) throw new Error('Invalid response')
|
|
243
|
+
snapshot = data; render()
|
|
244
|
+
// Other panels (the ask results) re-read the snapshot to refresh "open now" state.
|
|
245
|
+
document.dispatchEvent(new CustomEvent('fleet-snapshot'))
|
|
246
|
+
$('connection').textContent = 'Live connection'; $('connection-dot').className = 'dot busy'
|
|
247
|
+
$('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
|
|
248
|
+
$('error').hidden = !data.storageError
|
|
249
|
+
if (data.storageError) $('error').textContent = data.storageError
|
|
250
|
+
} catch {
|
|
251
|
+
$('connection').textContent = 'Disconnected'; $('connection-dot').className = 'dot stale'
|
|
252
|
+
$('error').textContent = snapshot ? 'Connection lost. Showing the last successful snapshot; retrying automatically.' : 'Unable to connect to the local server. Retrying automatically.'
|
|
253
|
+
$('error').hidden = false
|
|
254
|
+
if (!snapshot) { update('session-list','<div class="empty">Waiting for the local server…</div>'); renderDetail(null) }
|
|
255
|
+
} finally { pending = false; $('refresh').disabled = false }
|
|
256
|
+
}
|
|
257
|
+
$('refresh').addEventListener('click',tick)
|
|
258
|
+
tick()
|
|
259
|
+
setInterval(() => { if (!document.hidden) tick() },2000)
|
|
260
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) tick() })
|
|
261
|
+
|
|
262
|
+
// Layout the operator controls: a draggable split between the session list and the
|
|
263
|
+
// inspector, and a resizable console. Both are remembered per browser; a storage
|
|
264
|
+
// failure (private window, blocked site data) only costs the remembered size.
|
|
265
|
+
const LAYOUT = { split: 'fleet:split', height: 'fleet:conv-height' }
|
|
266
|
+
const store = {
|
|
267
|
+
get(key) { try { return localStorage.getItem(key) } catch { return null } },
|
|
268
|
+
set(key, value) { try { localStorage.setItem(key, value) } catch {} },
|
|
269
|
+
clear(key) { try { localStorage.removeItem(key) } catch {} },
|
|
270
|
+
}
|
|
271
|
+
const SPLIT_DEFAULT = 58, LIST_MIN = 300, DETAIL_MIN = 380
|
|
272
|
+
|
|
273
|
+
function applySplit(percent, { save = true } = {}) {
|
|
274
|
+
const value = Math.round(percent * 10) / 10
|
|
275
|
+
document.documentElement.style.setProperty('--split', `${value}%`)
|
|
276
|
+
$('splitter')?.setAttribute('aria-valuenow', String(Math.round(value)))
|
|
277
|
+
if (save) store.set(LAYOUT.split, String(value))
|
|
278
|
+
}
|
|
279
|
+
// Clamp in pixels so neither pane can be squeezed past the point of being usable.
|
|
280
|
+
function clampSplit(percent, width) {
|
|
281
|
+
if (!width) return percent
|
|
282
|
+
return Math.min(Math.max(percent, LIST_MIN / width * 100), (width - DETAIL_MIN) / width * 100)
|
|
283
|
+
}
|
|
284
|
+
function initSplitter() {
|
|
285
|
+
const splitter = $('splitter'), workspace = document.querySelector('.workspace')
|
|
286
|
+
if (!splitter || !workspace) return
|
|
287
|
+
const saved = Number(store.get(LAYOUT.split))
|
|
288
|
+
if (Number.isFinite(saved) && saved > 0) applySplit(saved, { save: false })
|
|
289
|
+
|
|
290
|
+
const move = event => {
|
|
291
|
+
const rect = workspace.getBoundingClientRect()
|
|
292
|
+
if (!rect.width) return
|
|
293
|
+
applySplit(clampSplit((event.clientX - rect.left) / rect.width * 100, rect.width))
|
|
294
|
+
}
|
|
295
|
+
const stop = event => {
|
|
296
|
+
splitter.removeAttribute('data-dragging')
|
|
297
|
+
document.body.removeAttribute('data-resizing')
|
|
298
|
+
splitter.releasePointerCapture?.(event.pointerId)
|
|
299
|
+
removeEventListener('pointermove', move)
|
|
300
|
+
removeEventListener('pointerup', stop)
|
|
301
|
+
removeEventListener('pointercancel', stop)
|
|
302
|
+
}
|
|
303
|
+
splitter.addEventListener('pointerdown', event => {
|
|
304
|
+
if (event.button) return
|
|
305
|
+
event.preventDefault()
|
|
306
|
+
splitter.setAttribute('data-dragging', '')
|
|
307
|
+
document.body.setAttribute('data-resizing', '')
|
|
308
|
+
splitter.setPointerCapture?.(event.pointerId)
|
|
309
|
+
addEventListener('pointermove', move)
|
|
310
|
+
addEventListener('pointerup', stop)
|
|
311
|
+
addEventListener('pointercancel', stop)
|
|
312
|
+
})
|
|
313
|
+
splitter.addEventListener('dblclick', () => { store.clear(LAYOUT.split); applySplit(SPLIT_DEFAULT, { save: false }) })
|
|
314
|
+
splitter.addEventListener('keydown', event => {
|
|
315
|
+
const step = { ArrowLeft: -2, ArrowRight: 2, Home: -100, End: 100 }[event.key]
|
|
316
|
+
if (step === undefined) {
|
|
317
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
318
|
+
event.preventDefault()
|
|
319
|
+
store.clear(LAYOUT.split)
|
|
320
|
+
return applySplit(SPLIT_DEFAULT, { save: false })
|
|
321
|
+
}
|
|
322
|
+
event.preventDefault()
|
|
323
|
+
const width = workspace.getBoundingClientRect().width
|
|
324
|
+
const current = Number(splitter.getAttribute('aria-valuenow')) || SPLIT_DEFAULT
|
|
325
|
+
applySplit(clampSplit(current + step, width), { save: true })
|
|
326
|
+
})
|
|
327
|
+
// A window resize can leave a stored split too narrow for one of the panes.
|
|
328
|
+
addEventListener('resize', () => {
|
|
329
|
+
const width = workspace.getBoundingClientRect().width
|
|
330
|
+
const current = Number(splitter.getAttribute('aria-valuenow')) || SPLIT_DEFAULT
|
|
331
|
+
const clamped = clampSplit(current, width)
|
|
332
|
+
if (Math.abs(clamped - current) > 0.5) applySplit(clamped, { save: false })
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The console is rebuilt whenever a different agent is selected, so its height is
|
|
337
|
+
// restored on each build and written back when the native resize grip is released.
|
|
338
|
+
let conversationObserver = null
|
|
339
|
+
function watchConversation(element) {
|
|
340
|
+
if (!element) return
|
|
341
|
+
const saved = store.get(LAYOUT.height)
|
|
342
|
+
if (saved) element.style.height = saved
|
|
343
|
+
conversationObserver ||= new ResizeObserver(entries => {
|
|
344
|
+
for (const entry of entries) if (entry.target.style.height) store.set(LAYOUT.height, entry.target.style.height)
|
|
345
|
+
})
|
|
346
|
+
conversationObserver.disconnect()
|
|
347
|
+
conversationObserver.observe(element)
|
|
348
|
+
}
|
|
349
|
+
window.FleetLayout = { watchConversation }
|
|
350
|
+
initSplitter()
|
|
351
|
+
|
|
352
|
+
// The session details sit behind a disclosure: with a console on screen the terminal
|
|
353
|
+
// is the point, and the facts below it are reference material. A session with no
|
|
354
|
+
// console (a terminal one, monitor-only) has nothing else to show, so it stays open.
|
|
355
|
+
const DETAILS_KEY = 'fleet:details-open'
|
|
356
|
+
function syncDetails() {
|
|
357
|
+
const toggle = $('details-toggle'), content = $('detail-content'), hasConsole = !!$('composer')
|
|
358
|
+
if (!toggle || !content) return
|
|
359
|
+
toggle.hidden = !hasConsole
|
|
360
|
+
const open = !hasConsole || store.get(DETAILS_KEY) === '1'
|
|
361
|
+
content.hidden = !open
|
|
362
|
+
toggle.setAttribute('aria-expanded', String(open))
|
|
363
|
+
}
|
|
364
|
+
$('details-toggle')?.addEventListener('click', () => {
|
|
365
|
+
const open = $('details-toggle').getAttribute('aria-expanded') !== 'true'
|
|
366
|
+
store.set(DETAILS_KEY, open ? '1' : '0')
|
|
367
|
+
syncDetails()
|
|
368
|
+
})
|
|
369
|
+
window.FleetLayout.syncDetails = syncDetails
|
package/public/ask.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Ask panel: one question, searched across every transcript on this machine.
|
|
3
|
+
// Keyword matches render the moment the server has them; the written answer
|
|
4
|
+
// arrives a few seconds later and reorders the cards by what Claude found relevant.
|
|
5
|
+
let askJob = null, askPoll = null, askRequest = 0
|
|
6
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
|
|
7
|
+
$('ask-shortcut').textContent = isMac ? '⌘K' : 'Ctrl K'
|
|
8
|
+
|
|
9
|
+
const openAsk = () => { renderAsk(); openModal('ask-backdrop', '#ask-input') }
|
|
10
|
+
$('ask-welcome').addEventListener('click', event => {
|
|
11
|
+
const suggestion = event.target.closest('[data-question]')
|
|
12
|
+
if (!suggestion) return
|
|
13
|
+
$('ask-input').value = suggestion.dataset.question
|
|
14
|
+
$('ask-input').focus()
|
|
15
|
+
})
|
|
16
|
+
$('ask-sessions').addEventListener('click', () => modalIsOpen('ask-backdrop') ? closeModal() : openAsk())
|
|
17
|
+
document.addEventListener('keydown', event => {
|
|
18
|
+
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') { event.preventDefault(); openAsk() }
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
$('ask-form').addEventListener('submit', async event => {
|
|
22
|
+
event.preventDefault()
|
|
23
|
+
const question = $('ask-input').value.trim()
|
|
24
|
+
if (!question) return
|
|
25
|
+
const mine = ++askRequest
|
|
26
|
+
clearInterval(askPoll); askPoll = null
|
|
27
|
+
askJob = { status: 'searching', question, hits: [] }
|
|
28
|
+
renderAsk()
|
|
29
|
+
$('ask-submit').disabled = true
|
|
30
|
+
try {
|
|
31
|
+
const data = await api('/api/search', { question, model: $('ask-model').value })
|
|
32
|
+
if (mine !== askRequest) return
|
|
33
|
+
askJob = data.job
|
|
34
|
+
renderAsk()
|
|
35
|
+
if (askJob.status === 'thinking') askPoll = setInterval(pollAsk, 700)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (mine !== askRequest) return
|
|
38
|
+
askJob = { status: 'error', question, hits: [], error: error.message }
|
|
39
|
+
renderAsk()
|
|
40
|
+
} finally {
|
|
41
|
+
if (mine === askRequest) $('ask-submit').disabled = false
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
async function pollAsk() {
|
|
45
|
+
const id = askJob?.id
|
|
46
|
+
if (!id) return
|
|
47
|
+
try {
|
|
48
|
+
const data = await api(`/api/search/${id}`)
|
|
49
|
+
if (data.job.id !== askJob?.id) return
|
|
50
|
+
askJob = data.job
|
|
51
|
+
renderAsk()
|
|
52
|
+
if (askJob.status !== 'thinking') { clearInterval(askPoll); askPoll = null }
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const REL_WORD = { high: 'strong match', medium: 'related', low: 'loosely related' }
|
|
57
|
+
const dateOf = ms => ms ? new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' }) : ''
|
|
58
|
+
const liveSession = id => snapshot?.sessions.find(s => s.sessionId === id) || null
|
|
59
|
+
|
|
60
|
+
function hitCard(hit, match) {
|
|
61
|
+
const live = liveSession(hit.sessionId)
|
|
62
|
+
const title = hit.title || live?.title || live?.name || 'Untitled session'
|
|
63
|
+
const when = hit.lastAt ? `${dateOf(hit.lastAt)} · ${age(hit.lastAt)} ago` : ''
|
|
64
|
+
const action = live
|
|
65
|
+
? `<button type="button" class="button ask-open" data-open-session="${esc(key(live))}">Open in Fleet ↗</button>`
|
|
66
|
+
: `<button type="button" class="button ask-open" data-copy-resume="claude --resume ${esc(hit.sessionId)}" title="This session is not open right now">Copy resume command</button>`
|
|
67
|
+
const snippets = (hit.snippets || []).map(s => `<p class="ask-snippet"><span class="ask-role">${s.role === 'user' ? 'you' : 'claude'}</span>${esc(s.text)}</p>`).join('')
|
|
68
|
+
return `<article class="ask-hit" data-relevance="${esc(match?.relevance || '')}"><div class="ask-hit-head"><span class="ask-hit-title">${esc(title)}</span>${match ? `<span class="ask-rel">${REL_WORD[match.relevance] || 'related'}</span>` : ''}<span class="ask-hit-meta">${esc(hit.project || 'unknown project')}${when ? ` · ${esc(when)}` : ''}${live ? ' · <span class="ask-live">open now</span>' : ''}</span></div>${match?.context ? `<p class="ask-context">${esc(match.context)}</p>` : ''}${match?.quote ? `<blockquote class="ask-quote">${esc(match.quote)}</blockquote>` : ''}${snippets ? `<details class="ask-snippets"${match ? '' : ' open'}><summary>${hit.matches} matching passage${hit.matches === 1 ? '' : 's'} · keyword excerpts</summary>${snippets}</details>` : ''}<div class="ask-hit-actions">${action}</div></article>`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderAsk() {
|
|
72
|
+
const job = askJob
|
|
73
|
+
$('ask-welcome').hidden = !!job
|
|
74
|
+
$('ask-results').setAttribute('aria-busy', String(job?.status === 'searching' || job?.status === 'thinking'))
|
|
75
|
+
if (!job) return update('ask-results', '')
|
|
76
|
+
const stats = job.id ? `<span class="ask-stats">${job.sessions} session${job.sessions === 1 ? '' : 's'} · ${job.passages} passages · ${job.searchMs} ms</span>` : ''
|
|
77
|
+
let status
|
|
78
|
+
if (job.status === 'searching') status = `<p class="ask-status is-live">Searching your transcripts…</p>`
|
|
79
|
+
else if (job.status === 'thinking') status = `<p class="ask-status is-live">Reading the ${Math.min(job.hits.length, 10)} best matches with ${esc(job.model)}…</p>`
|
|
80
|
+
else if (job.status === 'error') status = `<p class="ask-status is-failed">${esc(job.error || 'The answer failed.')}${job.hits.length ? ' Keyword matches are still shown below.' : ''}</p>`
|
|
81
|
+
else if (job.status === 'stopped') status = `<p class="ask-status">${esc(job.error || 'Replaced by a newer search.')}</p>`
|
|
82
|
+
else status = ''
|
|
83
|
+
const answer = job.ai ? `<div class="ask-answer"><span class="ask-answer-label">Answer</span><p>${esc(job.ai.answer)}</p></div>` : ''
|
|
84
|
+
|
|
85
|
+
let cards = ''
|
|
86
|
+
if (job.ai && job.ai.matches.length) {
|
|
87
|
+
const byId = new Map(job.hits.map(h => [h.sessionId, h]))
|
|
88
|
+
const cited = job.ai.matches.map(m => [m, byId.get(m.sessionId)]).filter(([, h]) => h)
|
|
89
|
+
const rest = job.hits.filter(h => !job.ai.matches.some(m => m.sessionId === h.sessionId))
|
|
90
|
+
cards = cited.map(([m, h]) => hitCard(h, m)).join('')
|
|
91
|
+
if (rest.length) cards += `<details class="ask-rest"><summary>${rest.length} other keyword match${rest.length === 1 ? '' : 'es'} Claude did not find relevant</summary>${rest.map(h => hitCard(h, null)).join('')}</details>`
|
|
92
|
+
} else if (job.hits.length) {
|
|
93
|
+
cards = `<p class="ask-section">Keyword matches${job.ai ? ' · none judged relevant' : ''}</p>` + job.hits.map(h => hitCard(h, null)).join('')
|
|
94
|
+
} else if (job.id) {
|
|
95
|
+
cards = '<p class="ask-empty">No matching threads yet. Try a project name, a feature, or a few words you remember.</p>'
|
|
96
|
+
}
|
|
97
|
+
update('ask-results', `<div class="ask-head"><span class="ask-question">“${esc(job.question)}”</span>${stats}</div>${status}${answer}${cards}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$('ask-results').addEventListener('click', async event => {
|
|
101
|
+
const button = event.target.closest('button')
|
|
102
|
+
if (!button) return
|
|
103
|
+
if (button.dataset.openSession) {
|
|
104
|
+
selected = button.dataset.openSession
|
|
105
|
+
filter = 'all'
|
|
106
|
+
closeModal()
|
|
107
|
+
render()
|
|
108
|
+
if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({ behavior: 'instant', block: 'start' })
|
|
109
|
+
else document.querySelector(`.session[data-session="${CSS.escape(selected)}"]`)?.scrollIntoView({ block: 'nearest' })
|
|
110
|
+
}
|
|
111
|
+
if (button.dataset.copyResume) {
|
|
112
|
+
try { await navigator.clipboard.writeText(button.dataset.copyResume); toast('Resume command copied') }
|
|
113
|
+
catch { toast(button.dataset.copyResume) }
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Live sessions may appear or vanish while the results are on screen; refresh the
|
|
118
|
+
// "open now" state and the Open button from the latest snapshot.
|
|
119
|
+
document.addEventListener('fleet-snapshot', () => { if (askJob && modalIsOpen('ask-backdrop')) renderAsk() })
|