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