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:
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { ChatSession } from '../../lib/chat-session.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
|
||||
import { renderMsg, renderAttachmentChips, renderViewContextPill } from '../copilot-render.js';
|
||||
import { renderTaskStrip } from './agent-tasks.js';
|
||||
|
||||
export class ChatPage extends ChatSession {
|
||||
@@ -223,6 +223,7 @@ export class ChatPage extends ChatSession {
|
||||
title=${t('chat.attach')}
|
||||
@click=${() => this.querySelector('.chat-page-file-input')?.click()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${renderViewContextPill(this)}
|
||||
${this._providers.length > 1 ? html`
|
||||
<select
|
||||
class="chat-page-model-pill"
|
||||
|
||||
@@ -2,6 +2,14 @@ import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { fileWatcher } from '../../lib/file-watcher.js';
|
||||
import { setSlice, clearSlice } from '../../lib/view-context.js';
|
||||
|
||||
/// The view-context slice this component owns (see `lib/view-context.js`).
|
||||
/// One explorer is on screen at a time — `#files` renders `nothing` when it is
|
||||
/// not the open page, and the project board is a page of its own — so a single
|
||||
/// key is right and two explorers cannot both claim it.
|
||||
const VIEW_SLICE = 'path';
|
||||
const VIEW_LABEL = 'Open folder';
|
||||
|
||||
/// A live file explorer over one subtree of the caller's namespace.
|
||||
///
|
||||
@@ -29,6 +37,18 @@ import { fileWatcher } from '../../lib/file-watcher.js';
|
||||
/// fires only for a click, never for a `rel` the host itself set — so echoing
|
||||
/// the event back as a property is a no-op, and a host that ignores the event
|
||||
/// entirely (`project-board.js`) still gets a working explorer.
|
||||
///
|
||||
/// **It also tells the assistant which folder is open.** The current directory
|
||||
/// is published as the `path` view-context slice, in agent-path vocabulary — the
|
||||
/// same string the fs-tools take — so "what is in this folder?" needs no
|
||||
/// explaining. That it works inside the project board as well as on `#files` is
|
||||
/// the whole reason the store is push-based: nobody threads a handle down here.
|
||||
/// The slice is cleared on `disconnectedCallback`, which is when both hosts drop
|
||||
/// the element (`#files` renders `nothing` while it is not the open page, and so
|
||||
/// does the projects page). The board hiding the explorer behind its *Sharing*
|
||||
/// tab leaves the slice standing, deliberately: the project's folder is still
|
||||
/// the folder the page is about, and which tab is showing is the board's own
|
||||
/// slice to publish.
|
||||
export class FileExplorer extends LightElement {
|
||||
static properties = {
|
||||
/// Agent path of the subtree to browse (`~`, `shared/x`, `projects/a/b`,
|
||||
@@ -80,6 +100,7 @@ export class FileExplorer extends LightElement {
|
||||
disconnectedCallback() {
|
||||
this._unwatch?.();
|
||||
clearTimeout(this._reloadTimer);
|
||||
clearSlice(VIEW_SLICE);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -87,11 +108,20 @@ export class FileExplorer extends LightElement {
|
||||
return this._rel ? `${this.root}/${this._rel}` : this.root;
|
||||
}
|
||||
|
||||
/// Say which folder is open, before the listing lands: the path is known the
|
||||
/// moment we navigate, and a message sent while the fetch is in flight is
|
||||
/// still a message about *this* folder.
|
||||
_publishViewContext() {
|
||||
if (!this.root) return;
|
||||
setSlice(VIEW_SLICE, [{ label: VIEW_LABEL, value: this._dirPath() }]);
|
||||
}
|
||||
|
||||
async _open(rel) {
|
||||
this._unwatch?.();
|
||||
this._unwatch = null;
|
||||
this._rel = rel;
|
||||
this._error = null;
|
||||
this._publishViewContext();
|
||||
await this._load();
|
||||
// Live updates for the open directory (best-effort: a dead watcher just
|
||||
// means manual refresh; auto-reconnect + re-subscribe are handled inside).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user