@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.
@@ -0,0 +1,180 @@
1
+ 'use strict'
2
+ // Warp-style conversation blocks. Every message, tool call and result is its own
3
+ // block with a sticky header, a copy action and collapse. Rendering is incremental:
4
+ // a block is only rebuilt when its content signature changes, so a streaming turn
5
+ // does not re-highlight the whole log on every poll.
6
+
7
+ const LIBS = () => window.FleetLibs || null
8
+ const ICONS = {
9
+ Bash: '⚡', BashOutput: '⚡', Read: '▤', Write: '✎', Edit: '✎', NotebookEdit: '✎',
10
+ Grep: '⌕', Glob: '⌕', WebSearch: '⌕', WebFetch: '↓', Task: '✳', Skill: '◆',
11
+ TodoWrite: '☑', AskUserQuestion: '?', ExitPlanMode: '▸',
12
+ }
13
+ const EXTENSIONS = {
14
+ js:'javascript', jsx:'javascript', mjs:'javascript', cjs:'javascript', ts:'typescript', tsx:'typescript',
15
+ kt:'kotlin', kts:'kotlin', java:'java', py:'python', rs:'rust', go:'go', swift:'swift',
16
+ json:'json', yml:'yaml', yaml:'yaml', sql:'sql', css:'css', scss:'css', html:'xml', xml:'xml', svg:'xml',
17
+ md:'markdown', sh:'bash', bash:'bash', zsh:'bash', diff:'diff', patch:'diff', toml:'plaintext',
18
+ }
19
+ // Mirrors toolTarget() in managed.js: the input key already shown in the block header.
20
+ const TARGET_KEYS = {
21
+ Bash: 'command', BashOutput: 'bash_id', Task: 'description', WebSearch: 'query',
22
+ WebFetch: 'url', Grep: 'pattern', Glob: 'pattern', Skill: 'skill',
23
+ }
24
+ const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
25
+ const languageFor = file => EXTENSIONS[String(file || '').split('.').pop().toLowerCase()] || null
26
+ const duration = ms => ms == null ? null : ms < 1000 ? `${ms}ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 60000)}m ${Math.round(ms % 60000 / 1000)}s`
27
+ const clock = at => new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
28
+
29
+ // hljs escapes its own output, so its HTML is safe to inject directly.
30
+ function highlight(code, language) {
31
+ const libs = LIBS()
32
+ if (!libs) return escapeHtml(code)
33
+ try {
34
+ if (language && libs.hljs.getLanguage(language)) return libs.hljs.highlight(code, { language, ignoreIllegals: true }).value
35
+ return libs.hljs.highlightAuto(code).value
36
+ } catch { return escapeHtml(code) }
37
+ }
38
+ function codeHtml(code, language, extraClass = '') {
39
+ const text = String(code ?? '').replace(/\s+$/, '')
40
+ if (!text) return ''
41
+ return `<pre class="block-code ${extraClass}"><code class="hljs">${highlight(text, language)}</code></pre>`
42
+ }
43
+
44
+ // Markdown for prose blocks: parse, sanitise, then highlight fenced code in place.
45
+ function proseHtml(text, { skipHighlight = false } = {}) {
46
+ const libs = LIBS()
47
+ const source = String(text ?? '')
48
+ if (!libs) return `<pre class="block-plain">${escapeHtml(source)}</pre>`
49
+ let html
50
+ try { html = libs.marked.parse(source) } catch { return `<pre class="block-plain">${escapeHtml(source)}</pre>` }
51
+ const clean = libs.DOMPurify.sanitize(html, {
52
+ ADD_ATTR: ['target', 'rel'],
53
+ FORBID_TAGS: ['style', 'form', 'input', 'button', 'iframe', 'object', 'embed'],
54
+ FORBID_ATTR: ['style'],
55
+ })
56
+ const holder = document.createElement('div')
57
+ holder.className = 'block-prose'
58
+ holder.innerHTML = clean
59
+ for (const link of holder.querySelectorAll('a[href]')) { link.target = '_blank'; link.rel = 'noreferrer noopener' }
60
+ if (!skipHighlight) for (const code of holder.querySelectorAll('pre > code')) {
61
+ const declared = [...code.classList].map(c => c.match(/^language-(.+)$/)?.[1]).find(Boolean)
62
+ code.innerHTML = highlight(code.textContent, declared)
63
+ code.classList.add('hljs')
64
+ code.parentElement.classList.add('block-code')
65
+ }
66
+ return holder.outerHTML
67
+ }
68
+
69
+ // What a tool block shows in its body, per tool. Falls back to its JSON input.
70
+ function toolBody(message) {
71
+ const input = message.input || {}
72
+ const name = message.tool
73
+ if (name === 'Bash' || name === 'BashOutput') return codeHtml(input.command || input.bash_id || '', 'bash', 'is-command')
74
+ if (name === 'Edit' || name === 'NotebookEdit') {
75
+ const before = String(input.old_string ?? input.old_source ?? '')
76
+ const after = String(input.new_string ?? input.new_source ?? '')
77
+ if (!before && !after) return codeHtml(JSON.stringify(input, null, 2), 'json')
78
+ const lines = []
79
+ if (before) for (const line of before.split('\n')) lines.push(`- ${line}`)
80
+ if (after) for (const line of after.split('\n')) lines.push(`+ ${line}`)
81
+ return codeHtml(lines.join('\n'), 'diff')
82
+ }
83
+ if (name === 'Write') return codeHtml(input.content || '', languageFor(input.file_path))
84
+ if (name === 'TodoWrite') {
85
+ const todos = Array.isArray(input.todos) ? input.todos : []
86
+ if (!todos.length) return ''
87
+ const mark = t => t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '▸' : '☐'
88
+ return `<ul class="block-todos">${todos.map(t => `<li class="todo-${escapeHtml(t.status)}"><span aria-hidden="true">${mark(t)}</span>${escapeHtml(t.content || t.activeForm || '')}</li>`).join('')}</ul>`
89
+ }
90
+ if (name === 'Task' || name === 'Skill') return codeHtml(input.prompt || input.args || input.description || '', 'plaintext')
91
+ if (name === 'AskUserQuestion') return ''
92
+ // The header already shows the main argument, so repeating it as JSON is noise.
93
+ const shown = TARGET_KEYS[name] || 'file_path'
94
+ const rest = Object.fromEntries(Object.entries(input).filter(([k, value]) => k !== shown && k !== 'path' && value !== '' && value != null))
95
+ return Object.keys(rest).length ? codeHtml(JSON.stringify(rest, null, 2), 'json') : ''
96
+ }
97
+ function resultHtml(message) {
98
+ if (!message.result) return ''
99
+ const language = message.tool === 'Read' ? languageFor(message.input?.file_path) : message.status === 'error' ? 'plaintext' : null
100
+ const note = message.truncated ? '<p class="block-note">Output truncated by Fleet.</p>' : ''
101
+ return `<div class="block-result ${message.status === 'error' ? 'is-error' : ''}">${codeHtml(message.result, language)}${note}</div>`
102
+ }
103
+ const actionsHtml = '<span class="block-actions"><button type="button" class="block-button" data-copy title="Copy block">⧉</button><button type="button" class="block-button" data-collapse title="Collapse block" aria-expanded="true">⌄</button></span>'
104
+
105
+ function blockHtml(message, { streaming = false } = {}) {
106
+ if (message.role === 'tool') {
107
+ const icon = ICONS[message.tool] || '▸'
108
+ const meta = [duration(message.ms), clock(message.at)].filter(Boolean).join(' · ')
109
+ const state = message.status === 'error' ? 'is-failed' : message.status === 'running' ? 'is-running' : message.status === 'interrupted' ? 'is-interrupted' : 'is-done'
110
+ const stateLabel = state === 'is-done' ? '' : state.slice(3)
111
+ const target = message.target ? `<span class="block-target" title="${escapeHtml(message.target)}">${escapeHtml(message.target)}</span>` : ''
112
+ const auto = message.approval === 'auto' ? '<span class="block-auto" title="Fleet approved this automatically">auto</span>' : ''
113
+ return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${escapeHtml(message.tool)}</span>${target}<span class="block-meta">${escapeHtml(meta)}</span>${auto}<span class="block-state ${state}">${stateLabel}</span>${actionsHtml}</div><div class="block-body">${toolBody(message)}${resultHtml(message)}</div>`
114
+ }
115
+ const who = message.role === 'user' ? 'YOU' : 'CLAUDE'
116
+ const icon = message.role === 'user' ? '›' : '✳'
117
+ const live = streaming ? '<span class="block-state is-running">streaming</span>' : ''
118
+ const attachments = Array.isArray(message.attachments) && message.attachments.length
119
+ ? `<div class="block-attachments">${message.attachments.map(a => `<a href="/api/attachments/${escapeHtml(a.id)}" target="_blank" rel="noreferrer noopener" title="${escapeHtml(a.mediaType)} · ${Math.round((a.bytes || 0) / 1024)} KB"><img src="/api/attachments/${escapeHtml(a.id)}" alt="Attached image" loading="lazy"></a>`).join('')}</div>`
120
+ : ''
121
+ const body = message.text ? proseHtml(message.text, { skipHighlight: streaming }) : ''
122
+ return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${who}</span><span class="block-meta">${clock(message.at)}</span>${live}${actionsHtml}</div><div class="block-body">${attachments}${body}</div>`
123
+ }
124
+
125
+ // Signature drives the incremental update: an identical signature means an identical block.
126
+ const signature = (message, streaming) => [
127
+ message.role, message.tool || '', message.status || '', message.ms ?? '', message.approval || '', streaming ? 'S' : '',
128
+ (message.text || '').length, (message.result || '').length, (message.attachments || []).length,
129
+ message.role === 'tool' ? JSON.stringify(message.input || {}).length : 0,
130
+ (message.text || '').slice(-80), (message.result || '').slice(-80),
131
+ ].join('~|~')
132
+
133
+ function copyText(message) {
134
+ if (message.role !== 'tool') return message.text || ''
135
+ const parts = [`${message.tool}${message.target ? ` · ${message.target}` : ''}`]
136
+ if (message.tool === 'Bash' && message.input?.command) parts.push(message.input.command)
137
+ else parts.push(JSON.stringify(message.input || {}, null, 2))
138
+ if (message.result) parts.push('', message.result)
139
+ return parts.join('\n')
140
+ }
141
+
142
+ function renderBlocks(container, messages, { streamingId = null, onCopy = () => {} } = {}) {
143
+ const seen = new Set()
144
+ let previous = null
145
+ for (const message of messages) {
146
+ const streaming = message.id === streamingId
147
+ const sig = signature(message, streaming)
148
+ seen.add(message.id)
149
+ let element = container.querySelector(`[data-block="${CSS.escape(message.id)}"]`)
150
+ if (!element) {
151
+ element = document.createElement('article')
152
+ element.className = 'block'
153
+ element.dataset.block = message.id
154
+ // Long tool output starts collapsed, the way a long command block does in a terminal.
155
+ if (message.role === 'tool' && (message.result || '').length > 1200) element.classList.add('collapsed')
156
+ }
157
+ if (element.dataset.sig !== sig) {
158
+ element.dataset.sig = sig
159
+ element.dataset.role = message.role
160
+ element.dataset.tool = message.tool || ''
161
+ element.dataset.status = message.status || ''
162
+ element.innerHTML = blockHtml(message, { streaming })
163
+ element.querySelector('[data-collapse]')?.addEventListener('click', event => {
164
+ const collapsed = element.classList.toggle('collapsed')
165
+ event.currentTarget.setAttribute('aria-expanded', String(!collapsed))
166
+ })
167
+ element.querySelector('[data-copy]')?.addEventListener('click', () => {
168
+ navigator.clipboard?.writeText(copyText(message)).then(() => onCopy('Block copied'), () => onCopy('Copying needs clipboard permission'))
169
+ })
170
+ if (element.classList.contains('collapsed')) element.querySelector('[data-collapse]')?.setAttribute('aria-expanded', 'false')
171
+ }
172
+ // Keep DOM order aligned with message order without rebuilding untouched blocks.
173
+ const shouldFollow = previous ? previous.nextElementSibling : container.firstElementChild
174
+ if (shouldFollow !== element) container.insertBefore(element, shouldFollow)
175
+ previous = element
176
+ }
177
+ for (const element of [...container.children]) if (!seen.has(element.dataset?.block)) element.remove()
178
+ }
179
+
180
+ window.FleetBlocks = { renderBlocks, proseHtml, codeHtml, highlight }
@@ -0,0 +1,426 @@
1
+ 'use strict'
2
+ let controlToken=null, controlSession=null, controlId=null, controlFetch=null, controlVersion=0
3
+ const drafts=new Map()
4
+ const inFlight=new Set()
5
+ let launchRequestId=null, resumeSource=null, fallbackWarned=false
6
+ const managedLabels={starting:'Starting Claude…',running:'Working on your task',approval:'Your input is needed',stopping:'Stopping the agent…',stopped:'Stopped · ready to continue',error:'Turn failed',idle:'Ready for your next message'}
7
+ const isWorking=s=>['starting','running','approval','stopping'].includes(s.status)
8
+
9
+ async function api(url,body) {
10
+ if(!controlToken) await initializeControls()
11
+ const response=await fetch(url,{method:body ? 'POST':'GET',headers:body ? {'content-type':'application/json','x-fleet-token':controlToken}: {},body:body ? JSON.stringify(body):undefined,signal:AbortSignal.timeout(15000)})
12
+ const data=await response.json()
13
+ if(!response.ok) throw new Error(data.error || 'The request failed.')
14
+ return data
15
+ }
16
+ async function initializeControls() {
17
+ const response=await fetch('/api/control',{cache:'no-store',signal:AbortSignal.timeout(8000)})
18
+ if(!response.ok) throw new Error('Agent controls are unavailable. Restart the updated Fleet server.')
19
+ const data=await response.json()
20
+ controlToken=data.token
21
+ if(!$('launch-cwd').value) $('launch-cwd').value=data.defaultCwd
22
+ }
23
+ function openLaunch(source=null) {
24
+ resumeSource=source
25
+ $('launch-title').textContent=source ? 'Continue this conversation in Fleet.' : 'Give your next task a home.'
26
+ if(source){$('launch-cwd').value=source.cwd || '';$('launch-form').elements.name.value=source.title || source.name || ''}
27
+ $('launch-cwd').readOnly=!!source
28
+ openModal('launch-backdrop', '[name=prompt]')
29
+ }
30
+ $('new-session').addEventListener('click',()=>modalIsOpen('launch-backdrop') ? closeModal() : openLaunch())
31
+ document.addEventListener('keydown', event => {
32
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'n') {
33
+ event.preventDefault()
34
+ if (modalIsOpen('launch-backdrop')) $('launch-form').elements.prompt.focus()
35
+ else openLaunch()
36
+ }
37
+ })
38
+ $('launch-form').addEventListener('input',()=>{launchRequestId=null})
39
+ $('launch-form').addEventListener('submit',async event=>{
40
+ event.preventDefault()
41
+ const button=$('launch-submit'); if(button.disabled)return
42
+ button.disabled=true;button.textContent='Launching…';$('launch-error').hidden=true
43
+ const form=event.currentTarget
44
+ form.querySelectorAll('input,textarea').forEach(el=>el.disabled=true)
45
+ launchRequestId ||= crypto.randomUUID()
46
+ try{
47
+ const data=await api('/api/managed',{cwd:form.elements.cwd.value,name:form.elements.name.value,prompt:form.elements.prompt.value,approvalMode:form.elements.approvalMode.value,model:form.elements.model.value,requestId:launchRequestId,...(resumeSource ? {resumeSessionId:resumeSource.sessionId}: {})})
48
+ selected=data.session.id;filter='all'
49
+ form.elements.prompt.value='';launchRequestId=null
50
+ closeModal()
51
+ await tick();toast('Agent launched')
52
+ if(matchMedia('(max-width:720px)').matches)$('detail').scrollIntoView({block:'start',behavior:'instant'})
53
+ }catch(error){$('launch-error').textContent=error.message;$('launch-error').hidden=false}
54
+ finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea').forEach(el=>el.disabled=false)}
55
+ })
56
+ function selectControl(session) {
57
+ const next=session?.managedId || null
58
+ if(next===controlId && next) return
59
+ controlId=next;controlSession=null;controlVersion++
60
+ $('control-panel').innerHTML=''
61
+ if(next){
62
+ $('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>`
63
+ window.FleetLayout?.watchConversation($('conversation'))
64
+ catalog=[];catalogFor=null;closePicker();renderTray()
65
+ window.FleetLayout?.syncDetails?.()
66
+ $('message-input').value=drafts.get(next)?.text || ''
67
+ $('message-input').addEventListener('input',()=>drafts.set(next,{text:$('message-input').value,requestId:crypto.randomUUID()}))
68
+ $('message-input').addEventListener('keydown',event=>{
69
+ composerKeydown(event)
70
+ if(event.defaultPrevented) return
71
+ if(event.key==='Enter' && !event.shiftKey && !event.isComposing){event.preventDefault();if(!$('send-message').disabled)$('composer').requestSubmit()}
72
+ })
73
+ $('message-input').addEventListener('input',composerInput)
74
+ $('message-input').addEventListener('paste',event=>{
75
+ const files=[...(event.clipboardData?.items || [])].filter(i=>i.kind==='file' && i.type.startsWith('image/')).map(i=>i.getAsFile()).filter(Boolean)
76
+ if(!files.length) return // ordinary text paste proceeds untouched
77
+ event.preventDefault(); attachImages(files)
78
+ })
79
+ $('composer').addEventListener('dragover',event=>{ if([...(event.dataTransfer?.types || [])].includes('Files')){ event.preventDefault(); $('composer').classList.add('is-dropping') } })
80
+ $('composer').addEventListener('dragleave',()=>$('composer').classList.remove('is-dropping'))
81
+ $('composer').addEventListener('drop',event=>{ event.preventDefault(); $('composer').classList.remove('is-dropping'); attachImages([...(event.dataTransfer?.files || [])].filter(f=>f.type.startsWith('image/'))) })
82
+ $('attach-tray').addEventListener('click',event=>{ const b=event.target.closest('[data-remove]'); if(b){ removeImage(Number(b.dataset.remove)) } })
83
+ renderTray()
84
+ $('message-input').addEventListener('blur',()=>setTimeout(closePicker,120))
85
+ $('slash-picker').addEventListener('mousedown',event=>{
86
+ const item=event.target.closest('[data-index]')
87
+ if(item){event.preventDefault();insertPick(Number(item.dataset.index))}
88
+ })
89
+ $('composer').addEventListener('submit',sendMessage)
90
+ $('approval-mode').addEventListener('change',changeMode)
91
+ fillModels($('model-choice')).then(()=>$('model-choice')?.addEventListener('change',changeModel))
92
+ $('close-agent').addEventListener('click',closeAgent)
93
+ $('stop-agent').addEventListener('click',stopAgent)
94
+ refreshControl()
95
+ }else if(session){
96
+ $('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>`
97
+ $('resume-in-fleet')?.addEventListener('click',()=>openLaunch(session))
98
+ window.FleetLayout?.syncDetails?.()
99
+ }
100
+ }
101
+ async function refreshControl() {
102
+ const id=controlId,version=controlVersion
103
+ if(!id)return
104
+ if(controlFetch?.id===id){controlFetch.again=true;return}
105
+ const task={id,again:false};controlFetch=task
106
+ try{
107
+ const data=await api(`/api/managed/${id}`)
108
+ if(controlId!==id || controlVersion!==version)return
109
+ controlSession=data.session;renderControl()
110
+ }catch(error){if(controlId===id && $('agent-error')){$('agent-error').hidden=false;$('agent-error').textContent=error.message}}
111
+ finally{if(controlFetch===task)controlFetch=null;if(task.again && controlId===id)refreshControl()}
112
+ }
113
+ function renderControl() {
114
+ const s=controlSession;if(!s || s.id!==controlId || !$('composer'))return
115
+ $('conversation-title').textContent=s.aiTitle || s.name
116
+ $('agent-state').textContent=s.currentTool && s.status==='running' ? `Using ${s.currentTool}` : managedLabels[s.status]
117
+ $('agent-state').className=`subtle ${s.status==='approval' ? 'stale' : ''}`
118
+ const used=s.contextTokens, limit=s.contextLimit || 200000
119
+ const share=used==null ? null : Math.min(100,Math.round(used/limit*100))
120
+ $('agent-context').textContent=share==null ? '' : `${share}%`
121
+ $('agent-context').className=`subtle context-chip ${share>=90 ? 'hot' : share>=75 ? 'warn' : ''}`
122
+ $('agent-context').title=share==null ? '' : `${used.toLocaleString()} of ${limit.toLocaleString()} tokens used`
123
+ if($('model-choice')!==document.activeElement && $('model-choice').options.length) $('model-choice').value=s.selectedModel || ''
124
+ if($('approval-mode')!==document.activeElement) $('approval-mode').value=s.approvalMode || 'auto'
125
+ $('approval-mode').dataset.mode=s.approvalMode || 'auto'
126
+ $('agent-error').hidden=!s.error
127
+ $('agent-error').textContent=s.error || ''
128
+ const log=$('conversation'),atBottom=log.scrollHeight-log.scrollTop-log.clientHeight<60
129
+ const last=s.messages[s.messages.length-1]
130
+ const streamingId=isWorking(s) && last?.role==='assistant' ? last.id : null
131
+ if(!s.messages.length){
132
+ if(!log.querySelector('.note')) log.innerHTML='<p class="note">Send your first instruction below.</p>'
133
+ }else if(window.FleetBlocks){
134
+ log.querySelector('.note')?.remove()
135
+ window.FleetBlocks.renderBlocks(log,s.messages,{streamingId,onCopy:toast})
136
+ }else{
137
+ // Console assets unavailable — usually a page loaded from an older running server.
138
+ update('conversation',s.messages.map(m=>`<article class="block" data-role="${esc(m.role)}"><div class="block-head"><span class="block-tool">${m.role==='tool' ? esc(m.tool) : m.role==='user' ? 'YOU' : 'CLAUDE'}</span><span class="block-meta">${new Date(m.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span></div><pre class="block-plain">${esc(m.role==='tool' ? [m.target,m.result].filter(Boolean).join('\n\n') : m.text)}</pre></article>`).join(''))
139
+ if(!fallbackWarned){fallbackWarned=true;toast('Console assets did not load. Restart Fleet, then reload this page.')}
140
+ }
141
+ if(atBottom)log.scrollTop=log.scrollHeight
142
+ // Approval DOM is independent of the streamed response so answers keep their focus and values.
143
+ const ids=s.approvals.map(p=>p.id).join(',')
144
+ if($('approvals').dataset.ids!==ids){$('approvals').dataset.ids=ids;renderApprovals(s.approvals)}
145
+ const working=isWorking(s)
146
+ const held=s.openElsewhere
147
+ const heldText=held ? `Open ${held.entrypoint==='cli' ? 'in a terminal' : 'in another program'}${held.name ? ' · '+held.name : ''}${held.startedAt ? ' · since '+new Date(held.startedAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) : ''}. Close it there to continue here.` : null
148
+ if(held){ $('agent-state').textContent=held.state==='busy' ? 'Working in a terminal' : 'Open in a terminal'; $('agent-state').className='subtle stale' }
149
+ $('send-message').disabled=working || inFlight.has(s.id) || !!held
150
+ $('stop-agent').hidden=!working
151
+ $('stop-agent').disabled=s.status==='stopping' || inFlight.has(`stop:${s.id}`)
152
+ $('composer-hint').textContent=heldText || (working ? 'You can draft your next message while Claude works.' : 'Enter to send · Shift + Enter for a new line')
153
+ $('composer-hint').classList.toggle('is-held',!!held)
154
+ }
155
+ function renderApprovals(approvals) {
156
+ $('approvals').innerHTML=approvals.map(p=>{
157
+ const question=p.tool==='AskUserQuestion'
158
+ return `<form class="approval" data-approval="${esc(p.id)}"><div class="eyebrow">${question?'CLAUDE HAS A QUESTION':'APPROVAL REQUIRED'}</div><h4>${esc(p.description || p.tool)}</h4>${p.reason && !question ? `<p class="approval-reason">${esc(p.reason)}</p>` : ''}${question ? (p.input.questions || []).map((q,i)=>`<fieldset><legend>${esc(q.question)}</legend>${(q.options || []).map(o=>`<label class="answer-option"><input type="${q.multiSelect?'checkbox':'radio'}" name="q${i}" value="${esc(o.label)}"><span>${esc(o.label)}${o.description?`<small>${esc(o.description)}</small>`:''}</span></label>`).join('')}<label class="other-answer">Your answer<input type="text" name="other${i}" placeholder="Or type your own answer" maxlength="4000"></label></fieldset>`).join('') : `<pre class="tool-input">${esc(JSON.stringify(p.input,null,2))}</pre>`}<div class="approval-actions"><button class="button" type="button" data-deny="${esc(p.id)}">${question?'Skip question':'Deny'}</button><button class="button resume" type="submit">${question?'Send answer':'Allow once'}</button></div><p class="form-error" role="alert" hidden></p></form>`
159
+ }).join('')
160
+ $('approvals').querySelectorAll('form').forEach(form=>{
161
+ const approval=approvals.find(p=>p.id===form.dataset.approval)
162
+ form.addEventListener('submit',event=>{
163
+ event.preventDefault()
164
+ const answers={}
165
+ if(approval.tool==='AskUserQuestion')for(const [i,q] of (approval.input.questions || []).entries()){
166
+ const other=form.elements[`other${i}`].value.trim()
167
+ const chosen=[...form.querySelectorAll(`input[name="q${i}"]:checked`)].map(input=>input.value)
168
+ answers[q.question]=other || chosen.join(', ')
169
+ if(!answers[q.question]){const e=form.querySelector('.form-error');e.hidden=false;e.textContent='Answer each question before continuing.';return}
170
+ }
171
+ decide(form,'allow',answers)
172
+ })
173
+ form.querySelector('[data-deny]').addEventListener('click',()=>decide(form,'deny'))
174
+ })
175
+ }
176
+ async function decide(form,decision,answers) {
177
+ const id=controlId,approval=form.dataset.approval
178
+ const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true)
179
+ try{await api(`/api/managed/${id}/approvals/${approval}`,{decision,answers});await refreshControl();await tick()}
180
+ catch(error){const e=form.querySelector('.form-error');e.hidden=false;e.textContent=error.message;buttons.forEach(b=>b.disabled=false)}
181
+ }
182
+ const pendingImages=new Map()
183
+ const MAX_IMAGES=6, MAX_IMAGE_BYTES=8*1024*1024
184
+ const IMAGE_OK=new Set(['image/png','image/jpeg','image/gif','image/webp'])
185
+ const attachedImages=()=>pendingImages.get(controlId) || []
186
+ async function attachImages(files) {
187
+ const list=attachedImages()
188
+ for(const file of files){
189
+ if(!IMAGE_OK.has(file.type)){ toast('Only PNG, JPEG, GIF and WebP images can be attached.'); continue }
190
+ if(file.size>MAX_IMAGE_BYTES){ toast(`${file.name || 'That image'} is over 8 MB.`); continue }
191
+ if(list.length>=MAX_IMAGES){ toast(`Up to ${MAX_IMAGES} images per message.`); break }
192
+ const dataUrl=await new Promise((resolve,reject)=>{ const r=new FileReader(); r.onload=()=>resolve(r.result); r.onerror=reject; r.readAsDataURL(file) })
193
+ list.push({ mediaType:file.type, dataUrl, bytes:file.size, name:file.name || 'Pasted image' })
194
+ }
195
+ pendingImages.set(controlId,list)
196
+ renderTray()
197
+ $('message-input')?.focus()
198
+ }
199
+ function removeImage(index) {
200
+ const list=attachedImages(); list.splice(index,1); pendingImages.set(controlId,list); renderTray()
201
+ }
202
+ function renderTray() {
203
+ const tray=$('attach-tray'); if(!tray) return
204
+ const list=attachedImages()
205
+ tray.hidden=!list.length
206
+ tray.innerHTML=list.map((img,i)=>`<figure class="attach-thumb"><img src="${img.dataUrl}" alt="${esc(img.name)}"><figcaption>${esc(img.name)} · ${Math.round(img.bytes/1024)} KB</figcaption><button type="button" class="attach-remove" data-remove="${i}" aria-label="Remove ${esc(img.name)}">×</button></figure>`).join('')
207
+ const hint=$('composer-hint'); if(hint && list.length && !isWorking(controlSession || {})) hint.textContent=`${list.length} image${list.length===1?'':'s'} attached · Enter to send`
208
+ }
209
+ async function sendMessage(event) {
210
+ event.preventDefault()
211
+ const id=controlId
212
+ if(!id || inFlight.has(id) || !controlSession || isWorking(controlSession))return
213
+ const message=$('message-input').value.trim()
214
+ const images=attachedImages().map(img=>({ mediaType:img.mediaType, data:img.dataUrl.slice(img.dataUrl.indexOf(',')+1) }))
215
+ if(!message && !images.length)return
216
+ const draft=drafts.get(id) || {text:message,requestId:crypto.randomUUID()};drafts.set(id,draft)
217
+ inFlight.add(id);renderControl();$('send-error').hidden=true
218
+ try{
219
+ await api(`/api/managed/${id}/messages`,{message,...(images.length ? {images} : {}),requestId:draft.requestId})
220
+ if(drafts.get(id)?.requestId===draft.requestId){drafts.delete(id);if(controlId===id)$('message-input').value=''}
221
+ pendingImages.delete(id); if(controlId===id) renderTray()
222
+ await refreshControl();await tick()
223
+ }catch(error){if(controlId===id){$('send-error').hidden=false;$('send-error').textContent=error.message}}
224
+ finally{inFlight.delete(id);renderControl()}
225
+ }
226
+ // The model list comes from the running Claude runtime once one has reported it.
227
+ let modelList=null
228
+ async function fillModels(select) {
229
+ try{ modelList ||= (await api('/api/models')).models || [] }catch{ modelList=[] }
230
+ if(!select.isConnected) return
231
+ const current=controlSession?.selectedModel || ''
232
+ select.innerHTML=modelList.map(m=>`<option value="${esc(m.value)}" title="${esc(m.description || '')}">${esc(m.displayName || m.value || 'Default')}</option>`).join('')
233
+ select.value=current
234
+ if($('launch-model')) $('launch-model').innerHTML=select.innerHTML
235
+ }
236
+ async function changeModel(event) {
237
+ const id=controlId, model=event.target.value
238
+ try{ await api(`/api/managed/${id}/model`,{model}); await refreshControl(); toast(model ? `Next message uses ${event.target.selectedOptions[0].textContent}` : 'Back to the project default') }
239
+ catch(error){ toast(error.message); refreshControl() }
240
+ }
241
+ async function changeMode(event) {
242
+ const id=controlId, mode=event.target.value
243
+ try{ await api(`/api/managed/${id}/mode`,{mode}); await refreshControl(); toast(mode==='ask' ? 'Every tool will ask' : mode==='all' ? 'Approving everything, including destructive commands' : 'Auto approvals on') }
244
+ catch(error){ toast(error.message); refreshControl() }
245
+ }
246
+ async function closeAgent(event) {
247
+ const id=controlId, button=event.currentTarget
248
+ if(button.dataset.armed!=='1'){
249
+ button.dataset.armed='1'
250
+ button.textContent=controlSession && isWorking(controlSession) ? 'Stop and close?' : 'Close for good?'
251
+ setTimeout(()=>{if(button.isConnected){button.dataset.armed='';button.textContent='Close'}},4000)
252
+ return
253
+ }
254
+ button.disabled=true;button.textContent='Closing…'
255
+ try{
256
+ await api(`/api/managed/${id}/close`,{})
257
+ if(controlId===id){selected=null;selectControl(null);$('control-panel').innerHTML=''}
258
+ await tick()
259
+ toast('Closed. Claude still has its own transcript of it.')
260
+ }catch(error){button.disabled=false;button.dataset.armed='';button.textContent='Close';toast(error.message)}
261
+ }
262
+ async function stopAgent() {
263
+ const id=controlId;if(!id || inFlight.has(`stop:${id}`))return
264
+ inFlight.add(`stop:${id}`);renderControl()
265
+ try{await api(`/api/managed/${id}/stop`,{});await refreshControl();await tick()}
266
+ catch(error){toast(error.message)}finally{inFlight.delete(`stop:${id}`);renderControl()}
267
+ }
268
+ window.addEventListener('fleet-libs-ready',()=>{
269
+ const log=$('conversation')
270
+ if(log) for(const block of log.children) delete block.dataset.sig
271
+ renderControl()
272
+ })
273
+ ;(async()=>{ try{ modelList=(await api('/api/models')).models || []; if($('launch-model')) $('launch-model').innerHTML=modelList.map(m=>`<option value="${esc(m.value)}">${esc(m.displayName || m.value || 'Default')}</option>`).join('') }catch{} })()
274
+ initializeControls().catch(error=>toast(error.message))
275
+ const events=new EventSource('/api/events')
276
+ events.addEventListener('sessions',event=>{try{if(JSON.parse(event.data).includes(controlId))refreshControl()}catch{}})
277
+ events.onopen=()=>{refreshControl();tick()}
278
+ // Polling also recovers from a dropped event stream or a server restart.
279
+ setInterval(()=>{if(!document.hidden)refreshControl()},2500)
280
+ window.addEventListener('beforeunload',()=>events.close())
281
+
282
+ // Slash picker: typing `/` at the start of a line offers this project's commands
283
+ // and skills. It only inserts text into the composer; nothing runs until you send.
284
+ let catalog = [], catalogFor = null, picked = 0, matches = []
285
+ const SCOPE_LABEL = { project: 'project', user: 'user', plugin: 'plugin' }
286
+
287
+ async function loadCatalog(id) {
288
+ if (catalogFor === id) return
289
+ catalogFor = id
290
+ try { catalog = (await api(`/api/managed/${id}/commands`)).commands || [] }
291
+ catch { catalog = [] }
292
+ }
293
+ // The token being typed, or null when the caret is not in a slash word.
294
+ function slashQuery(input) {
295
+ if (input.selectionStart !== input.selectionEnd) return null
296
+ const before = input.value.slice(0, input.selectionStart)
297
+ const line = before.slice(before.lastIndexOf('\n') + 1)
298
+ const match = line.match(/^\/([\w:-]*)$/)
299
+ return match ? match[1] : null
300
+ }
301
+ function closePicker() {
302
+ matches = []
303
+ const list = $('slash-picker')
304
+ if (list) { list.hidden = true; list.innerHTML = '' }
305
+ $('message-input')?.removeAttribute('aria-activedescendant')
306
+ }
307
+ function renderPicker(query) {
308
+ const list = $('slash-picker')
309
+ if (!list) return
310
+ const needle = query.toLowerCase()
311
+ matches = catalog
312
+ .filter(entry => entry.name.toLowerCase().includes(needle))
313
+ // Prefer a prefix match, then the project's own entries.
314
+ .sort((a, b) => (b.name.toLowerCase().startsWith(needle) - a.name.toLowerCase().startsWith(needle)) || 0)
315
+ .slice(0, 40)
316
+ if (!matches.length) return closePicker()
317
+ picked = Math.min(picked, matches.length - 1)
318
+ list.hidden = false
319
+ list.innerHTML = matches.map((entry, index) => `<li id="slash-${index}" role="option" aria-selected="${index === picked}" class="${index === picked ? 'is-picked' : ''}" data-index="${index}"><span class="slash-name">/${esc(entry.name)}</span><span class="slash-kind">${entry.kind === 'skill' ? '◆' : '›'} ${esc(SCOPE_LABEL[entry.scope] || '')}</span>${entry.hint ? `<span class="slash-hint">${esc(entry.hint)}</span>` : ''}<span class="slash-desc">${esc(entry.description)}</span></li>`).join('')
320
+ list.querySelector('.is-picked')?.scrollIntoView({ block: 'nearest' })
321
+ $('message-input').setAttribute('aria-activedescendant', `slash-${picked}`)
322
+ }
323
+ function insertPick(index) {
324
+ const entry = matches[index]
325
+ const input = $('message-input')
326
+ if (!entry || !input) return
327
+ const before = input.value.slice(0, input.selectionStart)
328
+ const start = before.lastIndexOf('\n') + 1
329
+ const after = input.value.slice(input.selectionStart)
330
+ const insertion = `/${entry.name} `
331
+ input.value = input.value.slice(0, start) + insertion + after
332
+ input.selectionStart = input.selectionEnd = start + insertion.length
333
+ closePicker()
334
+ input.focus()
335
+ drafts.set(controlId, { text: input.value, requestId: crypto.randomUUID() })
336
+ }
337
+ function composerInput() {
338
+ const query = slashQuery($('message-input'))
339
+ if (query === null) return closePicker()
340
+ picked = 0
341
+ loadCatalog(controlId).then(() => { if (slashQuery($('message-input')) !== null) renderPicker(slashQuery($('message-input'))) })
342
+ if (catalog.length) renderPicker(query)
343
+ }
344
+ function composerKeydown(event) {
345
+ if (!matches.length) return
346
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
347
+ event.preventDefault()
348
+ picked = (picked + (event.key === 'ArrowDown' ? 1 : matches.length - 1)) % matches.length
349
+ return renderPicker(slashQuery($('message-input')) ?? '')
350
+ }
351
+ if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) { event.preventDefault(); return insertPick(picked) }
352
+ if (event.key === 'Tab') { event.preventDefault(); return insertPick(picked) }
353
+ if (event.key === 'Escape') { event.preventDefault(); return closePicker() }
354
+ }
355
+
356
+ // ---- Updates -------------------------------------------------------------
357
+ // The server asks npm whether a newer Fleet has been published; the pill only
358
+ // appears when there is one, and nothing installs without a click.
359
+ let fleetUpdate = null, fleetUpdateBusy = false
360
+
361
+ // Takes its state as arguments rather than reading the two globals, so the states
362
+ // can be exercised one by one from a test.
363
+ function renderUpdate(update = fleetUpdate, busy = fleetUpdateBusy) {
364
+ const pill = $('update-pill')
365
+ if (!update || !update.available) { pill.hidden = true; return }
366
+ pill.hidden = false
367
+ pill.disabled = busy || !update.canInstall
368
+ if (busy) {
369
+ pill.textContent = update.state === 'installed' ? 'Restarting…' : 'Installing…'
370
+ pill.title = 'Fleet will reload itself when this finishes.'
371
+ return
372
+ }
373
+ pill.textContent = `↑ v${update.latest}`
374
+ // A checkout is the operator's to pull; only an npm install can replace itself.
375
+ pill.title = update.canInstall
376
+ ? `Claude Fleet v${update.latest} is available. Click to install it and reload.`
377
+ : update.channel === 'source'
378
+ ? `v${update.latest} is published. This Fleet runs from a git checkout — update it with git pull.`
379
+ : `v${update.latest} is published. This Fleet was not installed with npm, so it cannot update itself.`
380
+ }
381
+ async function pollUpdate() {
382
+ try {
383
+ const response = await fetch('/api/update', { cache: 'no-store', signal: AbortSignal.timeout(8000) })
384
+ if (!response.ok) return
385
+ fleetUpdate = (await response.json()).update
386
+ renderUpdate()
387
+ } catch {} // An older server, or no network. Either way there is nothing to show.
388
+ }
389
+ // The server hands the port to the new version, so wait for it to answer again
390
+ // rather than reloading into a closed socket.
391
+ async function waitForRestart(deadline = Date.now() + 60000) {
392
+ while (Date.now() < deadline) {
393
+ await new Promise(resolve => setTimeout(resolve, 700))
394
+ try {
395
+ const response = await fetch('/api/control', { cache: 'no-store', signal: AbortSignal.timeout(3000) })
396
+ if (response.ok) return location.reload()
397
+ } catch {}
398
+ }
399
+ fleetUpdateBusy = false
400
+ renderUpdate()
401
+ toast('Fleet installed the update but did not come back. Start it again.')
402
+ }
403
+ $('update-pill').addEventListener('click', async () => {
404
+ if (fleetUpdateBusy || !fleetUpdate || !fleetUpdate.canInstall) return
405
+ fleetUpdateBusy = true
406
+ renderUpdate()
407
+ try {
408
+ const data = await api('/api/update', {})
409
+ fleetUpdate = data.update
410
+ renderUpdate()
411
+ if (data.update.restarting) return waitForRestart()
412
+ fleetUpdateBusy = false
413
+ renderUpdate()
414
+ toast(`v${data.update.installed} installed. Restart Fleet to use it.`)
415
+ } catch (error) {
416
+ fleetUpdateBusy = false
417
+ fleetUpdate = { ...fleetUpdate, state: 'failed' }
418
+ renderUpdate()
419
+ toast(error.message || 'The update could not be installed.')
420
+ }
421
+ })
422
+ pollUpdate()
423
+ // The first check runs in the background on the server; ask again once it has had
424
+ // time to answer, then settle into a slow poll for long-lived windows.
425
+ setTimeout(pollUpdate, 9000)
426
+ setInterval(pollUpdate, 60 * 60 * 1000)
Binary file
Binary file