import { html, nothing } from 'lit'; import { ChatSession } from '../../lib/chat-session.js'; import { t } from '../../lib/i18n.js'; import { renderMsg, renderAttachmentChips, renderViewContextPill } from '../copilot-render.js'; import { renderTaskStrip } from './agent-tasks.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 }, _me: { state: true }, }; constructor() { super(); this.visible = false; this.source = 'mobile'; this.label = ''; this._me = null; } async _loadMe() { try { const res = await fetch('/api/auth/me'); if (res.ok) this._me = await res.json(); } catch { /* greeting falls back to the generic one */ } } 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(); this._loadMe(); } updated(changed) { if (changed.has('visible') && this.visible) { this._forceScrollToBottom(); } // The owner (mobile-app) re-points this chat by changing `source`. Switch the // live connection — base `_switchTo` tears down the WS, reloads that source's // history, and reconnects. The guard skips the initial no-op render. Mobile // has no tab bar, so it only ever addresses a chat by source. if (changed.has('source') && this.source !== this._source) { this._switchTo(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'); } _messagesContainer() { return this.querySelector('.chat-page-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(); } } // ── 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; } _sendSuggestion(text) { const el = this._inputEl(); if (!el) return; el.value = text; this._send(); } // Welcome hero (main session) with a few prompt suggestions, mirroring the // desktop home. Inside a project the empty state stays compact — the header // already carries the context. _renderEmptyState() { if (this._inProject) { return html`

${this.label || t('chat.mobile.project')}

`; } 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`

${name ? t('chat.greeting.named', { name }) : t('chat.greeting')}

${t('chat.greeting.sub')}

${suggestions.map(s => html` `)}
`; } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this.visible) return nothing; return html`
${this._inProject ? html` ${this.label || t('chat.mobile.project')} ` : html` ${t('chat.mobile.chat')}`}
${this._messages.length === 0 ? this._renderEmptyState() : this._messages.map(m => renderMsg(this, m))} ${this._waiting ? html`
${t('chat.thinking')}
` : nothing} ${this._showJump ? html` ` : nothing}
${renderTaskStrip(this)} ${this._renderNoModelsBanner()}
e.preventDefault()} @drop=${(e) => this._onDrop(e)}> ${renderAttachmentChips(this, this._attachments, { removable: true })} { this._addFiles(e.target.files); e.target.value = ''; }} />
${renderViewContextPill(this)} ${this._providers.length > 1 ? html` ` : nothing}
${this._hasTranscribe ? html` ` : nothing} ${this._waiting ? html`` : nothing}
`; } } customElements.define('chat-page', ChatPage);