feat(chat): view context — tell the assistant what you're looking at
Nightly Build / build (push) Successful in 7m51s

An eye next to the paperclip shares what the user has open with their next
message: the page, the folder being browsed, the file open in the viewer and
any highlighted passage (line numbers where a source view exists), plus which
entity a detail page is about. The bag is client-authored {label, value} pairs
in English — the backend only clamps (chars, never bytes), neutralizes the
harness tag and renders one <system-extra> block per message, deduped
consecutively so it appears exactly when the view changed. On by default,
per-device toggle, hover/tap to preview, a chip on every sent message;
docs/view-context.md for users, an updated harness.md clause for the model.
This commit is contained in:
Daniele
2026-08-23 20:53:30 +01:00
parent 488c702517
commit 505f2e95c1
42 changed files with 2096 additions and 122 deletions
+22 -2
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
import {
announceChange, authLabel, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf,
} from './shared/connector-common.js';
@@ -23,6 +24,9 @@ import {
const ADMIN_ID = 'admin';
const PAGE_ID = 'connector';
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@connector';
function nameFromHash() {
const m = location.hash.match(/^#connector\?name=(.*)$/);
if (!m) return null;
@@ -82,7 +86,10 @@ export class ConnectorDetailPage extends LightElement {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadFromHash();
else this._stopQrPoll(); // never poll a connector's login off-screen
else {
this._stopQrPoll(); // never poll a connector's login off-screen
clearSlice(VIEW_SLICE);
}
});
window.addEventListener('hashchange', () => {
if (this._open) this._loadFromHash();
@@ -92,6 +99,7 @@ export class ConnectorDetailPage extends LightElement {
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
this._stopQrPoll();
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
@@ -106,13 +114,24 @@ export class ConnectorDetailPage extends LightElement {
async _loadFromHash() {
const name = nameFromHash();
if (!name) return;
if (!name) { clearSlice(VIEW_SLICE); return; }
// A different connector must not inherit the previous one's typed secrets.
if (name !== this._name) this._reset();
this._name = name;
// Say which connector is open before the fetch lands — the status line is
// added by `_load` once the runtime rows are known.
this._publishViewContext(false);
await this._load();
}
// The entity slice: which connector this page is about, and — once loaded —
// its state. Never a config value or a credential: *which*, not *what's in it*.
_publishViewContext(withStatus) {
if (!this._name) return;
const value = withStatus ? `${this._name} (status: ${this._status})` : this._name;
setSlice(VIEW_SLICE, [{ label: 'Open connector', value }]);
}
async _load() {
this._error = null;
try {
@@ -133,6 +152,7 @@ export class ConnectorDetailPage extends LightElement {
this._entry = entry;
this._glob = glob;
this._act = act;
this._publishViewContext(true);
const schema = normalizeSchema(parseJson(entry?.config_schema_json, []));
this._schema = schema;
+98 -1
View File
@@ -508,6 +508,103 @@ export function renderAttachmentChips(host, attachments, { removable = false } =
</div>`;
}
/* ── View context: the eye in the composer, the chip in the bubble ───────────── */
// Whether this device has a pointer that can hover. A mouse gets the panel on
// hover and needs no click-away target; a touch screen has no hover, so there
// the pill's tap opens the panel and a full-screen overlay closes it — the same
// shape as the model dropdown. Read once: hover capability does not change
// under a running page in any way worth re-rendering for.
const CAN_HOVER = typeof window === 'undefined'
|| !window.matchMedia
|| window.matchMedia('(hover: hover)').matches;
/** The literal pairs, as they would appear (and as they were sent). */
function renderViewContextItems(items) {
return html`
<div class="view-ctx-items">
${items.map((it) => html`
<div class="view-ctx-item">
<div class="view-ctx-label">${it.label}</div>
<div class="view-ctx-value">${it.value}</div>
</div>
`)}
</div>`;
}
/**
* The eye: the composer's view-context control, shared by the desktop copilot
* and the mobile chat.
*
* Always rendered, on or off, empty store or not — it is a privacy control, so
* it has to be findable in the same place every time rather than appearing only
* once there is something to share. Hovering (or tapping) it shows the literal
* `label: value` pairs it would send: that is the verifiable half of §3, and it
* is also the only way to debug a contributor without sending a message.
*
* `host` supplies `_viewContextEnabled`, `_viewContext`, `_viewContextOpen` and
* `_toggleViewContext()` — all from `ChatSession`.
*/
export function renderViewContextPill(host) {
const on = !!host._viewContextEnabled;
const items = on ? (host._viewContext ?? []) : [];
const open = !!host._viewContextOpen;
const hover = CAN_HOVER
? { enter: () => { host._viewContextOpen = true; }, leave: () => { host._viewContextOpen = false; } }
: { enter: () => {}, leave: () => {} };
return html`
<div class="view-ctx-wrap"
@mouseenter=${hover.enter}
@mouseleave=${hover.leave}>
${open && !CAN_HOVER
? html`<div class="view-ctx-overlay" @click=${() => { host._viewContextOpen = false; }}></div>`
: nothing}
${open ? html`
<div class="view-ctx-panel">
<div class="view-ctx-panel-title">
${on ? t('chat.view_context.title') : t('chat.view_context.off_title')}
</div>
${!on
? html`<div class="view-ctx-empty">${t('chat.view_context.off_hint')}</div>`
: items.length
? renderViewContextItems(items)
: html`<div class="view-ctx-empty">${t('chat.view_context.empty')}</div>`}
</div>
` : nothing}
<button
class="view-ctx-btn ${on ? 'view-ctx-btn--on' : ''}"
type="button"
aria-pressed=${on ? 'true' : 'false'}
title=${on ? t('chat.view_context.on') : t('chat.view_context.off')}
@focus=${() => { host._viewContextOpen = true; }}
@blur=${() => { host._viewContextOpen = false; }}
@click=${() => { host._toggleViewContext(); host._viewContextOpen = true; }}
>
<i class="bi ${on ? 'bi-eye' : 'bi-eye-slash'}"></i>
${on && items.length ? html`<span class="view-ctx-count">${items.length}</span>` : nothing}
</button>
</div>`;
}
/**
* The proof, in the sent bubble: what this message actually carried. Rendered
* from the server's echo (and, after a reload, from the REST history), so it
* shows the sanitized pairs the model was given — never the browser's intent.
*/
function renderViewContextChip(host, msg) {
const items = msg.view_context;
if (!items?.length) return nothing;
return html`
<details class="view-ctx-chip">
<summary>
<i class="bi bi-eye"></i>
<span>${t('chat.view_context.chip', { n: items.length })}</span>
</summary>
${renderViewContextItems(items)}
</details>`;
}
/**
* Collapsible chain-of-thought block: small, muted, collapsed by default so it
* never weighs on the UI. A native <details> — Lit keeps the element stable
@@ -528,7 +625,7 @@ export function renderMsg(host, msg) {
try {
switch (msg.kind) {
case 'user':
return html`<div class="copilot-msg user ${msg.failed ? 'copilot-msg--failed' : ''}" style="white-space:pre-wrap">${msg.failed ? failedBadge() : nothing}${msg.content}${renderAttachmentChips(host, msg.attachments)}</div>`;
return html`<div class="copilot-msg user ${msg.failed ? 'copilot-msg--failed' : ''}" style="white-space:pre-wrap">${msg.failed ? failedBadge() : nothing}${msg.content}${renderAttachmentChips(host, msg.attachments)}${renderViewContextChip(host, msg)}</div>`;
case 'thinking':
return html`
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">
+9 -5
View File
@@ -1,7 +1,8 @@
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 { pageFromHash } from '../lib/routes.js';
import { renderMsg, renderAttachmentChips, renderViewContextPill } from './copilot-render.js';
import { renderTaskStrip } from './shared/agent-tasks.js';
// Built-in (server-handled) slash commands shown at the top of the composer
@@ -138,11 +139,13 @@ export class AppCopilot extends I18nMixin(ChatSession) {
window.addEventListener('llm-page-change', this._onPageChange);
}
// Shared with the sidebar and the view-context store (`lib/routes.js`). It used
// to be a third copy of the same list, and had already drifted: `files`,
// `plugins`, `shared-folders` and the plugin routes were missing, so a deep
// link to one of those opened the chat full-screen over the page it should
// have docked beside.
_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';
return pageFromHash();
}
_onPageChange(e) {
@@ -738,6 +741,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
title=${t('chat.attach')}
@click=${() => this.querySelector('.copilot-file-input')?.click()}
><i class="bi bi-paperclip"></i></button>
${renderViewContextPill(this)}
${this._providers.length > 1 ? html`
<div class="copilot-model-wrap">
${this._modelOpen ? html`
+20
View File
@@ -1,10 +1,18 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
const PAGE_ID = 'llm-requests';
const PAGE_SIZE = 20;
/// The view-context slice this page owns (see `lib/view-context.js`). It is
/// published from here and not from `<llm-request-detail>` on purpose: the
/// detail stays connected (only `display:none`) while the page is hidden, so
/// the host — which knows both `_open` and `_detailId` — is the one place that
/// can guarantee the slice never describes a page nobody is looking at.
const VIEW_SLICE = 'entity@llm-requests';
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
@@ -75,14 +83,24 @@ export class LlmRequestsPage extends LightElement {
this._detailId = id;
if (id == null && this._items.length === 0) this._fetch(1);
}
this._publishViewContext();
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
// The entity slice: which request is open, if any. On the list there is none
// — the route slice already describes it.
_publishViewContext() {
setSlice(VIEW_SLICE, this._open && this._detailId != null
? [{ label: 'Open LLM request', value: `#${this._detailId}` }]
: null);
}
_idFromHash() {
const parts = location.hash.replace('#', '').split('/');
if (parts[0] === PAGE_ID && parts[1]) {
@@ -94,11 +112,13 @@ export class LlmRequestsPage extends LightElement {
_openDetail(id) {
this._detailId = id;
this._publishViewContext();
history.pushState({}, '', `#${PAGE_ID}/${id}`);
}
_back() {
this._detailId = null;
this._publishViewContext();
history.pushState({}, '', `#${PAGE_ID}`);
if (this._items.length === 0) this._fetch(1);
}
+15 -2
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
// Connector marketplace — blueprint §14/§15.
//
@@ -17,6 +18,9 @@ import { t } from '../lib/i18n.js';
const ADMIN_ID = 'admin';
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@marketplace';
async function jf(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
@@ -64,15 +68,24 @@ export class MarketplacePage extends LightElement {
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'marketplace';
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load();
if (this._open) { this._load(); this._publishViewContext(); }
else clearSlice(VIEW_SLICE);
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
// The entity slice: the active search filter, if any. An empty box is no
// slice at all, and it is the search term — never a card's fields.
_publishViewContext() {
const q = this._q.trim();
setSlice(VIEW_SLICE, q ? [{ label: 'Search', value: q }] : null);
}
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
async _load() {
@@ -209,7 +222,7 @@ export class MarketplacePage extends LightElement {
<div class="connector-search">
<i class="bi bi-search"></i>
<input class="form-control form-control-sm" placeholder=${t('marketplace.filter.search')}
.value=${this._q} @input=${(e) => { this._q = e.target.value; }} />
.value=${this._q} @input=${(e) => { this._q = e.target.value; this._publishViewContext(); }} />
</div>
${this._segment(t('marketplace.filter.scope'), this._scope, (v) => { this._scope = v; },
[[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.global'), 'global'], [t('marketplace.filter.per_user'), 'per_user']])}
+18
View File
@@ -1,5 +1,7 @@
import { LitElement, html, nothing } from 'lit';
import { t } from '../lib/i18n.js';
import { claimRouteProvider, refreshRoute } from '../lib/view-context.js';
import { mobileRouteSliceFor } from '../lib/view-context-routes.js';
import { LoginPage } from './login-page.js';
import { installSessionExpiryWatch } from '../lib/session-expiry.js';
import { installSessionRelogin } from './session-relogin.js';
@@ -65,6 +67,14 @@ class MobileApp extends LitElement {
this._onHashChange = () => this._applyHash();
window.addEventListener('hashchange', this._onHashChange);
window.addEventListener('popstate', this._onHashChange);
// This shell's sections are not desktop pages, so `pageFromHash` cannot
// describe them: the route slice is rendered from here instead. The
// provider re-reads the hash at every sync, which is what makes it immune
// to listener ordering between this element and the store.
claimRouteProvider(() => {
const { section, projectId } = this._readHash();
return mobileRouteSliceFor({ section, projectId, projectLabel: this._chatLabel || null });
});
// Default route when no hash is present (replaceState: no history entry).
if (!location.hash) history.replaceState(null, '', '#chat');
this._applyHash();
@@ -74,6 +84,7 @@ class MobileApp extends LitElement {
super.disconnectedCallback();
window.removeEventListener('hashchange', this._onHashChange);
window.removeEventListener('popstate', this._onHashChange);
claimRouteProvider(null);
}
// ── Hash routing ───────────────────────────────────────────────────────────
@@ -133,6 +144,11 @@ class MobileApp extends LitElement {
projectId ? 'project-' + projectId : null,
section === 'file_viewer' ? filePath : null,
);
// Re-publish the route slice with the state just applied: the store's own
// `hashchange` listener may have run before this one, and the provider only
// reads current state, so a refresh here is what keeps the two independent
// of listener order.
refreshRoute();
}
// Resolve the display label for a project id (shown in the chat header). Cached
@@ -141,6 +157,7 @@ class MobileApp extends LitElement {
async _resolveLabel(projectId) {
if (this._projectLabels[projectId] != null) {
this._chatLabel = this._projectLabels[projectId];
refreshRoute();
return;
}
try {
@@ -149,6 +166,7 @@ class MobileApp extends LitElement {
for (const p of await res.json()) this._projectLabels[p.id] = p.name;
} catch { /* keep whatever label we have */ }
this._chatLabel = this._projectLabels[projectId] ?? projectId;
refreshRoute();
}
_nav(section) {
+18
View File
@@ -1,6 +1,10 @@
import { html } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@models';
const CARDS = [
{
@@ -52,16 +56,28 @@ export class ModelsHubPage extends LightElement {
this.style.display = open ? 'flex' : 'none';
if (open) {
this._section = this._sectionFromHash();
this._publishViewContext();
if (!this._section) this._loadCounts();
} else {
clearSlice(VIEW_SLICE);
}
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
// The entity slice: which section is open. The hub root is no one section,
// so it publishes nothing — the route slice already describes it.
_publishViewContext() {
setSlice(VIEW_SLICE, this._section
? [{ label: 'Open section', value: this._section }]
: null);
}
_sectionFromHash() {
const parts = location.hash.slice(1).split('/');
if (parts[0] === 'models' && parts[1]) {
@@ -100,11 +116,13 @@ export class ModelsHubPage extends LightElement {
_openSection(id) {
this._section = id;
this._publishViewContext();
history.pushState({ page: 'models', section: id }, '', `#models/${id}`);
}
_goBack() {
this._section = null;
this._publishViewContext();
this._loadCounts();
history.replaceState({ page: 'models' }, '', '#models');
}
+10 -1
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
import { jf, schemaFields, pluginHealth } from './shared/plugin-common.js';
// One plugin's admin page (`#plugin-detail?id=<plugin id>`), reached from the
@@ -21,6 +22,9 @@ import { jf, schemaFields, pluginHealth } from './shared/plugin-common.js';
const PAGE_ID = 'plugin-detail';
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@plugin-detail';
function idFromHash() {
const m = location.hash.match(/^#plugin-detail\?id=(.*)$/);
if (!m) return null;
@@ -68,6 +72,7 @@ export class PluginDetailPage extends LightElement {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadFromHash();
else clearSlice(VIEW_SLICE);
});
window.addEventListener('hashchange', () => {
if (this._open) this._loadFromHash();
@@ -76,15 +81,19 @@ export class PluginDetailPage extends LightElement {
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
async _loadFromHash() {
const id = idFromHash();
if (!id) return;
if (!id) { clearSlice(VIEW_SLICE); return; }
// A different plugin must not inherit the previous one's typed config.
if (id !== this._id) this._reset();
this._id = id;
// The entity slice: which plugin is open. The id is the whole answer — a
// detail page says *which* object, never what its config holds.
setSlice(VIEW_SLICE, [{ label: 'Open plugin', value: id }]);
await this._load();
}
+24 -4
View File
@@ -1,6 +1,10 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@plugin-page-host';
// Host for plugin-contributed pages (`#plugin/<plugin_id>/<page_id>`).
//
@@ -29,6 +33,7 @@ export class PluginPageHost extends LightElement {
this._error = null;
this._loading = false;
this._mounted = null; // currently mounted fragment element
this._titles = new Map(); // "plugin/page" element tag → the page's own title
}
connectedCallback() {
@@ -42,10 +47,16 @@ export class PluginPageHost extends LightElement {
this._open = false;
this._route = null;
this.style.display = 'none';
clearSlice(VIEW_SLICE);
}
});
}
disconnectedCallback() {
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
async _openPage(route) {
this._open = true;
this.style.display = 'flex';
@@ -55,17 +66,26 @@ export class PluginPageHost extends LightElement {
this._loading = true;
const [, pluginId, pageId] = route.split('/');
// The entity slice: which plugin page this is. The ids go out at once; the
// page's own title — the part the route cannot carry — replaces them as
// soon as the pages list resolves (cached: a re-open refetches nothing).
setSlice(VIEW_SLICE, [{ label: 'Open plugin page', value: `${pluginId} / ${pageId}` }]);
const tag = `skald-plugin-${pluginId}-${pageId}`;
try {
if (!customElements.get(tag)) {
const entry_url = await this._resolveEntry(pluginId, pageId);
const mod = await import(/* @vite-ignore */ entry_url);
const page = await this._resolvePage(pluginId, pageId);
this._titles.set(tag, page.title);
const mod = await import(/* @vite-ignore */ page.entry_url);
const cls = mod.default;
if (!cls || !(cls.prototype instanceof HTMLElement)) {
throw new Error('fragment must default-export an HTMLElement class');
}
customElements.define(tag, cls);
}
const title = this._titles.get(tag);
if (title && this._route === route) {
setSlice(VIEW_SLICE, [{ label: 'Open plugin page', value: `${title} (${pluginId} / ${pageId})` }]);
}
const el = document.createElement(tag);
el.setAttribute('plugin-id', pluginId);
if (this._mounted) this._mounted.remove();
@@ -78,13 +98,13 @@ export class PluginPageHost extends LightElement {
}
}
async _resolveEntry(pluginId, pageId) {
async _resolvePage(pluginId, pageId) {
const res = await fetch('/api/plugins/pages');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const pages = await res.json();
const page = pages.find(p => p.plugin_id === pluginId && p.page_id === pageId);
if (!page) throw new Error(t('plugin_page.unavailable'));
return page.entry_url;
return page;
}
render() {
+27 -1
View File
@@ -1,8 +1,14 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { t } from '../../lib/i18n.js';
import { setSlice, clearSlice } from '../../lib/view-context.js';
import '../shared/file-explorer.js';
/// The view-context slice this board owns (see `lib/view-context.js`). The
/// board element is dropped by the host whenever the page closes or returns to
/// the list, so `disconnectedCallback` is the whole cleanup story.
const VIEW_SLICE = 'entity@projects';
/// A project's detail page: header + description, then two tabs — **Files** (the
/// shared `<file-explorer>`, pointed at the project folder) and **Sharing**
/// (member picker with read/write, mirroring the shared-folders UI).
@@ -33,9 +39,23 @@ export class ProjectBoardSection extends LightElement {
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
// Say which project this is, and which tab is showing. The tab matters because
// the explorer keeps its `path` slice standing while hidden behind *Sharing*:
// without this, the context would describe a folder and not that the user is
// looking at the member list.
_publishViewContext() {
const p = this._project;
if (!p) return;
setSlice(VIEW_SLICE, [
{ label: 'Open project', value: p.root_path ? `${p.name} — folder ${p.root_path}` : String(p.name) },
{ label: 'Open tab', value: this._tab === 'sharing' ? 'Sharing' : 'Files' },
]);
}
async load(projectId, tab) {
this._projectId = projectId;
this._project = null;
@@ -49,6 +69,7 @@ export class ProjectBoardSection extends LightElement {
if (!projRes.ok) throw new Error(`HTTP ${projRes.status}`);
this._project = await projRes.json();
if (usersRes.ok) this._users = await usersRes.json();
this._publishViewContext();
} catch (e) {
this._error = e.message;
}
@@ -57,7 +78,10 @@ export class ProjectBoardSection extends LightElement {
async _reload() {
try {
const res = await fetch(`/api/projects/${this._projectId}`);
if (res.ok) this._project = await res.json();
if (res.ok) {
this._project = await res.json();
this._publishViewContext();
}
} catch { /* transient */ }
}
@@ -121,11 +145,13 @@ export class ProjectBoardSection extends LightElement {
// Switch the visible tab without reloading (host back/forward sync).
setTab(tab) {
this._tab = tab === 'sharing' ? 'sharing' : 'files';
this._publishViewContext();
}
_selectTab(tab) {
if (tab === this._tab) return;
this._tab = tab;
this._publishViewContext();
this.dispatchEvent(new CustomEvent('project-tab-change', {
detail: { tab }, bubbles: true, composed: true,
}));
+10 -2
View File
@@ -2,9 +2,13 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
const PAGE_ID = 'session';
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@session';
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
@@ -64,7 +68,7 @@ export class SessionDetailPage extends LightElement {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadFromHash();
else this._closeWs();
else { this._closeWs(); clearSlice(VIEW_SLICE); }
};
this.__onHashChange = () => {
if (this._open) this._loadFromHash();
@@ -79,6 +83,7 @@ export class SessionDetailPage extends LightElement {
window.removeEventListener('llm-page-change', this.__onPageChange);
window.removeEventListener('hashchange', this.__onHashChange);
this._closeWs();
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
@@ -90,7 +95,10 @@ export class SessionDetailPage extends LightElement {
_loadFromHash() {
const id = this._idFromHash();
if (id == null) return;
if (id == null) { clearSlice(VIEW_SLICE); return; }
// The entity slice: which conversation is open. Published before the fetch
// — a message sent while the transcript loads is still about this session.
setSlice(VIEW_SLICE, [{ label: 'Open conversation', value: `#${id}` }]);
// Reload on the same id too when the socket is down: leaving the page closes
// it, so coming back to the session we already hold would otherwise show a
// frozen snapshot with nothing streaming into it.
+2 -1
View File
@@ -1,7 +1,7 @@
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 { renderMsg, renderAttachmentChips, renderViewContextPill } from '../copilot-render.js';
import { renderTaskStrip } from './agent-tasks.js';
export class ChatPage extends ChatSession {
@@ -223,6 +223,7 @@ export class ChatPage extends ChatSession {
title=${t('chat.attach')}
@click=${() => this.querySelector('.chat-page-file-input')?.click()}
><i class="bi bi-paperclip"></i></button>
${renderViewContextPill(this)}
${this._providers.length > 1 ? html`
<select
class="chat-page-model-pill"
+30
View File
@@ -2,6 +2,14 @@ import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { t } from '../../lib/i18n.js';
import { fileWatcher } from '../../lib/file-watcher.js';
import { setSlice, clearSlice } from '../../lib/view-context.js';
/// The view-context slice this component owns (see `lib/view-context.js`).
/// One explorer is on screen at a time — `#files` renders `nothing` when it is
/// not the open page, and the project board is a page of its own — so a single
/// key is right and two explorers cannot both claim it.
const VIEW_SLICE = 'path';
const VIEW_LABEL = 'Open folder';
/// A live file explorer over one subtree of the caller's namespace.
///
@@ -29,6 +37,18 @@ import { fileWatcher } from '../../lib/file-watcher.js';
/// fires only for a click, never for a `rel` the host itself set — so echoing
/// the event back as a property is a no-op, and a host that ignores the event
/// entirely (`project-board.js`) still gets a working explorer.
///
/// **It also tells the assistant which folder is open.** The current directory
/// is published as the `path` view-context slice, in agent-path vocabulary — the
/// same string the fs-tools take — so "what is in this folder?" needs no
/// explaining. That it works inside the project board as well as on `#files` is
/// the whole reason the store is push-based: nobody threads a handle down here.
/// The slice is cleared on `disconnectedCallback`, which is when both hosts drop
/// the element (`#files` renders `nothing` while it is not the open page, and so
/// does the projects page). The board hiding the explorer behind its *Sharing*
/// tab leaves the slice standing, deliberately: the project's folder is still
/// the folder the page is about, and which tab is showing is the board's own
/// slice to publish.
export class FileExplorer extends LightElement {
static properties = {
/// Agent path of the subtree to browse (`~`, `shared/x`, `projects/a/b`,
@@ -80,6 +100,7 @@ export class FileExplorer extends LightElement {
disconnectedCallback() {
this._unwatch?.();
clearTimeout(this._reloadTimer);
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
@@ -87,11 +108,20 @@ export class FileExplorer extends LightElement {
return this._rel ? `${this.root}/${this._rel}` : this.root;
}
/// Say which folder is open, before the listing lands: the path is known the
/// moment we navigate, and a message sent while the fetch is in flight is
/// still a message about *this* folder.
_publishViewContext() {
if (!this.root) return;
setSlice(VIEW_SLICE, [{ label: VIEW_LABEL, value: this._dirPath() }]);
}
async _open(rel) {
this._unwatch?.();
this._unwatch = null;
this._rel = rel;
this._error = null;
this._publishViewContext();
await this._load();
// Live updates for the open directory (best-effort: a dead watcher just
// means manual refresh; auto-reconnect + re-subscribe are handled inside).
+175
View File
@@ -5,6 +5,7 @@ import { LightElement, renderMarkdown } from '../../lib/base.js';
import { codeLangForExt, highlightCode } from '../../lib/highlight.js';
import { fileWatcher } from '../../lib/file-watcher.js';
import { t } from '../../lib/i18n.js';
import { setSlice, clearSlice } from '../../lib/view-context.js';
import './pdf-view.js'; // registers <pdf-view>; pdf.js itself is imported lazily
/**
@@ -119,6 +120,38 @@ function rewriteMarkdownAssets(htmlStr, baseDir, rev) {
* 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.
*/
// ── View context (see `lib/view-context.js`) ─────────────────────────────────
// Doing this in the base means doing it once: desktop and mobile inherit the
// same behaviour, not just the same markup. One viewer is on screen per shell,
// so a single key per slice is right.
const VIEW_FILE_SLICE = 'file';
const VIEW_SELECTION_SLICE = 'selection';
// How the file is being *shown*, which is not the same as what it is: the model
// needs to know whether the user is looking at source (where a line number
// means something) or at a rendering of it — and, for an image or a PDF, that
// the person is seeing something the text of the message does not carry.
const VIEW_KINDS = {
image: 'an image',
pdf: 'a PDF',
svg: 'an SVG image',
html: 'a rendered HTML page',
latex: 'a compiled LaTeX document',
binary: 'a binary file, whose content is not displayed',
};
/**
* 1-based first and last line covered by `[start, end)` in `text`.
*
* The end is measured one character back so a selection stopping exactly at a
* line break claims the line it ends on, not the empty one after it.
*/
function lineRange(text, start, end) {
const first = text.slice(0, start).split('\n').length;
const last = text.slice(0, Math.max(start, end - 1)).split('\n').length;
return { first, last };
}
function formatLatexError(log) {
if (!log) return '';
const lines = log.split('\n');
@@ -186,10 +219,22 @@ export class FileViewerBase extends LightElement {
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
this._selTimer = null; // debounce timer for selection capture
this._onSelectionChange = () => this._scheduleSelectionCapture();
}
connectedCallback() {
super.connectedCallback();
// `selectionchange` only exists on the document, so the listener is global
// and the filtering (is this selection inside *my* body?) is ours.
document.addEventListener('selectionchange', this._onSelectionChange);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('selectionchange', this._onSelectionChange);
if (this._selTimer) clearTimeout(this._selTimer);
this._clearViewContext();
this._teardownWatch();
if (this._reloadTimer) clearTimeout(this._reloadTimer);
this._revokeBlobUrl();
@@ -220,6 +265,128 @@ export class FileViewerBase extends LightElement {
_hide() {
this._reset();
this._teardownWatch();
// Both viewers stay in the DOM while hidden, so nothing else would take
// these down: a file the user closed is context that lies.
this._clearViewContext();
}
// ── View context ────────────────────────────────────────────────────────────
_clearViewContext() {
clearSlice(VIEW_FILE_SLICE);
clearSlice(VIEW_SELECTION_SLICE);
}
/**
* How this file is being shown, in one English noun phrase.
*
* The two view/source toggles are folded in because they change the answer:
* saying "rendered Markdown" while the user is in the editor would contradict
* the line numbers the selection slice is putting on the very same file.
*/
_viewDescription() {
if (this._kind === 'html') {
return this._htmlMode === 'source' ? 'HTML source' : VIEW_KINDS.html;
}
if (this._kind === 'text') {
const ext = extOf(this._path);
if (ext !== 'md' && ext !== 'markdown') return 'source text';
return this._mdMode === 'edit' && this._canWrite
? 'Markdown source, open in the editor'
: 'rendered Markdown';
}
return VIEW_KINDS[this._kind] ?? 'source text';
}
/**
* Announce the open file. Called on every non-silent load — so a new file, a
* reload and stepping into a past revision all refresh it — and never on the
* watcher's silent reload, where nothing the model would care about moved.
*/
_publishFile() {
if (!this._path) return;
setSlice(VIEW_FILE_SLICE, [{
label: 'Open file',
value: `${this._path} (shown as ${this._viewDescription()})`,
}]);
}
_scheduleSelectionCapture() {
if (!this._path) return;
if (this._selTimer) clearTimeout(this._selTimer);
this._selTimer = setTimeout(() => this._captureSelection(), 200);
}
/**
* Keep the last non-empty selection made inside this viewer.
*
* Reading it at send time instead would be too late: by then the focus has
* moved to the composer's textarea and the document selection is gone. Hence
* also the asymmetry — a selection that collapses (the user clicked
* somewhere) or one made outside the viewer leaves the slice standing, and
* only a change of file clears it.
*/
_captureSelection() {
this._selTimer = null;
if (!this._path) return;
const item = this._readSelection();
if (item) setSlice(VIEW_SELECTION_SLICE, [item]);
}
_readSelection() {
// The Markdown source editor first: a textarea owns its selection, and
// `window.getSelection()` says nothing about what is highlighted inside it.
const ta = this.querySelector('.fv-edit-textarea');
if (ta && document.activeElement === ta && ta.selectionStart !== ta.selectionEnd) {
const start = ta.selectionStart;
const end = ta.selectionEnd;
return this._selectionItem(ta.value.slice(start, end), lineRange(ta.value, start, end));
}
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
// Restricted to the file's body: text selected in the chat itself — or in
// this page's own header — is not something the user is pointing at.
const body = this.querySelector('.fv-body') ?? this;
if (!body.contains(range.commonAncestorContainer)) return null;
return this._selectionItem(sel.toString(), this._selectionLines(range));
}
/**
* Line numbers, but only where a source view exists.
*
* `pre.fv-code` is rendered for plain text, for an HTML file in source mode
* and for a LaTeX file whose compile failed — never for rendered Markdown, a
* PDF or an image, where a DOM selection has no obvious mapping back to the
* source. There the label carries the text alone, which is enough: the model
* can find it with a grep.
*/
_selectionLines(range) {
const pre = this.querySelector('pre.fv-code');
if (!pre || !pre.contains(range.commonAncestorContainer)) return null;
try {
const before = document.createRange();
before.selectNodeContents(pre);
before.setEnd(range.startContainer, range.startOffset);
const start = before.toString().length;
// Counted in the element's own text rather than in `_content`: syntax
// highlighting wraps the source in spans, and measuring both the offset
// and the lines against the same DOM is what keeps them in step.
return lineRange(pre.textContent ?? '', start, start + range.toString().length);
} catch {
return null; // a detached or reordered range: the text alone will do
}
}
_selectionItem(text, lines) {
if (!text || !text.trim()) return null;
let label = 'Selected text';
if (lines) {
label += lines.first === lines.last
? ` (line ${lines.first})`
: ` (lines ${lines.first}-${lines.last})`;
}
return { label, value: text };
}
/**
@@ -299,6 +466,11 @@ export class FileViewerBase extends LightElement {
this._loading = true;
this._versions = null; // no stale history button while the new file loads
this._loadVersions(path);
// Say what is open before the bytes land — a message sent while the fetch
// is in flight is still a message about this file — and drop the previous
// file's selection, which belongs to a document nobody is looking at now.
this._publishFile();
clearSlice(VIEW_SELECTION_SLICE);
} else {
// Silent reload (file changed externally): keep showing the old content
// until the new fetch lands; only update visible state on success.
@@ -496,6 +668,7 @@ export class FileViewerBase extends LightElement {
_toggleHtmlMode() {
this._htmlMode = this._htmlMode === 'preview' ? 'source' : 'preview';
this._publishFile();
}
// ── Markdown View | Edit ────────────────────────────────────────────────────
@@ -510,6 +683,7 @@ export class FileViewerBase extends LightElement {
if (!this._editDirty) this._editBuffer = this._content;
}
this._mdMode = mode;
this._publishFile();
}
_onEditInput(e) {
@@ -524,6 +698,7 @@ export class FileViewerBase extends LightElement {
this._editDirty = false;
this._conflict = false;
this._mdMode = 'view';
this._publishFile();
}
/**
+4 -16
View File
@@ -1,6 +1,9 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t, I18nMixin } from '../lib/i18n.js';
// Shared with the view-context store: one reading of the hash, so the page the
// assistant is told about is always the page the menu highlights.
import { pageFromHash } from '../lib/routes.js';
// ── Navigation model ──────────────────────────────────────────────────────────
@@ -237,22 +240,7 @@ export class AppSidebar extends I18nMixin(LightElement) {
}
_pageFromHash() {
const hash = location.hash.slice(1);
if (!hash) return 'home';
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
const match = hash.match(/^([^/?]+)/);
const segment = match ? match[1] : '';
// Plugin pages: `#plugin/<plugin_id>/<page_id>` — the route is accepted by
// shape (deep links must survive the async `/api/plugins/pages` load); the
// host reports an error if the page turns out not to exist for this user.
if (segment === 'plugin') {
const m = hash.match(/^plugin\/([^/?]+)\/([^/?]+)/);
return m ? `plugin/${m[1]}/${m[2]}` : 'home';
}
// `connector` (singular) is the per-connector detail page, `connectors` the list.
// `plugin-catalog` is the pre-merge hash of what is now `#plugins`.
const page = segment === 'plugin-catalog' ? 'plugins' : segment;
return ['inbox', 'dashboard', 'tasks', 'projects', 'files', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-detail', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'].includes(page) ? page : 'home';
return pageFromHash();
}
_tasksSectionFromHash() {
+16 -2
View File
@@ -1,11 +1,15 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
import { ConfigFormController, maybeT, propKeyId } from './shared/config-form.js';
const PAGE_ID = 'system-agents';
const PER_PAGE = 20;
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@system-agents';
/** The overview tab: every agent's runs, interleaved. */
const ALL_TAB = '__all__';
@@ -98,19 +102,28 @@ export class SystemAgentsPage extends LightElement {
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadAll();
if (this._open) { this._loadAll(); this._publishViewContext(); }
// Navigating away stops the polling; the pass keeps running server-side
// and its row is waiting on the next visit.
else this._stopPolling();
else { this._stopPolling(); clearSlice(VIEW_SLICE); }
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
this._stopPolling();
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
// The entity slice: which agent's tab is open. The "All" tab is no one agent,
// so it publishes nothing — the route slice already describes the page.
_publishViewContext() {
setSlice(VIEW_SLICE, this._tab === ALL_TAB
? null
: [{ label: 'Open tab', value: this._tab }]);
}
async _loadAll() {
await this._fetchAgents();
await this._fetch(this._page);
@@ -158,6 +171,7 @@ export class SystemAgentsPage extends LightElement {
this._tab = id;
this._page = 1;
this._runMsg = null;
this._publishViewContext();
this._fetch(1);
}
+18
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { setSlice, clearSlice } from '../../lib/view-context.js';
import { RunningTasksSection } from './running.js';
import { CronJobsSection } from './cron.js';
import { ScheduledTasksSection } from './scheduled.js';
@@ -7,6 +8,9 @@ import { TaskHistorySection } from './history.js';
const SECTIONS = ['running', 'cron', 'scheduled', 'history'];
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@tasks';
export class TasksPage extends LightElement {
static properties = {
_open: { state: true },
@@ -29,9 +33,12 @@ export class TasksPage extends LightElement {
const sec = this._sectionFromHash();
this._section = sec;
this._loadSection(sec);
this._publishViewContext();
if (!location.hash.includes('/')) {
history.replaceState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
}
} else {
clearSlice(VIEW_SLICE);
}
});
window.addEventListener('tasks-section-change', (e) => {
@@ -39,9 +46,20 @@ export class TasksPage extends LightElement {
const sec = e.detail.section;
this._section = sec;
this._loadSection(sec);
this._publishViewContext();
});
}
disconnectedCallback() {
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
// The entity slice: which of the four sections is showing.
_publishViewContext() {
setSlice(VIEW_SLICE, [{ label: 'Open section', value: this._section }]);
}
_sectionFromHash() {
const parts = location.hash.slice(1).split('/');
if (parts[0] === 'tasks' && parts[1]) {
+17 -1
View File
@@ -1,10 +1,14 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
import { fetchToolDetail, renderToolBody, STATUS_ICON } from './shared/tool-detail-view.js';
const PAGE_ID = 'tool_detail';
/// The view-context slice this page owns (see `lib/view-context.js`).
const VIEW_SLICE = 'entity@tool_detail';
function idFromHash() {
const h = location.hash;
const prefix = `#${PAGE_ID}?id=`;
@@ -45,20 +49,32 @@ export class ToolDetailPage extends LightElement {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadFromHash();
else clearSlice(VIEW_SLICE);
});
window.addEventListener('hashchange', () => {
if (this._open) this._loadFromHash();
});
}
disconnectedCallback() {
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
async _loadFromHash() {
const id = idFromHash();
if (id == null) return;
if (id == null) { clearSlice(VIEW_SLICE); return; }
this._loading = true;
this._error = null;
this._tool = null;
try {
this._tool = await fetchToolDetail(id);
// The entity slice: which tool this call ran. Only published on success —
// the hash carries the call's id, which tells the model nothing.
setSlice(VIEW_SLICE, [{
label: 'Open tool call',
value: this._tool.display_name || this._tool.name,
}]);
} catch (e) {
this._error = e.message || String(e);
} finally {
+20
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { setSlice, clearSlice } from '../lib/view-context.js';
import { connectorIconUrl } from './shared/connector-common.js';
// Users admin — the list at `#users`, one user's page at `#users/{id}`.
@@ -29,6 +30,12 @@ function avatarColor(name) {
return `hsl(${h % 360}, 55%, 52%)`;
}
// The view-context slice this page owns (see `lib/view-context.js`). Qualified
// per page, like every `entity` contributor: these pages stay in the DOM while
// hidden, and a shared key would let a page that hides *after* another one
// shows wipe the fresh slice (listener order on `llm-page-change`).
const VIEW_SLICE = 'entity@users';
export class UsersPage extends LightElement {
static get properties() {
@@ -84,6 +91,15 @@ export class UsersPage extends LightElement {
this._connSaved = false;
this._plugSaved = false;
this._trgSaved = false;
clearSlice(VIEW_SLICE);
}
// The entity slice: which person's page is open — the username, and never a
// profile field (a detail page says *which* object, not what it contains).
_publishViewContext() {
const u = this._user;
setSlice(VIEW_SLICE,
this._view === 'user' && u ? [{ label: 'Open user', value: u.username }] : null);
}
connectedCallback() {
@@ -94,6 +110,7 @@ export class UsersPage extends LightElement {
this._open = e.detail.page === 'users';
this.style.display = this._open ? 'flex' : 'none';
if (this._open) { this._syncViewFromHash(); this._load(); }
else clearSlice(VIEW_SLICE);
});
window.addEventListener('hashchange', () => {
if (this._open) this._syncViewFromHash();
@@ -102,6 +119,7 @@ export class UsersPage extends LightElement {
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
clearSlice(VIEW_SLICE);
super.disconnectedCallback();
}
@@ -117,6 +135,7 @@ export class UsersPage extends LightElement {
this._users = await uRes.json();
this._roles = await rRes.json();
if (this._view === 'user') this._enterDetail();
this._publishViewContext();
} catch (e) {
this._error = e.message;
}
@@ -137,6 +156,7 @@ export class UsersPage extends LightElement {
this._userId = id;
if (this._users) this._enterDetail();
}
this._publishViewContext();
}
get _user() { return (this._users ?? []).find(u => u.id === this._userId) ?? null; }