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
+3
View File
@@ -38,6 +38,9 @@ import { LoginPage } from './components/login-page.js';
// Register the global `openFile(path)` / `openToolDetail(id)` helpers.
import './lib/open-file.js';
import './lib/open-tool.js';
// The view-context store keeps its own `route` slice in step with navigation,
// so it has to be loaded from boot — not lazily by whoever reads it first.
import './lib/view-context.js';
import { initI18n } from './lib/i18n.js';
import { installSessionExpiryWatch } from './lib/session-expiry.js';
import { installSessionRelogin } from './components/session-relogin.js';
+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; }
+138
View File
@@ -780,6 +780,144 @@
.attach-chip-remove:hover { background: var(--sidebar-hover); color: #dc2626; }
/* ── View context: composer eye + sent-bubble chip ───────────────────────────
The pill belongs to the composer and would sit more naturally in
copilot-input.css — but that file is desktop-only, and the eye is the same
control on both shells. This stylesheet is the one both index.html and
mobile.html load, so both halves of the feature live here together. */
.view-ctx-wrap {
position: relative;
display: inline-flex;
}
.view-ctx-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
height: 2rem;
padding: 0 0.45rem;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--placeholder-color);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.view-ctx-btn:hover {
background: var(--sidebar-hover);
color: var(--text-primary, #1e293b);
}
.view-ctx-btn--on {
color: var(--accent);
}
.view-ctx-btn--on:hover {
background: rgba(var(--accent-rgb), 0.08);
border-color: rgba(var(--accent-rgb), 0.3);
color: var(--accent);
}
.view-ctx-count {
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.view-ctx-overlay {
position: fixed;
inset: 0;
z-index: 99;
}
.view-ctx-panel {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 100;
width: max-content;
max-width: min(28rem, 80vw);
max-height: 18rem;
overflow-y: auto;
padding: 0.5rem 0.65rem;
border: 1px solid var(--toolbar-border);
border-radius: 0.5rem;
background: var(--msg-assistant-bg);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
font-size: 0.75rem;
/* The composer bubble uses pre-wrap; the panel formats its own values. */
white-space: normal;
}
.view-ctx-panel-title {
font-weight: 600;
color: var(--text-primary, #1e293b);
margin-bottom: 0.35rem;
}
.view-ctx-empty {
color: var(--placeholder-color);
line-height: 1.35;
}
.view-ctx-items {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.view-ctx-item {
min-width: 0;
}
.view-ctx-label {
color: var(--placeholder-color);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* A selection arrives as it was selected: keep its line breaks, but never let a
long unbroken path or word push the panel (or the bubble) wider. */
.view-ctx-value {
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.35;
}
/* The chip inside a sent user bubble: collapsed by default, same muted weight as
the reasoning block — it is evidence, not content. */
.view-ctx-chip {
margin-top: 0.4rem;
font-size: 0.75rem;
white-space: normal;
}
.view-ctx-chip > summary {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.45rem;
border: 1px solid var(--toolbar-border);
border-radius: 0.5rem;
color: var(--placeholder-color);
cursor: pointer;
list-style: none;
line-height: 1.2;
}
.view-ctx-chip > summary::-webkit-details-marker { display: none; }
.view-ctx-chip > summary:hover { border-color: var(--accent); color: var(--accent); }
.view-ctx-chip .view-ctx-items {
margin-top: 0.4rem;
padding-left: 0.15rem;
}
/* ── Tool card "view details" (eye) ────────────────────────────────────────── */
.copilot-tool-eye {
+10
View File
@@ -73,6 +73,16 @@ export default {
'chat.new_session': 'New conversation',
'chat.scroll_to_latest': 'Scroll to latest',
'chat.security_group': 'Security group',
// The eye in the composer. These are labels a person reads, so they are
// translated — unlike the view-context sentences themselves, which are written
// for the model and stay English (see web/lib/view-context-routes.js).
'chat.view_context.on': 'Sharing what you have open — click to stop',
'chat.view_context.off': 'Not sharing what you have open — click to share',
'chat.view_context.title': 'Sent with your next message',
'chat.view_context.off_title': 'Not shared',
'chat.view_context.off_hint': 'The assistant is not told which page, folder or file you have open. Turn the eye on to share it.',
'chat.view_context.empty': 'Nothing to share from this page yet.',
'chat.view_context.chip': 'What you had open ({n})',
'chat.collapse': 'Hide chat',
'chat.close_tab': 'Close tab',
'chat.new_tab': 'New chat',
+7
View File
@@ -73,6 +73,13 @@ export default {
'chat.new_session': 'Nouvelle conversation',
'chat.scroll_to_latest': 'Aller aux derniers messages',
'chat.security_group': 'Groupe de sécurité',
'chat.view_context.on': 'Vous partagez ce que vous avez ouvert — cliquez pour arrêter',
'chat.view_context.off': 'Vous ne partagez pas ce que vous avez ouvert — cliquez pour le partager',
'chat.view_context.title': 'Envoyé avec votre prochain message',
'chat.view_context.off_title': 'Non partagé',
'chat.view_context.off_hint': 'L\'assistant ne sait pas quelle page, quel dossier ou quel fichier vous avez ouvert. Activez l\'œil pour le lui dire.',
'chat.view_context.empty': 'Rien à partager depuis cette page pour l\'instant.',
'chat.view_context.chip': 'Ce que vous aviez ouvert ({n})',
'chat.collapse': 'Masquer la discussion',
'chat.close_tab': 'Fermer l\'onglet',
'chat.new_tab': 'Nouvelle discussion',
+7
View File
@@ -73,6 +73,13 @@ export default {
'chat.new_session': 'Nuova conversazione',
'chat.scroll_to_latest': 'Vai agli ultimi messaggi',
'chat.security_group': 'Gruppo di sicurezza',
'chat.view_context.on': 'Stai condividendo quello che hai aperto — clicca per smettere',
'chat.view_context.off': 'Non stai condividendo quello che hai aperto — clicca per condividerlo',
'chat.view_context.title': 'Inviato con il prossimo messaggio',
'chat.view_context.off_title': 'Non condiviso',
'chat.view_context.off_hint': 'L\'assistente non sa quale pagina, cartella o file hai aperto. Accendi l\'occhio per dirglielo.',
'chat.view_context.empty': 'Da questa pagina non c\'è ancora niente da condividere.',
'chat.view_context.chip': 'Cosa avevi aperto ({n})',
'chat.collapse': 'Nascondi la chat',
'chat.close_tab': 'Chiudi scheda',
'chat.new_tab': 'Nuova chat',
+69 -6
View File
@@ -3,6 +3,25 @@ import { LightElement } from './base.js';
import { InboxCardsMixin } from './inbox-cards.js';
import { t } from './i18n.js';
import { isSessionExpired, isNativeShell, notifySessionExpired, probeSession } from './session-expiry.js';
import { getViewContext, subscribe as subscribeViewContext } from './view-context.js';
// Whether what the user is looking at rides along with their messages. A UI
// preference about this person on this browser, so `localStorage` rather than a
// round-trip through `user_config` — and shared by both chat surfaces, so the
// eye means the same thing in the copilot and in the mobile chat.
//
// Default **on**: off by default is the same as not shipping the feature for
// everyone who never opens a settings panel. The eye in the composer is what
// makes that honest — always visible, always showing the literal pairs it is
// about to send (blueprint §3).
const VIEW_CONTEXT_PREF_KEY = 'view-context-enabled';
function readViewContextPref() {
// Anything but the explicit opt-out reads as on, so a corrupt or absent value
// fails towards the documented default rather than towards silence.
try { return localStorage.getItem(VIEW_CONTEXT_PREF_KEY) !== 'off'; }
catch { return true; }
}
// Slash commands handled entirely server-side: they reply with a `Done` and never
// echo back as a `user_message`, so they are the only commands rendered
@@ -68,6 +87,13 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
// Approvals and questions raised by those tasks: `{ approvals, clarifications }`,
// each item carrying the `job_id` / `job_title` that asked.
_taskInbox: { state: true },
// View context — what the user is looking at (see `lib/view-context.js`).
// `_viewContextEnabled` is the eye's on/off state; `_viewContext` is a live
// mirror of the store, so the eye's panel shows what would be sent *now*
// without anybody having to send a message; `_viewContextOpen` is that panel.
_viewContextEnabled: { state: true },
_viewContext: { state: true },
_viewContextOpen: { state: true },
};
// Live events whose arrival implies a turn is in flight (used to restore the
@@ -128,12 +154,21 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
this._taskTimer = null;
// Timers that drop a finished task from the strip after a grace period.
this._taskDropTimers = new Map();
this._viewContextEnabled = readViewContextPref();
this._viewContext = getViewContext();
this._viewContextOpen = false;
this._unsubViewContext = null;
this._onAuthRestored = this._onAuthRestored.bind(this);
}
async connectedCallback() {
super.connectedCallback();
window.addEventListener('auth-restored', this._onAuthRestored);
// Keep the eye's panel honest between messages: the store changes as the
// user navigates and selects, and the whole point of the control is that it
// answers "what would you send right now?".
this._viewContext = getViewContext();
this._unsubViewContext = subscribeViewContext((items) => { this._viewContext = items; });
// Fire-and-forget: availability of a transcription provider determines
// whether the mic button is rendered at all.
this._checkTranscribe();
@@ -146,6 +181,8 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
disconnectedCallback() {
super.disconnectedCallback?.();
window.removeEventListener('auth-restored', this._onAuthRestored);
this._unsubViewContext?.();
this._unsubViewContext = null;
this._stopTaskClock();
for (const timer of this._taskDropTimers.values()) clearTimeout(timer);
this._taskDropTimers.clear();
@@ -864,10 +901,14 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
// rendered optimistically (only slash commands are, and those are never
// echoed). `message_id` is the real chat_history row id.
this._push({
kind: 'user',
content: msg.content,
attachments: msg.attachments ?? [],
message_id: msg.message_id,
kind: 'user',
content: msg.content,
attachments: msg.attachments ?? [],
// Sanitized server-side and echoed to every client, so the chip in the
// bubble shows what actually went out — not what this browser meant to
// send — and matches what a reload rebuilds from the REST history.
view_context: msg.view_context ?? [],
message_id: msg.message_id,
});
break;
@@ -1034,6 +1075,12 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
({ name, path, mimetype, filesize }));
this._attachments = [];
// What the sender has on screen, read here because here is the last moment
// it is still true: the store is live, and by the time the echo comes back
// the user may have navigated away. The eye off means the field is *absent*
// from the payload, not an empty list — absent is what says "not shared".
const view_context = this._viewContextEnabled ? getViewContext() : [];
// Sending implies the reader wants to follow the conversation: always land at
// the latest, even if they had scrolled up to read before sending.
this._forceScrollToBottom();
@@ -1045,10 +1092,12 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
// event (for a custom command, carrying the typed form as its content), placing
// it correctly (e.g. after the current round's tools when injected mid-turn).
if (SYSTEM_SLASH_COMMANDS.has(content.split(/\s+/)[0])) {
this._push({ kind: 'user', content, attachments });
this._push({ kind: 'user', content, attachments, view_context });
}
this._waiting = true;
this._ws.send(JSON.stringify({ content, attachments }));
const payload = { content, attachments };
if (view_context.length) payload.view_context = view_context;
this._ws.send(JSON.stringify(payload));
}
// ── Attachments ────────────────────────────────────────────────────────────
@@ -1087,6 +1136,20 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
this._attachments = this._attachments.filter((_, idx) => idx !== i);
}
// ── View context ───────────────────────────────────────────────────────────
/**
* Flip whether what the user is looking at rides along with their messages.
* Global rather than per-conversation: it is a preference about the person,
* not about one chat.
*/
_toggleViewContext() {
this._viewContextEnabled = !this._viewContextEnabled;
try {
localStorage.setItem(VIEW_CONTEXT_PREF_KEY, this._viewContextEnabled ? 'on' : 'off');
} catch { /* a browser refusing storage still honours the toggle for this session */ }
}
/** Handler for a paste event: uploads any files on the clipboard. */
_onPaste(e) {
const files = e.clipboardData?.files;
+42
View File
@@ -0,0 +1,42 @@
/**
* Hash routing, shared.
*
* `pageFromHash()` turns `location.hash` into the page id the app navigates by
* (`llm-page-change`'s `detail.page`, the sidebar's `_activePage`, the value the
* view-context store describes). It lives here — and not in `sidebar.js`, where
* it grew — because there are now two readers of it: the menu highlight and the
* view context attached to a message. Two copies of this logic would drift, and
* the drift would be invisible: the assistant would be told the user is on one
* page while the menu highlights another.
*
* The mobile shell (`mobile-app.js`) routes a fixed set of sections of its own
* and does not go through this.
*/
// Every hash segment the app accepts as a page. Anything else falls back to
// `home`, so a hand-typed or stale URL lands on the chat rather than nowhere.
export const KNOWN_PAGES = [
'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',
];
export function 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 KNOWN_PAGES.includes(page) ? page : 'home';
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Route → one English sentence about the page the user is on.
*
* ── THESE STRINGS ARE NOT TRANSLATED, AND MUST NEVER BE ──────────────────────
* They are not interface copy: they are sent to the LLM as part of the message,
* inside the harness block, and every system prompt around them is in English.
* Routing them through `t()` would make an Italian user send "Pagina file" to a
* model reading an English prompt — worse than saying nothing. This is exactly
* the kind of thing someone "fixes" by mistake six months from now; it is not a
* missing translation, it is the design.
*
* The i18n rule for this feature splits on the reader: labels the *user* reads
* (the eye toggle, the chip in the bubble) are translated like any other UI
* string; the text the *model* reads lives here, in English.
*
* Each entry says what the page shows and what can be done on it — one or two
* lines. Where `docs/` has a page for the feature, the sentence ends with a
* pointer to it, so the assistant can read the real documentation instead of
* guessing; the pointed-at file must exist (a pointer to a missing page sends
* the agent into a dead end).
*/
// The label of the route slice. Constant, so the frontend and the eye tooltip
// cannot disagree on what to call it.
export const ROUTE_LABEL = 'Open page';
// route id → { title, what, doc? }
// `title` is the page's name as the user sees it in the menu; `what` is the
// sentence; `doc` is a path under the workspace's read-only `docs/` mount.
export const ROUTE_DESCRIPTIONS = {
home: {
title: 'Chat',
what: 'the assistant chat, which is also the app\'s home page — the conversation fills the page instead of sitting in a side panel.',
},
inbox: {
title: 'Inbox',
what: 'everything raised by background work and waiting for an answer: approval requests, questions from background agents, and sign-in prompts from connectors.',
doc: 'docs/tasks.md',
},
dashboard: {
title: 'Dashboard',
what: 'instance status, LLM usage charts, the pending inbox items and a short guide.',
},
tasks: {
title: 'Task Manager',
what: 'background work: what is running now, recurring (cron) jobs, one-off scheduled runs, and the history of past runs.',
doc: 'docs/tasks.md',
},
projects: {
title: 'Projects',
what: 'the projects this user is a member of — shared workspaces, each with its own folder, chat and member list; a project can be opened, created or shared from here.',
doc: 'docs/projects.md',
},
files: {
title: 'Files',
what: 'the file browser over everything this user can reach: their home, both memory stores, shared folders, projects, skills and docs.',
doc: 'docs/files.md',
},
models: {
title: 'Models',
what: 'the admin page for the models the instance uses — language, transcription, text-to-speech and image generation.',
},
providers: {
title: 'LLM providers',
what: 'the admin page for the LLM provider accounts and their endpoints and keys.',
},
approval: {
title: 'Security',
what: 'the admin page for security groups and approval rules — which tools an agent may use freely, which need a human to approve them, and which are denied.',
},
agents: {
title: 'Agents',
what: 'the agents installed on this instance: what each one is for, its model and its settings.',
doc: 'docs/agents.md',
},
users: {
title: 'Users',
what: 'the admin directory of the people on this instance; each person\'s page holds their profile and what they may use (connectors, plugins, security).',
doc: 'docs/access.md',
},
roles: {
title: 'Roles',
what: 'the admin page for roles — the permissions, default assistant and interface mode a group of people gets.',
},
'shared-folders': {
title: 'Shared folders',
what: 'the admin page for the folders shared across the instance, and who may read or write each one.',
doc: 'docs/shared-folders.md',
},
connectors: {
title: 'Connectors',
what: 'the connectors (MCP servers) available here, which ones this user has turned on, and — for an admin — adding or removing them.',
doc: 'docs/connectors.md',
},
connector: {
title: 'Connector detail',
what: 'one connector\'s own page: its configuration, its sign-in or pairing state, and a button to test it.',
doc: 'docs/connectors.md',
},
marketplace: {
title: 'Connector marketplace',
what: 'the catalogue of connectors that can be installed on this instance, with their versions and updates.',
doc: 'docs/connectors.md',
},
plugins: {
title: 'Plugins',
what: 'the admin status board of the installed plugins — one card each, with an enable switch, a health indicator and a link to its settings.',
doc: 'docs/access.md',
},
'plugin-detail': {
title: 'Plugin detail',
what: 'one plugin\'s admin page: its instance-wide settings and a read-only list of who currently has access to it.',
doc: 'docs/access.md',
},
profile: {
title: 'Profile',
what: 'this user\'s own account page: display name, avatar, interface language and password.',
},
config: {
title: 'Config',
what: 'the admin page for instance-wide settings, such as the default interface language, the compaction model and debug mode.',
doc: 'docs/settings.md',
},
'llm-requests': {
title: 'LLM requests',
what: 'the debug log of the requests sent to the LLM providers, with the payload of each one.',
},
session: {
title: 'Conversation detail',
what: 'the full record of one conversation, tool calls included.',
},
'system-agents': {
title: 'Background agents',
what: 'the agents that run on a schedule (event triage, the memory lints, the conversation review): what each does, its settings, and this user\'s own run history.',
doc: 'docs/system-agents.md',
},
file_viewer: {
title: 'File viewer',
what: 'one file from the user\'s workspace, opened for reading.',
doc: 'docs/files.md',
},
tool_detail: {
title: 'Tool call detail',
what: 'the full record of one tool call: its arguments, its result and how long it took.',
},
};
// A plugin-contributed page (`#plugin/<plugin_id>/<page_id>`). The route only
// carries ids — the page's own title is the plugin's to publish, and the
// contributor slice (T6) is what adds it.
function describePluginRoute(route) {
const m = route.match(/^plugin\/([^/?]+)\/([^/?]+)$/);
if (!m) return null;
return `Plugin page (#${route}) — a page contributed by the "${m[1]}" plugin (page "${m[2]}").`;
}
/**
* The sentence for a page id, or `null` for an unknown route.
*
* Shape: `Title (#route) — what it is. More in docs/x.md.` The hash is part of
* the sentence on purpose: it is the same string the user sees in the address
* bar, so a follow-up question about "this page" and the URL they might paste
* refer to the same thing.
*/
export function describeRoute(page) {
if (!page) return null;
if (page.startsWith('plugin/')) return describePluginRoute(page);
const entry = ROUTE_DESCRIPTIONS[page];
if (!entry) return null;
const where = page === 'home' ? 'the home page' : `#${page}`;
const doc = entry.doc ? ` More in ${entry.doc}.` : '';
return `${entry.title} (${where}) — ${entry.what}${doc}`;
}
/** The `route` slice: zero or one item, ready for `setSlice('route', …)`. */
export function routeSliceFor(page) {
const value = describeRoute(page);
return value ? [{ label: ROUTE_LABEL, value }] : [];
}
// ── The mobile shell ─────────────────────────────────────────────────────────
// `mobile-app.js` routes a fixed set of sections of its own (`#chat`, `#inbox`,
// …) and never goes through `pageFromHash`, so the desktop table above cannot
// describe them. The shell claims the route slice (`claimRouteProvider` in
// `view-context.js`) and renders it from here — same sentence shape, same rule
// as the rest of this file: English, and never through t().
const MOBILE_SECTIONS = {
chat: { title: 'Chat', what: 'the assistant chat' },
inbox: { title: 'Inbox', what: 'everything raised by background work and waiting for an answer: approval requests, questions from background agents, and sign-in prompts from connectors' },
projects: { title: 'Projects', what: 'the projects this user is a member of; opening one opens its chat' },
notifications:{ title: 'Notifications',what: 'a placeholder section — there is nothing here yet' },
settings: { title: 'Settings', what: 'this user\'s own account page: display name, interface language and password' },
file_viewer: { title: 'File viewer', what: 'one file from the user\'s workspace, opened for reading' },
tool_detail: { title: 'Tool call detail', what: 'the full record of one tool call: its arguments, its result and how long it took' },
};
/**
* The mobile route slice, for the shell's current section. `projectId` /
* `projectLabel` are set when the chat is bound to a project
* (`#chat/project-<id>`); the label arrives asynchronously, so a project chat
* may briefly read as the bare id.
*/
export function mobileRouteSliceFor({ section, projectId, projectLabel } = {}) {
const entry = MOBILE_SECTIONS[section];
if (!entry) return [];
let where = `#${section}`;
let extra = '';
if (section === 'chat' && projectId) {
where = `#chat/project-${projectId}`;
extra = `, bound to the chat of the project "${projectLabel ?? projectId}"`;
}
return [{ label: ROUTE_LABEL, value: `${entry.title} (${where}, mobile app) — ${entry.what}${extra}.` }];
}
+171
View File
@@ -0,0 +1,171 @@
/**
* View context — what the user is looking at, as an ordered list of
* `{label, value}` pairs in English, ready to ride along with the next message.
*
* ## Push, not pull
*
* The copilot is a *sibling* of the page, not its parent: in dock mode the two
* live side by side in the workspace, so the chat cannot walk the page's tree
* and ask what it holds. Pages therefore publish their slice here, and the chat
* reads the merged result at send time. The other half of the payoff is depth:
* a component nested inside a page (`<file-explorer>` inside the project board)
* contributes its own slice without anybody threading a handle down to it.
*
* ## Slices
*
* A slice is one contributor's contribution, replaced wholesale by its owner and
* removed when that owner goes away. Ordering is `SLICE_ORDER` — page, then the
* thing the page is about, then the folder, the file, the selection — because
* that reads as a sentence, and because a deterministic order is what keeps the
* rendered block stable across messages (the provider's prefix cache keys on it,
* and the backend's consecutive-dedupe compares bags structurally).
*
* ## What this store does not do
*
* It does not enforce the size caps. Those live in the backend
* (`core-api::message_meta`), which is the only side that cannot be bypassed by
* a modified client; duplicating them here would only mean two numbers to keep
* in step. A UI showing the pairs may of course shorten them for display.
*
* It also does not decide *whether* to send anything: the eye toggle in the
* composer does that (T4), and when it is off the field is simply absent from
* the message.
*/
import { pageFromHash } from './routes.js';
import { routeSliceFor } from './view-context-routes.js';
// Known slices, in rendering order. A key may be *qualified* — `entity@users` —
// so that two pages alive at once (they stay in the DOM while hidden) can never
// overwrite each other: the family before the `@` is what orders the slice, the
// qualifier only names its owner. A key whose family is unknown is not an error
// — it lands after these, alphabetically, so a contributor nobody planned for
// still gets a deterministic position instead of an accidental one.
export const SLICE_ORDER = ['route', 'entity', 'path', 'file', 'selection'];
function familyOf(key) {
const i = key.indexOf('@');
return i === -1 ? key : key.slice(0, i);
}
function rankOf(key) {
const i = SLICE_ORDER.indexOf(familyOf(key));
return i === -1 ? SLICE_ORDER.length : i;
}
/** @type {Map<string, {label: string, value: string}[]>} */
const slices = new Map();
const listeners = new Set();
// Drop anything that says nothing: an item with no label, or with an empty
// value, is noise in the prompt. Values keep their internal whitespace (a
// selection is meant to arrive as it was selected) but are dropped when blank.
function normalize(items) {
if (!Array.isArray(items)) return [];
const out = [];
for (const it of items) {
if (!it) continue;
const label = String(it.label ?? '').trim();
const value = String(it.value ?? '');
if (!label || !value.trim()) continue;
out.push({ label, value });
}
return out;
}
function sameItems(a, b) {
if (a.length !== b.length) return false;
return a.every((it, i) => it.label === b[i].label && it.value === b[i].value);
}
function notify() {
const snapshot = getViewContext();
for (const fn of listeners) {
try { fn(snapshot); } catch { /* a broken subscriber must not stop the others */ }
}
}
/**
* Publish (or replace) a contributor's slice.
*
* `items` is `[{label, value}]`; `null`, `undefined` or an empty list remove the
* slice. Setting a slice to what it already holds notifies nobody — this matters
* for the selection contributor, which fires on every `selectionchange`.
*/
export function setSlice(key, items) {
if (!key) return;
const next = items == null ? [] : normalize(items);
const prev = slices.get(key);
if (!next.length) {
if (!prev) return;
slices.delete(key);
} else {
if (prev && sameItems(prev, next)) return;
slices.set(key, next);
}
notify();
}
/** Remove a contributor's slice. Call it on `disconnectedCallback` and when the
* page hides — these pages stay in the DOM, and a stale slice is context that
* lies. */
export function clearSlice(key) {
setSlice(key, null);
}
/** The merged, ordered list — a fresh copy, safe for the caller to keep. */
export function getViewContext() {
return [...slices.keys()]
.sort((a, b) => rankOf(a) - rankOf(b) || (a < b ? -1 : a > b ? 1 : 0))
.flatMap((k) => slices.get(k).map((it) => ({ label: it.label, value: it.value })));
}
/** Subscribe to changes (the eye's live tooltip). Returns an unsubscribe fn. */
export function subscribe(fn) {
if (typeof fn !== 'function') return () => {};
listeners.add(fn);
return () => listeners.delete(fn);
}
// ── The `route` slice is the store's own ─────────────────────────────────────
// No page has to remember to declare itself: the store follows navigation and
// looks the route up in the table. That is what makes "every page has at least
// its own sentence" true, rather than true of the pages someone remembered.
//
// Both events are watched because both happen: `hashchange` covers typed URLs
// and browser back/forward, `llm-page-change` covers in-app navigation (every
// dispatcher pushes the new hash *before* firing, so reading the hash is right
// either way, and going through `pageFromHash()` keeps the route we describe
// identical to the one the sidebar highlights).
//
// The desktop reading is the default. The mobile shell routes a fixed set of
// sections of its own that `pageFromHash` does not know (`#chat`, `#inbox`…),
// so it *claims* the slice: `claimRouteProvider(fn)` installs `fn` as the
// source and re-syncs at once, and from then on every sync — hashchange
// included — asks the provider. `refreshRoute()` re-asks it on demand, for when
// what the provider renders from changed without a navigation (e.g. a project
// label that resolved asynchronously).
let routeProvider = null;
export function claimRouteProvider(fn) {
routeProvider = typeof fn === 'function' ? fn : null;
syncRoute();
}
export function refreshRoute() {
syncRoute();
}
function syncRoute() {
setSlice('route', routeProvider ? routeProvider() : routeSliceFor(pageFromHash()));
}
if (typeof window !== 'undefined') {
window.addEventListener('hashchange', syncRoute);
window.addEventListener('llm-page-change', syncRoute);
syncRoute();
// Debug handle: the feature is about transparency, so being able to ask the
// page what it would send — from the console, without a message — is part of
// it. Nothing in the app reads this.
window.viewContext = { getViewContext, setSlice, clearSlice, subscribe, SLICE_ORDER };
}