import { html, nothing } from 'lit';
import { ChatSession } from '../lib/chat-session.js';
import { t, I18nMixin } from '../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from './copilot-render.js';
import { renderTaskStrip } from './shared/agent-tasks.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: () => t('copilot.cmd.help') },
{ name: 'clear', description: () => t('copilot.cmd.clear') },
{ name: 'new', description: () => t('copilot.cmd.new') },
{ name: 'models', description: () => t('copilot.cmd.models') },
{ name: 'model', description: () => t('copilot.cmd.model') },
{ name: 'context', description: () => t('copilot.cmd.context') },
{ name: 'cost', description: () => t('copilot.cmd.cost') },
{ name: 'compact', description: () => t('copilot.cmd.compact') },
{ name: 'resettools', description: () => t('copilot.cmd.resettools') },
{ name: 'sethome', description: () => t('copilot.cmd.sethome') },
];
// The always-present General tab. It is never stored as an open tab: it exists
// because the copilot exists, and it cannot be closed.
const GENERAL_SOURCE = 'web';
// Which tab is selected is per browser window, so it lives in sessionStorage —
// two windows would otherwise fight over one value, and every tab click would be
// a write. The *set* of open tabs is server-side (`chat_sessions.is_open`), which
// is why it follows the user across devices and a second household member on the
// same browser never sees it.
const ACTIVE_TAB_KEY = 'copilot-active-tab';
export class AppCopilot extends I18nMixin(ChatSession) {
static properties = {
_collapsed: { state: true },
_mode: { state: true },
_me: { state: true },
_modelOpen: { state: true },
_groupOpen: { state: true },
_tabs: { state: true },
_activeSource: { state: true },
_cmdMenu: { state: true },
_cmdSel: { state: true },
};
constructor() {
super();
this._collapsed = false;
// 'full' fills the workspace (home route), 'dock' is the side panel.
this._mode = 'dock';
this._me = null;
this._modelOpen = false;
this._groupOpen = 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.
// Each carries the id of the session it shows, which is what `is_open` hangs on
// server-side — and what a `/new` reset moves to a different row.
this._tabs = [{ source: GENERAL_SOURCE, label: t('chat.tab.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);
this._onPageChange = this._onPageChange.bind(this);
}
// The desktop shell routes `#session/{id}`, so a background task's row links
// through to what it is doing.
get _canOpenTaskSession() { return true; }
connectedCallback() {
// Before super: the base loads history and opens the WS for `_source` in its
// own connectedCallback, so the restored selection has to be in place or the
// first paint fetches General and then immediately throws it away.
// sessionStorage is synchronous, which is what makes this possible; the tab
// *set* arrives over the network and reconciles in `_restoreTabs`.
const active = sessionStorage.getItem(ACTIVE_TAB_KEY);
if (active && active !== GENERAL_SOURCE) this._activeSource = active;
// The base's is async and owns the first WS: hand it to `_restoreTabs`, which
// must not switch source while that connection is still being set up.
const ready = super.connectedCallback?.();
this._restoreState();
this._restoreTabs(ready);
this._loadCommands();
this._loadMe();
this._loadSecurityGroups();
// Same element, two layouts: the chat is the home page ('full') and docks
// to the side on every other route — state is never lost, it only resizes.
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
window.addEventListener('keydown', this._onKeydown);
window.addEventListener('keyup', this._onKeyup);
window.addEventListener('project-chat-open', this._onProjectChatOpen);
window.addEventListener('copilot-open', this._onCopilotOpen);
window.addEventListener('llm-page-change', this._onPageChange);
}
_pageFromHash() {
const m = location.hash.slice(1).match(/^([^/?]+)/);
const seg = m ? m[1] : '';
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'];
return known.includes(seg) ? seg : 'home';
}
_onPageChange(e) {
this._applyMode(e.detail?.page === 'home' ? 'full' : 'dock');
}
_applyMode(mode) {
if (mode === this._mode && this.getAttribute('mode') === mode) return;
this._mode = mode;
this.setAttribute('mode', mode);
}
async _loadMe() {
try {
const res = await fetch('/api/auth/me');
if (res.ok) this._me = await res.json();
} catch { /* ignore */ }
}
_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);
window.removeEventListener('llm-page-change', this._onPageChange);
}
_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 ────────────────────────────────────────────────────────────────────
// Restore the tabs the user left open. They come from their own (encrypted)
// database rather than this browser, so the bar is the same on every device and
// a shared laptop never mixes two members' tabs.
async _restoreTabs(ready) {
// A source switch tears down the WS, so it has to wait for the one the base
// opens on mount — otherwise both run and the connection is left doubled.
const settled = Promise.resolve(ready).catch(() => {});
let tabs = [];
try {
const res = await fetch('/api/sessions/open');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
tabs = await res.json();
} catch (e) {
console.error('Failed to restore copilot tabs:', e);
// The restored selection can't be trusted without the set that justifies it.
await settled;
if (this._source !== GENERAL_SOURCE) this._selectTab(GENERAL_SOURCE);
return;
}
// One tab per source, showing its most recent session — the rows arrive in
// creation order, so a source left with two open rows resolves to the newer.
const bySource = new Map();
for (const { source, label, session_id } of tabs) {
bySource.set(source, { source, label: label || source, sessionId: session_id });
}
// Merged, not replaced: the user may already have opened a tab while this was
// in flight, and it would not be in a response the server built before it.
const merged = [...this._tabs];
for (const tab of bySource.values()) {
const known = merged.find(t => t.source === tab.source);
if (known) { known.sessionId ??= tab.sessionId; continue; }
merged.push(tab);
}
this._tabs = merged;
// The selection is per window and the set is per user, so they can disagree:
// another window may have closed the tab this one had selected.
await settled;
if (this._source !== GENERAL_SOURCE && !this._tabs.some(t => t.source === this._source)) {
this._selectTab(GENERAL_SOURCE);
}
}
// 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, session_id } = e.detail ?? {};
if (!source) return;
const known = this._tabs.find(t => t.source === source);
if (known) {
// Keep the id fresh — the session behind a source changes on every reset.
this._bindTabSession(known, session_id);
} else {
this._tabs = [...this._tabs, { source, label: label || source, sessionId: session_id }];
this._persistTab(session_id, true);
}
this._setCollapsed(false);
this._selectTab(source);
}
_selectTab(source) {
try { sessionStorage.setItem(ACTIVE_TAB_KEY, source); } catch { /* private mode */ }
if (source === this._source) return;
this._switchSource(source); // base: tear down WS, reload history, reconnect
}
// Close a project tab. The conversation itself is untouched and can be reopened
// from the board — closing only clears its `is_open` flag. The General tab is
// never closable and is not a stored tab at all.
_closeTab(source, e) {
e?.stopPropagation();
if (source === GENERAL_SOURCE) return;
const tab = this._tabs.find(t => t.source === source);
const wasActive = source === this._source;
this._tabs = this._tabs.filter(t => t.source !== source);
this._persistTab(tab?.sessionId, false);
if (wasActive) this._selectTab(GENERAL_SOURCE);
}
// A `/new` in a project tab replaced that source's session. `is_open` hangs on
// the row, so it has to be carried over or the tab would close itself out from
// under the user at the next login.
_onSessionReplaced(source, sessionId) {
if (source === GENERAL_SOURCE) return;
const tab = this._tabs.find(t => t.source === source);
if (tab) this._bindTabSession(tab, sessionId);
}
// Point a tab at the session it now shows. The previous one is closed in the
// same breath: leaving it open would have the source restore twice, and the
// stale row would be the one a later close cleared.
_bindTabSession(tab, sessionId) {
if (!sessionId || tab.sessionId === sessionId) return;
const previous = tab.sessionId;
tab.sessionId = sessionId;
if (previous) this._persistTab(previous, false);
this._persistTab(sessionId, true);
}
// Best-effort: a tab that failed to persist reappears (or lingers) at the next
// login, which is a nuisance, never a loss.
async _persistTab(sessionId, open) {
if (!sessionId) return;
try {
await fetch(`/api/sessions/${sessionId}/open`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ open }),
});
} catch (e) {
console.error('Failed to persist copilot tab:', e);
}
}
// ── DOM hooks ─────────────────────────────────────────────────────────────────
_inputEl() {
return this.querySelector('.copilot-textarea');
}
_messagesContainer() {
return this.querySelector('.copilot-messages');
}
_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 ────────────────────────────────────────────────────────────────────
_sendSuggestion(text) {
const el = this._inputEl();
if (!el) return;
el.value = text;
this._send();
}
_renderEmptyState() {
// Dock mode keeps the compact greeting bubble; full mode (home) shows the
// welcome hero with a few prompt suggestions to get a conversation going.
if (this._mode !== 'full') {
return html`
${name ? t('chat.greeting.named', { name }) : t('chat.greeting')}
${t('chat.greeting.sub')}
${suggestions.map(s => html`
`)}
`;
}
render() {
// Collapse applies to the dock only: on the home route the chat IS the page.
if (this._collapsed && this._mode !== 'full') return nothing;
const full = this._mode === 'full';
return html`
${!full ? html`