import { html, nothing } from 'lit'; import { ChatSession } from '../../lib/chat-session.js'; import { renderMsg, renderAttachmentChips } from '../copilot-render.js'; export class ChatPage extends ChatSession { static properties = { visible: { type: Boolean }, // Target source. Defaults to the main mobile session; set to `project-{id}` // to bind this chat to a project's coordinator session. source: { type: String }, // Human-readable label for the active source (e.g. the project name), shown // in the header when inside a project. label: { type: String }, }; constructor() { super(); this.visible = false; this.source = 'mobile'; this.label = ''; } connectedCallback() { // Honour the initial `source` prop on the first connect so a cold deep-link // (e.g. the native shell opening #chat/project-) connects straight to it, // instead of connecting to the 'mobile' default and switching a tick later // (which would briefly open two WebSockets). Later `source` prop changes are // still handled by `updated` below. if (this.source && this.source !== this._wsSource) this._activeSource = this.source; super.connectedCallback(); } updated(changed) { if (changed.has('visible') && this.visible) { this._scrollToBottom(); } // The owner (mobile-app) re-points this chat by changing `source`. Switch the // live connection — base `_switchSource` tears down the WS, reloads that // source's history, and reconnects. The guard skips the initial no-op render. if (changed.has('source') && this.source !== this._source) { this._switchSource(this.source); } } // ── Source identity ──────────────────────────────────────────────────────── // Static fallback used only before the first `source` prop is applied. get _wsSource() { return 'mobile'; } get _inProject() { return typeof this.source === 'string' && this.source.startsWith('project-'); } _exitProject() { this.dispatchEvent(new CustomEvent('project-exit', { bubbles: true, composed: true })); } // ── DOM hooks ────────────────────────────────────────────────────────────── _inputEl() { return this.querySelector('.chat-page-textarea'); } _scrollToBottom() { this.updateComplete.then(() => { const el = this.querySelector('.chat-page-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(); } } // ── Input ────────────────────────────────────────────────────────────────── // Note: unlike the desktop copilot, Enter does NOT send here. On mobile there // is no practical Shift+Enter, so Enter inserts a newline (the textarea's // default) and the explicit send button is the only way to submit — making // multi-line messages possible. // ── Toggle expand ────────────────────────────────────────────────────────── _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.visible) return nothing; return html`
${this._inProject ? html` ${this.label || 'Project'} ` : html` Chat`}
${this._messages.length === 0 ? html`

Ask me anything

` : this._messages.map(m => renderMsg(this, m))} ${this._waiting ? html`
Thinking…
` : nothing}
e.preventDefault()} @drop=${(e) => this._onDrop(e)}> ${renderAttachmentChips(this, this._attachments, { removable: true })} { this._addFiles(e.target.files); e.target.value = ''; }} />
${this._providers.length > 1 ? html` ` : nothing}
${this._hasTranscribe ? html` ` : nothing} ${this._waiting ? html`` : nothing}
`; } } customElements.define('chat-page', ChatPage);