Skip to content

Commit 07c4fb7

Browse files
Leo310claude
andauthored
feat(views): agent-generated views as sandboxed frames (#505)
Adds the `s2b-view` fence: a self-contained HTML/CSS/JS document the agent writes in its reply, rendered by a code-block processor into a sandboxed iframe (opaque origin, no-network CSP) wrapped in a trusted outer frame whose `frame-src 'none'` refuses any navigation out. Optional frontmatter declares a title, a height, Dataview DQL queries the host runs and keeps live, and bundled libraries (`libs: plotly` — the gl3d build, inlined and patched at build time so main.js stays free of dynamic code). In chat the block gets a toolbar to copy it as a block or save it as a note under the new "Views folder" setting; in notes and embeds the same processor renders it. A bundled `views` core skill teaches the format, and `load_skill` now reports whether Dataview is available so the agent only writes queries when it is enabled. Also: the chat renderer owns a Component per document and streaming tail (render children no longer live until plugin unload), renders a sealed view fence as soon as it closes, and double-buffers the settle render so a view that appeared mid-stream is never blanked. Frames size to their drawn content, a declared height shrinking to it when shorter. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent baa4ee8 commit 07c4fb7

28 files changed

Lines changed: 2154 additions & 22 deletions

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"fake-indexeddb": "^6.2.5",
1818
"jsdom": "^27.4.0",
1919
"obsidian": "^1.13.1",
20+
"plotly.js-gl3d-dist-min": "^4.1.1",
2021
"postcss": "^8.5.28",
2122
"svelte": "^5.57.0",
2223
"svelte-check": "^4.7.6",

‎src/agent/AgentManager.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ import {
8282
type PluginIntegration,
8383
pluginExposesApi,
8484
isCommunityPluginEnabled,
85+
communityPluginDisplayName,
86+
communityPluginStatus,
8587
isInternalPluginEnabled,
8688
getPluginIcon,
8789
skillIcon,
@@ -959,6 +961,10 @@ export class AgentManager {
959961
if (toolId.startsWith("exec_")) return boundExecTools.has(toolId);
960962
return true;
961963
},
964+
pluginStatus: (pluginId) => ({
965+
status: communityPluginStatus(this.plugin.app, pluginId),
966+
displayName: communityPluginDisplayName(pluginId),
967+
}),
962968
}),
963969
);
964970
}

‎src/agent/integrations/pluginIntegrations.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,26 @@ export function isCommunityPluginEnabled(app: App, pluginId: string): boolean {
7070
return Boolean(app.plugins?.enabledPlugins?.has(pluginId));
7171
}
7272

73+
export type CommunityPluginStatus = "enabled" | "disabled" | "missing";
74+
75+
/**
76+
* Installed-and-enabled / installed-but-disabled / not installed, for a community
77+
* plugin id. "Installed" is read from the manifests Obsidian loaded from the plugins
78+
* folder, so a plugin the user turned off still reads as installed.
79+
*/
80+
export function communityPluginStatus(app: App, pluginId: string): CommunityPluginStatus {
81+
if (isCommunityPluginEnabled(app, pluginId)) return "enabled";
82+
// @ts-ignore - Obsidian plugin API (not in official types)
83+
return app.plugins?.manifests?.[pluginId] ? "disabled" : "missing";
84+
}
85+
86+
/** Human-friendly name for a community plugin id, from the curated table; the id otherwise. */
87+
export function communityPluginDisplayName(pluginId: string): string {
88+
return (
89+
CURATED_PLUGIN_INTEGRATIONS.find((integration) => integration.pluginId === pluginId)?.displayName ?? pluginId
90+
);
91+
}
92+
7393
/**
7494
* Whether an Obsidian core (internal) plugin is enabled (e.g. "canvas", "bases").
7595
* Uses undocumented internal API — may need updates with Obsidian changes.
@@ -179,14 +199,15 @@ const BUNDLED_CORE_SKILL_ICONS: Record<string, string> = {
179199
"manage-notes": "file-pen",
180200
web: "globe",
181201
"manage-skills": "wand-2",
202+
views: "layout-dashboard",
182203
};
183204

184205
/**
185206
* Fixed display order for the S2B built-in core skills (the 4 former capabilities). Used by
186207
* both the agent editor's Core Skills list and the agents-summary icon strip so the two never
187208
* drift. Core-plugin skills (Canvas, Bases, …) are ranked after all of these.
188209
*/
189-
const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills"];
210+
const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills", "views"];
190211

