import { html, nothing } from 'lit';
import { ChatSession } from '../../lib/chat-session.js';
import { t } from '../../lib/i18n.js';
import { renderMsg, renderAttachmentChips } 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 `_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');
}
_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`