Files
Skald-Circle/web/lib/view-context.js
Daniele 505f2e95c1
Nightly Build / build (push) Successful in 7m51s
feat(chat): view context — tell the assistant what you're looking at
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.
2026-08-23 20:53:30 +01:00

172 lines
6.8 KiB
JavaScript

/**
* 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 };
}