191212
/**
192213
* Sort rank for a core-category skill: listed S2B built-ins first in `S2B_CORE_SKILL_ORDER`,

‎src/agent/tools/loadSkill.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { tool } from "@langchain/core/tools";
22
import { z } from "zod";
33
import type { SkillsService } from "../../skills/SkillsService";
4+
import type { CommunityPluginStatus } from "../integrations/pluginIntegrations";
5+
6+
/** Frontmatter key (under `metadata:`) listing community plugin ids a skill can make use of. */
7+
export const OPTIONAL_PLUGINS_METADATA_KEY = "optionalPlugins";
8+
9+
const STATUS_TEXT: Record<CommunityPluginStatus, string> = {
10+
enabled: "enabled",
11+
disabled: "installed but disabled — the user can enable it under Settings → Community plugins",
12+
missing: "not installed — the user can install it from Settings → Community plugins → Browse",
13+
};
14+
15+
/** Split a space- or comma-separated id list from frontmatter. */
16+
export function parseOptionalPlugins(metadata: Record<string, string> | undefined): string[] {
17+
const raw = metadata?.[OPTIONAL_PLUGINS_METADATA_KEY];
18+
if (!raw) return [];
19+
return raw
20+
.split(/[\s,]+/)
21+
.map((id) => id.trim())
22+
.filter(Boolean);
23+
}
424

525
export interface LoadSkillToolOptions {
626
/**
@@ -17,6 +37,13 @@ export interface LoadSkillToolOptions {
1737
* and without the note the model would call a tool that doesn't exist.
1838
*/
1939
isToolAvailable?: (toolId: string) => boolean;
40+
/**
41+
* Live status of a community plugin a skill lists under `metadata.optionalPlugins`.
42+
* Appended to the loaded body so a skill whose *best* path depends on another
43+
* plugin (the views skill's Dataview queries) can tell the model up front whether
44+
* that path exists, instead of letting it find out from a failed result.
45+
*/
46+
pluginStatus?: (pluginId: string) => { status: CommunityPluginStatus; displayName: string };
2047
}
2148

