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:
+69
-6
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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}.` }];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user