From 0bf52d0ecf944c5a251318309c004b10f328b6ef Mon Sep 17 00:00:00 2001 From: JeremiahM37 Date: Fri, 2 Oct 2026 21:31:47 -0600 Subject: [PATCH 1/5] List every page in the desktop sidebar instead of hiding most under More --- e2e/session_sheet.py | 5 +- e2e/test_desktop_navigation.py | 15 ++-- e2e/test_light_mode_sweep.py | 6 +- e2e/test_relay.py | 5 +- e2e/test_ui.py | 5 +- frontend/src/App.tsx | 68 +------------------ frontend/src/shell/routes.test.ts | 16 +---- frontend/src/shell/routes.ts | 26 ++++--- frontend/src/shell/simple.css | 19 +----- web/index.html | 4 +- ...iews-DJzsTvtC.js => PaneViews-BD_gva6s.js} | 2 +- web/static/react/assets/app-BI8ndAEV.js | 53 +++++++++++++++ .../{app-DPf_pxSE.css => app-BfgWj0UB.css} | 2 +- web/static/react/assets/app-CI51nX0E.js | 53 --------------- web/static/sw.js | 2 +- 15 files changed, 84 insertions(+), 197 deletions(-) rename web/static/react/assets/{PaneViews-DJzsTvtC.js => PaneViews-BD_gva6s.js} (97%) create mode 100644 web/static/react/assets/app-BI8ndAEV.js rename web/static/react/assets/{app-DPf_pxSE.css => app-BfgWj0UB.css} (88%) delete mode 100644 web/static/react/assets/app-CI51nX0E.js diff --git a/e2e/session_sheet.py b/e2e/session_sheet.py index e4e74288..b302cc3f 100644 --- a/e2e/session_sheet.py +++ b/e2e/session_sheet.py @@ -30,12 +30,9 @@ def nav_to(page, name): if tab.count() and tab.is_visible(): tab.click() return - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() diff --git a/e2e/test_desktop_navigation.py b/e2e/test_desktop_navigation.py index 07d1b485..dcb48103 100644 --- a/e2e/test_desktop_navigation.py +++ b/e2e/test_desktop_navigation.py @@ -1,5 +1,5 @@ -"""The desktop sidebar is the phone's three items plus an in-place More -group; neither it nor the compact More can overlay the terminal.""" +"""The desktop sidebar lists every page, with no More; neither it nor the +compact More can overlay the terminal.""" from playwright.sync_api import expect from test_terminal_workspace import real_terminal from test_terminal_tabs import attach, frame, ready @@ -10,15 +10,9 @@ def test_desktop_sidebar_stays_outside_a_connected_terminal(page, real_terminal) page.set_viewport_size({"width": 1440, "height": 900}) page.goto(t["url"] + "/#sessions") expect(page.locator("#nav-overflow")).to_have_count(0) - expect(page.locator("#tabbar > .tab")).to_have_count(3) - for destination in ["sessions", "approvals", "settings"]: + expect(page.locator("#nav-more-group")).to_have_count(0) + for destination in ["sessions", "approvals", "tasks", "terminals", "overview", "issues", "media", "evals", "settings"]: expect(page.locator(f'#tabbar > .tab[data-nav-target="{destination}"]')).to_be_visible() - # Everything else is one click away, in place in the sidebar. - group = page.locator("#nav-more-group") - expect(group.locator('[data-nav-target="overview"]')).to_be_hidden() - group.locator("summary").click() - for destination in ["tasks", "terminals", "overview", "issues", "media", "evals"]: - expect(group.locator(f'[data-nav-target="{destination}"]')).to_be_visible() # Machines and Plugins are Settings tabs; the sidebar does not repeat them. for duplicate in ["machines", "plugins"]: expect(page.locator(f'#tabbar [data-nav-target="{duplicate}"]')).to_have_count(0) @@ -43,7 +37,6 @@ def test_open_mobile_more_is_removed_when_resizing_to_desktop(page, server): expect(page.locator('#nav-overflow [data-nav-target="tasks"]')).to_be_visible() page.set_viewport_size({"width": 1440, "height": 900}) expect(page.locator("#nav-overflow")).to_have_count(0) - page.locator("#nav-more-group > summary").click() page.locator('.tab[data-tab="tasks"]').click() expect(page.locator("#board")).to_be_visible() page.set_viewport_size({"width": 390, "height": 844}) diff --git a/e2e/test_light_mode_sweep.py b/e2e/test_light_mode_sweep.py index 0f4be010..8a897a80 100644 --- a/e2e/test_light_mode_sweep.py +++ b/e2e/test_light_mode_sweep.py @@ -57,12 +57,9 @@ def nav(page, name): if button.is_visible(): button.click() else: - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() if legacy == "targets": # The old Settings page opened on Machines. @@ -158,7 +155,6 @@ def test_light_mode_board_settings_and_dialogs(page, server, theme): sweep.check("more-menu") page.locator("#nav-overflow > summary").click() else: - page.locator("#nav-more-group > summary").click() sweep.check("desktop-navigation") page.goto(server + "/#evals") page.wait_for_timeout(600) diff --git a/e2e/test_relay.py b/e2e/test_relay.py index 17abb617..0047fef5 100644 --- a/e2e/test_relay.py +++ b/e2e/test_relay.py @@ -93,12 +93,9 @@ def _tab(page, name): page.get_by_role("button", name="Show navigation", exact=True).click() button = page.locator(f'.tab[data-tab="{name}"]') if not button.is_visible(): - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() else: button.click() diff --git a/e2e/test_ui.py b/e2e/test_ui.py index b2c76d45..b1953ead 100644 --- a/e2e/test_ui.py +++ b/e2e/test_ui.py @@ -16,12 +16,9 @@ def _tab(page, name): page.get_by_role('button',name='Show navigation',exact=True).click() button=page.locator(f'.tab[data-tab="{name}"]') if not button.is_visible(): - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() else: button.click() if legacy == "targets": diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5917c344..88ea57c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -146,7 +146,6 @@ const NAV_SHORTCUT: Record = { evals: "nav.evals", settings: "nav.targets", }; -const NAV_MORE_KEY = "lectern.nav.moreOpen"; // #media/ narrows the feed to one session's posts. const mediaSessionOf = (hash: string) => { const match = /^#?media\/([1-9]\d*)$/.exec(hash); @@ -161,15 +160,6 @@ export default function App() { update(); return () => media.removeEventListener("change", update); }, []); - // The desktop sidebar's More group starts collapsed and remembers being - // opened, on this device only. - const [moreOpen, setMoreOpen] = useState(() => { - try { return localStorage.getItem(NAV_MORE_KEY) === "1"; } catch { return false; } - }); - const rememberMoreOpen = useCallback((open: boolean) => { - setMoreOpen(open); - try { localStorage.setItem(NAV_MORE_KEY, open ? "1" : "0"); } catch { /* private window */ } - }, []); useLocale(); const [view, setView] = useState(HOME), [showEvals, setShowEvals] = useState(false), @@ -1260,7 +1250,7 @@ export default function App() { {t("board.newTask")} +
{toasts.map((toast) => (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5c7b8d2f..1b2b8907 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -19,6 +19,23 @@ export interface ClientOptions { run(path: string, load: () => Promise): Promise; clear(): void; }; + /** Skip request interceptors (a mod's own $.api calls). */ + bypassInterceptors?: boolean; +} + +/** + * Sees every request a client sends and may change or refuse it before it + * leaves the page. Mods use it to hook messages sent to a session and + * approval decisions in one place, whichever screen made them. + */ +export type RequestInterceptor = ( + path: string, + options: RequestOptions, + send: (path: string, options: RequestOptions) => Promise, +) => Promise; +let interceptor: RequestInterceptor | undefined; +export function setRequestInterceptor(next: RequestInterceptor | undefined) { + interceptor = next; } export interface RequestOptions extends Omit { @@ -39,6 +56,11 @@ export function createClient(options: ClientOptions = {}) { const getToken = options.token ?? authToken; const offline = options.offline; async function api(path: string, optionsIn: RequestOptions = {}): Promise { + if (interceptor && !options.bypassInterceptors) + return interceptor(path, optionsIn, (p, o) => direct(p, o)) as Promise; + return direct(path, optionsIn); + } + async function direct(path: string, optionsIn: RequestOptions): Promise { if (offline?.cacheable(path, optionsIn.method) && optionsIn.body === undefined) return offline.run(path, () => send(path, optionsIn)); return send(path, optionsIn); diff --git a/frontend/src/i18n/en/plugins.ts b/frontend/src/i18n/en/plugins.ts index df5a7d67..4986f3be 100644 --- a/frontend/src/i18n/en/plugins.ts +++ b/frontend/src/i18n/en/plugins.ts @@ -80,6 +80,14 @@ const catalog: Record = { "plugins.hook.host": "on the server", "plugins.hook.target": "on the machine", "plugins.hookRuns": "Recent hook runs", + "plugins.kind.mods": "Mods", + "plugins.cap.mods": "Runs its own code in your browser or terminal console", + "plugins.cap.api": "Can use Lectern's API as you", + "plugins.modsHere": "Mods in this browser", + "plugins.mod.loading": "starting", + "plugins.mod.running": "running", + "plugins.mod.paused": "paused", + "plugins.mod.failed": "failed", "plugins.hookRun.ok": "ok", "plugins.hookRun.failed": "failed", "plugins.scope": "Where it applies", diff --git a/frontend/src/i18n/es/plugins.ts b/frontend/src/i18n/es/plugins.ts index a041f1a1..50fe83a3 100644 --- a/frontend/src/i18n/es/plugins.ts +++ b/frontend/src/i18n/es/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "en el servidor", "plugins.hook.target": "en la máquina", "plugins.hookRuns": "Ejecuciones recientes de hooks", + "plugins.kind.mods": "Mods", + "plugins.cap.mods": "Ejecuta su propio código en tu navegador o en la consola de terminal", + "plugins.cap.api": "Puede usar la API de Lectern en tu nombre", + "plugins.modsHere": "Mods en este navegador", + "plugins.mod.loading": "iniciando", + "plugins.mod.running": "en ejecución", + "plugins.mod.paused": "en pausa", + "plugins.mod.failed": "falló", "plugins.hookRun.ok": "ok", "plugins.hookRun.failed": "falló", "plugins.scope": "Dónde se aplica", diff --git a/frontend/src/i18n/fr/plugins.ts b/frontend/src/i18n/fr/plugins.ts index 490415a9..0834312a 100644 --- a/frontend/src/i18n/fr/plugins.ts +++ b/frontend/src/i18n/fr/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "sur le serveur", "plugins.hook.target": "sur la machine", "plugins.hookRuns": "Exécutions récentes des hooks", + "plugins.kind.mods": "Mods", + "plugins.cap.mods": "Exécute son propre code dans votre navigateur ou la console du terminal", + "plugins.cap.api": "Peut utiliser l'API de Lectern en votre nom", + "plugins.modsHere": "Mods dans ce navigateur", + "plugins.mod.loading": "démarrage", + "plugins.mod.running": "actif", + "plugins.mod.paused": "en pause", + "plugins.mod.failed": "échec", "plugins.hookRun.ok": "ok", "plugins.hookRun.failed": "échec", "plugins.scope": "Où il s’applique", diff --git a/frontend/src/i18n/ja/plugins.ts b/frontend/src/i18n/ja/plugins.ts index 77586916..73d46269 100644 --- a/frontend/src/i18n/ja/plugins.ts +++ b/frontend/src/i18n/ja/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "サーバー上", "plugins.hook.target": "マシン上", "plugins.hookRuns": "最近のフック実行", + "plugins.kind.mods": "Mod", + "plugins.cap.mods": "ブラウザーまたはターミナルコンソールで独自のコードを実行する", + "plugins.cap.api": "あなたとして Lectern の API を使える", + "plugins.modsHere": "このブラウザーの Mod", + "plugins.mod.loading": "起動中", + "plugins.mod.running": "実行中", + "plugins.mod.paused": "一時停止", + "plugins.mod.failed": "失敗", "plugins.hookRun.ok": "成功", "plugins.hookRun.failed": "失敗", "plugins.scope": "適用範囲", diff --git a/frontend/src/i18n/ko/plugins.ts b/frontend/src/i18n/ko/plugins.ts index 8af05700..b9d5f22a 100644 --- a/frontend/src/i18n/ko/plugins.ts +++ b/frontend/src/i18n/ko/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "서버에서", "plugins.hook.target": "머신에서", "plugins.hookRuns": "최근 훅 실행", + "plugins.kind.mods": "모드", + "plugins.cap.mods": "브라우저나 터미널 콘솔에서 자체 코드를 실행함", + "plugins.cap.api": "사용자로서 Lectern API를 사용할 수 있음", + "plugins.modsHere": "이 브라우저의 모드", + "plugins.mod.loading": "시작 중", + "plugins.mod.running": "실행 중", + "plugins.mod.paused": "일시 중지됨", + "plugins.mod.failed": "실패", "plugins.hookRun.ok": "성공", "plugins.hookRun.failed": "실패", "plugins.scope": "적용 범위", diff --git a/frontend/src/i18n/zh/plugins.ts b/frontend/src/i18n/zh/plugins.ts index 8946138b..eae47719 100644 --- a/frontend/src/i18n/zh/plugins.ts +++ b/frontend/src/i18n/zh/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "在服务器上", "plugins.hook.target": "在机器上", "plugins.hookRuns": "最近的钩子运行", + "plugins.kind.mods": "模组", + "plugins.cap.mods": "在你的浏览器或终端控制台中运行自己的代码", + "plugins.cap.api": "可以以你的身份使用 Lectern 的 API", + "plugins.modsHere": "此浏览器中的模组", + "plugins.mod.loading": "启动中", + "plugins.mod.running": "运行中", + "plugins.mod.paused": "已暂停", + "plugins.mod.failed": "失败", "plugins.hookRun.ok": "成功", "plugins.hookRun.failed": "失败", "plugins.scope": "适用范围", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d7bfca8f..7ffbb8ad 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -26,6 +26,7 @@ import "./remote/remote.css"; import "./pairing/pair.css"; import "./review/review.css"; import "./shell/simple.css"; +import "./mods/mods.css"; // Last, so a phone's density overrides every view's desktop sizing. import "./shell/mobile.css"; import "./theme/theme.css"; diff --git a/frontend/src/mods/host.ts b/frontend/src/mods/host.ts new file mode 100644 index 00000000..38793a85 --- /dev/null +++ b/frontend/src/mods/host.ts @@ -0,0 +1,409 @@ +// The web side of mods (docs/mods.md): loads each enabled mod into its own +// sandboxed iframe, runs the handler chains, and does what a mod asks of `$`. +// Mods never touch the page; everything they draw is data that React renders. + +import { ApiError, createClient, setRequestInterceptor, type RequestOptions } from "../api/client"; +import { sandboxDocument } from "./sandbox"; + +export type ModInfo = { plugin: string; id: string; name: string; api?: string; hash: string; script?: string; error?: string }; +export type ModElement = { type: string; props: Record }; +export type RenderResult = { hidden: boolean; append: ModElement[] }; +type Matcher = { eq: string } | { re: string; flags: string } | null; +type Handler = { hid: number; event: string; matcher: Matcher }; +type FnRef = { __fn: number; __mod?: string }; + +export type ModStatus = { + key: string; + plugin: string; + id: string; + name: string; + state: "loading" | "running" | "paused" | "failed"; + error?: string; + log: string[]; +}; + +const HANDLER_MS = 2000; +const RENDER_MS = 100; +const FAILS = 5; +const STATE_BYTES = 256 * 1024; +export const MATCHED_EVENT: Record) => string> = { + "ui.render": (e) => String(e.component ?? ""), + "command.run": (e) => String(e.id ?? ""), + "server.event": (e) => String(e.type ?? ""), +}; + +// Paths a mod may never reach, whatever its api capability: installing or +// trusting plugins, deciding approvals, sign-in, pairing and secrets. +const FORBIDDEN = [ + /^\/api\/(plugins|plugin-sources|auth|pairing|devices|secrets|tokens?|whoami)\b/, + /^\/api\/approvals\/[^/]+\/decision\b/, + /secret|token|password|credential/i, +]; +export function apiAllowed(api: string | undefined, method: string, path: string): string | null { + if (!api) return "this mod has no api capability"; + if (!path.startsWith("/api/") || path.includes("..")) return "paths start with /api/"; + if (FORBIDDEN.some((re) => re.test(path.split("?")[0]!))) return `${path} is not available to mods`; + if (method !== "GET" && api !== "write") return "this mod may only read (api: read)"; + return null; +} + +class Mod { + frame?: HTMLIFrameElement; + port?: MessagePort; + handlers: Handler[] = []; + state: ModStatus["state"] = "loading"; + error?: string; + log: string[] = []; + fails: number[] = []; + status: string | null = null; + commands: { id: string; title: string }[] = []; + data: Record; + waits = new Map) => void>(); + /** Button references this mod has drawn; another mod cannot forge one. */ + drawn = new Set(); + constructor(readonly info: ModInfo) { + this.data = readState(this.key); + } + get key() { return `${this.info.plugin}/${this.info.id}`; } + note(line: string) { + this.log.push(new Date().toLocaleTimeString() + " " + line); + if (this.log.length > 50) this.log.shift(); + } +} + +const stateKey = (key: string) => "lec-mod-state:" + key; +function readState(key: string): Record { + try { return JSON.parse(localStorage.getItem(stateKey(key)) || "{}"); } catch { return {}; } +} + +let seq = 0; + +export class ModHost { + private mods: Mod[] = []; + private listeners = new Set<() => void>(); + private version = 0; + panes: { key: string; id: string; title: string }[] = []; + notify: (text: string, error?: boolean) => void = () => {}; + private request = createClient({ bypassInterceptors: true }); + + constructor(private readonly root: () => HTMLElement = () => document.body) {} + + subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }; + snapshot = () => this.version; + private changed() { this.version++; for (const fn of this.listeners) fn(); } + + statuses(): ModStatus[] { + return this.mods.map((m) => ({ key: m.key, plugin: m.info.plugin, id: m.info.id, name: m.info.name, state: m.state, error: m.error, log: [...m.log] })); + } + statusTexts(): { key: string; text: string }[] { + return this.mods.filter((m) => m.status && m.state === "running").map((m) => ({ key: m.key, text: m.status! })); + } + commands(): { mod: string; id: string; title: string; name: string }[] { + return this.mods.filter((m) => m.state === "running").flatMap((m) => m.commands.map((c) => ({ mod: m.key, id: c.id, title: c.title, name: m.info.name }))); + } + has(event: string) { return this.mods.some((m) => m.state === "running" && m.handlers.some((h) => h.event === event)); } + + /** Starts, keeps or replaces mods so the running set matches list. */ + load(list: ModInfo[]) { + const want = new Map(list.map((m) => [`${m.plugin}/${m.id}`, m])); + for (const m of this.mods) { + const next = want.get(m.key); + if (!next || next.hash !== m.info.hash || next.script !== m.info.script) this.stop(m); + } + const kept = new Map(this.mods.filter((m) => m.frame || m.state === "failed").map((m) => [m.key, m])); + this.mods = list.map((info) => { + const key = `${info.plugin}/${info.id}`; + const old = kept.get(key); + if (old && old.info.hash === info.hash && old.info.script === info.script) return old; + const mod = new Mod(info); + if (info.error || !info.script) { + mod.state = "failed"; + mod.error = info.error || "no code"; + } else this.start(mod); + return mod; + }); + this.panes = this.panes.filter((p) => this.mods.some((m) => m.key === p.key)); + this.changed(); + } + + async refresh() { + const list = await this.request("/plugins/mods?surface=web"); + this.load(list); + } + + private stop(m: Mod) { + m.port?.close(); + m.frame?.remove(); + m.frame = m.port = undefined; + m.state = "paused"; + } + + private start(m: Mod) { + const frame = document.createElement("iframe"); + frame.setAttribute("sandbox", "allow-scripts"); + frame.setAttribute("aria-hidden", "true"); + frame.dataset.mod = m.key; + frame.style.display = "none"; + frame.srcdoc = sandboxDocument(); + frame.addEventListener("load", () => { + const channel = new MessageChannel(); + m.port = channel.port1; + m.port.onmessage = (event) => void this.receive(m, event.data); + frame.contentWindow?.postMessage({ t: "init" }, "*", [channel.port2]); + m.port.postMessage({ t: "load", script: m.info.script, mod: { plugin: m.info.plugin, id: m.info.id }, state: m.data }); + }, { once: true }); + m.frame = frame; + this.root().appendChild(frame); + } + + private async receive(m: Mod, msg: Record) { + switch (msg.t) { + case "ready": + m.state = "running"; + this.changed(); + void this.dispatch("app.start", { surface: "web", version: document.documentElement.dataset.version ?? "" }, async () => null, [m]); + return; + case "failed": + m.state = "failed"; + m.error = msg.error; + m.note("failed to start: " + msg.error); + this.changed(); + return; + case "on": + m.handlers.push({ hid: msg.hid, event: msg.event, matcher: msg.matcher }); + return; + case "result": + m.waits.get(msg.rid)?.(msg); + return; + case "$": + return this.dollar(m, msg); + } + } + + private async dollar(m: Mod, msg: Record) { + const [a, b, c] = msg.args ?? []; + const reply = (value: unknown, error?: string) => msg.rid && m.port?.postMessage({ t: "reply", rid: msg.rid, value, error }); + switch (msg.op) { + case "toast": this.notify(`${m.info.name}: ${a}`); return; + case "status": m.status = a; this.changed(); return; + case "open": + if (!this.panes.some((p) => p.key === m.key && p.id === a.id)) this.panes = [...this.panes, { key: m.key, id: a.id, title: a.title }]; + this.changed(); + return; + case "close": this.panes = this.panes.filter((p) => !(p.key === m.key && p.id === a)); this.changed(); return; + case "render": this.changed(); return; + case "state": { + const next = { ...m.data, [a]: b }; + const text = JSON.stringify(next); + if (text.length > STATE_BYTES) { m.note(`state.set(${a}) refused: more than 256 KB`); return; } + m.data = next; + try { localStorage.setItem(stateKey(m.key), text); } catch { /* private window: kept for this page */ } + this.changed(); + return; + } + case "command": + m.commands = [...m.commands.filter((x) => x.id !== a.id), a]; + this.changed(); + return; + case "navigate": if (typeof a === "string" && a.startsWith("#")) location.hash = a; return; + case "log": m.note(String(a)); this.changed(); return; + case "api": { + const refused = apiAllowed(m.info.api, a, b); + if (refused) return reply(undefined, refused); + try { + reply(await this.request(b.replace(/^\/api/, ""), { method: a, body: c ?? undefined })); + } catch (e) { + reply(undefined, e instanceof Error ? e.message : String(e)); + } + return; + } + case "next": { + const cont = this.continuations.get(a); + if (!cont) return reply(undefined, "next() was called after this handler finished"); + try { reply(await cont(b)); } catch (e) { reply(undefined, e instanceof Error ? e.message : String(e)); } + return; + } + } + } + + private continuations = new Map Promise>(); + + private fail(m: Mod, what: string) { + m.note(what); + const now = Date.now(); + m.fails = [...m.fails.filter((t) => now - t < 60_000), now]; + if (m.fails.length >= FAILS && m.state === "running") { + m.state = "paused"; + m.error = `paused after ${FAILS} failures in a minute: ${what}`; + this.notify(`${m.info.name}: ${m.error}`, true); + } + this.changed(); + } + + private chain(event: string, e: Record, only?: Mod[]) { + const subject = MATCHED_EVENT[event]?.(e); + const out: { m: Mod; h: Handler }[] = []; + for (const m of only ?? this.mods) { + if (m.state !== "running") continue; + for (const h of m.handlers) { + if (h.event !== event) continue; + if (h.matcher && "eq" in h.matcher && h.matcher.eq !== subject) continue; + if (h.matcher && "re" in h.matcher) { + try { if (!new RegExp(h.matcher.re, h.matcher.flags).test(subject ?? "")) continue; } catch { continue; } + } + out.push({ m, h }); + } + } + return out; + } + + /** + * Runs event through every matching handler, in mod order, ending in def. + * A handler that throws or runs over its time is skipped: the chain goes + * on as if it had called next(e). If it had already called next, that + * call's result stands, so Lectern's own action never runs twice. + */ + async dispatch(event: string, e: Record, def: (e: Record) => Promise, only?: Mod[]): Promise { + const links = this.chain(event, e, only); + const run = async (i: number, ev: Record): Promise => { + if (i >= links.length) return def(ev); + const { m, h } = links[i]!; + const rid = ++seq; + let downstream: Promise | undefined; + this.continuations.set(rid, (e2) => { + if (downstream) return downstream; + downstream = run(i + 1, (e2 && typeof e2 === "object" ? e2 : ev) as Record); + return downstream; + }); + try { + const msg = await new Promise>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${event} handler took longer than ${HANDLER_MS / 1000}s`)), HANDLER_MS); + m.waits.set(rid, (reply) => { clearTimeout(timer); resolve(reply); }); + m.port?.postMessage({ t: "invoke", rid, hid: h.hid, e: ev }); + }).finally(() => m.waits.delete(rid)); + if (msg.error !== undefined) throw new Error(msg.error); + return tag(msg.value, m, this.mods); + } catch (err) { + this.fail(m, `${event}: ${err instanceof Error ? err.message : String(err)}`); + return downstream ?? run(i + 1, ev); + } finally { + // Late next() calls get downstream if it exists; otherwise refused. + if (!downstream) this.continuations.delete(rid); + else setTimeout(() => this.continuations.delete(rid), HANDLER_MS); + } + }; + return run(0, e) as Promise; + } + + /** What mods add to (or whether they hide) a component. */ + async render(component: string, props: Record, owner?: string): Promise { + const empty: RenderResult = { hidden: false, append: [] }; + if (!this.has("ui.render")) return empty; + // A pane is drawn by the mod that opened it. + const only = owner ? this.mods.filter((m) => m.key === owner) : undefined; + const e = { component, props, surface: "web", viewport: { width: innerWidth, height: innerHeight } }; + const result = await Promise.race([ + this.dispatch("ui.render", e, async () => ({ hidden: false, append: [] }), only), + new Promise((r) => setTimeout(() => r(null), RENDER_MS)), + ]); + if (!result || typeof result !== "object") return empty; + const r = result as Partial; + return { hidden: !!r.hidden, append: Array.isArray(r.append) ? r.append.filter(isElement) : [] }; + } + + closePane(key: string, id: string) { + this.panes = this.panes.filter((p) => !(p.key === key && p.id === id)); + this.changed(); + } + + /** A Button's onPress, run in the mod that drew it. */ + press(ref: unknown, ...args: unknown[]) { + const fn = ref as FnRef; + const m = this.mods.find((x) => x.key === fn?.__mod); + if (m?.state === "running" && m.drawn.has(fn.__fn)) m.port?.postMessage({ t: "fn", id: fn.__fn, args }); + } + + /** Runs a mod's palette command through the command.run chain. */ + runCommand(mod: string, id: string, args: Record = {}) { + const m = this.mods.find((x) => x.key === mod); + return this.dispatch("command.run", { id, mod, args }, async (e) => { + if (!m || m.state !== "running") return null; + const rid = ++seq; + const msg = await new Promise>((resolve) => { + m.waits.set(rid, resolve); + m.port?.postMessage({ t: "command", rid, id: e.id, args: e.args }); + }).finally(() => m.waits.delete(rid)); + if (msg.error !== undefined) this.fail(m, `command ${id}: ${msg.error}`); + return msg.value ?? null; + }); + } + + /** + * prompt.submit and approval.decide, hooked where every screen sends them: + * the API client. A deny becomes the request's error, which each screen + * already shows. + */ + intercept = async (path: string, options: RequestOptions, send: (p: string, o: RequestOptions) => Promise) => { + const body = options.body as Record | undefined; + const sendTo = /^\/sessions\/(\d+)\/send$/.exec(path); + if (sendTo && options.method === "POST" && body && typeof body.text === "string" && this.has("prompt.submit")) { + const result = await this.dispatch("prompt.submit", { session_id: Number(sendTo[1]), text: body.text }, async (e) => + send(path, { ...options, body: { ...body, text: String(e.text ?? "") } })); + return refuse(result); + } + const decide = /^\/approvals\/(\d+)\/decision$/.exec(path); + if (decide && options.method === "POST" && body && this.has("approval.decide")) { + const decision = body.decision === "denied" ? "deny" : "allow"; + const result = await this.dispatch("approval.decide", { approval: { id: Number(decide[1]) }, decision }, async () => send(path, options)); + return refuse(result); + } + return send(path, options); + }; + + serverEvent(type: string, data: unknown) { + if (this.has("server.event")) void this.dispatch("server.event", { type, data }, async () => null); + } + + install() { + setRequestInterceptor(this.intercept); + return () => setRequestInterceptor(undefined); + } +} + +function refuse(result: unknown) { + if (result && typeof result === "object" && "deny" in result) { + const reason = String((result as { deny: unknown }).deny || "A mod stopped this."); + throw new ApiError(409, reason, { detail: reason, mod_denied: true }); + } + return result; +} + +function isElement(v: unknown): v is ModElement { + return !!v && typeof v === "object" && typeof (v as ModElement).type === "string"; +} + +// Marks every function reference a mod returned with the mod it belongs to. +// A reference passed along from a mod further down the chain keeps its +// owner, but only one that owner really drew. +function tag(v: unknown, m: Mod, mods: Mod[], depth = 0): unknown { + if (depth > 40) return null; + if (Array.isArray(v)) return v.map((x) => tag(x, m, mods, depth + 1)); + if (v && typeof v === "object") { + const o = v as Record; + if (typeof o.__fn === "number") { + if (typeof o.__mod !== "string" || o.__mod === m.key) { + m.drawn.add(o.__fn); + if (m.drawn.size > 5000) m.drawn.delete(m.drawn.values().next().value as number); + return { __fn: o.__fn, __mod: m.key }; + } + const owner = mods.find((x) => x.key === o.__mod); + return owner?.drawn.has(o.__fn) ? { __fn: o.__fn, __mod: owner.key } : null; + } + const out: Record = {}; + for (const k of Object.keys(o)) out[k] = tag(o[k], m, mods, depth + 1); + return out; + } + return v; +} + +export const modHost = new ModHost(); diff --git a/frontend/src/mods/mods.css b/frontend/src/mods/mods.css new file mode 100644 index 00000000..7142d9d3 --- /dev/null +++ b/frontend/src/mods/mods.css @@ -0,0 +1,31 @@ +/* Mods (docs/mods.md): what mods draw, in the app's own tokens. */ +.mod-slot, .mod-box { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; } +.mod-col { flex-direction: column; align-items: flex-start; } +.mod-text { color: var(--ink); font-size: 12.5px; overflow-wrap: anywhere; } +.mod-bold { font-weight: 600; } +.mod-mono { font-family: var(--mono); } +.mod-badge { + display: inline-block; padding: 1px 7px; border: 1px solid var(--line-2); border-radius: 999px; + color: var(--ink-dim); font-size: 10.5px; font-weight: 600; line-height: 16px; white-space: nowrap; +} +.mod-dim { color: var(--ink-faint); } +.mod-accent { color: var(--accent-soft); border-color: color-mix(in srgb, var(--accent-soft) 45%, var(--bg)); } +.mod-warn { color: var(--amber); border-color: var(--amber); } +.mod-danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, var(--bg)); } +.mod-ok { color: var(--green); border-color: color-mix(in srgb, var(--green) 55%, var(--bg)); } +.mod-button { min-height: 28px; padding: 2px 10px; font-size: 12px; } +.mod-link { color: var(--accent-soft); font-size: 12.5px; } +.mod-status { display: inline-flex; align-items: center; gap: 10px; margin-right: 10px; min-width: 0; max-width: 40vw; overflow: hidden; } +.mod-panes { + position: fixed; right: 12px; bottom: calc(var(--tabbar-h, 0px) + 12px); z-index: 40; + display: flex; flex-direction: column; gap: 10px; width: min(380px, calc(100vw - 24px)); max-height: 70dvh; +} +.mod-pane { + display: flex; flex-direction: column; min-height: 0; border: 1px solid var(--line-2); border-radius: 12px; + background: var(--panel); box-shadow: var(--shadow); +} +.mod-pane > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); } +.mod-close { min-height: 28px; padding: 0 9px; } +.mod-pane-body { padding: 12px; overflow: auto; } +.mod-pane-body > .mod-slot { align-items: stretch; } +@media (min-width: 1024px) { .mod-panes { bottom: 16px; right: 20px; } } diff --git a/frontend/src/mods/react.tsx b/frontend/src/mods/react.tsx new file mode 100644 index 00000000..176bee45 --- /dev/null +++ b/frontend/src/mods/react.tsx @@ -0,0 +1,100 @@ +// React's side of mods: where their elements are drawn (docs/mods.md). +import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react"; +import { modHost, type ModElement, type RenderResult } from "./host"; +import { safeHref } from "../plugins/contributions"; + +export function useModsVersion() { + return useSyncExternalStore(modHost.subscribe, modHost.snapshot); +} + +/** + * What mods hide or add to one component. Drawn without them first; their + * part arrives a moment later, and again whenever a mod asks to redraw. + */ +export function useModRender(component: string, props: Record, owner?: string): RenderResult | null { + const version = useModsVersion(); + const [result, setResult] = useState(null); + const key = JSON.stringify(props); + useEffect(() => { + if (!modHost.has("ui.render")) { + setResult(null); + return; + } + let alive = true; + void modHost.render(component, JSON.parse(key), owner).then((r) => alive && setResult(r.hidden || r.append.length ? r : null)); + return () => { alive = false; }; + }, [component, key, owner, version]); + return result; +} + +const TONES = new Set(["dim", "accent", "warn", "danger", "ok"]); +const tone = (p: Record) => (typeof p.tone === "string" && TONES.has(p.tone) ? ` mod-${p.tone}` : ""); +const str = (v: unknown) => (v == null ? "" : String(v)); + +function ModElementView({ el }: { el: ModElement }): ReactNode { + const p = el.props ?? {}; + const children = Array.isArray(p.children) ? (p.children as ModElement[]) : []; + switch (el.type) { + case "Box": + return ( + + {children.map((c, i) => )} + + ); + case "Text": + return {str(p.text ?? p.children)}; + case "Badge": + return {str(p.text ?? p.children)}; + case "Button": + return ( + + ); + case "Link": { + const href = safeHref(str(p.href)); + if (!href) return {str(p.label)}; + return event.stopPropagation()}>{str(p.label ?? href)}; + } + } + return null; +} + +export function ModElements({ elements, className = "" }: { elements: ModElement[]; className?: string }) { + if (!elements.length) return null; + return {elements.map((el, i) => )}; +} + +/** The status component: each mod's $.ui.status text and its render. */ +export function ModStatus() { + useModsVersion(); + const rendered = useModRender("status", {}); + const texts = modHost.statusTexts(); + if (!texts.length && !rendered?.append.length) return null; + return ( + + {texts.map((s) => {s.text})} + {rendered && } + + ); +} + +function Pane({ pane }: { pane: { key: string; id: string; title: string } }) { + const rendered = useModRender("pane", { id: pane.id }, pane.key); + return ( +
+
+ {pane.title} + +
+
{rendered ? : null}
+
+ ); +} + +/** Panes mods opened with $.ui.open, docked on the right. */ +export function ModPanes() { + useModsVersion(); + if (!modHost.panes.length) return null; + return
{modHost.panes.map((p) => )}
; +} diff --git a/frontend/src/mods/sandbox.ts b/frontend/src/mods/sandbox.ts new file mode 100644 index 00000000..35c95907 --- /dev/null +++ b/frontend/src/mods/sandbox.ts @@ -0,0 +1,151 @@ +// The code that runs inside a mod's iframe (docs/mods.md). The iframe is +// sandboxed without allow-same-origin and carries its own CSP with no +// network, so a mod reaches Lectern only through the messages below. Keep +// everything inside sandboxMain: it is sent as source text, so nothing +// outside the function exists in the iframe. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function sandboxMain() { + type Msg = Record; + let port: MessagePort | undefined; + const handlers: Array<(...args: any[]) => any> = []; + const commands = new Map any>(); + const fns = new Map any>(); + const pending = new Map void; rej: (e: any) => void }>(); + let fnSeq = 0; + let callSeq = 0; + let state: Record = {}; + let mod = { plugin: "", id: "" }; + + const post = (msg: Msg) => port?.postMessage(msg); + const call = (op: string, args: unknown[]) => + new Promise((res, rej) => { + const rid = ++callSeq; + pending.set(rid, { res, rej }); + post({ t: "$", rid, op, args }); + }); + const fire = (op: string, args: unknown[]) => post({ t: "$", op, args }); + const clone = (v: unknown) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); + // Functions cannot cross postMessage: a Button's onPress becomes a + // reference the host calls back through. + const ser = (v: any, depth = 0): any => { + if (depth > 40) return null; + if (typeof v === "function") { + const id = ++fnSeq; + fns.set(id, v); + if (fns.size > 2000) fns.delete(fns.keys().next().value as number); + return { __fn: id }; + } + if (Array.isArray(v)) return v.map((x) => ser(x, depth + 1)); + if (v && typeof v === "object") { + if (v instanceof RegExp) return String(v); + const out: Record = {}; + for (const k of Object.keys(v)) out[k] = ser(v[k], depth + 1); + return out; + } + return v; + }; + const el = (type: string) => (props: Record = {}) => ({ type, props }); + const elements = { Box: el("Box"), Text: el("Text"), Badge: el("Badge"), Button: el("Button"), Link: el("Link") }; + const $ = { + surface: "web", + get mod() { return { ...mod }; }, + ui: { + resolve: () => elements, + toast: (text: unknown) => fire("toast", [String(text)]), + status: (text: unknown) => fire("status", [text == null ? null : String(text)]), + open: (o: { id: string; title?: string; focus?: boolean }) => fire("open", [{ id: String(o?.id), title: String(o?.title ?? o?.id) }]), + close: (id: string) => fire("close", [String(id)]), + render: () => fire("render", []), + }, + state: { + get: (key: string) => clone(state[key]), + set: (key: string, value: unknown) => { + state[key] = clone(value); + fire("state", [key, state[key] ?? null]); + }, + }, + command: { + register: (c: { id: string; title: string; run: (...a: any[]) => any }) => { + commands.set(String(c.id), c.run); + fire("command", [{ id: String(c.id), title: String(c.title ?? c.id) }]); + }, + }, + navigate: (hash: string) => fire("navigate", [String(hash)]), + api: { + get: (path: string) => call("api", ["GET", path]), + post: (path: string, body?: unknown) => call("api", ["POST", path, clone(body)]), + put: (path: string, body?: unknown) => call("api", ["PUT", path, clone(body)]), + delete: (path: string) => call("api", ["DELETE", path]), + }, + sleep: (ms: number) => new Promise((r) => setTimeout(r, Math.max(0, Number(ms) || 0))), + log: (...args: unknown[]) => fire("log", [args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")]), + }; + const on = (event: string, a: any, b?: any) => { + const fn = typeof b === "function" ? b : a; + const m = typeof b === "function" ? a : undefined; + if (typeof fn !== "function") throw new TypeError("on(event, [matcher], handler): handler must be a function"); + const hid = handlers.push(fn) - 1; + const matcher = m instanceof RegExp ? { re: m.source, flags: m.flags } : m == null ? null : { eq: String(m) }; + post({ t: "on", hid, event: String(event), matcher }); + }; + const errorText = (e: any) => (e && e.stack ? String(e.stack).split("\n").slice(0, 3).join("\n") : String(e)); + + async function receive(msg: Msg) { + switch (msg.t) { + case "load": { + mod = msg.mod; state = msg.state || {}; + try { + const register = new Function(msg.script + "\n;return typeof register === 'function' ? register : null;")(); + if (!register) throw new Error("the mod does not define register"); + await register(on, {}); + post({ t: "ready" }); + } catch (e) { + post({ t: "failed", error: errorText(e) }); + } + return; + } + case "invoke": { + const next = (e2?: unknown) => call("next", [msg.rid, e2 === undefined ? msg.e : clone(e2)]); + try { + const value = await handlers[msg.hid]!($, msg.e, next); + post({ t: "result", rid: msg.rid, value: ser(value) }); + } catch (e) { + post({ t: "result", rid: msg.rid, error: errorText(e) }); + } + return; + } + case "reply": { + const p = pending.get(msg.rid); + pending.delete(msg.rid); + if (msg.error !== undefined) p?.rej(new Error(msg.error)); + else p?.res(msg.value); + return; + } + case "fn": { + try { await fns.get(msg.id)?.(...(msg.args || [])); } catch (e) { fire("log", ["error: " + errorText(e)]); } + return; + } + case "command": { + try { + const value = await commands.get(msg.id)?.($, msg.args || {}); + post({ t: "result", rid: msg.rid, value: ser(value) }); + } catch (e) { + post({ t: "result", rid: msg.rid, error: errorText(e) }); + } + return; + } + } + } + window.addEventListener("message", (event) => { + if (port || event.data?.t !== "init" || !event.ports[0]) return; + port = event.ports[0]; + port.onmessage = (m) => void receive(m.data); + }); +} + +/** The iframe document a mod runs in. */ +export const sandboxDocument = () => + "" + + `` + + ""; diff --git a/frontend/src/sessions/SessionCard.tsx b/frontend/src/sessions/SessionCard.tsx index 50e1f07e..b60fb208 100644 --- a/frontend/src/sessions/SessionCard.tsx +++ b/frontend/src/sessions/SessionCard.tsx @@ -1,4 +1,5 @@ import { OpenInEditor } from "../remote/OpenInEditor"; +import { ModElements, useModRender } from "../mods/react"; import { SessionLineage } from "../continuity/SessionLineage"; import { SessionMemory } from "./SessionMemory"; import { useState } from "react"; @@ -63,6 +64,12 @@ interface Props { // and tests that predate the feature keep compiling. approval?: Approval; } +// The part of a session a mod sees: what a card shows, not its output. +const modSession = (s: SessionView) => ({ + id: s.id, name: s.name, agent: s.agent, model: s.model ?? "", status: s.status, + project_id: s.project_id ?? null, project_name: s.project_name ?? "", idle_seconds: s.idle_seconds, + ended: !!s.ended_at, +}); export function SessionCard({ session: s, approval, @@ -88,6 +95,8 @@ export function SessionCard({ onRevive, onClosed, }: Props) { + // What mods hide or add to this card (docs/mods.md). + const modded = useModRender("session.card", { session: modSession(s) }); useLocale(); const [progress, setProgress] = useState(""), [progressBusy, setProgressBusy] = useState(false); @@ -248,6 +257,7 @@ export function SessionCard({ : s.setup_error ? t("sessions.card.setupFailedPreview", { error: s.setup_error }) : s.pane_tail || ""; + if (modded?.hidden) return null; return (
+ {modded && } {activeApproval && (
diff --git a/frontend/src/settings/Plugins.tsx b/frontend/src/settings/Plugins.tsx index a67e88e6..a5e340e0 100644 --- a/frontend/src/settings/Plugins.tsx +++ b/frontend/src/settings/Plugins.tsx @@ -4,6 +4,8 @@ // preview of exactly what the plugin may do. The server refuses every change // that does not come from a signed-in person; this page only shows the // decision clearly enough to make it. +import { modHost } from "../mods/host"; +import { useModsVersion } from "../mods/react"; import { useEffect, useState } from "react"; import type { Project } from "../types"; import type { SettingsApi } from "./Settings"; @@ -58,6 +60,7 @@ interface PluginDetail extends PluginRow { quick_commands?: { id: string; label: string; text: string }[]; themes?: { id: string; name: string }[]; palette_commands?: { id: string; title: string }[]; + mods?: { id: string; path: string; surfaces?: string[] }[]; }; agents_more?: number; hook_runs?: HookRun[]; @@ -108,7 +111,7 @@ interface Attachment { // The bundled plugin that fills Settings → Agents → Add from catalog. const CATALOG_PLUGIN = "lectern.agent-catalog"; -const KINDS = ["agents", "mcp_servers", "skills", "workflows", "hooks", "sandbox_providers", "quick_commands", "themes", "palette_commands"]; +const KINDS = ["agents", "mcp_servers", "skills", "workflows", "hooks", "sandbox_providers", "quick_commands", "themes", "palette_commands", "mods"]; const short = (sha?: string) => (sha ? sha.slice(0, 12) : ""); const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); @@ -126,13 +129,35 @@ function Contributions({ counts }: { counts?: Record }) { ); } +// How this plugin's mods are doing in this browser: running, paused after +// failures, or failed to start, with what they logged. +function ModsHere({ plugin }: { plugin: string }) { + useModsVersion(); + const rows = modHost.statuses().filter((m) => m.plugin === plugin); + if (!rows.length) return null; + return ( + <> +

{t("plugins.modsHere")}

+
    + {rows.map((m) => ( +
  • + {m.id} {t("plugins.mod." + m.state)} + {m.error &&
    {m.error}
    } + {m.log.slice(-5).map((line, i) =>
    {line}
    )} +
  • + ))} +
+ + ); +} + export function CapabilityList({ capabilities, grown = [] }: { capabilities: Capability[]; grown?: string[] }) { if (!capabilities.length) return

{t("plugins.cap.none")}

; const isNew = (key: string, detail?: string) => grown.includes(detail ? `${key}: ${detail}` : key); return (