import { html, nothing } from 'lit'; import { ChatSession } from '../lib/chat-session.js'; import { renderMsg, renderAttachmentChips } from './copilot-render.js'; // Built-in (server-handled) slash commands shown at the top of the composer // autocomplete. Custom commands (from `commands//`) are fetched from // `/api/commands` and appended below. const SYSTEM_COMMAND_ITEMS = [ { name: 'help', description: 'Show available commands' }, { name: 'clear', description: 'Start a new conversation' }, { name: 'new', description: 'Alias for /clear' }, { name: 'models', description: 'List available LLM models' }, { name: 'model', description: 'Select the model for this chat' }, { name: 'context', description: "Last turn's token usage" }, { name: 'cost', description: 'Session spend (USD)' }, { name: 'compact', description: 'Force context compaction' }, { name: 'resettools', description: 'Remove activated tool groups' }, { name: 'sethome', description: 'Set web as notification home' }, ]; export class AppCopilot extends ChatSession { static properties = { _collapsed: { state: true }, _modelOpen: { state: true }, _tabs: { state: true }, _activeSource: { state: true }, _cmdMenu: { state: true }, _cmdSel: { state: true }, }; constructor() { super(); this._collapsed = false; this._modelOpen = false; this._resizing = false; // Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown // (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged // system + custom list fetched once from `/api/commands`. this._cmdMenu = null; this._cmdSel = 0; this._allCommands = null; // Browser-style tabs: 'General' (the default 'web' source) is always present and // not closable; project chats are added on demand and addressed by their source. this._tabs = [{ source: 'web', label: 'General' }]; this._onResizeMove = this._onResizeMove.bind(this); this._onResizeUp = this._onResizeUp.bind(this); this._onKeydown = this._onKeydown.bind(this); this._onKeyup = this._onKeyup.bind(this); this._onProjectChatOpen = this._onProjectChatOpen.bind(this); this._onCopilotOpen = this._onCopilotOpen.bind(this); } connectedCallback() { super.connectedCallback?.(); this._restoreState(); this._loadCommands(); window.addEventListener('keydown', this._onKeydown); window.addEventListener('keyup', this._onKeyup); window.addEventListener('project-chat-open', this._onProjectChatOpen); window.addEventListener('copilot-open', this._onCopilotOpen); } _restoreState() { const w = localStorage.getItem('copilot-width'); if (w) document.documentElement.style.setProperty('--copilot-width', w); if (localStorage.getItem('copilot-collapsed') === 'true') { this._setCollapsed(true); } } disconnectedCallback() { super.disconnectedCallback?.(); window.removeEventListener('keydown', this._onKeydown); window.removeEventListener('keyup', this._onKeyup); window.removeEventListener('project-chat-open', this._onProjectChatOpen); window.removeEventListener('copilot-open', this._onCopilotOpen); } _onCopilotOpen() { this._setCollapsed(false); } _setCollapsed(value) { this._collapsed = value; this.classList.toggle('collapsed', value); localStorage.setItem('copilot-collapsed', value); window.dispatchEvent(new CustomEvent('copilot-collapsed', { detail: { collapsed: value } })); } // ── Tabs ──────────────────────────────────────────────────────────────────── // A project chat was opened elsewhere (e.g. the project board): add its tab if // new, expand the copilot, and switch the live connection to it. _onProjectChatOpen(e) { const { source, label } = e.detail ?? {}; if (!source) return; if (!this._tabs.some(t => t.source === source)) { this._tabs = [...this._tabs, { source, label: label || source }]; } this._setCollapsed(false); this._selectTab(source); } _selectTab(source) { if (source === this._source) return; this._switchSource(source); // base: tear down WS, reload history, reconnect } // Close a project tab (UI only — the session persists server-side and can be // reopened from the board). The 'web'/General tab is never closable. _closeTab(source, e) { e?.stopPropagation(); if (source === 'web') return; const wasActive = source === this._source; this._tabs = this._tabs.filter(t => t.source !== source); if (wasActive) this._switchSource('web'); } // ── DOM hooks ───────────────────────────────────────────────────────────────── _inputEl() { return this.querySelector('.copilot-textarea'); } _scrollToBottom() { this.updateComplete.then(() => { const el = this.querySelector('.copilot-messages'); if (el) el.scrollTop = el.scrollHeight; }); } _onMessagePushed(item) { if (item.kind === 'pending_write') { this.updateComplete.then(() => { const panels = this.querySelectorAll('.copilot-approval'); const el = panels[panels.length - 1]; if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); } else { this._scrollToBottom(); } } // ── Resize ──────────────────────────────────────────────────────────────────── _startResize(e) { this._resizing = true; this._resizeStartX = e.clientX; this._resizeStartW = this.offsetWidth; window.addEventListener('mousemove', this._onResizeMove); window.addEventListener('mouseup', this._onResizeUp); e.preventDefault(); } _onResizeMove(e) { if (!this._resizing) return; const delta = this._resizeStartX - e.clientX; const newWidth = Math.max(260, Math.min(720, this._resizeStartW + delta)); document.documentElement.style.setProperty('--copilot-width', `${newWidth}px`); } _onResizeUp() { this._resizing = false; window.removeEventListener('mousemove', this._onResizeMove); window.removeEventListener('mouseup', this._onResizeUp); const w = getComputedStyle(document.documentElement).getPropertyValue('--copilot-width').trim(); if (w) localStorage.setItem('copilot-width', w); } // ── Input ───────────────────────────────────────────────────────────────────── _handleKeydown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._send(); } } // ── Slash-command autocomplete ──────────────────────────────────────────────── /** Fetch custom commands once and merge them below the built-in system ones. */ async _loadCommands() { try { const res = await fetch('/api/commands'); const custom = res.ok ? await res.json() : []; this._allCommands = [...SYSTEM_COMMAND_ITEMS, ...custom]; } catch { this._allCommands = [...SYSTEM_COMMAND_ITEMS]; } } /** Recompute the menu from the current input value. Shown only while typing the * command name (a leading `/` with no whitespace yet); hidden once args start. */ _updateCmdMenu(value) { const m = /^\/([a-z0-9_-]*)$/i.exec(value); if (!m) { if (this._cmdMenu) this._cmdMenu = null; return; } const prefix = m[1].toLowerCase(); const items = (this._allCommands || SYSTEM_COMMAND_ITEMS) .filter(c => c.name.toLowerCase().startsWith(prefix)); this._cmdMenu = items.length ? items : null; this._cmdSel = 0; } /** Insert the chosen command (`/name `) and close the menu, ready for arguments. */ _applyCmd(name) { const el = this._inputEl(); if (el) { el.value = `/${name} `; el.focus(); this._autoResize(el); } this._cmdMenu = null; } /** Composer keydown: drive the menu when open, else fall back to send-on-Enter. */ _composerKeydown(e) { const menu = this._cmdMenu; if (menu && menu.length) { if (e.key === 'ArrowDown') { e.preventDefault(); this._cmdSel = (this._cmdSel + 1) % menu.length; return; } if (e.key === 'ArrowUp') { e.preventDefault(); this._cmdSel = (this._cmdSel - 1 + menu.length) % menu.length; return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); this._applyCmd(menu[this._cmdSel].name); return; } if (e.key === 'Escape') { e.preventDefault(); this._cmdMenu = null; return; } } this._handleKeydown(e); } // ── Ctrl+Space push-to-talk shortcut (desktop only) ────────────────────────── // Voice recording + transcription is owned by the ChatSession base class; the // only desktop-specific bit is the global Ctrl+Space hold-to-record shortcut. _onKeydown(e) { if (!this._hasTranscribe) return; if (e.code === 'Space' && e.ctrlKey && !e.repeat) { e.preventDefault(); if (!this._recording) this._startRecording(true); } } _onKeyup(e) { if (!this._hasTranscribe) return; if (e.code === 'Space' && this._recording && this._shortcutRecording) { e.preventDefault(); this._stopRecording(); } } // ── Render helpers ──────────────────────────────────────────────────────────── _toggleExpand(id) { const next = new Set(this._expanded); if (next.has(id)) next.delete(id); else next.add(id); this._expanded = next; } // ── Render ──────────────────────────────────────────────────────────────────── render() { if (this._collapsed) return nothing; return html`
this._startResize(e)}>
Copilot
${this._tabs.length > 1 ? html`
${this._tabs.map(t => html`
this._selectTab(t.source)} title=${t.label} > ${t.label} ${t.source !== 'web' ? html` ` : nothing}
`)}
` : nothing}
${this._messages.length === 0 ? html`
Hello! How can I help you today?
` : this._messages.map(m => renderMsg(this, m))} ${this._waiting ? html`
Thinking…
` : nothing}
e.preventDefault()} @drop=${(e) => this._onDrop(e)}> ${this._cmdMenu?.length ? html`
${this._cmdMenu.map((c, i) => html` `)}
` : nothing} ${renderAttachmentChips(this, this._attachments, { removable: true })} { this._addFiles(e.target.files); e.target.value = ''; }} />
${this._providers.length > 1 ? html`
${this._modelOpen ? html`
{ this._modelOpen = false; }}>
${this._providers.map(p => html` `)}
` : nothing}
` : nothing}
${this._hasTranscribe ? html` ` : nothing} ${this._waiting ? html`` : nothing}
`; } }