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
+98 -1
View File
@@ -508,6 +508,103 @@ export function renderAttachmentChips(host, attachments, { removable = false } =
</div>`;
}
/* ── View context: the eye in the composer, the chip in the bubble ───────────── */
// Whether this device has a pointer that can hover. A mouse gets the panel on
// hover and needs no click-away target; a touch screen has no hover, so there
// the pill's tap opens the panel and a full-screen overlay closes it — the same
// shape as the model dropdown. Read once: hover capability does not change
// under a running page in any way worth re-rendering for.
const CAN_HOVER = typeof window === 'undefined'
|| !window.matchMedia
|| window.matchMedia('(hover: hover)').matches;
/** The literal pairs, as they would appear (and as they were sent). */
function renderViewContextItems(items) {
return html`
<div class="view-ctx-items">
${items.map((it) => html`
<div class="view-ctx-item">
<div class="view-ctx-label">${it.label}</div>
<div class="view-ctx-value">${it.value}</div>
</div>
`)}
</div>`;
}
/**
* The eye: the composer's view-context control, shared by the desktop copilot
* and the mobile chat.
*
* Always rendered, on or off, empty store or not — it is a privacy control, so
* it has to be findable in the same place every time rather than appearing only
* once there is something to share. Hovering (or tapping) it shows the literal
* `label: value` pairs it would send: that is the verifiable half of §3, and it
* is also the only way to debug a contributor without sending a message.
*
* `host` supplies `_viewContextEnabled`, `_viewContext`, `_viewContextOpen` and
* `_toggleViewContext()` — all from `ChatSession`.
*/
export function renderViewContextPill(host) {
const on = !!host._viewContextEnabled;
const items = on ? (host._viewContext ?? []) : [];
const open = !!host._viewContextOpen;
const hover = CAN_HOVER
? { enter: () => { host._viewContextOpen = true; }, leave: () => { host._viewContextOpen = false; } }
: { enter: () => {}, leave: () => {} };
return html`
<div class="view-ctx-wrap"
@mouseenter=${hover.enter}
@mouseleave=${hover.leave}>
${open && !CAN_HOVER
? html`<div class="view-ctx-overlay" @click=${() => { host._viewContextOpen = false; }}></div>`
: nothing}
${open ? html`
<div class="view-ctx-panel">
<div class="view-ctx-panel-title">
${on ? t('chat.view_context.title') : t('chat.view_context.off_title')}
</div>
${!on
? html`<div class="view-ctx-empty">${t('chat.view_context.off_hint')}</div>`
: items.length
? renderViewContextItems(items)
: html`<div class="view-ctx-empty">${t('chat.view_context.empty')}</div>`}
</div>
` : nothing}
<button
class="view-ctx-btn ${on ? 'view-ctx-btn--on' : ''}"
type="button"
aria-pressed=${on ? 'true' : 'false'}
title=${on ? t('chat.view_context.on') : t('chat.view_context.off')}
@focus=${() => { host._viewContextOpen = true; }}
@blur=${() => { host._viewContextOpen = false; }}
@click=${() => { host._toggleViewContext(); host._viewContextOpen = true; }}
>
<i class="bi ${on ? 'bi-eye' : 'bi-eye-slash'}"></i>
${on && items.length ? html`<span class="view-ctx-count">${items.length}</span>` : nothing}
</button>
</div>`;
}
/**
* The proof, in the sent bubble: what this message actually carried. Rendered
* from the server's echo (and, after a reload, from the REST history), so it
* shows the sanitized pairs the model was given — never the browser's intent.
*/
function renderViewContextChip(host, msg) {
const items = msg.view_context;
if (!items?.length) return nothing;
return html`
<details class="view-ctx-chip">
<summary>
<i class="bi bi-eye"></i>
<span>${t('chat.view_context.chip', { n: items.length })}</span>
</summary>
${renderViewContextItems(items)}
</details>`;
}
/**
* Collapsible chain-of-thought block: small, muted, collapsed by default so it
* never weighs on the UI. A native <details> — Lit keeps the element stable
@@ -528,7 +625,7 @@ export function renderMsg(host, msg) {
try {
switch (msg.kind) {
case 'user':
return html`<div class="copilot-msg user ${msg.failed ? 'copilot-msg--failed' : ''}" style="white-space:pre-wrap">${msg.failed ? failedBadge() : nothing}${msg.content}${renderAttachmentChips(host, msg.attachments)}</div>`;
return html`<div class="copilot-msg user ${msg.failed ? 'copilot-msg--failed' : ''}" style="white-space:pre-wrap">${msg.failed ? failedBadge() : nothing}${msg.content}${renderAttachmentChips(host, msg.attachments)}${renderViewContextChip(host, msg)}</div>`;
case 'thinking':
return html`
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">