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
+175
View File
@@ -5,6 +5,7 @@ import { LightElement, renderMarkdown } from '../../lib/base.js';
import { codeLangForExt, highlightCode } from '../../lib/highlight.js';
import { fileWatcher } from '../../lib/file-watcher.js';
import { t } from '../../lib/i18n.js';
import { setSlice, clearSlice } from '../../lib/view-context.js';
import './pdf-view.js'; // registers <pdf-view>; pdf.js itself is imported lazily
/**
@@ -119,6 +120,38 @@ function rewriteMarkdownAssets(htmlStr, baseDir, rev) {
* source line right after) so the user can read it — or paste it straight into
* an agent. Falls back to the log tail when no error line is recognised.
*/
// ── View context (see `lib/view-context.js`) ─────────────────────────────────
// Doing this in the base means doing it once: desktop and mobile inherit the
// same behaviour, not just the same markup. One viewer is on screen per shell,
// so a single key per slice is right.
const VIEW_FILE_SLICE = 'file';
const VIEW_SELECTION_SLICE = 'selection';
// How the file is being *shown*, which is not the same as what it is: the model
// needs to know whether the user is looking at source (where a line number
// means something) or at a rendering of it — and, for an image or a PDF, that
// the person is seeing something the text of the message does not carry.
const VIEW_KINDS = {
image: 'an image',
pdf: 'a PDF',
svg: 'an SVG image',
html: 'a rendered HTML page',
latex: 'a compiled LaTeX document',
binary: 'a binary file, whose content is not displayed',
};
/**
* 1-based first and last line covered by `[start, end)` in `text`.
*
* The end is measured one character back so a selection stopping exactly at a
* line break claims the line it ends on, not the empty one after it.
*/
function lineRange(text, start, end) {
const first = text.slice(0, start).split('\n').length;
const last = text.slice(0, Math.max(start, end - 1)).split('\n').length;
return { first, last };
}
function formatLatexError(log) {
if (!log) return '';
const lines = log.split('\n');
@@ -186,10 +219,22 @@ export class FileViewerBase extends LightElement {
this._watchPath = null; // path currently being watched (async-verified)
this._watchUnsub = null; // unsubscribe function returned by fileWatcher
this._reloadTimer = null; // debounce timer for change-triggered reloads
this._selTimer = null; // debounce timer for selection capture
this._onSelectionChange = () => this._scheduleSelectionCapture();
}
connectedCallback() {
super.connectedCallback();
// `selectionchange` only exists on the document, so the listener is global
// and the filtering (is this selection inside *my* body?) is ours.
document.addEventListener('selectionchange', this._onSelectionChange);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('selectionchange', this._onSelectionChange);
if (this._selTimer) clearTimeout(this._selTimer);
this._clearViewContext();
this._teardownWatch();
if (this._reloadTimer) clearTimeout(this._reloadTimer);
this._revokeBlobUrl();
@@ -220,6 +265,128 @@ export class FileViewerBase extends LightElement {
_hide() {
this._reset();
this._teardownWatch();
// Both viewers stay in the DOM while hidden, so nothing else would take
// these down: a file the user closed is context that lies.
this._clearViewContext();
}
// ── View context ────────────────────────────────────────────────────────────
_clearViewContext() {
clearSlice(VIEW_FILE_SLICE);
clearSlice(VIEW_SELECTION_SLICE);
}
/**
* How this file is being shown, in one English noun phrase.
*
* The two view/source toggles are folded in because they change the answer:
* saying "rendered Markdown" while the user is in the editor would contradict
* the line numbers the selection slice is putting on the very same file.
*/
_viewDescription() {
if (this._kind === 'html') {
return this._htmlMode === 'source' ? 'HTML source' : VIEW_KINDS.html;
}
if (this._kind === 'text') {
const ext = extOf(this._path);
if (ext !== 'md' && ext !== 'markdown') return 'source text';
return this._mdMode === 'edit' && this._canWrite
? 'Markdown source, open in the editor'
: 'rendered Markdown';
}
return VIEW_KINDS[this._kind] ?? 'source text';
}
/**
* Announce the open file. Called on every non-silent load — so a new file, a
* reload and stepping into a past revision all refresh it — and never on the
* watcher's silent reload, where nothing the model would care about moved.
*/
_publishFile() {
if (!this._path) return;
setSlice(VIEW_FILE_SLICE, [{
label: 'Open file',
value: `${this._path} (shown as ${this._viewDescription()})`,
}]);
}
_scheduleSelectionCapture() {
if (!this._path) return;
if (this._selTimer) clearTimeout(this._selTimer);
this._selTimer = setTimeout(() => this._captureSelection(), 200);
}
/**
* Keep the last non-empty selection made inside this viewer.
*
* Reading it at send time instead would be too late: by then the focus has
* moved to the composer's textarea and the document selection is gone. Hence
* also the asymmetry — a selection that collapses (the user clicked
* somewhere) or one made outside the viewer leaves the slice standing, and
* only a change of file clears it.
*/
_captureSelection() {
this._selTimer = null;
if (!this._path) return;
const item = this._readSelection();
if (item) setSlice(VIEW_SELECTION_SLICE, [item]);
}
_readSelection() {
// The Markdown source editor first: a textarea owns its selection, and
// `window.getSelection()` says nothing about what is highlighted inside it.
const ta = this.querySelector('.fv-edit-textarea');
if (ta && document.activeElement === ta && ta.selectionStart !== ta.selectionEnd) {
const start = ta.selectionStart;
const end = ta.selectionEnd;
return this._selectionItem(ta.value.slice(start, end), lineRange(ta.value, start, end));
}
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
// Restricted to the file's body: text selected in the chat itself — or in
// this page's own header — is not something the user is pointing at.
const body = this.querySelector('.fv-body') ?? this;
if (!body.contains(range.commonAncestorContainer)) return null;
return this._selectionItem(sel.toString(), this._selectionLines(range));
}
/**
* Line numbers, but only where a source view exists.
*
* `pre.fv-code` is rendered for plain text, for an HTML file in source mode
* and for a LaTeX file whose compile failed — never for rendered Markdown, a
* PDF or an image, where a DOM selection has no obvious mapping back to the
* source. There the label carries the text alone, which is enough: the model
* can find it with a grep.
*/
_selectionLines(range) {
const pre = this.querySelector('pre.fv-code');
if (!pre || !pre.contains(range.commonAncestorContainer)) return null;
try {
const before = document.createRange();
before.selectNodeContents(pre);
before.setEnd(range.startContainer, range.startOffset);
const start = before.toString().length;
// Counted in the element's own text rather than in `_content`: syntax
// highlighting wraps the source in spans, and measuring both the offset
// and the lines against the same DOM is what keeps them in step.
return lineRange(pre.textContent ?? '', start, start + range.toString().length);
} catch {
return null; // a detached or reordered range: the text alone will do
}
}
_selectionItem(text, lines) {
if (!text || !text.trim()) return null;
let label = 'Selected text';
if (lines) {
label += lines.first === lines.last
? ` (line ${lines.first})`
: ` (lines ${lines.first}-${lines.last})`;
}
return { label, value: text };
}
/**
@@ -299,6 +466,11 @@ export class FileViewerBase extends LightElement {
this._loading = true;
this._versions = null; // no stale history button while the new file loads
this._loadVersions(path);
// Say what is open before the bytes land — a message sent while the fetch
// is in flight is still a message about this file — and drop the previous
// file's selection, which belongs to a document nobody is looking at now.
this._publishFile();
clearSlice(VIEW_SELECTION_SLICE);
} else {
// Silent reload (file changed externally): keep showing the old content
// until the new fetch lands; only update visible state on success.
@@ -496,6 +668,7 @@ export class FileViewerBase extends LightElement {
_toggleHtmlMode() {
this._htmlMode = this._htmlMode === 'preview' ? 'source' : 'preview';
this._publishFile();
}
// ── Markdown View | Edit ────────────────────────────────────────────────────
@@ -510,6 +683,7 @@ export class FileViewerBase extends LightElement {
if (!this._editDirty) this._editBuffer = this._content;
}
this._mdMode = mode;
this._publishFile();
}
_onEditInput(e) {
@@ -524,6 +698,7 @@ export class FileViewerBase extends LightElement {
this._editDirty = false;
this._conflict = false;
this._mdMode = 'view';
this._publishFile();
}
/**