Something went wrong. Try again.
I'm going to build my own OpenClaw, with blackjack... and bun!
Something went wrong. Try again.
105 kB · 2256 lines
TypeScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257// @ts-nocheckimport { html, useRef, useState, useEffect, useCallback, useMemo } from '../vendor/preact-htm.js';import { findPopupTypeaheadMatch, isPopupTypeaheadKey, resolvePopupTypeaheadMatch, updatePopupTypeaheadBuffer } from '../ui/popup-typeahead.js';import { getAgentModels, sendAgentMessage, uploadMedia } from '../api.js';import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage.js';import { buildMentionValue, filterMentionAgents, parseMentionAutocompleteQuery } from '../ui/agent-mentions.js';import { shouldOpenSessionSwitcherFromBlankCompose } from '../ui/compose-session-switcher.js';import { formatBranchPickerLabel, formatCurrentBranchLabel } from '../ui/branch-lifecycle.js';import { buildComposeStatusDotClass } from '../ui/status-dot.js';import { getStatusElapsedLabel, isCompactionStatus, resolveStatusPanelTitle } from '../ui/status-duration.js';import { FilePill } from './file-pill.js';import { refreshAgentModelStateBestEffort } from './compose-model-refresh.js';
/** * Slash command definitions for autocomplete. * Kept in sync with agent-control/command-registry.ts. */export const SLASH_COMMANDS = [ { name: "/model", description: "Select model or list available models" }, { name: "/cycle-model", description: "Cycle to the next available model" }, { name: "/thinking", description: "Show or set thinking/effort level" }, { name: "/effort", description: "Show or set thinking/effort level (alias for /thinking)" }, { name: "/cycle-thinking", description: "Cycle thinking level" }, { name: "/theme", description: "Set UI theme (no name to show available themes)" }, { name: "/meters", description: "Toggle the top-right CPU/RAM HUD (/meters on|off|toggle)" }, { name: "/tint", description: "Tint default light/dark UI (usage: /tint #hex or /tint off)" }, { name: "/btw", description: "Open a side conversation panel without interrupting the main chat" }, { name: "/state", description: "Show current session state" }, { name: "/stats", description: "Show session token and cost stats" }, { name: "/context", description: "Show context window usage" }, { name: "/last", description: "Show last assistant response" }, { name: "/compact", description: "Manually compact the session" }, { name: "/auto-compact", description: "Toggle auto-compaction" }, { name: "/auto-retry", description: "Toggle auto-retry" }, { name: "/abort", description: "Abort the current response" }, { name: "/abort-retry", description: "Abort retry backoff" }, { name: "/abort-bash", description: "Abort running bash command" }, { name: "/shell", description: "Run a shell command and return output" }, { name: "/bash", description: "Run a shell command and add output to context" }, { name: "/queue", description: "Queue a follow-up message (one-at-a-time)" }, { name: "/queue-all", description: "Queue a follow-up message (batch all)" }, { name: "/steer", description: "Steer the current response" }, { name: "/steering-mode", description: "Set steering mode (all|one)" }, { name: "/followup-mode", description: "Set follow-up mode (all|one)" }, { name: "/session-name", description: "Set or show the session name" }, { name: "/new-session", description: "Start a new session" }, { name: "/switch-session", description: "Switch to a session file" }, { name: "/session-rotate", description: "Rotate the current persisted session into an archived file" }, { name: "/fork", description: "Fork from a previous message" }, { name: "/forks", description: "List forkable messages" }, { name: "/tree", description: "List the session tree" }, { name: "/label", description: "Set or clear a label on a tree entry" }, { name: "/labels", description: "List labeled entries" }, { name: "/agent-name", description: "Set or show the agent display name" }, { name: "/agent-avatar", description: "Set or show the agent avatar URL" }, { name: "/user-name", description: "Set or show your display name" }, { name: "/user-avatar", description: "Set or show your avatar URL" }, { name: "/user-github", description: "Set name/avatar from GitHub profile" }, { name: "/export-html", description: "Export session to HTML" }, { name: "/passkey", description: "Manage passkeys (enrol/list/delete)" }, { name: "/totp", description: "Show a TOTP enrolment QR code" }, { name: "/qr", description: "Generate a QR code for text or URL" }, { name: "/search", description: "Search notes and skills in the workspace" }, { name: "/dream", description: "Run Dream memory maintenance over recent days (default 7)" }, { name: "/tasks", description: "List scheduled tasks" }, { name: "/scheduled", description: "List scheduled tasks" }, { name: "/restart", description: "Restart the agent and stop subprocesses" }, { name: "/exit", description: "Exit the current piclaw process immediately (Supervisor will restart it)" }, { name: "/login", description: "Login to an AI model provider (OAuth or API key)" }, { name: "/logout", description: "Logout from an AI model provider" }, { name: "/commands", description: "List available commands" },];
const COMPOSE_HISTORY_STORAGE_KEY = 'piclaw_compose_history';
export function resolveComposePrefillRequest(prefillRequest, lastHandledToken, searchMode = false) { if (searchMode) return { shouldApply: false, nextToken: lastHandledToken, text: '' }; if (!prefillRequest || typeof prefillRequest !== 'object') { return { shouldApply: false, nextToken: lastHandledToken, text: '' }; } const token = typeof prefillRequest.token === 'string' ? prefillRequest.token : ''; const text = typeof prefillRequest.text === 'string' ? prefillRequest.text : ''; if (!token || token === lastHandledToken || !text.trim()) { return { shouldApply: false, nextToken: lastHandledToken, text: '' }; } return { shouldApply: true, nextToken: token, text };}
export function getComposeHistoryStorageKey(chatJid = 'web:default') { const normalized = typeof chatJid === 'string' && chatJid.trim() ? chatJid.trim() : 'web:default'; if (normalized === 'web:default') return COMPOSE_HISTORY_STORAGE_KEY; return `${COMPOSE_HISTORY_STORAGE_KEY}:${encodeURIComponent(normalized)}`;}
export function resolveUiOnlyCommandNotice(commandText, response) { const message = typeof response?.command?.message === 'string' ? response.command.message.trim() : ''; if (!response?.ui_only || !message) return null;
const trimmed = typeof commandText === 'string' ? commandText.trim() : ''; if (!trimmed.startsWith('/')) return null;
const parts = trimmed.split(/\s+/).filter(Boolean); const slashName = parts[0]?.toLowerCase() || ''; const hasArgs = parts.length > 1;
if (!hasArgs && (slashName === '/model' || slashName === '/thinking' || slashName === '/effort')) { return message; }
return null;}
/** * Tiny SVG pie chart showing context window usage. * Green when <75%, amber 75–90%, red >90%. Tooltip shows exact numbers. */function ContextPie({ usage, onCompact }) { const pct = Math.min(100, Math.max(0, usage.percent || 0)); const tokens = usage.tokens; const window = usage.contextWindow; const compactLabel = `Compact context`; const label = tokens != null ? `Context: ${formatK(tokens)} / ${formatK(window)} tokens (${pct.toFixed(0)}%)` : `Context: ${pct.toFixed(0)}%`; const title = `${label} — ${compactLabel}`;
// Pie arc: SVG circle with stroke-dasharray trick. // Circle circumference = 2πr = 2π×9 ≈ 56.55 const r = 9; const circ = 2 * Math.PI * r; const filled = (pct / 100) * circ;
const color = pct > 90 ? 'var(--context-red, #ef4444)' : pct > 75 ? 'var(--context-amber, #f59e0b)' : 'var(--context-green, #22c55e)';
return html` <button class="compose-context-pie icon-btn" type="button" title=${title} aria-label="Compact context" onClick=${(e) => { e.preventDefault(); e.stopPropagation(); onCompact?.(); }} > <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"> <circle cx="12" cy="12" r=${r} fill="none" stroke="var(--context-track, rgba(128,128,128,0.2))" stroke-width="2.5" /> <circle cx="12" cy="12" r=${r} fill="none" stroke=${color} stroke-width="2.5" stroke-dasharray=${`${filled} ${circ}`} stroke-linecap="round" transform="rotate(-90 12 12)" /> </svg> </button> `;}
function formatK(n) { if (n == null) return '?'; if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; if (n >= 1000) return (n / 1000).toFixed(0) + 'K'; return String(n);}
export function formatModelPickerContextWindow(contextWindow) { const value = Number(contextWindow); if (!Number.isFinite(value) || value <= 0) return ''; return `${formatK(value)} ctx`;}
export function formatModelPickerDisplayLabel(label, contextWindow) { const primaryLabel = typeof label === 'string' ? label.trim() : ''; const contextLabel = formatModelPickerContextWindow(contextWindow); if (!primaryLabel) return contextLabel; if (!contextLabel) return primaryLabel; return `${primaryLabel} • ${contextLabel}`;}
function normalizeModelPickerLabel(value, provider = '', id = '') { const explicit = typeof value === 'string' ? value.trim() : ''; if (explicit) return explicit; const normalizedProvider = typeof provider === 'string' ? provider.trim() : ''; const normalizedId = typeof id === 'string' ? id.trim() : ''; if (normalizedProvider && normalizedId) return `${normalizedProvider}/${normalizedId}`; return normalizedId || normalizedProvider || '';}
export function normalizeModelPickerOptions(payload) { const structured = Array.isArray(payload?.model_options) ? payload.model_options : null; const legacy = Array.isArray(payload?.models) ? payload.models : []; const rawItems = structured && structured.length > 0 ? structured : legacy; const options = [];
for (const item of rawItems) { if (typeof item === 'string') { const label = item.trim(); if (!label) continue; const slashIndex = label.indexOf('/'); const provider = slashIndex > 0 ? label.slice(0, slashIndex).trim() : ''; const id = slashIndex > 0 ? label.slice(slashIndex + 1).trim() : label; options.push({ label, provider, id, name: null, contextWindow: null, reasoning: false, }); continue; }
if (!item || typeof item !== 'object') continue; const provider = typeof item.provider === 'string' ? item.provider.trim() : ''; const id = typeof item.id === 'string' ? item.id.trim() : ''; const label = normalizeModelPickerLabel(item.label, provider, id); if (!label) continue; const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null; const contextWindow = Number(item.context_window ?? item.contextWindow); options.push({ label, provider, id, name, contextWindow: Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : null, reasoning: Boolean(item.reasoning), }); }
options.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); return options;}
export function getModelPickerOptionSearchLabel(option) { if (!option || typeof option !== 'object') return ''; return [ option.label, option.provider, option.id, option.name, formatModelPickerContextWindow(option.contextWindow), ].filter(Boolean).join(' ');}
export function resolveComposeModelPickerState(activeModel, agentModelsPayload) { const modelLabel = typeof activeModel === 'string' ? activeModel.trim() : ''; if (modelLabel) { return { showPicker: true, label: modelLabel, hasAvailableModels: true, }; }
const hasAvailableModels = normalizeModelPickerOptions(agentModelsPayload).length > 0; return { showPicker: hasAvailableModels, label: hasAvailableModels ? 'Select model' : '', hasAvailableModels, };}
function unwrapQueuedTranscriptContent(value) { if (!value) return value; const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); if (!normalized.includes(' @ ') || !normalized.includes(':\n')) return value;
const lines = normalized.split('\n'); const collected = []; let index = 0; let sawTranscript = false;
while (index < lines.length) { const line = lines[index]; const trimmed = line.trim(); if (!trimmed) { index += 1; continue; } if (trimmed === 'Messages:' || trimmed.startsWith('Channel:')) { sawTranscript = true; index += 1; continue; } if (/^[^\n]+\s@\s[^\n]+:$/.test(trimmed)) { sawTranscript = true; index += 1; const bodyLines = []; while (index < lines.length) { const current = lines[index]; const currentTrimmed = current.trim(); if (/^[^\n]+\s@\s[^\n]+:$/.test(currentTrimmed)) break; if (currentTrimmed.startsWith('Channel:') || currentTrimmed === 'Messages:') break; bodyLines.push(current.startsWith(' ') ? current.slice(2) : current); index += 1; } if (bodyLines.length > 0) { collected.push(bodyLines.join('\n').trim()); } continue; } return value; }
return sawTranscript && collected.length > 0 ? collected.filter(Boolean).join('\n\n') : value;}
function extractQueuedFileRefs(value) { if (!value) return { content: value, fileRefs: [] }; const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = normalized.split('\n'); let start = -1; for (let i = 0; i < lines.length; i += 1) { if (lines[i].trim() === 'Files:' && lines[i + 1] && /^\s*-\s+/.test(lines[i + 1])) { start = i; break; } } if (start === -1) return { content: value, fileRefs: [] }; const refs = []; let end = start + 1; for (; end < lines.length; end += 1) { const line = lines[end]; if (/^\s*-\s+/.test(line)) { refs.push(line.replace(/^\s*-\s+/, '').trim()); } else if (!line.trim()) { break; } else { break; } } if (refs.length === 0) return { content: value, fileRefs: [] }; const before = lines.slice(0, start); const after = lines.slice(end); const cleaned = [...before, ...after].join('\n').replace(/\n{3,}/g, '\n\n').trim(); return { content: cleaned, fileRefs: refs };}
function extractQueuedMessageRefs(value) { if (!value) return { content: value, messageRefs: [] }; const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = normalized.split('\n'); let start = -1; for (let i = 0; i < lines.length; i += 1) { if (lines[i].trim() === 'Referenced messages:' && lines[i + 1] && /^\s*-\s+/.test(lines[i + 1])) { start = i; break; } } if (start === -1) return { content: value, messageRefs: [] }; const refs = []; let end = start + 1; for (; end < lines.length; end += 1) { const line = lines[end]; if (/^\s*-\s+/.test(line)) { const match = line.replace(/^\s*-\s+/, '').trim().match(/^message:(\S+)$/i); if (match) refs.push(match[1]); } else if (!line.trim()) { break; } else { break; } } if (refs.length === 0) return { content: value, messageRefs: [] }; const before = lines.slice(0, start); const after = lines.slice(end); const cleaned = [...before, ...after].join('\n').replace(/\n{3,}/g, '\n\n').trim(); return { content: cleaned, messageRefs: refs };}
function extractQueuedAttachmentRefs(value) { if (!value) return { content: value, attachmentRefs: [] }; const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = normalized.split('\n'); let start = -1; for (let i = 0; i < lines.length; i += 1) { if (lines[i].trim() === 'Attachments:' && lines[i + 1] && /^\s*-\s+/.test(lines[i + 1])) { start = i; break; } } if (start === -1) return { content: value, attachmentRefs: [] }; const refs = []; let end = start + 1; for (; end < lines.length; end += 1) { const line = lines[end]; if (/^\s*-\s+/.test(line)) { const item = line.replace(/^\s*-\s+/, '').trim(); const match = item.match(/^attachment:(\d+)(?:\s*\((.+)\))?$/i); if (match) { refs.push({ id: match[1], label: (match[2] || '').trim() || `attachment:${match[1]}`, raw: item, }); } } else if (!line.trim()) { break; } else { break; } } if (refs.length === 0) return { content: value, attachmentRefs: [] }; const before = lines.slice(0, start); const after = lines.slice(end); const cleaned = [...before, ...after].join('\n').replace(/\n{3,}/g, '\n\n').trim(); return { content: cleaned, attachmentRefs: refs };}
export function parseQueuedContent(value) { const unwrapped = unwrapQueuedTranscriptContent(value || ''); const withFiles = extractQueuedFileRefs(unwrapped || ''); const withMessages = extractQueuedMessageRefs(withFiles.content || ''); const withAttachments = extractQueuedAttachmentRefs(withMessages.content || ''); return { text: withAttachments.content || '', fileRefs: withFiles.fileRefs, messageRefs: withMessages.messageRefs, attachmentRefs: withAttachments.attachmentRefs, };}
export function QueuedFollowupStack({ items = [], onInjectQueuedFollowup, onRemoveQueuedFollowup, onMoveQueuedFollowup, onOpenFilePill,}) { if (!Array.isArray(items) || items.length === 0) return null; return html` <div class="compose-queue-stack"> ${items.map((item, index) => { const rowText = typeof item?.content === 'string' ? item.content : ''; const parsed = parseQueuedContent(rowText); if (!parsed.text.trim() && parsed.fileRefs.length === 0 && parsed.messageRefs.length === 0 && parsed.attachmentRefs.length === 0) return null; const canMoveUp = index > 0; const canMoveDown = index < items.length - 1; return html` <div class="compose-queue-stack-item" role="listitem"> <div class="compose-queue-stack-content" title=${rowText}> ${parsed.text.trim() && html`<div class="compose-queue-stack-text">${parsed.text}</div>`} ${(parsed.messageRefs.length > 0 || parsed.fileRefs.length > 0 || parsed.attachmentRefs.length > 0) && html` <div class="compose-queue-stack-refs"> ${parsed.messageRefs.map((id) => html` <${FilePill} key=${'queue-msg-' + id} prefix="compose" label=${'msg:' + id} title=${'Message reference: ' + id} icon="message" /> `)} ${parsed.fileRefs.map((path) => { const label = path.split('/').pop() || path; return html` <${FilePill} key=${'queue-file-' + path} prefix="compose" label=${label} title=${path} onClick=${() => onOpenFilePill?.(path)} /> `; })} ${parsed.attachmentRefs.map((attachment) => html` <${FilePill} key=${'queue-attachment-' + attachment.id} prefix="compose" label=${attachment.label} title=${attachment.raw} /> `)} </div> `} </div> <div class="compose-queue-stack-actions" role="group" aria-label="Queued follow-up controls"> ${items.length > 1 && html` <button class="compose-queue-stack-move-btn" type="button" title="Move up" aria-label="Move up in queue" disabled=${!canMoveUp} onClick=${() => canMoveUp && onMoveQueuedFollowup?.(index, index - 1)} > <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <polyline points="18 15 12 9 6 15"></polyline> </svg> </button> <button class="compose-queue-stack-move-btn" type="button" title="Move down" aria-label="Move down in queue" disabled=${!canMoveDown} onClick=${() => canMoveDown && onMoveQueuedFollowup?.(index, index + 1)} > <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> `} <button class="compose-queue-stack-steer-btn" type="button" title="Inject queued follow-up as steer" aria-label="Inject queued follow-up as steer" onClick=${() => onInjectQueuedFollowup?.(item)} > <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <path d="M4 20h12a2 2 0 0 0 2-2V8" /> <polyline points="14 12 18 8 22 12" /> </svg> <span>Steer</span> </button> <button class="compose-queue-stack-close-btn" type="button" title="Cancel queued message" aria-label="Cancel queued message" onClick=${() => onRemoveQueuedFollowup?.(item)} > <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> </button> </div> </div> `; })} </div> `;}
/** * Compose box component */export function ComposeBox({ onPost, onFocus, searchMode, searchScope = 'current', onSearch, onSearchScopeChange, onEnterSearch, onExitSearch, fileRefs = [], onRemoveFileRef, onClearFileRefs, messageRefs = [], onRemoveMessageRef, onClearMessageRefs, activeModel = null, agentModelsPayload = null, modelUsage = null, thinkingLevel = null, supportsThinking = false, contextUsage = null, onContextCompact, notificationsEnabled = false, notificationPermission = 'default', onToggleNotifications, onModelChange, onModelStateChange, activeEditorPath = null, onAttachEditorFile, onOpenFilePill, followupQueueItems = [], onInjectQueuedFollowup, onRemoveQueuedFollowup, onMoveQueuedFollowup, onSubmitIntercept, onMessageResponse, onPopOutChat, isAgentActive = false, activeChatAgents = [], currentChatJid = 'web:default', connectionStatus = 'connected', onSetFileRefs, onSetMessageRefs, onSubmitError, onSwitchChat, onRenameSession, isRenameSessionInProgress = false, onCreateSession, onDeleteSession, onRestoreSession, showQueueStack = true, statusNotice = null, prefillRequest = null,}) { const [content, setContent] = useState(''); const [searchText, setSearchText] = useState(''); const [mediaFiles, setMediaFiles] = useState([]); const [isDragActive, setIsDragActive] = useState(false); const [slashMatches, setSlashMatches] = useState([]); const [slashIndex, setSlashIndex] = useState(0); const [showSlash, setShowSlash] = useState(false); const [mentionMatches, setMentionMatches] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); const [showMention, setShowMention] = useState(false); const [switchingModel, setSwitchingModel] = useState(false); const [showModelPopup, setShowModelPopup] = useState(false); const [showSessionPopup, setShowSessionPopup] = useState(false); const [modelOptions, setModelOptions] = useState([]); const [modelPopupIndex, setModelPopupIndex] = useState(0); const [sessionPopupIndex, setSessionPopupIndex] = useState(0); const [loadingModels, setLoadingModels] = useState(false); const [footerWidth, setFooterWidth] = useState(0); const [submitError, setSubmitError] = useState(null); const [submitNotice, setSubmitNotice] = useState(null); const [statusNoticeNowMs, setStatusNoticeNowMs] = useState(() => Date.now()); const textareaRef = useRef(null); const slashRef = useRef(null); const mentionRef = useRef(null); const modelPopupRef = useRef(null); const modelHintRef = useRef(null); const sessionPopupRef = useRef(null); const sessionTriggerRef = useRef(null); const footerRef = useRef(null); const popupTypeaheadRef = useRef({ value: '', updatedAt: 0 }); const dragCounterRef = useRef(0); const renameSessionInProgressRef = useRef(false); const historyMax = 200; const historyStorageKey = getComposeHistoryStorageKey(currentChatJid); const normaliseHistory = (items) => { const seen = new Set(); const cleaned = []; for (const item of items || []) { if (typeof item !== 'string') continue; const trimmed = item.trim(); if (!trimmed || seen.has(trimmed)) continue; seen.add(trimmed); cleaned.push(trimmed); } return cleaned; }; const loadHistory = (storageKey = historyStorageKey) => { const raw = getLocalStorageItem(storageKey); if (!raw) return []; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return normaliseHistory(parsed); } catch { return []; } }; const saveHistory = (history, storageKey = historyStorageKey) => { setLocalStorageItem(storageKey, JSON.stringify(history)); }; const historyRef = useRef(loadHistory(historyStorageKey)); const historyIndexRef = useRef(-1); const historyDraftRef = useRef(''); const lastPrefillTokenRef = useRef('');
useEffect(() => { historyRef.current = loadHistory(historyStorageKey); historyIndexRef.current = -1; historyDraftRef.current = ''; }, [historyStorageKey]);
useEffect(() => { const resolved = resolveComposePrefillRequest(prefillRequest, lastPrefillTokenRef.current, searchMode); if (!resolved.shouldApply) return; lastPrefillTokenRef.current = resolved.nextToken; setSubmitError(null); setContent(resolved.text); updateSlashAutocomplete(resolved.text); updateMentionAutocomplete(resolved.text); requestAnimationFrame(() => { resizeTextarea(); const textarea = textareaRef.current; if (!textarea) return; try { textarea.focus({ preventScroll: true }); } catch { textarea.focus(); } const end = resolved.text.length; textarea.setSelectionRange?.(end, end); }); }, [prefillRequest, searchMode]); const canSend = content.trim() || mediaFiles.length > 0 || fileRefs.length > 0 || messageRefs.length > 0; const canShareLocation = typeof window !== 'undefined' && typeof navigator !== 'undefined' && Boolean(navigator.geolocation) && Boolean(window.isSecureContext); const notificationsSupported = typeof window !== 'undefined' && typeof Notification !== 'undefined'; const notificationsSecure = typeof window !== 'undefined' ? Boolean(window.isSecureContext) : false; const notificationDenied = notificationPermission === 'denied'; const notificationsAvailable = notificationsSupported && notificationsSecure && !notificationDenied; const notificationActive = notificationPermission === 'granted' && notificationsEnabled; const statusNoticeIsCompaction = isCompactionStatus(statusNotice); const statusNoticeTitle = resolveStatusPanelTitle(statusNotice); const statusNoticeDetail = typeof statusNotice?.detail === 'string' && statusNotice.detail.trim() ? statusNotice.detail.trim() : ''; const statusNoticeElapsedLabel = statusNoticeIsCompaction ? getStatusElapsedLabel(statusNotice, statusNoticeNowMs) : null; const notificationTitle = notificationActive ? 'Disable notifications' : 'Enable notifications'; const hasAttachments = mediaFiles.length > 0 || fileRefs.length > 0 || messageRefs.length > 0; const connectionStatusLabel = connectionStatus === 'disconnected' ? 'Reconnecting' : String(connectionStatus || 'Connecting') .replace(/[-_]+/g, ' ') .replace(/^./, (match) => match.toUpperCase()); const connectionStatusTitle = connectionStatus === 'disconnected' ? 'Reconnecting' : `Connection: ${connectionStatusLabel}`;
const mentionAgents = (Array.isArray(activeChatAgents) ? activeChatAgents : []) .filter((chat) => !chat?.archived_at); const currentSessionAgent = (() => { for (const chat of Array.isArray(activeChatAgents) ? activeChatAgents : []) { const chatJid = typeof chat?.chat_jid === 'string' ? chat.chat_jid.trim() : ''; if (chatJid && chatJid === currentChatJid) return chat; } return null; })(); const isCurrentRootSession = Boolean( currentSessionAgent && currentSessionAgent.chat_jid === (currentSessionAgent.root_chat_jid || currentSessionAgent.chat_jid) ); const switchableChatAgents = useMemo(() => { const seen = new Set(); const chats = []; for (const chat of Array.isArray(activeChatAgents) ? activeChatAgents : []) { const chatJid = typeof chat?.chat_jid === 'string' ? chat.chat_jid.trim() : ''; if (!chatJid || chatJid === currentChatJid || seen.has(chatJid)) continue; const agentName = typeof chat?.agent_name === 'string' ? chat.agent_name.trim() : ''; if (!agentName) continue; seen.add(chatJid); chats.push(chat); } return chats; }, [activeChatAgents, currentChatJid]); const hasSwitchableChatAgents = switchableChatAgents.length > 0; const canSwitchSession = hasSwitchableChatAgents && typeof onSwitchChat === 'function'; const canRestoreSession = hasSwitchableChatAgents && typeof onRestoreSession === 'function'; const renameInProgress = Boolean(isRenameSessionInProgress || renameSessionInProgressRef.current); const canRenameSession = !searchMode && typeof onRenameSession === 'function' && !renameInProgress; const canCreateSession = !searchMode && typeof onCreateSession === 'function'; const canDeleteSession = !searchMode && typeof onDeleteSession === 'function' && !isCurrentRootSession; const showSessionSwitcherButton = !searchMode && (canSwitchSession || canRestoreSession || canRenameSession || canCreateSession || canDeleteSession); const modelPickerState = resolveComposeModelPickerState(activeModel, agentModelsPayload); const showModelPickerHint = modelPickerState.showPicker; const modelHintLabel = modelPickerState.label; const modelHintSuffix = supportsThinking && thinkingLevel ? ` (${thinkingLevel})` : ''; const modelThinkingLabel = modelHintSuffix.trim() ? `${thinkingLevel}` : ''; const modelUsageLabel = typeof modelUsage?.hint_short === 'string' ? modelUsage.hint_short.trim() : ''; const modelUsageSectionLabel = [ modelThinkingLabel || null, modelUsageLabel || null, ].filter(Boolean).join(' • '); const modelUsageTitleParts = [ activeModel ? `Current model: ${modelHintLabel}${modelHintSuffix}` : null, modelUsage?.plan ? `Plan: ${modelUsage.plan}` : null, modelUsageLabel || null, modelUsage?.primary?.reset_description || null, modelUsage?.secondary?.reset_description || null, ].filter(Boolean); const modelHintTitle = switchingModel ? 'Switching model…' : (modelUsageTitleParts.join(' • ') || (showModelPickerHint ? 'Select a model (tap to open model picker)' : `Current model: ${modelHintLabel}${modelHintSuffix} (tap to open model picker)`)); const showComposeMetaRow = !searchMode && (showModelPickerHint || (contextUsage && contextUsage.percent != null));
const emitModelState = (payload) => { if (!payload || typeof payload !== 'object') return; const modelLabel = payload.model ?? payload.current; if (typeof onModelStateChange === 'function') { onModelStateChange({ model: modelLabel ?? null, thinking_level: payload.thinking_level ?? null, thinking_level_label: payload.thinking_level_label ?? null, supports_thinking: payload.supports_thinking, provider_usage: payload.provider_usage ?? null, }); } if (modelLabel && typeof onModelChange === 'function') { onModelChange(modelLabel); } };
const resizeTextarea = (target) => { const textarea = target || textareaRef.current; if (!textarea) return; textarea.style.height = 'auto'; textarea.style.height = `${textarea.scrollHeight}px`; textarea.style.overflowY = 'hidden'; };
/** Update slash autocomplete matches based on current input. */ const updateSlashAutocomplete = (value) => { // Only trigger when the entire input is a slash command (starts with /) // and contains no newlines (single-line command) if (!value.startsWith('/') || value.includes('\n')) { setShowSlash(false); setSlashMatches([]); return; } const prefix = value.toLowerCase().split(' ')[0]; // only match the command part if (prefix.length < 1) { setShowSlash(false); setSlashMatches([]); return; } const matches = SLASH_COMMANDS.filter(cmd => cmd.name.startsWith(prefix) || cmd.name.replace(/-/g, '').startsWith(prefix.replace(/-/g, '')) ); if (matches.length > 0 && !(matches.length === 1 && matches[0].name === prefix)) { setShowMention(false); setMentionMatches([]); setSlashMatches(matches); setSlashIndex(0); setShowSlash(true); } else { setShowSlash(false); setSlashMatches([]); } };
/** Accept the currently highlighted slash command. */ const acceptSlashCommand = (cmd) => { const current = content; // Replace the command portion, keep any args after a space const spaceIdx = current.indexOf(' '); const args = spaceIdx >= 0 ? current.slice(spaceIdx) : ''; const newVal = cmd.name + args; setContent(newVal); setShowSlash(false); setSlashMatches([]); requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) return; // Place cursor at end const len = newVal.length; textarea.selectionStart = len; textarea.selectionEnd = len; textarea.focus(); }); };
const updateMentionAutocomplete = (value) => { if (parseMentionAutocompleteQuery(value) == null) { setShowMention(false); setMentionMatches([]); return; } const matches = filterMentionAgents(mentionAgents, value, { currentChatJid }); if (matches.length > 0 && !(matches.length === 1 && buildMentionValue(matches[0].agent_name).trim().toLowerCase() === String(value || '').trim().toLowerCase())) { setShowSlash(false); setSlashMatches([]); setMentionMatches(matches); setMentionIndex(0); setShowMention(true); } else { setShowMention(false); setMentionMatches([]); } };
const acceptMention = (agent) => { const newVal = buildMentionValue(agent?.agent_name); if (!newVal) return; setContent(newVal); setShowMention(false); setMentionMatches([]); requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) return; const len = newVal.length; textarea.selectionStart = len; textarea.selectionEnd = len; textarea.focus(); }); };
const openSessionPopup = () => { if (searchMode || (!canSwitchSession && !canRestoreSession && !canRenameSession && !canCreateSession && !canDeleteSession)) return false;
popupTypeaheadRef.current = { value: '', updatedAt: 0 }; setShowModelPopup(false); setShowSlash(false); setSlashMatches([]); setShowMention(false); setMentionMatches([]); setShowSessionPopup(true); return true; };
const toggleSessionPopup = (event) => { event?.preventDefault?.(); event?.stopPropagation?.(); if (searchMode || (!canSwitchSession && !canRestoreSession && !canRenameSession && !canCreateSession && !canDeleteSession)) return; if (showSessionPopup) { popupTypeaheadRef.current = { value: '', updatedAt: 0 }; setShowSessionPopup(false); return; } openSessionPopup(); };
const handleSessionSwitch = (chatJid) => { const nextChatJid = typeof chatJid === 'string' ? chatJid.trim() : ''; setShowSessionPopup(false); if (!nextChatJid || nextChatJid === currentChatJid) { requestAnimationFrame(() => textareaRef.current?.focus()); return; } onSwitchChat?.(nextChatJid); };
const handleRestoreSession = async (chatJid) => { const nextChatJid = typeof chatJid === 'string' ? chatJid.trim() : ''; setShowSessionPopup(false); if (!nextChatJid || typeof onRestoreSession !== 'function') { requestAnimationFrame(() => textareaRef.current?.focus()); return; } try { await onRestoreSession(nextChatJid); } catch (error) { console.warn('Failed to restore session:', error); requestAnimationFrame(() => textareaRef.current?.focus()); } };
const findFirstEnabledPopupIndex = (items) => { const list = Array.isArray(items) ? items : []; const index = list.findIndex((item) => !item?.disabled); return index >= 0 ? index : 0; };
const sessionPopupEntries = useMemo(() => { const entries = []; for (const chat of switchableChatAgents) { const archived = Boolean(chat?.archived_at); const agentName = typeof chat?.agent_name === 'string' ? chat.agent_name.trim() : ''; const chatJid = typeof chat?.chat_jid === 'string' ? chat.chat_jid.trim() : ''; if (!agentName || !chatJid) continue; entries.push({ type: 'session', key: `session:${chatJid}`, label: `@${agentName} — ${chatJid}${chat?.is_active ? ' active' : ''}${archived ? ' archived' : ''}`, chat, disabled: archived ? !canRestoreSession : !canSwitchSession, }); } if (canCreateSession) { entries.push({ type: 'action', key: 'action:new', label: 'New session', action: 'new', disabled: false }); } if (canRenameSession) { entries.push({ type: 'action', key: 'action:rename', label: 'Rename current session', action: 'rename', disabled: renameInProgress }); } if (canDeleteSession) { entries.push({ type: 'action', key: 'action:delete', label: 'Delete current session', action: 'delete', disabled: false }); } return entries; }, [switchableChatAgents, canRestoreSession, canSwitchSession, canCreateSession, canRenameSession, canDeleteSession, renameInProgress]);
const handleRenameSession = async (event) => { if (event?.preventDefault) event.preventDefault(); if (event?.stopPropagation) event.stopPropagation();
if (typeof onRenameSession !== 'function' || isRenameSessionInProgress || renameSessionInProgressRef.current) return; renameSessionInProgressRef.current = true; setShowSessionPopup(false); try { await onRenameSession(); } catch (error) { console.warn('Failed to rename session:', error); } finally { renameSessionInProgressRef.current = false; } requestAnimationFrame(() => textareaRef.current?.focus()); };
const handleCreateSession = async () => { if (typeof onCreateSession !== 'function') return; setShowSessionPopup(false); try { await onCreateSession(); } catch (error) { console.warn('Failed to create session:', error); } requestAnimationFrame(() => textareaRef.current?.focus()); };
const handleDeleteSession = async () => { if (typeof onDeleteSession !== 'function') return; setShowSessionPopup(false); try { await onDeleteSession(currentChatJid); } catch (error) { console.warn('Failed to delete session:', error); } requestAnimationFrame(() => textareaRef.current?.focus()); };
const updateValue = (value) => { if (searchMode) { setSearchText(value); } else { setContent(value); updateSlashAutocomplete(value); updateMentionAutocomplete(value); } requestAnimationFrame(() => resizeTextarea()); };
const appendToValue = (snippet) => { const current = searchMode ? searchText : content; const prefix = current && !current.endsWith('\n') ? '\n' : ''; const next = `${current}${prefix}${snippet}`.trimStart(); updateValue(next); };
const extractCurrentModel = (response) => { const fromLabel = response?.command?.model_label; if (fromLabel) return fromLabel; const message = response?.command?.message; if (typeof message === 'string') { const currentMatch = message.match(/•\s+([^\n]+?)\s+\(current\)/); if (currentMatch?.[1]) return currentMatch[1].trim(); } return null; };
const runModelCommand = async (commandText) => { if (searchMode || switchingModel) return;
setSubmitError(null); setSubmitNotice(null); setSwitchingModel(true); try { const response = await sendAgentMessage('default', commandText, null, [], null, currentChatJid); const nextModel = extractCurrentModel(response); emitModelState({ model: nextModel ?? activeModel ?? null, thinking_level: response?.command?.thinking_level, thinking_level_label: response?.command?.thinking_level_label, supports_thinking: response?.command?.supports_thinking, }); await refreshAgentModelStateBestEffort(getAgentModels, currentChatJid, emitModelState); setSubmitNotice(resolveUiOnlyCommandNotice(commandText, response)); onPost?.(response); return true; } catch (error) { console.error('Failed to switch model:', error); alert('Failed to switch model: ' + error.message); return false; } finally { setSwitchingModel(false); } };
const handleCycleModel = async () => { await runModelCommand('/cycle-model'); };
const handleSelectModel = async (modelOption) => { const modelLabel = typeof modelOption === 'string' ? modelOption : (typeof modelOption?.label === 'string' ? modelOption.label : ''); if (!modelLabel || switchingModel) return; const ok = await runModelCommand(`/model ${modelLabel}`); if (ok) setShowModelPopup(false); };
const runSessionPopupEntry = (entry) => { if (!entry || entry.disabled) return; if (entry.type === 'session') { const chat = entry.chat; if (chat?.archived_at) { void handleRestoreSession(chat.chat_jid); } else { handleSessionSwitch(chat.chat_jid); } return; } if (entry.type === 'action') { if (entry.action === 'new') { void handleCreateSession(); return; } if (entry.action === 'rename') { void handleRenameSession(); return; } if (entry.action === 'delete') { void handleDeleteSession(); } } };
const toggleModelPopup = (event) => { event.preventDefault(); event.stopPropagation(); popupTypeaheadRef.current = { value: '', updatedAt: 0 }; setShowSessionPopup(false); setShowModelPopup((prev) => !prev); };
const handleContextCompact = async () => { if (searchMode) return; onContextCompact?.(); await handleSubmit('/compact', null, { includeMedia: false, includeFileRefs: false, includeMessageRefs: false, clearAfterSubmit: false, recordHistory: false, }); };
const resolveSubmitMode = (mode) => { if (mode === 'queue' || mode === 'steer' || mode === 'auto') { return mode; } return isAgentActive ? 'queue' : undefined; };
const handleSubmit = async (overrideContent, submitMode, submitOptions = {}) => { const { includeMedia = true, includeFileRefs = true, includeMessageRefs = true, clearAfterSubmit = true, recordHistory = true, } = submitOptions || {};
const inferred = typeof overrideContent === 'string' ? overrideContent : (overrideContent && typeof overrideContent?.target?.value === 'string' ? overrideContent.target.value : content); const currentContent = typeof inferred === 'string' ? inferred : ''; if ( !currentContent.trim() && (includeMedia ? mediaFiles.length === 0 : true) && (includeFileRefs ? fileRefs.length === 0 : true) && (includeMessageRefs ? messageRefs.length === 0 : true) ) return;
setShowSlash(false); setSlashMatches([]); setShowMention(false); setMentionMatches([]); setShowSessionPopup(false); setSubmitError(null); setSubmitNotice(null);
// Capture media/refs before clearing so the async send can use them const capturedMediaFiles = includeMedia ? [...mediaFiles] : []; const capturedFileRefs = includeFileRefs ? [...fileRefs] : []; const capturedMessageRefs = includeMessageRefs ? [...messageRefs] : []; const baseContent = currentContent.trim();
// Record history synchronously if (recordHistory && baseContent) { const current = historyRef.current; const deduped = normaliseHistory(current.filter((item) => item !== baseContent)); deduped.push(baseContent); if (deduped.length > historyMax) { deduped.splice(0, deduped.length - historyMax); } historyRef.current = deduped; saveHistory(deduped); historyIndexRef.current = -1; historyDraftRef.current = ''; }
const restoreDraft = () => { if (includeMedia) setMediaFiles([...capturedMediaFiles]); if (includeFileRefs) onSetFileRefs?.(capturedFileRefs); if (includeMessageRefs) onSetMessageRefs?.(capturedMessageRefs); setContent(baseContent); requestAnimationFrame(() => resizeTextarea()); };
// Clear compose box immediately so user can keep typing if (clearAfterSubmit) { setContent(''); setMediaFiles([]); onClearFileRefs?.(); onClearMessageRefs?.(); }
// Fire-and-forget: send in background, never block the compose box (async () => { try { const intercepted = await onSubmitIntercept?.({ content: baseContent, submitMode, fileRefs: capturedFileRefs, messageRefs: capturedMessageRefs, mediaFiles: capturedMediaFiles, }); if (intercepted) { onPost?.(intercepted); return; }
// Upload media files first const mediaIds = []; for (const file of capturedMediaFiles) { const result = await uploadMedia(file); mediaIds.push(result.id); }
const fileBlock = capturedFileRefs.length ? `Files:\n${capturedFileRefs.map((path) => `- ${path}`).join('\n')}` : ''; const messageRefBlock = capturedMessageRefs.length ? `Referenced messages:\n${capturedMessageRefs.map((id) => `- message:${id}`).join('\n')}` : ''; const mediaBlock = mediaIds.length ? `Attachments:\n${mediaIds.map((id, index) => { const file = capturedMediaFiles[index]; const label = file?.name || `attachment-${index + 1}`; return `- attachment:${id} (${label})`; }).join('\n')}` : ''; const message = [baseContent, fileBlock, messageRefBlock, mediaBlock].filter(Boolean).join('\n\n'); const response = await sendAgentMessage('default', message, null, mediaIds, resolveSubmitMode(submitMode), currentChatJid); onMessageResponse?.(response);
if (response?.command) { emitModelState({ model: response.command.model_label ?? activeModel ?? null, thinking_level: response.command.thinking_level, thinking_level_label: response.command.thinking_level_label, supports_thinking: response.command.supports_thinking, }); await refreshAgentModelStateBestEffort(getAgentModels, currentChatJid, emitModelState); }
setSubmitNotice(resolveUiOnlyCommandNotice(baseContent, response)); onPost?.(response); } catch (error) { if (clearAfterSubmit) { restoreDraft(); } const message = error?.message || 'Failed to send message.'; setSubmitError(message); onSubmitError?.(message); console.error('Failed to post:', error); } })(); };
const handleInjectQueuedFollowup = (queuedItem) => { // Queue-item steering is backend-authoritative: the server removes the // queued item and either converts it into steering or immediately sends // it if the active stream already ended. Avoid a second client-side // submit here so removal + steering stay atomic. onInjectQueuedFollowup?.(queuedItem); };
const handlePopupKeyboardEvent = useCallback((e) => { if (searchMode || (!showModelPopup && !showSessionPopup) || e?.isComposing) return false; const consume = () => { e.preventDefault?.(); e.stopPropagation?.(); }; const resetPopupTypeahead = () => { popupTypeaheadRef.current = { value: '', updatedAt: 0 }; }; if (e.key === 'Escape') { consume(); resetPopupTypeahead(); if (showModelPopup) setShowModelPopup(false); if (showSessionPopup) setShowSessionPopup(false); return true; } if (showModelPopup) { if (e.key === 'ArrowDown') { consume(); resetPopupTypeahead(); if (modelOptions.length > 0) setModelPopupIndex((idx) => (idx + 1) % modelOptions.length); return true; } if (e.key === 'ArrowUp') { consume(); resetPopupTypeahead(); if (modelOptions.length > 0) setModelPopupIndex((idx) => (idx - 1 + modelOptions.length) % modelOptions.length); return true; } if ((e.key === 'Enter' || e.key === 'Tab') && modelOptions.length > 0) { consume(); resetPopupTypeahead(); void handleSelectModel(modelOptions[Math.max(0, Math.min(modelPopupIndex, modelOptions.length - 1))]); return true; } if (isPopupTypeaheadKey(e) && modelOptions.length > 0) { consume(); const nextBuffer = updatePopupTypeaheadBuffer(popupTypeaheadRef.current, e.key); popupTypeaheadRef.current = nextBuffer; const match = resolvePopupTypeaheadMatch(modelOptions, nextBuffer.value, modelPopupIndex, (item) => getModelPickerOptionSearchLabel(item)); if (match >= 0) setModelPopupIndex(match); return true; } } if (showSessionPopup) { if (e.key === 'ArrowDown') { consume(); resetPopupTypeahead(); if (sessionPopupEntries.length > 0) setSessionPopupIndex((idx) => (idx + 1) % sessionPopupEntries.length); return true; } if (e.key === 'ArrowUp') { consume(); resetPopupTypeahead(); if (sessionPopupEntries.length > 0) setSessionPopupIndex((idx) => (idx - 1 + sessionPopupEntries.length) % sessionPopupEntries.length); return true; } if ((e.key === 'Enter' || e.key === 'Tab') && sessionPopupEntries.length > 0) { consume(); resetPopupTypeahead(); runSessionPopupEntry(sessionPopupEntries[Math.max(0, Math.min(sessionPopupIndex, sessionPopupEntries.length - 1))]); return true; } if (isPopupTypeaheadKey(e) && sessionPopupEntries.length > 0) { consume(); const nextBuffer = updatePopupTypeaheadBuffer(popupTypeaheadRef.current, e.key); popupTypeaheadRef.current = nextBuffer; const match = resolvePopupTypeaheadMatch(sessionPopupEntries, nextBuffer.value, sessionPopupIndex, (item) => item.label); if (match >= 0) setSessionPopupIndex(match); return true; } } return false; }, [ searchMode, showModelPopup, showSessionPopup, modelOptions, modelPopupIndex, sessionPopupEntries, sessionPopupIndex, handleSelectModel, ]);
const handleKeyDown = (e) => { if (e.isComposing) return; if (searchMode && e.key === 'Escape') { e.preventDefault(); setSearchText(''); onExitSearch?.(); return; } if (handlePopupKeyboardEvent(e)) { return; } const currentValue = textareaRef.current?.value ?? (searchMode ? searchText : content); if (shouldOpenSessionSwitcherFromBlankCompose(e, currentValue, { searchMode, showSessionSwitcherButton, })) { e.preventDefault(); openSessionPopup(); return; } // @agent autocomplete navigation if (showMention && mentionMatches.length > 0) { const mentionValue = textareaRef.current?.value ?? (searchMode ? searchText : content); if (!String(mentionValue || '').match(/^@([a-zA-Z0-9_-]*)$/)) { setShowMention(false); setMentionMatches([]); } else { if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % mentionMatches.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + mentionMatches.length) % mentionMatches.length); return; } if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); acceptMention(mentionMatches[mentionIndex]); return; } if (e.key === 'Escape') { e.preventDefault(); setShowMention(false); setMentionMatches([]); return; } } } // Slash autocomplete navigation if (showSlash && slashMatches.length > 0) { const slashValue = textareaRef.current?.value ?? (searchMode ? searchText : content); if (!String(slashValue || '').startsWith('/')) { // Stale slash popup; hide and continue with normal key handling. setShowSlash(false); setSlashMatches([]); } else { if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex(i => (i + 1) % slashMatches.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex(i => (i - 1 + slashMatches.length) % slashMatches.length); return; } if (e.key === 'Tab') { e.preventDefault(); acceptSlashCommand(slashMatches[slashIndex]); return; } if (e.key === 'Enter' && !e.shiftKey) { const hasArgs = currentValue.includes(' '); if (!hasArgs) { e.preventDefault(); const cmd = slashMatches[slashIndex]; setShowSlash(false); setSlashMatches([]); // If the user hits Enter with only a command fragment, accept // the match and submit in one step to avoid double-Enter. void handleSubmit(cmd.name); return; } // When args are present, allow Enter to fall through to submit. } if (e.key === 'Escape') { e.preventDefault(); setShowSlash(false); setSlashMatches([]); return; } } } if (!searchMode && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { const textarea = textareaRef.current; if (!textarea) return; const value = textarea.value || ''; const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0; const atEnd = textarea.selectionStart === value.length && textarea.selectionEnd === value.length; if ((e.key === 'ArrowUp' && atStart) || (e.key === 'ArrowDown' && atEnd)) { const history = historyRef.current; if (!history.length) return; e.preventDefault(); let idx = historyIndexRef.current; if (e.key === 'ArrowUp') { if (idx === -1) { historyDraftRef.current = value; idx = history.length - 1; } else if (idx > 0) { idx -= 1; } historyIndexRef.current = idx; updateValue(history[idx] || ''); } else { if (idx === -1) return; if (idx < history.length - 1) { idx += 1; historyIndexRef.current = idx; updateValue(history[idx] || ''); } else { historyIndexRef.current = -1; updateValue(historyDraftRef.current || ''); historyDraftRef.current = ''; } } requestAnimationFrame(() => { const target = textareaRef.current; if (!target) return; const len = target.value.length; target.selectionStart = len; target.selectionEnd = len; }); return; } } if (e.key === 'Enter' && !e.shiftKey && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (searchMode) { if (currentValue.trim()) { onSearch?.(currentValue.trim(), searchScope); } } else { void handleSubmit(currentValue, "steer"); } return; }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (searchMode) { if (currentValue.trim()) { onSearch?.(currentValue.trim(), searchScope); } } else { void handleSubmit(currentValue); } } };
const addMediaFiles = (files) => { const list = Array.from(files || []).filter((file) => file instanceof File && !String(file.name || '').startsWith('.DS_Store')); if (!list.length) return; setMediaFiles((current) => [...current, ...list]); setSubmitError(null); };
const handleFileChange = (e) => { addMediaFiles(e.target.files); e.target.value = ''; };
const handleDragEnter = (e) => { if (searchMode) return; e.preventDefault(); e.stopPropagation(); dragCounterRef.current += 1; setIsDragActive(true); };
const handleDragLeave = (e) => { if (searchMode) return; e.preventDefault(); e.stopPropagation(); dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) setIsDragActive(false); };
const handleDragOver = (e) => { if (searchMode) return; e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; setIsDragActive(true); };
const handleDrop = (e) => { if (searchMode) return; e.preventDefault(); e.stopPropagation(); dragCounterRef.current = 0; setIsDragActive(false); addMediaFiles(e.dataTransfer?.files || []); };
const handlePaste = (e) => { if (searchMode) return; const items = e.clipboardData?.items; if (!items || !items.length) return; const files = []; for (const item of items) { if (item.kind !== 'file') continue; const file = item.getAsFile?.(); if (file) files.push(file); } if (files.length > 0) { e.preventDefault(); addMediaFiles(files); } };
const removeMediaFile = (index) => { setMediaFiles((current) => current.filter((_, idx) => idx !== index)); };
const clearAllAttachmentRefs = () => { setSubmitError(null); setMediaFiles([]); onClearFileRefs?.(); onClearMessageRefs?.(); };
const handleLocation = () => { if (!navigator.geolocation) { alert('Geolocation is not available in this browser.'); return; }
navigator.geolocation.getCurrentPosition( (pos) => { const { latitude, longitude, accuracy } = pos.coords; const coords = `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`; const accuracyLabel = Number.isFinite(accuracy) ? ` ±${Math.round(accuracy)}m` : ''; const mapLink = `https://maps.google.com/?q=${latitude},${longitude}`; const snippet = `Location: ${coords}${accuracyLabel} ${mapLink}`; appendToValue(snippet); }, (err) => { const message = err?.message || 'Unable to retrieve location.'; alert(`Location error: ${message}`); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } ); };
useEffect(() => { if (!showModelPopup) return;
popupTypeaheadRef.current = { value: '', updatedAt: 0 }; setLoadingModels(true); getAgentModels(currentChatJid) .then((payload) => { setModelOptions(normalizeModelPickerOptions(payload)); emitModelState(payload); }) .catch((error) => { console.warn('Failed to load model list:', error); setModelOptions([]); }) .finally(() => { setLoadingModels(false); }); }, [showModelPopup, activeModel]);
useEffect(() => { if (searchMode) { setShowModelPopup(false); setShowSessionPopup(false); setShowSlash(false); setSlashMatches([]); setShowMention(false); setMentionMatches([]); } }, [searchMode]);
useEffect(() => { if (showSessionPopup && !showSessionSwitcherButton) { setShowSessionPopup(false); } }, [showSessionPopup, showSessionSwitcherButton]);
useEffect(() => { if (!showModelPopup) return; const activeIndex = modelOptions.findIndex((model) => model?.label === activeModel); setModelPopupIndex(activeIndex >= 0 ? activeIndex : 0); }, [showModelPopup, modelOptions, activeModel]);
useEffect(() => { if (!showSessionPopup) return; setSessionPopupIndex(findFirstEnabledPopupIndex(sessionPopupEntries)); popupTypeaheadRef.current = { value: '', updatedAt: 0 }; }, [showSessionPopup, currentChatJid]);
useEffect(() => { if (!showModelPopup) return;
const onPointerDown = (event) => { const popup = modelPopupRef.current; const hint = modelHintRef.current; const target = event.target; if (popup && popup.contains(target)) return; if (hint && hint.contains(target)) return; setShowModelPopup(false); };
document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); }, [showModelPopup]);
useEffect(() => { if (!showSessionPopup) return;
const onPointerDown = (event) => { const popup = sessionPopupRef.current; const trigger = sessionTriggerRef.current; const target = event.target; if (popup && popup.contains(target)) return; if (trigger && trigger.contains(target)) return; setShowSessionPopup(false); };
document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); }, [showSessionPopup]);
useEffect(() => { if (searchMode || (!showModelPopup && !showSessionPopup)) return; const onKeyDown = (event) => { handlePopupKeyboardEvent(event); }; document.addEventListener('keydown', onKeyDown, true); return () => document.removeEventListener('keydown', onKeyDown, true); }, [searchMode, showModelPopup, showSessionPopup, handlePopupKeyboardEvent]);
useEffect(() => { if (!showModelPopup) return; const popup = modelPopupRef.current; popup?.focus?.(); const active = popup?.querySelector?.('.compose-model-popup-item.active'); active?.scrollIntoView?.({ block: 'nearest' }); }, [showModelPopup, modelPopupIndex, modelOptions]);
useEffect(() => { if (!showSessionPopup) return; const popup = sessionPopupRef.current; popup?.focus?.(); const active = popup?.querySelector?.('.compose-model-popup-item.active'); active?.scrollIntoView?.({ block: 'nearest' }); }, [showSessionPopup, sessionPopupIndex, sessionPopupEntries.length]);
useEffect(() => { if (!showMention || !mentionRef.current) return; const popup = mentionRef.current; const active = popup.querySelector?.('.slash-item.active'); active?.scrollIntoView?.({ block: 'nearest' }); }, [showMention, mentionIndex, mentionMatches.length]);
useEffect(() => { if (!showSlash || !slashRef.current) return; const popup = slashRef.current; const active = popup.querySelector?.('.slash-item.active'); active?.scrollIntoView?.({ block: 'nearest' }); }, [showSlash, slashIndex, slashMatches.length]);
useEffect(() => { const updateFooterWidth = () => { const width = footerRef.current?.clientWidth || 0; setFooterWidth((current) => (current === width ? current : width)); };
updateFooterWidth();
const footer = footerRef.current; let observerFrame = 0; const scheduleFooterResize = () => { if (observerFrame) { cancelAnimationFrame(observerFrame); } observerFrame = requestAnimationFrame(() => { observerFrame = 0; updateFooterWidth(); }); };
let observer = null; if (footer && typeof ResizeObserver !== 'undefined') { observer = new ResizeObserver(() => scheduleFooterResize()); observer.observe(footer); }
if (typeof window !== 'undefined') { window.addEventListener('resize', scheduleFooterResize); }
return () => { if (observerFrame) { cancelAnimationFrame(observerFrame); } observer?.disconnect?.(); if (typeof window !== 'undefined') { window.removeEventListener('resize', scheduleFooterResize); } }; }, [searchMode, activeModel, currentSessionAgent?.agent_name, showSessionSwitcherButton, contextUsage?.percent]);
// Auto-resize textarea const handleInput = (e) => { const value = e.target.value; setSubmitError(null); setSubmitNotice(null); if (showSessionPopup) setShowSessionPopup(false); resizeTextarea(e.target); updateValue(value); };
useEffect(() => { requestAnimationFrame(() => resizeTextarea()); }, [content, searchText, searchMode]);
useEffect(() => { if (!statusNoticeIsCompaction) return; setStatusNoticeNowMs(Date.now()); const timer = setInterval(() => setStatusNoticeNowMs(Date.now()), 1000); return () => clearInterval(timer); }, [statusNoticeIsCompaction, statusNotice?.started_at, statusNotice?.startedAt]);
useEffect(() => { if (searchMode) return; updateMentionAutocomplete(content); }, [mentionAgents, currentChatJid, content, searchMode]);
return html` <div class="compose-box"> ${showQueueStack && !searchMode && html` <${QueuedFollowupStack} items=${followupQueueItems} onInjectQueuedFollowup=${handleInjectQueuedFollowup} onRemoveQueuedFollowup=${onRemoveQueuedFollowup} onMoveQueuedFollowup=${onMoveQueuedFollowup} onOpenFilePill=${onOpenFilePill} /> `} ${statusNotice && html` <div class=${`compose-inline-status${statusNoticeIsCompaction ? ' compaction' : ''}`} role="status" aria-live="polite" title=${statusNoticeDetail || ''} > <div class="compose-inline-status-row"> <span class=${buildComposeStatusDotClass({ pulsing: statusNoticeIsCompaction })} aria-hidden="true"></span> <span class="compose-inline-status-title">${statusNoticeTitle}</span> ${statusNoticeElapsedLabel && html`<span class="compose-inline-status-elapsed">${statusNoticeElapsedLabel}</span>`} </div> ${statusNoticeDetail && html`<div class="compose-inline-status-detail">${statusNoticeDetail}</div>`} </div> `} ${submitError && html` <div class="compose-submit-error compose-submit-error-top" role="status" aria-live="polite">${submitError}</div> `} ${submitNotice && html` <div class="compose-inline-status compose-command-notice" role="status" aria-live="polite"> <div class="compose-inline-status-detail compose-command-notice-text">${submitNotice}</div> </div> `} <div class=${`compose-input-wrapper${isDragActive ? ' drag-active' : ''}`} onDragEnter=${handleDragEnter} onDragOver=${handleDragOver} onDragLeave=${handleDragLeave} onDrop=${handleDrop} > <div class="compose-input-main"> ${hasAttachments && html` <div class="compose-file-refs"> ${messageRefs.map((id) => { return html` <${FilePill} key=${'msg-' + id} prefix="compose" label=${'msg:' + id} title=${'Message reference: ' + id} removeTitle="Remove reference" icon="message" onRemove=${() => onRemoveMessageRef?.(id)} /> `; })} ${fileRefs.map((path) => { const label = path.split('/').pop() || path; return html` <${FilePill} prefix="compose" label=${label} title=${path} onClick=${() => onOpenFilePill?.(path)} removeTitle="Remove file" onRemove=${() => onRemoveFileRef?.(path)} /> `; })} ${mediaFiles.map((file, index) => { const label = file?.name || `attachment-${index + 1}`; return html` <${FilePill} key=${label + index} prefix="compose" label=${label} title=${label} removeTitle="Remove attachment" onRemove=${() => removeMediaFile(index)} /> `; })} <button type="button" class="compose-clear-attachments-btn" onClick=${clearAllAttachmentRefs} title="Clear all attachments and references" aria-label="Clear all attachments and references" > Clear all </button> </div> `} ${!searchMode && typeof onPopOutChat === 'function' && html` <button type="button" class="compose-popout-btn" onClick=${() => onPopOutChat?.()} title="Open this chat in a new chat-only window" aria-label="Open this chat in a new chat-only window" > <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M14 5h5v5" /> <path d="M10 14 19 5" /> <path d="M19 14v5h-5" /> <path d="M5 10V5h5" opacity="0" /> <path d="M5 19h5" /> <path d="M5 19v-5" /> </svg> </button> `} <textarea ref=${textareaRef} placeholder=${searchMode ? "Search (Enter to run)..." : "Message (Enter to send, Shift+Enter for newline)..."} value=${searchMode ? searchText : content} onInput=${handleInput} onKeyDown=${handleKeyDown} onPaste=${handlePaste} onFocus=${onFocus} onClick=${onFocus} rows="1" /> ${showMention && mentionMatches.length > 0 && html` <div class="slash-autocomplete" ref=${mentionRef}> ${mentionMatches.map((agent, i) => html` <div key=${agent.chat_jid || agent.agent_name} class=${`slash-item${i === mentionIndex ? ' active' : ''}`} onMouseDown=${(e) => { e.preventDefault(); acceptMention(agent); }} onMouseEnter=${() => setMentionIndex(i)} > <span class="slash-name">@${agent.agent_name}</span> <span class="slash-desc">${agent.chat_jid || 'Active agent'}</span> </div> `)} </div> `} ${showSlash && slashMatches.length > 0 && html` <div class="slash-autocomplete" ref=${slashRef}> ${slashMatches.map((cmd, i) => html` <div key=${cmd.name} class=${`slash-item${i === slashIndex ? ' active' : ''}`} onMouseDown=${(e) => { e.preventDefault(); acceptSlashCommand(cmd); }} onMouseEnter=${() => setSlashIndex(i)} > <span class="slash-name">${cmd.name}</span> <span class="slash-desc">${cmd.description}</span> </div> `)} </div> `} ${showModelPopup && !searchMode && html` <div class="compose-model-popup" ref=${modelPopupRef} tabIndex="-1" onKeyDown=${handlePopupKeyboardEvent}> <div class="compose-model-popup-title">Select model</div> <div class="compose-model-popup-menu" role="menu" aria-label="Model picker"> ${loadingModels && html` <div class="compose-model-popup-empty">Loading models…</div> `} ${!loadingModels && modelOptions.length === 0 && html` <div class="compose-model-popup-empty">No models available.</div> `} ${!loadingModels && modelOptions.map((modelOption, index) => { const modelLabel = typeof modelOption?.label === 'string' ? modelOption.label : ''; const contextWindowLabel = formatModelPickerContextWindow(modelOption?.contextWindow); return html` <button key=${modelLabel} type="button" role="menuitem" class=${`compose-model-popup-item compose-model-popup-model-item${modelPopupIndex === index ? ' active' : ''}${activeModel === modelLabel ? ' current-model' : ''}`} onClick=${() => { void handleSelectModel(modelOption); }} disabled=${switchingModel} title=${[modelLabel, contextWindowLabel].filter(Boolean).join(' • ')} > <span class="compose-model-popup-model-label">${formatModelPickerDisplayLabel(modelLabel, modelOption?.contextWindow)}</span> </button> `; })} </div> <div class="compose-model-popup-actions"> <button type="button" class="compose-model-popup-btn" onClick=${() => { void handleCycleModel(); }} disabled=${switchingModel} > Next model </button> </div> </div> `} ${showSessionPopup && !searchMode && html` <div class="compose-model-popup" ref=${sessionPopupRef} tabIndex="-1" onKeyDown=${handlePopupKeyboardEvent}> <div class="compose-model-popup-title">Manage sessions & agents</div> <div class="compose-model-popup-menu" role="menu" aria-label="Sessions and agents"> ${html` <div class="compose-model-popup-item current" role="note" aria-live="polite"> ${(() => { return formatCurrentBranchLabel(currentSessionAgent, currentChatJid); })()} </div> `} ${!hasSwitchableChatAgents && html` <div class="compose-model-popup-empty">No other sessions yet.</div> `} ${hasSwitchableChatAgents && switchableChatAgents.map((chat, listIndex) => { const archived = Boolean(chat.archived_at); const isRoot = chat.chat_jid === (chat.root_chat_jid || chat.chat_jid); const canPrune = !isRoot && !chat.is_active && !archived && typeof onDeleteSession === 'function'; const label = formatBranchPickerLabel(chat, { currentChatJid }); return html` <div key=${chat.chat_jid} class=${`compose-model-popup-item-row${archived ? ' archived' : ''}`}> <button type="button" role="menuitem" class=${`compose-model-popup-item${archived ? ' archived' : ''}${sessionPopupIndex === listIndex ? ' active' : ''}`} onClick=${() => { if (archived) { void handleRestoreSession(chat.chat_jid); return; } handleSessionSwitch(chat.chat_jid); }} disabled=${archived ? !canRestoreSession : !canSwitchSession} title=${archived ? `Restore archived ${`@${chat.agent_name}`}` : `Switch to ${`@${chat.agent_name}`}`} > ${label} </button> ${canPrune && html` <button type="button" class="compose-model-popup-item-delete" title="Delete this branch" aria-label=${`Delete @${chat.agent_name}`} onClick=${(e) => { e.stopPropagation(); setShowSessionPopup(false); void onDeleteSession(chat.chat_jid); }} > <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> </button> `} </div> `; })} </div> ${(canCreateSession || canRenameSession || canDeleteSession) && html` <div class="compose-model-popup-actions"> ${canCreateSession && html` <button type="button" class=${`compose-model-popup-btn primary${sessionPopupEntries.findIndex((entry) => entry.key === 'action:new') === sessionPopupIndex ? ' active' : ''}`} onClick=${() => { void handleCreateSession(); }} title="Create a new agent/session branch from this chat" > New </button> `} ${canRenameSession && html` <button type="button" class=${`compose-model-popup-btn${sessionPopupEntries.findIndex((entry) => entry.key === 'action:rename') === sessionPopupIndex ? ' active' : ''}`} onClick=${(e) => { void handleRenameSession(e); }} title="Rename the current branch handle" disabled=${renameInProgress} > Rename current… </button> `} ${canDeleteSession && html` <button type="button" class=${`compose-model-popup-btn danger${sessionPopupEntries.findIndex((entry) => entry.key === 'action:delete') === sessionPopupIndex ? ' active' : ''}`} onClick=${() => { void handleDeleteSession(); }} title="Delete (prune) current agent/session branch" > Delete current… </button> `} </div> `} </div> `} </div> <div class="compose-footer" ref=${footerRef}> ${showComposeMetaRow && html` <div class="compose-meta-row"> ${showModelPickerHint && html` <div class="compose-model-meta"> <button ref=${modelHintRef} type="button" class="compose-model-hint compose-model-hint-btn" title=${modelHintTitle} aria-label="Open model picker" onClick=${toggleModelPopup} disabled=${switchingModel} > ${switchingModel ? 'Switching…' : modelHintLabel} </button> <div class="compose-model-meta-subline"> ${!switchingModel && modelUsageSectionLabel && html` <span class="compose-model-usage-hint" title=${modelHintTitle}> ${modelUsageSectionLabel} </span> `} </div> </div> `} ${!searchMode && contextUsage && contextUsage.percent != null && html` <${ContextPie} usage=${contextUsage} onCompact=${handleContextCompact} /> `} </div> `} <div class="compose-actions ${searchMode ? 'search-mode' : ''}"> ${showSessionSwitcherButton && html` <div ref=${sessionTriggerRef} class="compose-session-trigger-group" > ${currentSessionAgent?.agent_name && html` <button type="button" class=${`compose-session-trigger compose-session-trigger-pill${showSessionPopup ? ' active' : ''}`} onClick=${toggleSessionPopup} title=${currentSessionAgent?.chat_jid || currentChatJid} aria-label=${`Manage sessions for @${currentSessionAgent.agent_name}`} aria-expanded=${showSessionPopup ? 'true' : 'false'} > <span class="compose-current-agent-label active">@${currentSessionAgent.agent_name}</span> </button> `} <button type="button" class=${`compose-session-trigger compose-session-trigger-icon-btn${showSessionPopup ? ' active' : ''}`} onClick=${toggleSessionPopup} title=${currentSessionAgent?.chat_jid || currentChatJid} aria-label=${currentSessionAgent?.agent_name ? `Manage sessions for @${currentSessionAgent.agent_name}` : 'Manage Sessions/Agents'} aria-expanded=${showSessionPopup ? 'true' : 'false'} > <span class="compose-session-trigger-icon" aria-hidden="true"> <svg class="compose-mention-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" focusable="false"> <circle cx="12" cy="12" r="4.25" /> <path d="M16.25 7.75v5.4a2.1 2.1 0 0 0 4.2 0V12a8.45 8.45 0 1 0-4.2 7.33" /> </svg> </span> </button> </div> `} ${searchMode && html` <label class="compose-search-scope-wrap" title="Search scope"> <span class="compose-search-scope-label">Scope</span> <select class="compose-search-scope-select" value=${searchScope} onChange=${(e) => onSearchScopeChange?.(e.currentTarget.value)} > <option value="current">Current</option> <option value="root">Branch family</option> <option value="all">All chats</option> </select> </label> `} <button class="icon-btn search-toggle" onClick=${searchMode ? onExitSearch : onEnterSearch} title=${searchMode ? "Close search" : "Search"} > ${searchMode ? html` <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 6L6 18M6 6l12 12"/> </svg> ` : html` <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="11" cy="11" r="8"/> <path d="M21 21l-4.35-4.35"/> </svg> `} </button> ${canShareLocation && !searchMode && html` <button class="icon-btn location-btn" onClick=${handleLocation} title="Share location" type="button" disabled=${false} > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="10" /> <path d="M12 2a14 14 0 0 1 0 20a14 14 0 0 1 0-20" /> <path d="M2 12h20" /> </svg> </button> `} ${notificationsAvailable && !searchMode && html` <button class=${`icon-btn notification-btn${notificationActive ? ' active' : ''}`} onClick=${onToggleNotifications} title=${notificationTitle} type="button" > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" /> <path d="M13.73 21a2 2 0 0 1-3.46 0" /> </svg> </button> `} ${!searchMode && html` ${activeEditorPath && onAttachEditorFile && html` <button class="icon-btn attach-editor-btn" onClick=${onAttachEditorFile} title=${`Attach open file: ${activeEditorPath}`} type="button" disabled=${fileRefs.includes(activeEditorPath)} > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg> </button> `} <label class="icon-btn" title="Attach file"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> <input type="file" multiple hidden onChange=${handleFileChange} /> </label> `} ${(connectionStatus !== 'connected' || !searchMode) && html` <div class="compose-send-stack"> ${connectionStatus !== 'connected' && html` <span class="compose-connection-status connection-status ${connectionStatus}" title=${connectionStatusTitle}> ${connectionStatusLabel} </span> `} ${!searchMode && html` <button class="icon-btn send-btn" type="button" onClick=${() => { void handleSubmit(); }} disabled=${!canSend} title="Send (Enter)" > <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg> </button> `} </div> `} </div> </div> </div> </div> `;}