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;
|
||||
|
||||
Reference in New Issue
Block a user