2249
/**
@@ -28,7 +55,7 @@ export interface LoadSkillToolOptions {
2855
* @returns A LangChain tool for loading skill content
2956
*/
3057
export function createLoadSkillTool(skillsService: SkillsService, options: LoadSkillToolOptions) {
31-
const { skillNames, isToolAvailable } = options;
58+
const { skillNames, isToolAvailable, pluginStatus } = options;
3259

3360
// If no skills available, return a tool that explains this
3461
if (skillNames.length === 0) {
@@ -77,6 +104,20 @@ export function createLoadSkillTool(skillsService: SkillsService, options: LoadS
77104
}
78105
}
79106

107+
const optionalPlugins = pluginStatus ? parseOptionalPlugins(skill.frontmatter.metadata) : [];
108+
if (optionalPlugins.length > 0) {
109+
lines.push("");
110+
lines.push("## Plugin availability");
111+
lines.push("");
112+
for (const pluginId of optionalPlugins) {
113+
const { status, displayName } = pluginStatus?.(pluginId) ?? {
114+
status: "missing",
115+
displayName: pluginId,
116+
};
117+
lines.push(`- ${displayName} (\`${pluginId}\`): ${STATUS_TEXT[status]}`);
118+
}
119+
}
120+
80121
lines.push("");
81122
lines.push("## Instructions");
82123
lines.push("");

‎src/components/ui/MarkdownRenderer.svelte‎

Lines changed: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script lang="ts">
2-
import { Keymap, MarkdownRenderer, loadMathJax } from "obsidian";
2+
import { Component, Keymap, MarkdownRenderer, loadMathJax } from "obsidian";
33
import { onDestroy } from "svelte";
44
import { getPlugin } from "../../stores/state.svelte";
5+
import { VIEW_READY_EVENT } from "../../genview/viewFrame";
56
import { findSealableEnd } from "../../utils/streamingMarkdown";
67
import { openTagSearch } from "../../utils/tagSearch";
78
import { VIEW_TYPE_CHAT } from "../../views/chat/Chat";
@@ -155,9 +156,27 @@ function normalizeLinks(containerEl: HTMLElement) {
155156
// untouched, and a live tail — the block still being written — that is the only
156157
// part re-parsed each frame. Without this the whole accumulated reply was torn
157158
// down and re-parsed every frame, O(length) per frame, which pinned the main
158-
// thread for the duration of a long reply (#482). Outside streaming, and when the
159-
// reply settles, the content is rendered as one document, so link-reference and
160-
// footnote definitions resolve exactly as before.
159+
// thread for the duration of a long reply (#482). The tail's staging element
160+
// carries `s2b-md-tail` while it renders, so a block processor can tell "still
161+
// being written" (show a placeholder) from "sealed" (render for real) — see
162+
// `genview/registerViewBlocks.ts`.
163+
//
164+
// Outside streaming — a settled reply's first render, and the moment a streamed
165+
// reply settles — the content is rendered as one document, so every construct
166+
// resolves exactly as a whole-document parse does (reference definitions, HTML
167+
// blocks and loose lists that a seam between segments would cut). That render is
168+
// double-buffered: it happens in a hidden wrapper while the previous DOM stays on
169+
// screen, and swaps in once the wrapper's view frames report ready (capped), so
170+
// settling never blanks a frame that was already showing. The wrapper is then
171+
// unwrapped so the container stays flat (call sites style with direct-child
172+
// selectors such as `[&>p]`) — unless it holds an iframe, which a DOM move would
173+
// reload; only then does the wrapper stay.
174+
175+
const TAIL_CLASS = "s2b-md-tail";
176+
const DOC_CLASS = "s2b-md-doc";
177+
const DOC_PENDING_CLASS = "s2b-md-doc-pending";
178+
/** Upper bound on waiting for a settled document's view frames before swapping it in. */
179+
const VIEW_READY_GRACE_MS = 2000;
161180
162181
let latest = { content: "", sourcePath: "", enableMath: true, streaming: false };
163182
let frame: number | null = null;
@@ -168,6 +187,23 @@ let destroyed = false;
168187
let sealedText = "";
169188
/** Nodes belonging to the live tail; replaced on every render while streaming. */
170189
let tailNodes: ChildNode[] = [];
190+
/** The last render was a whole document (so a resumed stream must start over). */
191+
let settled = false;
192+
/**
193+
* Owners of the render children Obsidian's processors attach to rendered blocks
194+
* (Dataview tables, `s2b-view` frames, embeds). Handing the renderer the plugin
195+
* itself kept every such child alive until plugin unload; these are unloaded when
196+
* the DOM they belong to goes away — the tail's on every tail render, the
197+
* document's on reset, swap and destroy.
198+
*/
199+
let docComponent = loadedComponent();
200+
let tailComponent: Component | null = null;
201+
202+
function loadedComponent(): Component {
203+
const component = new Component();
204+
component.load();
205+
return component;
206+
}
171207
172208
$effect(() => {
173209
// Read every reactive dep here so the effect re-runs when any of them change;
@@ -179,6 +215,8 @@ $effect(() => {
179215
onDestroy(() => {
180216
destroyed = true;
181217
if (frame !== null) cancelAnimationFrame(frame);
218+
tailComponent?.unload();
219+
docComponent.unload();
182220
});
183221
184222
function schedule() {
@@ -213,35 +251,83 @@ async function renderLatest() {
213251
if (destroyed || !container) return;
214252
215253
if (!live) {
216-
resetDom();
217-
await appendSegment(text, path);
254+
await renderDocument(text, path);
218255
return;
219256
}
220257
221258
// Streaming: content normally extends what is already sealed. Anything else
222-
// (a reset at a tool-call boundary, an edit) starts over.
223-
if (!text.startsWith(sealedText)) resetDom();
259+
// (a settled document on screen, a reset at a tool-call boundary, an edit)
260+
// starts over.
261+
if (settled || !text.startsWith(sealedText)) resetDom();
224262
const remainder = text.slice(sealedText.length);
225263
const sealEnd = findSealableEnd(remainder);
226264
removeTail();
227265
if (sealEnd > 0) {
228266
const segment = remainder.slice(0, sealEnd);
229-
await appendSegment(segment, path);
267+
await appendSegment(segment, path, docComponent);
230268
if (destroyed || !container) return;
231269
sealedText += segment;
232270
}
233-
tailNodes = await appendSegment(text.slice(sealedText.length), path);
271+
tailComponent = loadedComponent();
272+
tailNodes = await appendSegment(text.slice(sealedText.length), path, tailComponent, true);
273+
}
274+
275+
/** Whole-document render, double-buffered behind whatever is currently showing. */
276+
async function renderDocument(text: string, path: string) {
277+
if (!container) return;
278+
const next = container.createDiv({ cls: `${DOC_CLASS} ${DOC_PENDING_CLASS}` });
279+
const nextComponent = loadedComponent();
280+
await renderInto(next, text, path, nextComponent);
281+
if (!destroyed && container) await waitForViews(next);
282+
if (destroyed || !container) {
283+
nextComponent.unload();
284+
next.remove();
285+
return;
286+
}
287+
for (const node of [...container.childNodes]) if (node !== next) node.remove();
288+
next.classList.remove(DOC_PENDING_CLASS);
289+
if (!next.querySelector("iframe")) next.replaceWith(...next.childNodes);
290+
docComponent.unload();
291+
tailComponent?.unload();
292+
tailComponent = null;
293+
tailNodes = [];
294+
sealedText = "";
295+
docComponent = nextComponent;
296+
settled = true;
297+
}
298+
299+
/** Resolve once every view frame under `root` has reported ready, or after the grace period. */
300+
function waitForViews(root: HTMLElement): Promise<void> {
301+
let pending = root.querySelectorAll(".s2b-view-frame:not([data-s2b-view-ready])").length;
302+
if (pending === 0) return Promise.resolve();
303+
return new Promise((resolve) => {
304+
const finish = () => {
305+
root.removeEventListener(VIEW_READY_EVENT, onReady);
306+
window.clearTimeout(timer);
307+
resolve();
308+
};
309+
const onReady = () => {
310+
if (--pending <= 0) finish();
311+
};
312+
root.addEventListener(VIEW_READY_EVENT, onReady);
313+
const timer = window.setTimeout(finish, VIEW_READY_GRACE_MS);
314+
});
234315
}
235316
236317
function resetDom() {
318+
removeTail();
237319
container?.empty();
238320
sealedText = "";
239-
tailNodes = [];
321+
settled = false;
322+
docComponent.unload();
323+
docComponent = loadedComponent();
240324
}
241325
242326
function removeTail() {
243327
for (const node of tailNodes) node.remove();
244328
tailNodes = [];
329+
tailComponent?.unload();
330+
tailComponent = null;
245331
}
246332
247333
/**
@@ -251,21 +337,30 @@ function removeTail() {
251337
* the container stays flat: no wrapper element, so `:first-child`/`:last-child`
252338
* styling on the container keeps working across segment seams.
253339
*/
254-
async function appendSegment(markdown: string, path: string): Promise<ChildNode[]> {
340+
async function appendSegment(markdown: string, path: string, component: Component, tail = false): Promise<ChildNode[]> {
255341
if (!markdown || !container) return [];
256-
const staging = container.createDiv({ attr: { style: "display: contents" } });
257-
await MarkdownRenderer.render(plugin.app, markdown, staging, path, plugin);
342+
const staging = container.createDiv({ cls: tail ? TAIL_CLASS : "", attr: { style: "display: contents" } });
343+
await renderInto(staging, markdown, path, component);
258344
if (destroyed || !container) {
259345
staging.remove();
260346
return [];
261347
}
262-
normalizeLinks(staging);
263-
// The renderer may tag the target element (e.g. `markdown-rendered`); carry that over.
264-
for (const cls of staging.classList) container.classList.add(cls);
265348
const nodes = [...staging.childNodes];
266349
staging.replaceWith(...nodes);
267350
return nodes;
268351
}
352+
353+
/** Render `markdown` into `target` (attached) and normalise the result. */
354+
async function renderInto(target: HTMLElement, markdown: string, path: string, component: Component) {
355+
if (!markdown) return;
356+
await MarkdownRenderer.render(plugin.app, markdown, target, path, component);
357+
if (destroyed || !container) return;
358+
normalizeLinks(target);
359+
// The renderer may tag the target element (e.g. `markdown-rendered`); carry that over.
360+
for (const cls of target.classList) {
361+
if (!cls.startsWith("s2b-md-")) container.classList.add(cls);
362+
}
363+
}
269364
</script>
270365

271366
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -278,3 +373,20 @@ async function appendSegment(markdown: string, path: string): Promise<ChildNode[
278373
onmouseover={handleMouseOver}
279374
onmouseout={handleMouseOut}
280375
></div>
376+
377+
<style>
378+
/* Anchor for the hidden double-buffer wrapper below. */
379+
div {
380+
position: relative;
381+
}
382+
/* A settling document renders here, off-flow and invisible but with the real
383+
width (post-processors measure layout), then swaps in. */
384+
:global(.s2b-md-doc-pending) {
385+
position: absolute;
386+
top: 0;
387+
left: 0;
388+
width: 100%;
389+
visibility: hidden;
390+
pointer-events: none;
391+
}
392+
</style>

0 commit comments

Comments
 (0)