Nightly Build / build (push) Successful in 7m49s
A source had exactly one live conversation, so the copilot could only ever replace a chat, never add one: the trash button reset the source and the old conversation was left orphaned. Working on two things at once meant losing one. The tab bar now holds two kinds of tab. A primary tab is a source — it shows whatever `web` or `project-7` currently points at, which is where background delivery lands (notify, a finished async task, an inbound Telegram message) and what a reset moves to a fresh row. A secondary tab, opened with `+`, is one specific conversation: its source points elsewhere, so it is unreachable by source name and is addressed by id throughout — REST, WebSocket, event filtering. `POST /api/sessions/new` creates one without touching `sources`, which is the whole difference from a reset; its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context. Project "Open chat" is untouched and still resumes the project's own. The load-bearing half is in ChatHub: the input queue and the model pin are now keyed by session, not by source. Two tabs on one source would otherwise serialize into a single queue and a single turn, and share a `/model` pin — the odd one out, since the security group was already per-session and persisted. The source-taking methods survive as one-line resolvers, so Telegram, mobile and cron are untouched. Because queues now grow with conversations rather than with the handful of sources, a reset retires the queue it replaces instead of leaving a consumer task parked forever. Events are filtered per conversation, so anything a chat must see has to carry a session id: `show_file_to_user`'s OpenFile and the security-group revalidation were emitting untagged and would have reached nobody. A primary connection additionally follows NewSession for its source, so a second window does not keep talking to a conversation another window just reset. Tabs can be renamed by double-click — `chat_sessions.title` existed and was dead until now. An empty name stores NULL, so the box is also the undo.
808 lines
34 KiB
JavaScript
808 lines
34 KiB
JavaScript
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/<name>/`) 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 },
|
|
_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;
|
|
// 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`
|
|
<div
|
|
class="copilot-tab ${tab.key === this._activeKey ? 'copilot-tab--active' : ''}"
|
|
@click=${() => this._selectTab(tab.key)}
|
|
@dblclick=${e => this._startRename(tab.key, e)}
|
|
title=${label}
|
|
>
|
|
${this._renamingKey === tab.key ? html`
|
|
<input
|
|
class="copilot-tab-rename"
|
|
.value=${tab.title ?? ''}
|
|
placeholder=${label}
|
|
@click=${e => e.stopPropagation()}
|
|
@keydown=${e => this._onRenameKey(tab.key, e)}
|
|
@blur=${e => this._commitRename(tab.key, e.target.value)}
|
|
>
|
|
` : html`
|
|
<span class="copilot-tab-label">${label}</span>
|
|
${tab.key !== `src:${GENERAL_SOURCE}` ? html`
|
|
<button class="copilot-tab-close" title=${t('chat.close_tab')}
|
|
@click=${e => this._closeTab(tab.key, e)}>
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
` : nothing}
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── The `+` menu ────────────────────────────────────────────────────────────
|
|
|
|
async _toggleNewTab() {
|
|
this._newTabOpen = !this._newTabOpen;
|
|
if (!this._newTabOpen || this._newTabTargets) return;
|
|
// General plus the caller's projects — the two things a chat can be *about*.
|
|
// A project entry starts a second conversation there, with the coordinator
|
|
// agent and the project's context, exactly like its own tab.
|
|
let projects = [];
|
|
try {
|
|
const res = await fetch('/api/projects');
|
|
if (res.ok) projects = await res.json();
|
|
} catch { /* the General entry is still useful */ }
|
|
this._newTabTargets = [
|
|
{ source: GENERAL_SOURCE, label: t('chat.tab.general') },
|
|
...projects.map(p => ({ source: `project-${p.id}`, label: p.name })),
|
|
];
|
|
}
|
|
|
|
async _openNewTab(target) {
|
|
this._newTabOpen = false;
|
|
try {
|
|
const res = await fetch(
|
|
`/api/sessions/new?source=${encodeURIComponent(target.source)}`, { method: 'POST' });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const row = await res.json();
|
|
const tab = secondaryTab(row.source, row.session_id, row.label || target.label, null);
|
|
this._tabs = [...this._tabs, tab];
|
|
this._setCollapsed(false);
|
|
this._selectTab(tab.key);
|
|
} catch (e) {
|
|
this._pushError('Could not open a new chat: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ── Renaming ────────────────────────────────────────────────────────────────
|
|
|
|
_startRename(key, e) {
|
|
e?.stopPropagation();
|
|
this._renamingKey = key;
|
|
this.updateComplete.then(() => {
|
|
const input = this.querySelector('.copilot-tab-rename');
|
|
input?.focus();
|
|
input?.select();
|
|
});
|
|
}
|
|
|
|
_onRenameKey(key, e) {
|
|
if (e.key === 'Enter') { e.preventDefault(); this._commitRename(key, e.target.value); }
|
|
if (e.key === 'Escape') { e.preventDefault(); this._renamingKey = null; }
|
|
}
|
|
|
|
// An empty name clears the title, which gives back the automatic label rather
|
|
// than a blank tab — so the box is also the way to undo a rename.
|
|
async _commitRename(key, value) {
|
|
this._renamingKey = null;
|
|
const tab = this._tabs.find(t => t.key === key);
|
|
if (!tab?.sessionId) return;
|
|
const title = value.trim();
|
|
if ((tab.title ?? '') === title) return;
|
|
tab.title = title || null;
|
|
this.requestUpdate();
|
|
try {
|
|
const res = await fetch(`/api/sessions/${tab.sessionId}/title`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: title || null }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
} catch (e) {
|
|
console.error('Failed to rename the chat:', e);
|
|
}
|
|
}
|
|
|
|
// What a tab prints. A user-set title always wins. Without one, secondary tabs
|
|
// on the same source would all read "General" — so they are numbered by their
|
|
// position among their siblings, which is stable and needs no extra state.
|
|
_tabLabel(tab) {
|
|
if (tab.title) return tab.title;
|
|
if (tab.primary) return tab.label;
|
|
const siblings = this._tabs.filter(t => !t.primary && t.source === tab.source);
|
|
return `${tab.label} ${siblings.indexOf(tab) + 2}`;
|
|
}
|
|
|
|
// 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`<div class="copilot-msg assistant">${t('chat.hello')}</div>`;
|
|
}
|
|
const name = this._me?.display_name || this._me?.username;
|
|
const suggestions = [
|
|
{ icon: 'bi-stars', text: t('chat.suggest.1') },
|
|
{ icon: 'bi-calendar-check', text: t('chat.suggest.2') },
|
|
{ icon: 'bi-book', text: t('chat.suggest.3') },
|
|
{ icon: 'bi-heart', text: t('chat.suggest.4') },
|
|
];
|
|
return html`
|
|
<div class="chat-hero">
|
|
<img class="chat-hero-logo" src="/assets/icons/icon-192.png" alt="" />
|
|
<h1 class="chat-hero-title">${name ? t('chat.greeting.named', { name }) : t('chat.greeting')}</h1>
|
|
<p class="chat-hero-sub">${t('chat.greeting.sub')}</p>
|
|
<div class="chat-suggestions">
|
|
${suggestions.map(s => html`
|
|
<button class="chat-suggestion" @click=${() => this._sendSuggestion(s.text)}>
|
|
<i class="bi ${s.icon}"></i>
|
|
<span>${s.text}</span>
|
|
</button>
|
|
`)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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`
|
|
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
|
|
` : nothing}
|
|
|
|
<div class="copilot-header">
|
|
<i class="bi bi-stars"></i>
|
|
<span>${t('chat.title')}</span>
|
|
<span class="chat-privacy" title=${t('chat.privacy.hint')}>
|
|
<i class="bi bi-lock-fill"></i>${t('chat.privacy')}
|
|
</span>
|
|
${!full ? html`
|
|
<button
|
|
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
|
|
title=${t('chat.collapse')}
|
|
@click=${() => { this._setCollapsed(true); }}
|
|
>
|
|
<i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
` : nothing}
|
|
</div>
|
|
|
|
<div class="copilot-tabs">
|
|
${this._tabs.map(tab => this._renderTab(tab))}
|
|
<div class="copilot-tab-new">
|
|
<button class="copilot-tab-add" title=${t('chat.new_tab')}
|
|
@click=${() => this._toggleNewTab()}>
|
|
<i class="bi bi-plus-lg"></i>
|
|
</button>
|
|
${this._newTabOpen ? html`
|
|
<div class="copilot-model-overlay" @click=${() => { this._newTabOpen = false; }}></div>
|
|
<div class="copilot-tab-menu">
|
|
${this._newTabTargets === null
|
|
? html`<div class="copilot-tab-menu-empty">${t('chat.new_tab.loading')}</div>`
|
|
: this._newTabTargets.map(target => html`
|
|
<button class="copilot-tab-menu-item"
|
|
@click=${() => this._openNewTab(target)}>${target.label}</button>
|
|
`)}
|
|
</div>
|
|
` : nothing}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="copilot-messages">
|
|
${this._messages.length === 0
|
|
? this._renderEmptyState()
|
|
: this._messages.map(m => renderMsg(this, m))}
|
|
|
|
${this._waiting ? html`
|
|
<div class="copilot-msg assistant copilot-thinking">
|
|
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
${t('chat.thinking')}
|
|
</div>
|
|
` : nothing}
|
|
|
|
${this._showJump ? html`
|
|
<button class="copilot-jump-btn" type="button"
|
|
title=${t('chat.scroll_to_latest')}
|
|
aria-label=${t('chat.scroll_to_latest')}
|
|
@click=${() => this._jumpToBottom()}>
|
|
<i class="bi bi-arrow-down-circle-fill"></i>
|
|
</button>
|
|
` : nothing}
|
|
</div>
|
|
|
|
<div class="copilot-input-area">
|
|
${renderTaskStrip(this)}
|
|
${this._renderNoModelsBanner()}
|
|
<div class="copilot-composer"
|
|
@dragover=${(e) => e.preventDefault()}
|
|
@drop=${(e) => this._onDrop(e)}>
|
|
${this._cmdMenu?.length ? html`
|
|
<div class="copilot-cmd-menu">
|
|
${this._cmdMenu.map((c, i) => html`
|
|
<button
|
|
class="copilot-cmd-item ${i === this._cmdSel ? 'active' : ''}"
|
|
@mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }}
|
|
>
|
|
<span class="copilot-cmd-name">/${c.name}</span>
|
|
<span class="copilot-cmd-desc">${typeof c.description === 'function' ? c.description() : c.description}</span>
|
|
</button>
|
|
`)}
|
|
</div>
|
|
` : nothing}
|
|
${renderAttachmentChips(this, this._attachments, { removable: true })}
|
|
<input
|
|
type="file"
|
|
multiple
|
|
class="copilot-file-input"
|
|
style="display:none"
|
|
@change=${(e) => { this._addFiles(e.target.files); e.target.value = ''; }}
|
|
/>
|
|
<textarea
|
|
class="copilot-textarea"
|
|
rows="1"
|
|
?disabled=${this._noModels}
|
|
placeholder=${t('chat.placeholder')}
|
|
@keydown=${this._composerKeydown}
|
|
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
|
|
@paste=${(e) => this._onPaste(e)}
|
|
></textarea>
|
|
<div class="copilot-toolbar">
|
|
<div class="copilot-toolbar-left">
|
|
<button
|
|
class="copilot-toolbar-btn"
|
|
title=${t('chat.attach')}
|
|
@click=${() => this.querySelector('.copilot-file-input')?.click()}
|
|
><i class="bi bi-paperclip"></i></button>
|
|
${this._providers.length > 1 ? html`
|
|
<div class="copilot-model-wrap">
|
|
${this._modelOpen ? html`
|
|
<div class="copilot-model-overlay" @click=${() => { this._modelOpen = false; }}></div>
|
|
<div class="copilot-model-dropdown">
|
|
${this._providers.map(p => html`
|
|
<button
|
|
class="copilot-model-item ${p === this._selectedClient ? 'active' : ''}"
|
|
@click=${() => { this._selectClient(p); this._modelOpen = false; }}
|
|
>${p}</button>
|
|
`)}
|
|
</div>
|
|
` : nothing}
|
|
<button class="copilot-model-pill" @click=${() => { this._modelOpen = !this._modelOpen; }}>
|
|
<i class="bi bi-stars"></i>
|
|
<span>${this._selectedClient ?? 'auto'}</span>
|
|
<i class="bi bi-chevron-${this._modelOpen ? 'down' : 'up'}"></i>
|
|
</button>
|
|
</div>
|
|
` : nothing}
|
|
${this._securityGroups.length > 1 ? html`
|
|
<div class="copilot-model-wrap">
|
|
${this._groupOpen ? html`
|
|
<div class="copilot-model-overlay" @click=${() => { this._groupOpen = false; }}></div>
|
|
<div class="copilot-model-dropdown">
|
|
${this._securityGroups.map(g => html`
|
|
<button
|
|
class="copilot-model-item ${g.id === this._selectedGroup ? 'active' : ''}"
|
|
@click=${() => { this._selectGroup(g.id); this._groupOpen = false; }}
|
|
>${g.name}</button>
|
|
`)}
|
|
</div>
|
|
` : nothing}
|
|
<button
|
|
class="copilot-model-pill"
|
|
title=${t('chat.security_group')}
|
|
@click=${() => { this._groupOpen = !this._groupOpen; }}>
|
|
<i class="bi bi-shield-lock"></i>
|
|
<span>${this._securityGroups.find(g => g.id === this._selectedGroup)?.name ?? this._selectedGroup}</span>
|
|
<i class="bi bi-chevron-${this._groupOpen ? 'down' : 'up'}"></i>
|
|
</button>
|
|
</div>
|
|
` : nothing}
|
|
<button
|
|
class="copilot-toolbar-btn"
|
|
title=${t('chat.new_session')}
|
|
@click=${() => this._startNewSession()}
|
|
><i class="bi bi-trash"></i></button>
|
|
</div>
|
|
<div class="copilot-toolbar-right">
|
|
${this._hasTranscribe ? html`
|
|
<button
|
|
class="copilot-send-btn ${this._recording ? 'copilot-send-btn--recording' : ''}"
|
|
title="${this._recording ? t('chat.stop') : t('chat.attach')}"
|
|
@click=${() => this._toggleRecording()}
|
|
>
|
|
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
|
|
</button>
|
|
` : nothing}
|
|
${this._waiting
|
|
? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title=${t('chat.stop')}>
|
|
<i class="bi bi-stop-fill"></i>
|
|
</button>`
|
|
: nothing}
|
|
<button class="copilot-send-btn" ?disabled=${this._noModels} @click=${() => this._send()} title=${t('chat.send')}>
|
|
<i class="bi bi-send-fill"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|