Nightly Build / build (push) Successful in 7m39s
Reopening the app closed every project tab: the copilot's tab bar lived in
RAM, so a reload dropped it and each conversation had to be found again from
its project board.
The set of open tabs is now a column on the session row, `chat_sessions.is_open`
(additive, `ensure_column`), restored by `GET /api/sessions/open` and written by
`PUT /api/sessions/{id}/open`. Not localStorage: that store is per-origin, so on
a shared laptop one member's tabs would greet the next, whereas the owner table
sits in their own encrypted file and follows them to another device. Which tab
is *selected* stays in sessionStorage — that one is genuinely per window, and a
shared value would have two windows fighting over it.
`is_open` defaults to 0 and `chat_sessions::create` never sets it: every `/new`
leaves its predecessor behind and every system-agent pass mints a row, so the
opposite default would restore a bar full of conversations nobody opened. Only
the copilot writes the column, at the moment it opens the tab. A reset moves the
flag rather than copying it — `POST /api/sessions` now returns the new id and
`new_session` carries it, and the old row is closed as the new one opens, or the
source would restore twice and a later close would clear the stale row.
Closing a tab clears the flag and nothing else: the conversation is kept and
comes back with its history when the project is reopened.
631 lines
27 KiB
JavaScript
631 lines
27 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';
|
|
|
|
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`<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>
|
|
|
|
${this._tabs.length > 1 ? html`
|
|
<div class="copilot-tabs">
|
|
${this._tabs.map(tab => html`
|
|
<div
|
|
class="copilot-tab ${tab.source === this._source ? 'copilot-tab--active' : ''}"
|
|
@click=${() => this._selectTab(tab.source)}
|
|
title=${tab.label}
|
|
>
|
|
<span class="copilot-tab-label">${tab.label}</span>
|
|
${tab.source !== 'web' ? html`
|
|
<button class="copilot-tab-close" title=${t('chat.close_tab')}
|
|
@click=${e => this._closeTab(tab.source, e)}>
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
` : nothing}
|
|
</div>
|
|
`)}
|
|
</div>
|
|
` : nothing}
|
|
|
|
<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>
|
|
`;
|
|
}
|
|
}
|