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.
172 lines
6.8 KiB
JavaScript
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 };
|
|
}
|