@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/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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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() })