feat(chat): view context — tell the assistant what you're looking at
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:
Daniele
2026-08-23 20:53:30 +01:00
parent 488c702517
commit 505f2e95c1
42 changed files with 2096 additions and 122 deletions
+69 -6
View File
@@ -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;
+42
View File
@@ -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';
}
+213
View File
@@ -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}.` }];
}
+171
View File
@@ -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 };
}