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
+18
View File
@@ -1,5 +1,7 @@
import { LitElement, html, nothing } from 'lit';
import { t } from '../lib/i18n.js';
import { claimRouteProvider, refreshRoute } from '../lib/view-context.js';
import { mobileRouteSliceFor } from '../lib/view-context-routes.js';
import { LoginPage } from './login-page.js';
import { installSessionExpiryWatch } from '../lib/session-expiry.js';
import { installSessionRelogin } from './session-relogin.js';
@@ -65,6 +67,14 @@ class MobileApp extends LitElement {
this._onHashChange = () => this._applyHash();
window.addEventListener('hashchange', this._onHashChange);
window.addEventListener('popstate', this._onHashChange);
// This shell's sections are not desktop pages, so `pageFromHash` cannot
// describe them: the route slice is rendered from here instead. The
// provider re-reads the hash at every sync, which is what makes it immune
// to listener ordering between this element and the store.
claimRouteProvider(() => {
const { section, projectId } = this._readHash();
return mobileRouteSliceFor({ section, projectId, projectLabel: this._chatLabel || null });
});
// Default route when no hash is present (replaceState: no history entry).
if (!location.hash) history.replaceState(null, '', '#chat');
this._applyHash();
@@ -74,6 +84,7 @@ class MobileApp extends LitElement {
super.disconnectedCallback();
window.removeEventListener('hashchange', this._onHashChange);
window.removeEventListener('popstate', this._onHashChange);
claimRouteProvider(null);
}
// ── Hash routing ───────────────────────────────────────────────────────────
@@ -133,6 +144,11 @@ class MobileApp extends LitElement {
projectId ? 'project-' + projectId : null,
section === 'file_viewer' ? filePath : null,
);
// Re-publish the route slice with the state just applied: the store's own
// `hashchange` listener may have run before this one, and the provider only
// reads current state, so a refresh here is what keeps the two independent
// of listener order.
refreshRoute();
}
// Resolve the display label for a project id (shown in the chat header). Cached
@@ -141,6 +157,7 @@ class MobileApp extends LitElement {
async _resolveLabel(projectId) {
if (this._projectLabels[projectId] != null) {
this._chatLabel = this._projectLabels[projectId];
refreshRoute();
return;
}
try {
@@ -149,6 +166,7 @@ class MobileApp extends LitElement {
for (const p of await res.json()) this._projectLabels[p.id] = p.name;
} catch { /* keep whatever label we have */ }
this._chatLabel = this._projectLabels[projectId] ?? projectId;
refreshRoute();
}
_nav(section) {