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';
// ── The two kinds of tab ──────────────────────────────────────────────────────
//
// A **primary** tab is a source: it shows whatever `web` or `project-7` currently
// points at, which is also where background delivery lands (a notification, a
// finished task, an inbound Telegram message) and what a `/new` moves to a fresh
// conversation. There is at most one per source, and every project's "Open chat"
// lands on it.
//
// A **secondary** tab is one specific conversation, opened with `+`. Its source
// points elsewhere, so it is unreachable by source name and is addressed by id
// throughout — REST, WebSocket and event filtering alike. Nothing is delivered to
// it from the outside; it is a place to work on a second thing at once.
//
// The key is what the selection is stored under and what the render loop tracks,
// so it must stay stable while a tab lives. A primary tab keeps its key across a
// reset (the source is the identity); a secondary tab's key is its session.
const primaryTab = (source, label, sessionId = null) =>
({ key: `src:${source}`, source, sessionId, label, title: null, primary: true });
const secondaryTab = (source, sessionId, label, title = null) =>
({ key: `ses:${sessionId}`, source, sessionId, label, title, primary: false });
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 },
_activeSessionId: { state: true },
_newTabOpen: { state: true },
_newTabTargets: { state: true },
_newTabAnchor: { state: true },
_renamingKey: { 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. Two kinds, and the difference is which conversation they
// name — see `TAB` below. 'General' is always present and not closable.
this._tabs = [primaryTab(GENERAL_SOURCE, t('chat.tab.general'))];
// The `+` menu: null when closed, otherwise the list of things a new chat can
// be started on (General + the caller's projects), fetched on first open.
this._newTabOpen = false;
this._newTabTargets = null;
this._newTabAnchor = null;
// Key of the tab being renamed inline, if any.
this._renamingKey = null;
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.startsWith('ses:')) {
this._activeSessionId = Number(active.slice(4)) || null;
} else if (active.startsWith('src:') && active !== `src:${GENERAL_SOURCE}`) {
this._activeSource = active.slice(4);
}
// 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 ────────────────────────────────────────────────────────────────────
// The tab this chat is currently bound to.
get _activeKey() {
return this._activeSessionId ? `ses:${this._activeSessionId}` : `src:${this._source}`;
}
// 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) {
// Switching tabs 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 rows = [];
try {
const res = await fetch('/api/sessions/open');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
rows = 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;
await this._fallBackToGeneral();
return;
}
// 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 row of rows) {
const tab = row.primary
? primaryTab(row.source, row.label || row.source, row.session_id)
: secondaryTab(row.source, row.session_id, row.label || row.source, row.title);
const known = merged.find(t => t.key === tab.key);
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;
await this._fallBackToGeneral();
}
// Land on General when the bound tab is not (or no longer) in the bar.
async _fallBackToGeneral() {
if (this._tabs.some(t => t.key === this._activeKey)) return;
this._selectTab(`src:${GENERAL_SOURCE}`);
}
// A project chat was opened elsewhere (the board, the sidebar): show its primary
// tab, expand the copilot, and switch the live connection to it. Deliberately
// never opens a second conversation — "Open chat" resumes the project's own.
_onProjectChatOpen(e) {
const { source, label, session_id } = e.detail ?? {};
if (!source) return;
const key = `src:${source}`;
const known = this._tabs.find(t => t.key === key);
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, primaryTab(source, label || source, session_id)];
this._persistTab(session_id, true);
}
this._setCollapsed(false);
this._selectTab(key);
}
_selectTab(key) {
const tab = this._tabs.find(t => t.key === key);
if (!tab) return;
try { sessionStorage.setItem(ACTIVE_TAB_KEY, key); } catch { /* private mode */ }
if (key === this._activeKey) return;
// A primary tab is addressed by source so it keeps following resets; a
// secondary one by id, because its source points at a different conversation.
this._switchTo(tab.source, tab.primary ? null : tab.sessionId);
}
// Close a tab. The conversation itself is untouched — closing only clears its
// `is_open` flag, and a project's chat comes back from the board with all its
// history. The General tab is never closable and is not a stored tab at all.
_closeTab(key, e) {
e?.stopPropagation();
if (key === `src:${GENERAL_SOURCE}`) return;
const tab = this._tabs.find(t => t.key === key);
if (!tab) return;
const wasActive = key === this._activeKey;
this._tabs = this._tabs.filter(t => t.key !== key);
this._persistTab(tab.sessionId, false);
if (wasActive) this._selectTab(`src:${GENERAL_SOURCE}`);
}
// This chat became a different conversation: a primary tab was reset, or a
// secondary one started over. `is_open` hangs on the row, so it has to be moved
// or the tab would close itself out from under the user at the next login.
_onSessionReplaced(source, sessionId, previous) {
const key = previous ? `ses:${previous}` : `src:${source}`;
const tab = this._tabs.find(t => t.key === key);
if (!tab) return;
if (tab.primary) { this._bindTabSession(tab, sessionId); return; }
// A secondary tab *is* its session, so starting over replaces the tab.
const fresh = secondaryTab(tab.source, sessionId, tab.label, null);
this._tabs = this._tabs.map(t => (t.key === key ? fresh : t));
this._persistTab(previous, false);
this._persistTab(sessionId, true);
try { sessionStorage.setItem(ACTIVE_TAB_KEY, fresh.key); } catch { /* private mode */ }
}
// Point a primary 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.
//
// General is the exception: it is never a stored tab, so marking its rows open
// would leave a trail of flags the bar deliberately ignores and nothing clears.
_bindTabSession(tab, sessionId) {
if (!sessionId || tab.sessionId === sessionId) return;
const previous = tab.sessionId;
tab.sessionId = sessionId;
if (tab.key === `src:${GENERAL_SOURCE}`) return;
if (previous) this._persistTab(previous, false);
this._persistTab(sessionId, true);
}
// Double-click renames — the affordance every tabbed interface already has, and
// it keeps the bar free of a per-tab edit button.
_renderTab(tab) {
const label = this._tabLabel(tab);
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`