feat(chat): view context — tell the assistant what you're looking at
Nightly Build / build (push) Successful in 7m51s
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:
@@ -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;
|
||||
|
||||
@@ -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' : ''}">
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']])}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user