First Version
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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-<id>) 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`
|
||||
<div class="chat-page">
|
||||
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
${this._inProject ? html`
|
||||
<button class="chat-page-back" title="Back to General"
|
||||
@click=${() => this._exitProject()}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<i class="bi bi-folder2-open"></i> ${this.label || 'Project'}
|
||||
` : html`<i class="bi bi-chat-dots-fill"></i> Chat`}
|
||||
</span>
|
||||
<div class="chat-page-header-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="New conversation"
|
||||
@click=${() => this._startNewSession()}
|
||||
><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-page-messages">
|
||||
${this._messages.length === 0 ? html`
|
||||
<div class="chat-page-empty">
|
||||
<i class="bi bi-stars"></i>
|
||||
<p>Ask me anything</p>
|
||||
</div>
|
||||
` : 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>
|
||||
Thinking…
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="chat-page-input-area">
|
||||
<div class="chat-page-composer"
|
||||
@dragover=${(e) => e.preventDefault()}
|
||||
@drop=${(e) => this._onDrop(e)}>
|
||||
${renderAttachmentChips(this, this._attachments, { removable: true })}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
class="chat-page-file-input"
|
||||
style="display:none"
|
||||
@change=${(e) => { this._addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<textarea
|
||||
class="chat-page-textarea"
|
||||
rows="1"
|
||||
placeholder="Type a message…"
|
||||
@input=${(e) => this._autoResize(e.target)}
|
||||
@paste=${(e) => this._onPaste(e)}
|
||||
></textarea>
|
||||
<div class="chat-page-toolbar">
|
||||
<div class="chat-page-toolbar-left">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary chat-page-attach-btn"
|
||||
title="Attach files"
|
||||
@click=${() => this.querySelector('.chat-page-file-input')?.click()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${this._providers.length > 1 ? html`
|
||||
<select
|
||||
class="chat-page-model-pill"
|
||||
.value=${this._selectedClient ?? 'auto'}
|
||||
@change=${(e) => { this._selectClient(e.target.value); }}
|
||||
>
|
||||
${this._providers.map(p => html`
|
||||
<option value=${p} ?selected=${p === (this._selectedClient ?? 'auto')}>${p}</option>
|
||||
`)}
|
||||
</select>
|
||||
` : nothing}
|
||||
</div>
|
||||
<div class="chat-page-toolbar-right">
|
||||
${this._hasTranscribe ? html`
|
||||
<button
|
||||
class="chat-page-mic-btn ${this._recording ? 'chat-page-mic-btn--recording' : ''}"
|
||||
title="${this._recording ? 'Stop recording' : 'Record voice'}"
|
||||
@click=${() => this._toggleRecording()}
|
||||
>
|
||||
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
${this._waiting
|
||||
? html`<button
|
||||
class="chat-page-send chat-page-send--stop"
|
||||
@click=${() => this._cancel()}
|
||||
title="Stop"
|
||||
><i class="bi bi-stop-fill"></i></button>`
|
||||
: nothing}
|
||||
<button
|
||||
class="chat-page-send"
|
||||
@click=${() => this._send()}
|
||||
title="Send"
|
||||
><i class="bi bi-send-fill"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('chat-page', ChatPage);
|
||||
@@ -0,0 +1,429 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../../lib/base.js';
|
||||
import { fileWatcher } from '../../lib/file-watcher.js';
|
||||
|
||||
/**
|
||||
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
|
||||
* markdown-asset-rewriting / LaTeX-compile / live-watch logic plus `_renderBody`,
|
||||
* driven purely by two methods: `_show(path)` and `_hide()`. It carries no
|
||||
* navigation or page chrome of its own — subclasses (desktop `<file-viewer-page>`
|
||||
* and mobile `<mobile-file-viewer-page>`) wire visibility/path to those methods
|
||||
* and provide their own `render()` header.
|
||||
*/
|
||||
|
||||
const IMG_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif'];
|
||||
const LATEX_EXTS = ['tex', 'latex'];
|
||||
const TEXT_EXTS = [
|
||||
'txt', 'md', 'markdown', 'rs', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
|
||||
'py', 'json', 'yml', 'yaml', 'toml', 'sh', 'bash', 'zsh', 'fish',
|
||||
'css', 'scss', 'less',
|
||||
'sql', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'cc', 'kt', 'scala',
|
||||
'lua', 'pl', 'php', 'rb', 'swift', 'dart',
|
||||
'xml', 'csv', 'tsv', 'log', 'env', 'ini', 'cfg', 'conf',
|
||||
'gitignore', 'dockerignore', 'editorconfig',
|
||||
'vue', 'svelte', 'astro',
|
||||
// LaTeX is also kept here as the fallback when compilation fails — kindFor
|
||||
// still routes it to 'latex' so the viewer knows to attempt a compile first.
|
||||
'tex', 'latex',
|
||||
];
|
||||
|
||||
export function extOf(path) {
|
||||
if (!path) return '';
|
||||
const dot = path.lastIndexOf('.');
|
||||
if (dot < 0) return '';
|
||||
// Reject dots that are inside a directory segment, not the file extension.
|
||||
if (path.indexOf('/', dot + 1) >= 0) return '';
|
||||
return path.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function kindFor(path) {
|
||||
const ext = extOf(path);
|
||||
// SVG is excluded from IMG_EXTS on purpose: rendered in a sandboxed iframe
|
||||
// (not <img>), which both scales viewBox-only SVGs to fill the viewport and
|
||||
// isolates any embedded <script> from the host page.
|
||||
if (ext === 'svg') return 'svg';
|
||||
if (IMG_EXTS.includes(ext)) return 'image';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
// HTML is rendered live in a script-enabled but origin-isolated iframe
|
||||
// (srcdoc + sandbox="allow-scripts", no allow-same-origin) — see _renderBody.
|
||||
if (ext === 'html' || ext === 'htm') return 'html';
|
||||
if (LATEX_EXTS.includes(ext)) return 'latex';
|
||||
if (TEXT_EXTS.includes(ext)) return 'text';
|
||||
return 'binary';
|
||||
}
|
||||
|
||||
/** Directory portion of a path: `docs/guide.md` → `docs`, `guide.md` → ``. */
|
||||
function dirOf(path) {
|
||||
const i = path.lastIndexOf('/');
|
||||
return i < 0 ? '' : path.slice(0, i);
|
||||
}
|
||||
|
||||
/** Lexically resolve `.`/`..` segments, preserving a leading slash for absolute paths. */
|
||||
function normalizePath(p) {
|
||||
const abs = p.startsWith('/');
|
||||
const out = [];
|
||||
for (const seg of p.split('/')) {
|
||||
if (seg === '' || seg === '.') continue;
|
||||
if (seg === '..') { out.pop(); continue; }
|
||||
out.push(seg);
|
||||
}
|
||||
return (abs ? '/' : '') + out.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an asset reference found inside a markdown file. External URLs, data
|
||||
* URIs, protocol-relative and root-relative paths are left untouched; a path
|
||||
* relative to the markdown file's directory is routed through `/api/file` so it
|
||||
* loads from disk instead of resolving against the SPA origin.
|
||||
*/
|
||||
function resolveAssetSrc(src, baseDir) {
|
||||
if (!src || /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(src)) return src;
|
||||
const joined = baseDir ? `${baseDir}/${src}` : src;
|
||||
return `/api/file?path=${encodeURIComponent(normalizePath(joined))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite relative `<img>` sources in rendered markdown HTML so they resolve
|
||||
* against the markdown file's location on disk (via `/api/file`). Parsed in an
|
||||
* inert <template> so the original (broken) URLs never trigger a fetch.
|
||||
*/
|
||||
function rewriteMarkdownAssets(htmlStr, baseDir) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = htmlStr;
|
||||
let changed = false;
|
||||
for (const img of tpl.content.querySelectorAll('img[src]')) {
|
||||
const src = img.getAttribute('src');
|
||||
const resolved = resolveAssetSrc(src, baseDir);
|
||||
if (resolved !== src) { img.setAttribute('src', resolved); changed = true; }
|
||||
}
|
||||
return changed ? tpl.innerHTML : htmlStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distil a raw latexmk / xelatex log into its actionable error block.
|
||||
*
|
||||
* The 422 body carries the *full* log. Under `-file-line-error` the meaningful
|
||||
* `path:line: message` errors (and `! TeX error` lines) sit deep in the log —
|
||||
* the opening lines are only the engine banner and package preamble. Slicing
|
||||
* the first N characters therefore hid the real error; instead we extract the
|
||||
* error lines plus a few trailing context lines (LaTeX echoes the offending
|
||||
* source line right after) so the user can read it — or paste it straight into
|
||||
* an agent. Falls back to the log tail when no error line is recognised.
|
||||
*/
|
||||
function formatLatexError(log) {
|
||||
if (!log) return '';
|
||||
const lines = log.split('\n');
|
||||
const blocks = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/:\d+: /.test(lines[i]) || lines[i].startsWith('! ')) {
|
||||
blocks.push(lines.slice(i, i + 4).join('\n').trimEnd());
|
||||
}
|
||||
}
|
||||
const excerpt = blocks.join('\n\n').trim();
|
||||
if (excerpt) return excerpt;
|
||||
return (log.length > 4000 ? log.slice(-4000) : log).trim();
|
||||
}
|
||||
|
||||
export class FileViewerBase extends LightElement {
|
||||
static properties = {
|
||||
_path: { state: true },
|
||||
_kind: { state: true },
|
||||
_content: { state: true },
|
||||
_blobUrl: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_compileError: { state: true },
|
||||
_htmlMode: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._path = null;
|
||||
this._kind = null;
|
||||
this._content = '';
|
||||
this._blobUrl = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._htmlMode = 'preview'; // HTML view: 'preview' (live iframe) | 'source'
|
||||
this._watchPath = null; // path currently being watched (async-verified)
|
||||
this._watchUnsub = null; // unsubscribe function returned by fileWatcher
|
||||
this._reloadTimer = null; // debounce timer for change-triggered reloads
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._teardownWatch();
|
||||
if (this._reloadTimer) clearTimeout(this._reloadTimer);
|
||||
this._revokeBlobUrl();
|
||||
}
|
||||
|
||||
// ── Drivers used by subclasses ──────────────────────────────────────────────
|
||||
|
||||
/** Show `path`: (re)subscribe the watcher and load it. No-op if unchanged. */
|
||||
_show(path) {
|
||||
if (!path) return;
|
||||
if (path === this._path && !this._error) return; // already loaded
|
||||
this._setupWatch(path);
|
||||
this._load(path);
|
||||
}
|
||||
|
||||
/** Hide: drop the content and release the watcher. */
|
||||
_hide() {
|
||||
this._reset();
|
||||
this._teardownWatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the current file. LaTeX sources always download the compiled PDF
|
||||
* (`compile-latex=true`); every kind is served with `force_download=true` so
|
||||
* the server sets `Content-Disposition: attachment` and the browser saves it
|
||||
* (with the server-supplied name) instead of rendering inline.
|
||||
*/
|
||||
_download() {
|
||||
const path = this._path;
|
||||
if (!path) return;
|
||||
const params = new URLSearchParams({ path });
|
||||
if (this._kind === 'latex') params.set('compile-latex', 'true');
|
||||
params.set('force_download', 'true');
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/file?${params.toString()}`;
|
||||
a.download = ''; // server Content-Disposition supplies the name
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
_revokeBlobUrl() {
|
||||
if (this._blobUrl) {
|
||||
URL.revokeObjectURL(this._blobUrl);
|
||||
this._blobUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this._path = null;
|
||||
this._kind = null;
|
||||
this._content = '';
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._htmlMode = 'preview';
|
||||
this._revokeBlobUrl();
|
||||
}
|
||||
|
||||
async _load(path, silent = false) {
|
||||
if (!silent) {
|
||||
this._path = path;
|
||||
this._kind = kindFor(path);
|
||||
this._content = '';
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._revokeBlobUrl();
|
||||
this._loading = true;
|
||||
} else {
|
||||
// Silent reload (file changed externally): keep showing the old content
|
||||
// until the new fetch lands; only update visible state on success.
|
||||
this._error = null;
|
||||
}
|
||||
try {
|
||||
const url = `/api/file?path=${encodeURIComponent(path)}`;
|
||||
if (this._kind === 'image' || this._kind === 'pdf' || this._kind === 'svg') {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
// Swap URLs only after the new blob is ready so the preview never flickers.
|
||||
const oldUrl = this._blobUrl;
|
||||
this._blobUrl = URL.createObjectURL(blob);
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
} else if (this._kind === 'latex') {
|
||||
await this._loadLatex(path);
|
||||
} else if (this._kind === 'text' || this._kind === 'html') {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._content = await res.text();
|
||||
}
|
||||
// binary: nothing to fetch
|
||||
} catch (e) {
|
||||
this._error = e.message || String(e);
|
||||
} finally {
|
||||
if (!silent) this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a `.tex` / `.latex` file. Tries to compile to PDF server-side first;
|
||||
* on any non-OK response (422 compilation error, 501 no latexmk, etc.) it
|
||||
* falls back to showing the raw source as plain text, preserving the error
|
||||
* message so the user can see why the compile failed.
|
||||
*/
|
||||
async _loadLatex(path) {
|
||||
const compileUrl = `/api/file?path=${encodeURIComponent(path)}&compile-latex=true`;
|
||||
try {
|
||||
const res = await fetch(compileUrl);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const oldUrl = this._blobUrl;
|
||||
this._blobUrl = URL.createObjectURL(blob);
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
this._content = '';
|
||||
this._compileError = null;
|
||||
return;
|
||||
}
|
||||
// The 422 body is the full latexmk log. Extract the actionable error
|
||||
// block (see formatLatexError) instead of slicing from the top, which
|
||||
// under -file-line-error only shows the preamble and hides the real error.
|
||||
let detail = '';
|
||||
try { detail = formatLatexError(await res.text()); } catch { /* ignore */ }
|
||||
this._compileError = detail || `HTTP ${res.status}`;
|
||||
} catch (e) {
|
||||
this._compileError = e.message || String(e);
|
||||
}
|
||||
// Fallback: fetch the raw .tex source.
|
||||
this._revokeBlobUrl();
|
||||
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._content = await res.text();
|
||||
}
|
||||
|
||||
// ── File watcher ────────────────────────────────────────────────────────────
|
||||
|
||||
_setupWatch(path) {
|
||||
this._teardownWatch();
|
||||
if (!path) return;
|
||||
this._watchPath = path;
|
||||
fileWatcher.watch(path, () => this._onFileChanged())
|
||||
.then(unsub => {
|
||||
// Race: if the path changed or the page closed while awaiting, release
|
||||
// the subscription immediately so the OS watcher is torn down.
|
||||
if (this._watchPath !== path) {
|
||||
try { unsub(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
this._watchUnsub = unsub;
|
||||
})
|
||||
.catch(() => { /* WS error; client auto-reconnects and re-subscribes */ });
|
||||
}
|
||||
|
||||
_teardownWatch() {
|
||||
if (this._reloadTimer) {
|
||||
clearTimeout(this._reloadTimer);
|
||||
this._reloadTimer = null;
|
||||
}
|
||||
if (this._watchUnsub) {
|
||||
try { this._watchUnsub(); } catch { /* ignore */ }
|
||||
this._watchUnsub = null;
|
||||
}
|
||||
this._watchPath = null;
|
||||
}
|
||||
|
||||
_onFileChanged() {
|
||||
// Debounce: collapse bursts of FS events into a single reload. `_watchPath`
|
||||
// is cleared by `_teardownWatch` (called on hide/path-change), so a queued
|
||||
// change never reloads a file the viewer has already navigated away from.
|
||||
if (this._reloadTimer) return;
|
||||
this._reloadTimer = setTimeout(() => {
|
||||
this._reloadTimer = null;
|
||||
const path = this._watchPath;
|
||||
if (path) this._load(path, true);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── HTML preview/source toggle ──────────────────────────────────────────────
|
||||
|
||||
_toggleHtmlMode() {
|
||||
this._htmlMode = this._htmlMode === 'preview' ? 'source' : 'preview';
|
||||
}
|
||||
|
||||
/**
|
||||
* Header button that flips an HTML file between the live preview and its raw
|
||||
* source. Returns `nothing` for every other kind, so subclasses can drop it
|
||||
* unconditionally into their header. `btnClass` carries the chrome-specific
|
||||
* button styling (desktop vs mobile use different classes).
|
||||
*/
|
||||
_renderModeToggle(btnClass) {
|
||||
if (this._kind !== 'html') return nothing;
|
||||
const showingSource = this._htmlMode === 'source';
|
||||
return html`<button
|
||||
class=${btnClass}
|
||||
title=${showingSource ? 'Show preview' : 'Show source'}
|
||||
@click=${() => this._toggleHtmlMode()}>
|
||||
<i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// ── Body rendering (shared by both chromes) ─────────────────────────────────
|
||||
|
||||
_renderBody() {
|
||||
// Spinner while loading, and also in the pre-load window: the mobile viewer
|
||||
// is prop-driven, so Lit runs render() (visible just flipped true) before
|
||||
// `updated()` kicks off `_show()` — at that point no kind/content exists yet.
|
||||
if (this._loading || (!this._kind && !this._error)) {
|
||||
return html`<div class="fv-state"><span class="spinner-border"></span></div>`;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`<div class="fv-state text-danger">
|
||||
<i class="bi bi-exclamation-triangle fs-3 d-block mb-2"></i>${this._error}
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'image' && this._blobUrl) {
|
||||
return html`<div class="fv-image-wrap"><img src=${this._blobUrl} alt=${this._path} class="fv-image" /></div>`;
|
||||
}
|
||||
if (this._kind === 'pdf' && this._blobUrl) {
|
||||
return html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`;
|
||||
}
|
||||
if (this._kind === 'latex' && this._blobUrl) {
|
||||
// Successfully compiled server-side — render the resulting PDF the same
|
||||
// way a native .pdf would be rendered.
|
||||
return html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`;
|
||||
}
|
||||
if (this._kind === 'svg' && this._blobUrl) {
|
||||
// `allow-same-origin` (and nothing else) is required so the iframe can load
|
||||
// the blob: URL — those are only readable from their creating origin. With
|
||||
// `allow-scripts` absent, any <script> inside the SVG still cannot execute,
|
||||
// so this stays an isolated, script-free render.
|
||||
return html`<div class="fv-image-wrap">
|
||||
<iframe class="fv-svg" sandbox="allow-same-origin" src=${this._blobUrl} title=${this._path}></iframe>
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'binary') {
|
||||
return html`<div class="fv-state text-muted">
|
||||
<i class="bi bi-file-earmark-binary fs-3 d-block mb-2"></i>
|
||||
Preview not available for this file type.
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'html') {
|
||||
if (this._htmlMode === 'source') {
|
||||
return html`<pre class="fv-code"><code>${this._content}</code></pre>`;
|
||||
}
|
||||
// Live render. `srcdoc` (not a blob: src) gives the frame a unique opaque
|
||||
// origin, so `allow-scripts` can run the page's JS while it stays fully
|
||||
// isolated from the app origin — no `allow-same-origin`, so it cannot read
|
||||
// cookies/localStorage or reach `/api/*`. Never add allow-same-origin here:
|
||||
// combined with allow-scripts it lets the frame remove its own sandbox.
|
||||
return html`<iframe
|
||||
class="fv-html"
|
||||
sandbox="allow-scripts allow-forms allow-modals allow-popups"
|
||||
srcdoc=${this._content}
|
||||
title=${this._path}></iframe>`;
|
||||
}
|
||||
const ext = extOf(this._path);
|
||||
if (ext === 'md' || ext === 'markdown') {
|
||||
const rendered = rewriteMarkdownAssets(renderMarkdown(this._content), dirOf(this._path || ''));
|
||||
return html`<div class="fv-md">${unsafeHTML(rendered)}</div>`;
|
||||
}
|
||||
if (this._kind === 'latex') {
|
||||
// Compile failed — show why, then fall back to the source.
|
||||
return html`
|
||||
${this._compileError
|
||||
? html`<details class="fv-compile-error">
|
||||
<summary><i class="bi bi-exclamation-triangle text-warning"></i> LaTeX compilation failed — showing source instead</summary>
|
||||
<pre>${this._compileError}</pre>
|
||||
</details>`
|
||||
: nothing}
|
||||
<pre class="fv-code"><code>${this._content}</code></pre>
|
||||
`;
|
||||
}
|
||||
return html`<pre class="fv-code"><code>${this._content}</code></pre>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { FileViewerBase } from './file-viewer-base.js';
|
||||
|
||||
/**
|
||||
* Mobile file-viewer page. Same engine as the desktop `<file-viewer-page>`, but
|
||||
* prop-driven: `<mobile-app>` binds `visible` / `path` from its hash router
|
||||
* (`#file_viewer?path=...`) instead of the component listening to the hash. The
|
||||
* back button returns to the previous mobile section via history.
|
||||
*/
|
||||
export class MobileFileViewerPage extends FileViewerBase {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
path: { type: String },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this.path = null;
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') || changed.has('path')) {
|
||||
if (this.visible && this.path) this._show(this.path);
|
||||
else if (!this.visible) this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
// Filename portion of the path, for the compact mobile header title.
|
||||
_basename() {
|
||||
const p = this.path || '';
|
||||
const i = p.lastIndexOf('/');
|
||||
return i < 0 ? p : p.slice(i + 1);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
return html`
|
||||
<div class="mobile-file-viewer">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
<button class="chat-page-back" title="Back" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span>
|
||||
</span>
|
||||
<span class="fv-header-actions">
|
||||
${this._renderModeToggle('chat-page-back')}
|
||||
<button class="chat-page-back" title="Download" @click=${() => this._download()}>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="fv-body">${this._renderBody()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mobile-file-viewer-page', MobileFileViewerPage);
|
||||
@@ -0,0 +1,271 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
|
||||
export class InboxPage extends LightElement {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
_data: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this._data = null;
|
||||
this._error = null;
|
||||
this._pollTimer = null;
|
||||
this._expanded = new Set();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (!changed.has('visible')) return;
|
||||
if (this.visible) {
|
||||
this._load();
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._pollTimer = setInterval(() => this._load(), 8000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const res = await fetch('/api/inbox');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._error = null;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _resolveApproval(requestId, action, note = '', toolCallId = null) {
|
||||
try {
|
||||
// Live items resolve by request_id; DB-persisted (post-restart) items carry
|
||||
// request_id 0 → resolve by the durable, source-agnostic tool_call_id.
|
||||
const url = requestId
|
||||
? `/api/inbox/approvals/${requestId}/resolve`
|
||||
: `/api/tools/${toolCallId}/resolve`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, note }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_rejectWithNote(requestId, toolCallId) {
|
||||
const note = prompt('Rejection reason (optional):') ?? '';
|
||||
this._resolveApproval(requestId, 'reject', note, toolCallId);
|
||||
}
|
||||
|
||||
async _resolveClarification(requestId, inputEl) {
|
||||
const answer = inputEl.value.trim();
|
||||
if (!answer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/inbox/clarifications/${requestId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_toggleRaw(id) {
|
||||
if (this._expanded.has(id)) this._expanded.delete(id);
|
||||
else this._expanded.add(id);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_keyArgs(args) {
|
||||
const entries = [];
|
||||
for (const key of ['path', 'command', 'url', 'origin', 'destination', 'name', 'message', 'query']) {
|
||||
if (args[key] !== undefined) {
|
||||
let val = args[key];
|
||||
if (typeof val === 'object') val = JSON.stringify(val);
|
||||
entries.push({ key, value: String(val) });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
_fmt(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
_renderApprovalCard(item) {
|
||||
const id = `raw-${item.request_id}`;
|
||||
const open = this._expanded.has(id);
|
||||
const label = item.context_label ?? item.source;
|
||||
const args = item.arguments ?? {};
|
||||
const keyArgs = this._keyArgs(args);
|
||||
|
||||
return html`
|
||||
<div class="inbox-card approval-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-warning text-dark">Approval</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-tool-name">
|
||||
<i class="bi bi-tools"></i>
|
||||
<strong>${item.tool_name}</strong>
|
||||
<span class="inbox-agent-tag">
|
||||
<i class="bi bi-person"></i> ${item.agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${keyArgs.length > 0 ? html`
|
||||
<div class="inbox-args-structured">
|
||||
${keyArgs.map(kv => html`
|
||||
<div class="inbox-arg-row">
|
||||
<span class="inbox-arg-key">${kv.key}</span>
|
||||
<span class="inbox-arg-value">${kv.value}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<button class="inbox-args-toggle" @click=${() => this._toggleRaw(id)}>
|
||||
<i class="bi ${open ? 'bi-chevron-up' : 'bi-chevron-down'}"></i>
|
||||
${open ? 'Hide raw JSON' : 'Show raw JSON'}
|
||||
</button>
|
||||
<pre class="inbox-args-raw ${open ? 'open' : ''}">${JSON.stringify(args, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-footer">
|
||||
<button class="inbox-btn inbox-btn-approve"
|
||||
@click=${() => this._resolveApproval(item.request_id, 'approve', '', item.tool_call_id)}>
|
||||
<i class="bi bi-check-lg"></i> Approve
|
||||
</button>
|
||||
<button class="inbox-btn inbox-btn-reject"
|
||||
@click=${() => this._rejectWithNote(item.request_id, item.tool_call_id)}>
|
||||
<i class="bi bi-x-lg"></i> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderClarificationCard(item) {
|
||||
const label = item.context_label ?? item.source;
|
||||
|
||||
return html`
|
||||
<div class="inbox-card clarification-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-info text-dark">Question</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-card-title">${item.title}</div>
|
||||
<div class="inbox-question">${item.question}</div>
|
||||
|
||||
${item.suggested_answers?.length ? html`
|
||||
<div class="inbox-chips">
|
||||
${item.suggested_answers.map(a => html`
|
||||
<button class="inbox-chip"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) { inp.value = a; inp.focus(); }
|
||||
}}>${a}</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="inbox-answer-area">
|
||||
<textarea class="inbox-answer-input" rows="2" placeholder="Your answer…"
|
||||
@keydown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._resolveClarification(item.request_id, e.target);
|
||||
}
|
||||
}}></textarea>
|
||||
<button class="inbox-answer-send"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) this._resolveClarification(item.request_id, inp);
|
||||
}}>
|
||||
<i class="bi bi-send"></i> Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
|
||||
const approvals = this._data?.approvals ?? [];
|
||||
const clarifications = this._data?.clarifications ?? [];
|
||||
const total = approvals.length + clarifications.length;
|
||||
|
||||
return html`
|
||||
<div class="mobile-inbox">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
Inbox
|
||||
${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing}
|
||||
</span>
|
||||
<button class="inbox-refresh-btn" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="mobile-alert-error">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${total === 0 ? html`
|
||||
<div class="inbox-empty">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<p>No pending requests</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="mobile-inbox-list">
|
||||
${approvals.length > 0 ? html`
|
||||
<div class="inbox-section-label">
|
||||
Approvals <span class="badge bg-warning text-dark">${approvals.length}</span>
|
||||
</div>
|
||||
${approvals.map(item => this._renderApprovalCard(item))}
|
||||
` : nothing}
|
||||
|
||||
${clarifications.length > 0 ? html`
|
||||
<div class="inbox-section-label">
|
||||
Questions <span class="badge bg-info text-dark">${clarifications.length}</span>
|
||||
</div>
|
||||
${clarifications.map(item => this._renderClarificationCard(item))}
|
||||
` : nothing}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('inbox-page', InboxPage);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
|
||||
/**
|
||||
* Mobile project list. Lists projects from `GET /api/projects`; tapping one opens
|
||||
* (or resumes) its coordinator session via `POST /api/projects/{id}/session` and
|
||||
* emits a `project-open` event so the shell can re-point the chat to that source.
|
||||
*
|
||||
* The session machinery is shared with the desktop copilot: the same `project-{id}`
|
||||
* source, the same provisioning (`project-coordinator` + project RunContext), so a
|
||||
* project chat is continuous across desktop, mobile browser, and the native shell.
|
||||
*/
|
||||
export class ProjectsPage extends LightElement {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
_data: { state: true },
|
||||
_error: { state: true },
|
||||
_loading: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this._data = null;
|
||||
this._error = null;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') && this.visible) this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._error = null;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _open(project) {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${project.id}/session`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { source } = await res.json();
|
||||
this.dispatchEvent(new CustomEvent('project-open', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { source, label: project.name },
|
||||
}));
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
const projects = this._data ?? [];
|
||||
|
||||
return html`
|
||||
<div class="mobile-projects">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
<i class="bi bi-folder2-open"></i> Projects
|
||||
</span>
|
||||
<button class="inbox-refresh-btn" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="mobile-alert-error">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${projects.length === 0 ? html`
|
||||
<div class="inbox-empty">
|
||||
<i class="bi bi-folder"></i>
|
||||
<p>${this._loading ? 'Loading…' : 'No projects yet'}</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="mobile-projects-list">
|
||||
${projects.map(p => html`
|
||||
<div class="project-card" @click=${() => this._open(p)}>
|
||||
<div class="project-card-icon">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</div>
|
||||
<div class="project-card-main">
|
||||
<div class="project-card-name">${p.name}</div>
|
||||
${p.description ? html`
|
||||
<div class="project-card-desc">${p.description}</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
<i class="bi bi-chevron-right project-card-chevron"></i>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('projects-page', ProjectsPage);
|
||||
Reference in New Issue
Block a user