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;