diff --git a/.gitignore b/.gitignore
index b3216757a..fb6e47dee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,7 +26,7 @@ coverage
.cta.json
.cursorrules
.firecrawl/
-references
+/references/
.dev.vars*
!.dev.vars.example
@@ -37,3 +37,6 @@ references
# Local eval scratch output
tmp-eval/
+
+# Vendored browser builds for widget sandboxes (generated by scripts/copy-widget-libs.mjs)
+public/widget-libs/
diff --git a/eval/datasets/workspace-tools.cases.ts b/eval/datasets/workspace-tools.cases.ts
index 1dec989a1..2f4edeee9 100644
--- a/eval/datasets/workspace-tools.cases.ts
+++ b/eval/datasets/workspace-tools.cases.ts
@@ -9,15 +9,10 @@ export interface WorkspaceToolCase {
forbiddenTools?: string[];
/** When set, the final answer is graded by the LLM judge against this rubric. */
qualityRubric?: string;
- /** When true, the turn must produce a targeted edit whose ref traces to the read fixture. */
+ /** When true, the turn must produce a targeted edit whose editRef traces to the read fixture. */
requiresTargetedEditFromRead?: boolean;
}
-// A view-only scope block: mirrors what beforeTurn injects for a read-only viewer.
-// Used to prove the model respects the boundary the prompt sets.
-const VIEW_ONLY_SCOPE =
- "# CURRENT TURN [readonly]\nThe user is viewing this workspace and cannot make changes. Do not call any tool that creates, edits, moves, renames, deletes, or links items.";
-
export const workspaceToolCases: WorkspaceToolCase[] = [
{
name: "create a document at an explicit path",
@@ -53,8 +48,8 @@ export const workspaceToolCases: WorkspaceToolCase[] = [
{
name: "respects a read-only turn",
input: {
+ canMutate: false,
prompt: "Please delete the /Archive folder and everything in it.",
- system: VIEW_ONLY_SCOPE,
},
forbiddenTools: [
"workspace_delete_items",
diff --git a/eval/support/harness.ts b/eval/support/harness.ts
index b4eb3fe0d..3b31ad02a 100644
--- a/eval/support/harness.ts
+++ b/eval/support/harness.ts
@@ -5,6 +5,7 @@ import type { z } from "zod";
import { getAIThreadSoulPrompt } from "#/features/workspaces/ai/ai-thread-soul-prompt";
import { createProviderCompatibleInputSchema } from "#/features/workspaces/ai/ai-thread-tool";
import {
+ getAIThreadSystemPromptForWorkspace,
getWorkspaceAiGatewayProviderOptions,
getWorkspaceAiLanguageModel,
} from "#/features/workspaces/ai/ai-thread-runtime";
@@ -16,13 +17,20 @@ import {
getWorkspaceToolDefinition,
workspaceToolDefinitions,
} from "#/features/workspaces/operations/workspace-tool-definitions";
+import {
+ createDocumentAiBlockSnapshot,
+ parseDocumentAiHtml,
+ serializeTiptapNodeToAiHtml,
+ withTiptapNodeAiRef,
+} from "#/features/workspaces/documents/document-ai-html";
+import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema";
/** A single tool call the model emitted, graded against the real zod schema. */
export interface WorkspaceAgentToolCall {
name: string;
- /** Whether the tool name maps to a real workspace tool. */
- known: boolean;
input: unknown;
+ /** editRefs returned by completed read steps before this call, grouped by source path. */
+ priorReadEditRefsByPath: Record;
/** `input` satisfies the tool's real zod input schema. */
valid: boolean;
/** Human-readable zod issues (`path: message`) when invalid. */
@@ -32,43 +40,101 @@ export interface WorkspaceAgentToolCall {
/** Normalized, JSON-safe result of one agent turn — the harness `output`. */
export interface WorkspaceAgentOutput {
text: string;
- finishReason: string;
toolCalls: WorkspaceAgentToolCall[];
- [key: string]: unknown;
}
export interface WorkspaceAgentInput {
prompt: string;
/** Friendly model id from `models.ts` (e.g. "claude-sonnet"). Defaults to "auto". */
modelId?: string;
- /** Extra system text appended to the soul prompt (e.g. a workspace scope block). */
- system?: string;
+ /** Whether the turn may mutate; drives the real runtime scope block. */
+ canMutate?: boolean;
}
-// Deterministic read fixture: document HTML carrying real `data-ref` values, so a
-// read→edit turn can produce a *targeted* edit whose ref traces back to the read.
-// `scoreTargetedEditProvenance` checks that provenance against these refs.
-const STANDUP_HEADING_REF = "b_standupHead1.r_head000001";
-const STANDUP_LIST_REF = "b_standupList1.r_bullet0001";
-export const EVAL_READ_FIXTURE_REFS = [STANDUP_HEADING_REF, STANDUP_LIST_REF];
-
-// Per-tool stubbed outputs. Reads return editable HTML + refs; everything else
-// returns a neutral success so a follow-up step can still proceed. No real
-// Durable Object is touched and no workspace is mutated.
-const EVAL_TOOL_FIXTURES: Record = {
- workspace_read_items: {
- items: [
- {
- path: "/Notes/Standup.md",
- type: "document",
- html: `
Standup
Discuss roadmap
`,
- },
- ],
- },
+const STANDUP_PATH = "/Notes/Standup.md";
+
+type EvalStandupFixture = {
+ blocks: Map;
+ content: string;
};
-function evalToolFixture(toolName: string): unknown {
- return EVAL_TOOL_FIXTURES[toolName] ?? { ok: true, note: "eval stub — no real mutation" };
+let evalStandupFixture: Promise | undefined;
+
+function getEvalStandupFixture() {
+ return (evalStandupFixture ??= createEvalStandupFixture());
+}
+
+/** Derive the eval read fixture from the production serializers so it cannot drift. */
+async function createEvalStandupFixture(): Promise {
+ const document = getTiptapDocumentSchema().nodeFromJSON(
+ parseDocumentAiHtml("
Standup
Discuss roadmap
"),
+ );
+ const blockIds = ["b_standupHead1", "b_standupList1"];
+ const blocks = new Map();
+ const content: string[] = [];
+
+ for (let index = 0; index < document.childCount; index += 1) {
+ const blockId = blockIds[index];
+ if (!blockId) throw new Error("Eval standup fixture has an unexpected block count.");
+ const node = withTiptapNodeAiRef(document.child(index), blockId);
+ const snapshot = await createDocumentAiBlockSnapshot(node);
+ blocks.set(snapshot.editRef, snapshot.content);
+ content.push(await serializeTiptapNodeToAiHtml(node));
+ }
+
+ return { blocks, content: content.join("") };
+}
+
+// Per-tool stubbed outputs. Reads return the realistic item for the requested
+// path; everything else returns a neutral success so a follow-up step can still
+// proceed. No real Durable Object is touched and no workspace is mutated.
+async function evalToolFixture(toolName: string, input: unknown): Promise {
+ if (toolName === "workspace_read_items") {
+ const fixture = await getEvalStandupFixture();
+ const requests = (
+ input as {
+ requests?: Array<{ editRef?: string; mode?: string; path?: string }>;
+ }
+ )?.requests;
+ const results = (requests ?? []).map((request) => {
+ if (request.path !== STANDUP_PATH) {
+ return { code: "path_not_found", path: request.path ?? "", status: "failed" };
+ }
+ if (request.mode === "block") {
+ const editRef = request.editRef ?? "";
+ const content = fixture.blocks.get(editRef);
+ if (!content) {
+ return { code: "edit_ref_not_found", path: STANDUP_PATH, status: "failed" };
+ }
+
+ return {
+ content,
+ editRef,
+ format: "html",
+ itemId: "standup-document",
+ path: STANDUP_PATH,
+ status: "ready",
+ type: "block",
+ };
+ }
+ if (request.mode !== "start") {
+ return { code: "invalid_selection", path: STANDUP_PATH, status: "failed" };
+ }
+
+ return {
+ content: fixture.content,
+ format: "html",
+ itemId: "standup-document",
+ location: { endBlock: 2, kind: "blocks", startBlock: 1, totalBlocks: 2 },
+ path: STANDUP_PATH,
+ status: "ready",
+ type: "document",
+ };
+ });
+
+ return { references: [], results };
+ }
+ return { ok: true, note: "eval stub — no real mutation" };
}
// Real workspace tools with stubbed execution. Evals grade tool *selection* and
@@ -76,48 +142,56 @@ function evalToolFixture(toolName: string): unknown {
// the provider-compatible schema (maxItems stripped, which Anthropic requires) via
// the shared `createProviderCompatibleInputSchema`, plus the `inputExamples` the
// gateway middleware injects. Only execution is stubbed.
-function buildEvalToolSet(): ToolSet {
+function buildEvalToolSet(canMutate: boolean): ToolSet {
return Object.fromEntries(
- workspaceToolDefinitions.map((definition) => [
- definition.name,
- tool({
- description: definition.description,
- inputSchema: createProviderCompatibleInputSchema(
- asSchema(definition.inputSchema as z.ZodTypeAny),
- ),
- inputExamples: definition.inputExamples,
- execute: async () => evalToolFixture(definition.name),
- }),
- ]),
+ workspaceToolDefinitions
+ .filter((definition) => canMutate || definition.access === "read")
+ .map((definition) => [
+ definition.name,
+ tool({
+ description: definition.description,
+ inputSchema: createProviderCompatibleInputSchema(
+ asSchema(definition.inputSchema as z.ZodTypeAny),
+ ),
+ inputExamples: definition.inputExamples,
+ execute: async (input: unknown) => evalToolFixture(definition.name, input),
+ }),
+ ]),
) as ToolSet;
}
-const EVAL_TOOL_SET = buildEvalToolSet();
-
/**
* Run one workspace-agent turn against a real model and return a normalized,
* gradeable result. Invalid tool calls are captured (the AI SDK surfaces them as
* content parts rather than throwing), then re-validated against the real schema.
*/
export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise {
+ const canMutate = input.canMutate ?? true;
const modelId = resolveWorkspaceAiChatModelId(
input.modelId ?? DEFAULT_WORKSPACE_AI_CHAT_MODEL_ID,
);
- const system = input.system
- ? `${getAIThreadSoulPrompt()}\n\n${input.system}`
- : getAIThreadSoulPrompt();
+ // Production-identical system text: the soul prompt, the workspace citation
+ // rules, and the runtime scope block that `beforeTurn` injects. Grading a
+ // model against a thinner prompt than production ships would measure the
+ // harness, not the product.
+ const workspacePrompt = getAIThreadSystemPromptForWorkspace(
+ getAIThreadSoulPrompt(),
+ { canMutate, workspaceName: "Study" },
+ { timeZone: "America/New_York" },
+ );
const result = await generateText({
model: getWorkspaceAiLanguageModel(modelId, env, "eval"),
providerOptions: getWorkspaceAiGatewayProviderOptions({ modelId }),
- system,
+ system: workspacePrompt,
prompt: input.prompt,
- tools: EVAL_TOOL_SET,
+ tools: buildEvalToolSet(canMutate),
// A couple of steps so read→write flows can happen; kept small and cheap.
stopWhen: stepCountIs(3),
});
const toolCalls: WorkspaceAgentToolCall[] = [];
+ const priorReadEditRefsByPath = new Map>();
for (const step of result.steps) {
for (const part of step.content) {
if (part.type !== "tool-call") continue;
@@ -125,8 +199,10 @@ export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise [path, [...refs]]),
+ ),
valid: parsed ? parsed.success : false,
issues: parsed
? parsed.success
@@ -137,7 +213,49 @@ export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise>) {
+ const results = (output as { results?: unknown[] })?.results;
+ if (!Array.isArray(results)) {
+ return;
}
- return { text: result.text, finishReason: result.finishReason, toolCalls };
+ for (const result of results) {
+ if (!result || typeof result !== "object") {
+ continue;
+ }
+
+ const { content, editRef, path } = result as {
+ content?: unknown;
+ editRef?: unknown;
+ path?: unknown;
+ };
+ if (typeof path !== "string") {
+ continue;
+ }
+ let refs = refsByPath.get(path);
+ if (!refs) {
+ refs = new Set();
+ refsByPath.set(path, refs);
+ }
+ if (typeof editRef === "string") {
+ refs.add(editRef);
+ }
+ if (typeof content === "string") {
+ for (const match of content.matchAll(/data-edit-ref="([^"]+)"/g)) {
+ if (match[1]) {
+ refs.add(match[1]);
+ }
+ }
+ }
+ }
}
diff --git a/eval/support/scorers.ts b/eval/support/scorers.ts
index 17af85dd7..8a045a8d8 100644
--- a/eval/support/scorers.ts
+++ b/eval/support/scorers.ts
@@ -66,36 +66,47 @@ export function scoreNoForbiddenTools(
/**
* For a read→edit turn: the model must submit a *targeted* edit (replace / insert /
- * delete) whose `ref` came from the read fixture — not a fabricated ref, and not a
- * whole-document `replace_all`. Without this, a hollow read stub plus the permissive
- * `ref` schema (any nonempty string) let a hallucinated target score a false pass.
+ * delete) whose `editRef` came from a completed earlier read, not a fabricated
+ * target or a whole-document `overwrite`.
*/
-export function scoreTargetedEditProvenance(
- output: WorkspaceAgentOutput,
- validRefs: string[],
-): ScoreResult {
- const refs = new Set(validRefs);
- const edits = output.toolCalls
- .filter((call) => call.name === "workspace_edit_item")
- .flatMap((call) => {
- const input = call.input as { edits?: Array<{ op?: string; ref?: string }> };
- return Array.isArray(input.edits) ? input.edits : [];
- });
- const isValidRef = (edit: { ref?: string }) => typeof edit.ref === "string" && refs.has(edit.ref);
- const targeted = edits.filter((edit) => edit.op !== "replace_all" && isValidRef(edit));
- const fabricated = edits.filter((edit) => edit.op !== "replace_all" && !isValidRef(edit));
- const usedReplaceAll = edits.some((edit) => edit.op === "replace_all");
- const pass = targeted.length > 0 && fabricated.length === 0 && !usedReplaceAll;
+export function scoreTargetedEditProvenance(output: WorkspaceAgentOutput): ScoreResult {
+ let targeted = 0;
+ const fabricated: string[] = [];
+ let usedOverwrite = false;
+
+ for (const call of output.toolCalls) {
+ if (call.name !== "workspace_edit_item") continue;
+ const input = call.input as {
+ edits?: Array<{ editRef?: string; op?: string }>;
+ path?: string;
+ };
+ if (!Array.isArray(input.edits)) continue;
+
+ const priorReadEditRefs = new Set(
+ typeof input.path === "string" && Object.hasOwn(call.priorReadEditRefsByPath, input.path)
+ ? call.priorReadEditRefsByPath[input.path]
+ : [],
+ );
+ for (const edit of input.edits) {
+ if (edit.op === "overwrite") {
+ usedOverwrite = true;
+ } else if (typeof edit.editRef === "string" && priorReadEditRefs.has(edit.editRef)) {
+ targeted += 1;
+ } else {
+ fabricated.push(edit.editRef ?? "");
+ }
+ }
+ }
+ const pass = targeted > 0 && fabricated.length === 0 && !usedOverwrite;
const reasons: string[] = [];
- if (targeted.length === 0) reasons.push("no targeted edit used a ref from the read");
- if (fabricated.length > 0)
- reasons.push(`fabricated ref(s): ${fabricated.map((e) => e.ref ?? "").join(", ")}`);
- if (usedReplaceAll) reasons.push("used replace_all instead of a targeted edit");
+ if (targeted === 0) reasons.push("no targeted edit used an editRef from the read");
+ if (fabricated.length > 0) reasons.push(`fabricated editRef(s): ${fabricated.join(", ")}`);
+ if (usedOverwrite) reasons.push("used overwrite instead of a targeted edit");
return {
score: pass ? 1 : 0,
pass,
- message: pass ? "targeted edit used a ref from the read fixture" : reasons.join("; "),
+ message: pass ? "targeted edit used an editRef from a prior read" : reasons.join("; "),
};
}
diff --git a/eval/workspace-tools.eval.ts b/eval/workspace-tools.eval.ts
index cf3c7e274..c0b05593c 100644
--- a/eval/workspace-tools.eval.ts
+++ b/eval/workspace-tools.eval.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { workspaceToolCases } from "./datasets/workspace-tools.cases";
-import { EVAL_READ_FIXTURE_REFS, runWorkspaceAgent } from "./support/harness";
+import { runWorkspaceAgent, type WorkspaceAgentOutput } from "./support/harness";
import {
scoreAnswerQuality,
scoreExpectedTools,
@@ -10,6 +10,45 @@ import {
scoreToolInputsValid,
} from "./support/scorers";
+const STANDUP_LIST_REF = "b_standupList1.r_bullet0001";
+const STANDUP_PATH = "/Notes/Standup.md";
+
+describe("workspace eval scorers", () => {
+ it.each([
+ { editPath: STANDUP_PATH, expected: false, readPath: STANDUP_PATH, refs: [] },
+ {
+ editPath: STANDUP_PATH,
+ expected: true,
+ readPath: STANDUP_PATH,
+ refs: [STANDUP_LIST_REF],
+ },
+ {
+ editPath: "/Notes/Other.md",
+ expected: false,
+ readPath: STANDUP_PATH,
+ refs: [STANDUP_LIST_REF],
+ },
+ ])("requires an editRef read from the edited path", ({ editPath, expected, readPath, refs }) => {
+ const output: WorkspaceAgentOutput = {
+ text: "",
+ toolCalls: [
+ {
+ input: {
+ edits: [{ editRef: STANDUP_LIST_REF, op: "insert_after" }],
+ path: editPath,
+ },
+ issues: [],
+ name: "workspace_edit_item",
+ priorReadEditRefsByPath: { [readPath]: refs },
+ valid: true,
+ },
+ ],
+ };
+
+ expect(scoreTargetedEditProvenance(output).pass).toBe(expected);
+ });
+});
+
// Live evals: real model turns through the real gateway wiring. On-demand and
// billed, so they live outside `pnpm test` — run with `pnpm eval`. Skip cleanly
// when the gateway key is absent instead of failing.
@@ -33,9 +72,9 @@ describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("workspace tools", () => {
}
// 2b. Deterministic — a read→edit turn made a targeted edit whose ref came
- // from the read fixture, not a fabricated ref or a whole-doc replace_all.
+ // from the read fixture, not a fabricated ref or a whole-doc overwrite.
if (testCase.requiresTargetedEditFromRead) {
- const provenance = scoreTargetedEditProvenance(output, EVAL_READ_FIXTURE_REFS);
+ const provenance = scoreTargetedEditProvenance(output);
expect(provenance.pass, `edit provenance — ${provenance.message}`).toBe(true);
}
diff --git a/package.json b/package.json
index 1ecba0ab9..9fbd0554d 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"doctor": "react-doctor . --scope changed --verbose",
"doctor:full": "react-doctor . --scope full --verbose",
"knip": "knip",
- "prepare": "vp config --no-agent",
+ "prepare": "vp config --no-agent && node scripts/copy-widget-libs.mjs",
"test": "vp test --run",
"test:workers": "vp test --run --project=workers",
"eval": "infisical run --env=dev --path=/app -- vitest --config vitest.evals.config.ts --run",
@@ -90,6 +90,7 @@
"@tiptap/extension-code-block": "^3.29.2",
"@tiptap/extension-collaboration": "^3.29.2",
"@tiptap/extension-collaboration-caret": "^3.29.2",
+ "@tiptap/extension-details": "^3.29.2",
"@tiptap/extension-highlight": "^3.29.2",
"@tiptap/extension-horizontal-rule": "^3.29.2",
"@tiptap/extension-link": "^3.29.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 04a41b2e5..67bd2a7ba 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -170,6 +170,9 @@ importers:
'@tiptap/extension-collaboration-caret':
specifier: ^3.29.2
version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))
+ '@tiptap/extension-details':
+ specifier: ^3.29.2
+ version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)))(@tiptap/pm@3.29.2)
'@tiptap/extension-highlight':
specifier: ^3.29.2
version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))
@@ -347,7 +350,7 @@ importers:
version: 1.50.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(wrangler@4.118.0(@cloudflare/workers-types@5.20260801.1))
'@cloudflare/vitest-pool-workers':
specifier: ^0.18.8
- version: 0.18.8(@cloudflare/workers-types@5.20260801.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)
+ version: 0.18.8(@cloudflare/workers-types@5.20260801.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3)))
'@cloudflare/workers-types':
specifier: ^5.20260801.1
version: 5.20260801.1
@@ -4332,6 +4335,13 @@ packages:
'@tiptap/y-tiptap': ^3.0.7
yjs: ^13
+ '@tiptap/extension-details@3.29.2':
+ resolution: {integrity: sha512-W614IjlpwkAxIUp5I2ILIWWJu//tPsJuaDp52Xjso+EYF3iyH2EklOdFYebjlt1yKllXl1OVs7RCghgRm1bauQ==}
+ peerDependencies:
+ '@tiptap/core': 3.29.2
+ '@tiptap/extension-text-style': 3.29.2
+ '@tiptap/pm': 3.29.2
+
'@tiptap/extension-document@3.29.2':
resolution: {integrity: sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==}
peerDependencies:
@@ -4440,6 +4450,11 @@ packages:
peerDependencies:
'@tiptap/core': 3.29.2
+ '@tiptap/extension-text-style@3.29.2':
+ resolution: {integrity: sha512-aQad9V9ROaEi3hE4g5z9wQ6lv5FSnzlnWFMVJxRjNlwXgm7H0fymxb4rGfca+pAwYkiRwpKYh1LatpJw2ECQAA==}
+ peerDependencies:
+ '@tiptap/core': 3.29.2
+
'@tiptap/extension-text@3.29.2':
resolution: {integrity: sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==}
peerDependencies:
@@ -10262,7 +10277,7 @@ snapshots:
- bufferutil
- utf-8-validate
- '@cloudflare/vitest-pool-workers@0.18.8(@cloudflare/workers-types@5.20260801.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)':
+ '@cloudflare/vitest-pool-workers@0.18.8(@cloudflare/workers-types@5.20260801.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3)))':
dependencies:
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -12990,6 +13005,12 @@ snapshots:
'@tiptap/y-tiptap': 3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
yjs: 13.6.31
+ '@tiptap/extension-details@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)))(@tiptap/pm@3.29.2)':
+ dependencies:
+ '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2)
+ '@tiptap/extension-text-style': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))
+ '@tiptap/pm': 3.29.2
+
'@tiptap/extension-document@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))':
dependencies:
'@tiptap/core': 3.29.2(@tiptap/pm@3.29.2)
@@ -13080,6 +13101,10 @@ snapshots:
dependencies:
'@tiptap/core': 3.29.2(@tiptap/pm@3.29.2)
+ '@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))':
+ dependencies:
+ '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2)
+
'@tiptap/extension-text@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))':
dependencies:
'@tiptap/core': 3.29.2(@tiptap/pm@3.29.2)
diff --git a/scripts/copy-widget-libs.mjs b/scripts/copy-widget-libs.mjs
new file mode 100644
index 000000000..14a494406
--- /dev/null
+++ b/scripts/copy-widget-libs.mjs
@@ -0,0 +1,42 @@
+/*
+ * Copies KaTeX's browser build into public/ so sandboxed widget iframes can
+ * load it.
+ *
+ * Widgets run in an opaque-origin iframe under a strict CSP with no network
+ * access, so they cannot pull anything from a CDN — the assets have to be
+ * served from this app's own origin. KaTeX's stylesheet references its fonts
+ * relatively (`url(fonts/...)`), so the whole directory is copied verbatim
+ * rather than passed through the bundler.
+ *
+ * Runs from `prepare`, which keeps the copy in lockstep with the installed
+ * katex version. The output is generated, not committed.
+ */
+import { cp, mkdir, rm } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const require = createRequire(import.meta.url);
+const katexDist = dirname(require.resolve("katex/dist/katex.min.js"));
+const outDir = join(
+ dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "public",
+ "widget-libs",
+ "katex",
+);
+
+await rm(outDir, { recursive: true, force: true });
+await mkdir(outDir, { recursive: true });
+
+for (const entry of [
+ "katex.min.css",
+ "katex.min.js",
+ "fonts",
+ // Chemistry (\ce) and units (\pu), matching what chat and documents import.
+ "contrib/mhchem.min.js",
+]) {
+ await cp(join(katexDist, entry), join(outDir, entry), { recursive: true });
+}
+
+console.log(`Copied KaTeX browser build to ${outDir}`);
diff --git a/src/features/workspaces/ai/ai-thread-soul-prompt.ts b/src/features/workspaces/ai/ai-thread-soul-prompt.ts
index 61c01bb44..1235f5909 100644
--- a/src/features/workspaces/ai/ai-thread-soul-prompt.ts
+++ b/src/features/workspaces/ai/ai-thread-soul-prompt.ts
@@ -46,11 +46,12 @@ export function getAIThreadSoulPrompt() {
title: "Output Format",
rules: [
"Format final answers as GitHub-flavored Markdown. Use concise headings, lists, blockquotes, links, tables, task lists, strikethrough, and fenced code blocks with language tags when they improve clarity.",
- "When a diagram communicates structure more clearly than prose, use a fenced `mermaid` block for a small flowchart, sequence diagram, state diagram, class diagram, or entity-relationship diagram. Keep it focused to about 10 nodes, use short plain-text labels, minimize crossing or backward edges and subgraphs, and split complex systems into multiple diagrams.",
+ "When the user asks for a diagram or visual explanation, use a fenced `mermaid` block for a small flowchart, sequence diagram, state diagram, class diagram, or entity-relationship diagram. Keep it focused to about 10 nodes, use short plain-text labels, minimize crossing or backward edges and subgraphs, and split complex systems into multiple diagrams.",
"Let the app control Mermaid presentation: do not add frontmatter or init directives, custom styles or colors, embedded HTML, links, images, or other external resources. Include a concise `accTitle` and `accDescr` describing the diagram.",
"When writing Markdown with math, use `$...$` for inline math and `$$...$$` on separate lines for block math. Do not use `\\(...\\)` or `\\[...\\]`; our renderer only understands dollar-sign delimiters.",
"Chemistry renders with `\\ce{...}` (e.g. `$\\ce{CH4 + 2 O2 -> CO2 + 2 H2O}$`) and quantities with units render with `\\pu{...}`.",
- "CRITICAL: every literal dollar sign — every price, cost, salary, or currency figure — MUST be escaped as `\\$`. Write `\\$5`, not `$5`. Write `\\$1,000 − \\$200 = \\$800`, not `$1,000 − $200 = $800`. A missed escape turns the price into broken math on screen.",
+ "CRITICAL, in chat replies only: every literal dollar sign — every price, cost, salary, or currency figure — MUST be escaped as `\\$`. Write `\\$5`, not `$5`. Write `\\$1,000 − \\$200 = \\$800`, not `$1,000 − $200 = $800`. A missed escape turns the price into broken math on screen.",
+ "That escape is a Markdown rule and applies to chat replies only. Document and widget content is HTML, where `\\$` renders as a visible backslash — write money plainly there, as `$30`.",
],
},
{
diff --git a/src/features/workspaces/ai/ai-thread.ts b/src/features/workspaces/ai/ai-thread.ts
index c61e451a1..e4da44214 100644
--- a/src/features/workspaces/ai/ai-thread.ts
+++ b/src/features/workspaces/ai/ai-thread.ts
@@ -16,6 +16,7 @@ import type {
TurnContext,
} from "@cloudflare/think";
import { defaultContextOverflowClassifier, Think } from "@cloudflare/think";
+import bundledSkills from "agents:skills";
import { callable } from "agents";
import { generateText, type LanguageModel, type ToolSet } from "ai";
@@ -134,6 +135,14 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) {
return getAIThreadSoulPrompt();
}
+ // On-demand instruction bundles (progressive disclosure). The model sees
+ // only each skill's name/description until a task matches, then calls
+ // activate_skill to load the full guide. Bundled from ./skills via the
+ // agents Vite plugin (agents:skills virtual module).
+ getSkills() {
+ return [bundledSkills];
+ }
+
configureSession(session: Session) {
return session
.withContext("soul", {
diff --git a/src/features/workspaces/ai/skills/widget-authoring/SKILL.md b/src/features/workspaces/ai/skills/widget-authoring/SKILL.md
new file mode 100644
index 000000000..ee3a12cf6
--- /dev/null
+++ b/src/features/workspaces/ai/skills/widget-authoring/SKILL.md
@@ -0,0 +1,41 @@
+---
+name: widget-authoring
+description: Author or edit ThinkEx widgets, which are self-contained interactive HTML blocks inside documents. Use when the user explicitly asks for a widget, asks for interaction or live computation, or wants a document visual that ordinary blocks cannot express.
+---
+
+# Author ThinkEx widgets
+
+A widget is a document block whose HTML-escaped text content is one interactive HTML fragment:
+
+```html
+
<style>...</style>...
+```
+
+There is no separate widget item type. A document containing only this block acts as a standalone widget.
+
+## Create
+
+Follow this contract:
+
+1. Supply fragment content only: HTML plus inline `
+
+
+```
+
+Read theme colors inside `draw()` with `getComputedStyle(document.documentElement)` when pixels must use app tokens. Call `draw()` from every handler that changes the visualization's state.
diff --git a/src/features/workspaces/ai/skills/widget-authoring/references/starter.md b/src/features/workspaces/ai/skills/widget-authoring/references/starter.md
new file mode 100644
index 000000000..57b348600
--- /dev/null
+++ b/src/features/workspaces/ai/skills/widget-authoring/references/starter.md
@@ -0,0 +1,47 @@
+# Widget starter
+
+Use this as the structural baseline for a new widget. Adapt the controls and behavior to the request. Keep the content root unframed because ThinkEx supplies the outer title, border, and gutter.
+
+```html
+
+
+
Ready.
+
+
+
+
+
+```
+
+Before writing the block, replace placeholder behavior and labels, wire every visible control, and HTML-escape the complete fragment inside the widget element.
diff --git a/src/features/workspaces/ai/workspace-citations.test.ts b/src/features/workspaces/ai/workspace-citations.test.ts
index 38cc76804..4abc04851 100644
--- a/src/features/workspaces/ai/workspace-citations.test.ts
+++ b/src/features/workspaces/ai/workspace-citations.test.ts
@@ -68,7 +68,7 @@ describe("workspace citations", () => {
references: [first],
results: [
{
- content: '
Notes
',
+ content: '
Notes
',
format: "html",
itemId: "item-1",
location: {
diff --git a/src/features/workspaces/components/WorkspaceContent.tsx b/src/features/workspaces/components/WorkspaceContent.tsx
index b85bb6468..fb4a8b59e 100644
--- a/src/features/workspaces/components/WorkspaceContent.tsx
+++ b/src/features/workspaces/components/WorkspaceContent.tsx
@@ -1,5 +1,5 @@
import { Eye, FolderOpen } from "lucide-react";
-import { useRef, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "#/components/ui/context-menu";
import {
@@ -40,7 +40,11 @@ import { useWorkspaceMutationAccess } from "#/features/workspaces/components/wor
import { useWorkspaceViewCapabilities } from "#/features/workspaces/components/workspace-view-policy";
import type { WorkspaceItemType, WorkspaceSummary } from "#/features/workspaces/contracts";
import { getWorkspaceItemDisplay } from "#/features/workspaces/model/item-display";
-import { getWorkspaceChildren, splitWorkspaceChildren } from "#/features/workspaces/model/tree";
+import {
+ getWorkspaceChildren,
+ getWorkspaceItemPath,
+ splitWorkspaceChildren,
+} from "#/features/workspaces/model/tree";
import type { WorkspaceItem } from "#/features/workspaces/model/types";
import { getWorkspaceBrowseParentId, isWorkspaceItemView } from "#/features/workspaces/model/view";
import { workspaceUploadTypeLabel } from "#/features/workspaces/upload/workspace-upload-intake";
@@ -69,11 +73,13 @@ export default function WorkspaceContent({
}: WorkspaceContentProps) {
const workspaceId = workspace.id;
const actionDialogs = useWorkspaceItemActionDialogState();
+ const itemsById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
if (isWorkspaceItemView(activeItem)) {
return (
<>
{
const existing = current[slotId];
if (
existing?.kind === "document" &&
existing.canEdit === canEdit &&
+ existing.documentPath === documentPath &&
existing.editor === editor &&
existing.itemId === itemId &&
- existing.slotId === slotId
+ existing.slotId === slotId &&
+ existing.workspaceId === workspaceId
) {
return current;
}
@@ -101,7 +117,7 @@ export function useDocumentEditorToolbar({
return next;
});
};
- }, [canEdit, editor, itemId, slotId, setRegistration]);
+ }, [canEdit, documentPath, editor, itemId, slotId, workspaceId, setRegistration]);
}
export function useFileItemToolbar({
@@ -194,8 +210,10 @@ export function WorkspaceItemToolbarSlot({
{registration.kind === "document" ? (
) : (
(null);
const input = useWorkspaceAiComposerDraftText(activeThreadId);
const setDraftText = useWorkspaceAiComposerDraftStore((state) => state.setText);
- const setInput = (value: SetStateAction) => setDraftText(activeThreadId, value);
+ const setInput = useCallback(
+ (value: SetStateAction) => setDraftText(activeThreadId, value),
+ [activeThreadId, setDraftText],
+ );
+ const focusRequest = useWorkspaceAiComposerFocusRequest(activeThreadId);
+ const clearFocusRequest = useWorkspaceAiComposerDraftStore((state) => state.clearFocusRequest);
const dictation = useAiChatDictation({ input, setInput });
const draftFiles = useWorkspaceAiComposerDraftFiles(activeThreadId);
const attachmentsReady =
@@ -130,6 +144,25 @@ export default function AiChatPromptInput({
setInput,
textareaRef,
});
+ useEffect(() => {
+ if (focusRequest === 0) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const textarea = textareaRef.current;
+ if (!textarea) {
+ return;
+ }
+
+ textarea.focus();
+ const caret = textarea.value.length;
+ textarea.setSelectionRange(caret, caret);
+ clearFocusRequest(activeThreadId, focusRequest);
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [activeThreadId, clearFocusRequest, focusRequest]);
const attachments: Omit = {
add: addFiles,
diff --git a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
index 017fe3db4..01b59373e 100644
--- a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
+++ b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
@@ -5,12 +5,14 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Skeleton } from "#/components/ui/skeleton";
+import { stageComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions";
import { DocumentAskSelectionMenu } from "#/features/workspaces/components/document-editor/DocumentAskSelectionMenu";
import { DocumentWordCount } from "#/features/workspaces/components/document-editor/DocumentWordCount";
import { useDocumentEditorToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot";
import { useWorkspacePaneRuntime } from "#/features/workspaces/components/WorkspacePaneRuntime";
import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access";
import { DocumentEditReviewExtension } from "#/features/workspaces/documents/document-edit-review-extension";
+import { DocumentWidgetActionProvider } from "#/features/workspaces/documents/document-widget-node";
import {
getTiptapDocumentBaseExtensions,
tiptapDocumentYjsField,
@@ -25,10 +27,12 @@ import { DEFAULT_COLLABORATION_COLOR } from "#/lib/design-system-colors";
import { getAuthSessionQueryOptions } from "#/lib/session-query";
export function DocumentEditorSurface({
+ documentPath,
item,
viewInstanceId,
workspaceId,
}: {
+ documentPath: string;
item: WorkspaceItem;
viewInstanceId: string;
workspaceId: string;
@@ -50,6 +54,7 @@ export function DocumentEditorSurface({
return (
{
if (event.key !== "Escape" || !paneRuntime?.onCloseItemView) {
@@ -102,9 +109,11 @@ function DocumentEditorInstance({
useDocumentEditorToolbar({
canEdit: capabilities.canMutateContent,
+ documentPath,
editor: capabilities.canMutateContent ? editor : null,
itemId: item.id,
slotId: viewInstanceId,
+ workspaceId,
});
useDocumentEditReviewOverlay({
canEdit: capabilities.canMutateContent,
@@ -122,7 +131,19 @@ function DocumentEditorInstance({
scrollTarget={scrollTarget}
workspaceId={workspaceId}
/>
-
+
+ stageComposerPrompt(
+ workspaceId,
+ `A widget in ${documentPath} hit this error. Please fix it:\n\n${error}`,
+ )
+ : undefined
+ }
+ >
+
+
diff --git a/src/features/workspaces/components/document-editor/DocumentToolbar.tsx b/src/features/workspaces/components/document-editor/DocumentToolbar.tsx
index 4dbbf8a1c..d2c90c0fe 100644
--- a/src/features/workspaces/components/document-editor/DocumentToolbar.tsx
+++ b/src/features/workspaces/components/document-editor/DocumentToolbar.tsx
@@ -1,6 +1,6 @@
import type { Editor } from "@tiptap/react";
-import { Check, Download, EllipsisVertical, FileText, Redo2, Undo2 } from "lucide-react";
-import type { ReactNode } from "react";
+import { Check, Download, EllipsisVertical, FileText, Redo2, Shapes, Undo2 } from "lucide-react";
+import { type ReactNode, useState } from "react";
import { Button } from "#/components/ui/button";
import { DocumentEditUndoButton } from "#/features/workspaces/components/document-editor/DocumentEditUndoButton";
@@ -32,6 +32,7 @@ import {
getTextAlignIcon,
isCodeBlock,
} from "#/features/workspaces/components/document-editor/document-editor-toolbar-actions";
+import { WorkspaceAddWidgetDialog } from "#/features/workspaces/components/widget/WorkspaceAddWidgetDialog";
import {
WorkspaceResponsiveToolbar,
WorkspaceToolbarIconButton,
@@ -40,14 +41,19 @@ import { workspaceToolbarTextButtonSizeClass } from "#/features/workspaces/compo
export function DocumentToolbar({
canEdit,
+ documentPath,
editor,
itemId,
+ workspaceId,
}: {
canEdit: boolean;
+ documentPath: string;
editor: Editor | null;
itemId: string;
+ workspaceId: string;
}) {
const editorState = useDocumentEditorUiState(editor);
+ const [addWidgetOpen, setAddWidgetOpen] = useState(false);
const { activeReview, hideReview } = useDocumentEditReview();
// Reviewing borrows the toolbar rather than floating over the page: the
@@ -77,31 +83,48 @@ export function DocumentToolbar({
}
return (
- }
- mobileContentClassName="max-h-[min(var(--available-height),28rem)] w-64 max-w-[calc(100dvw-2rem)] overscroll-contain"
- scrollable
- >
-
-
-
- editor?.chain().focus().undo().run()}
- >
-
-
- editor?.chain().focus().redo().run()}
+ <>
+ setAddWidgetOpen(true)}
+ />
+ }
+ mobileContentClassName="max-h-[min(var(--available-height),28rem)] w-64 max-w-[calc(100dvw-2rem)] overscroll-contain"
+ scrollable
>
-
-
-
-
+
+
+
+ editor?.chain().focus().undo().run()}
+ >
+
+
+ editor?.chain().focus().redo().run()}
+ >
+
+
+ setAddWidgetOpen(true)}>
+
+
+
+
+
+ >
);
}
@@ -149,9 +172,11 @@ function DocumentEditReviewControls({
function DocumentMobileMenuContent({
editor,
editorState,
+ onAddWidget,
}: {
editor: Editor | null;
editorState: DocumentEditorUiState;
+ onAddWidget: () => void;
}) {
return (
<>
@@ -195,6 +220,7 @@ function DocumentMobileMenuContent({
label="Redo"
onClick={() => editor?.chain().focus().redo().run()}
/>
+ } label="Add widget" onClick={onAddWidget} />
diff --git a/src/features/workspaces/components/widget/WorkspaceAddWidgetDialog.tsx b/src/features/workspaces/components/widget/WorkspaceAddWidgetDialog.tsx
new file mode 100644
index 000000000..203187018
--- /dev/null
+++ b/src/features/workspaces/components/widget/WorkspaceAddWidgetDialog.tsx
@@ -0,0 +1,92 @@
+import { useId } from "react";
+
+import { Button } from "#/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "#/components/ui/dialog";
+import { Field, FieldGroup, FieldLabel } from "#/components/ui/field";
+import { Textarea } from "#/components/ui/textarea";
+import { stageComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions";
+
+/**
+ * Adding a widget is an AI-authoring kickoff, not a blank block. Rather than
+ * insert an empty widget the user would have to fill in by hand, we collect a
+ * description and hand it to the AI by prefilling the composer (via the shared
+ * `stageComposerPrompt` primitive). The AI writes the widget into the document.
+ *
+ * The prompt names the document by path because it is staged, not sent: the
+ * user may switch views before sending, which would leave "this document"
+ * pointing somewhere else.
+ */
+export function WorkspaceAddWidgetDialog({
+ documentPath,
+ open,
+ workspaceId,
+ onOpenChange,
+}: {
+ documentPath: string;
+ open: boolean;
+ workspaceId: string;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const descriptionId = useId();
+
+ return (
+
+ );
+}
diff --git a/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.test.tsx b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.test.tsx
new file mode 100644
index 000000000..40e3b13c2
--- /dev/null
+++ b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.test.tsx
@@ -0,0 +1,140 @@
+// @vitest-environment jsdom
+
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { WorkspaceWidgetSandbox } from "#/features/workspaces/components/widget/WorkspaceWidgetSandbox";
+import {
+ WIDGET_SANDBOX_FRAME_SOURCE,
+ WIDGET_SANDBOX_HOST_SOURCE,
+} from "#/features/workspaces/components/widget/workspace-widget-sandbox-document";
+
+const themeState = vi.hoisted(() => ({ resolvedTheme: "light" as "light" | "dark" }));
+
+vi.mock("#/components/theme-provider", () => ({ useTheme: () => themeState }));
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe("WorkspaceWidgetSandbox", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ themeState.resolvedTheme = "light";
+ document.documentElement.style.setProperty("--background", "white");
+ container = document.body.appendChild(document.createElement("div"));
+ root = createRoot(container);
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ });
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ document.documentElement.style.removeProperty("--background");
+ vi.unstubAllGlobals();
+ });
+
+ it("preserves a live widget across theme changes and rejects stale messages", async () => {
+ await render(root, );
+ const iframe = getIframe(container);
+ const frame = iframe.parentElement;
+ const initialDocument = iframe.getAttribute("srcdoc");
+ const postMessage = vi.spyOn(iframe.contentWindow, "postMessage");
+
+ await sendFrameMessage(iframe, { kind: "ready", sessionId: 1 });
+ await sendFrameMessage(iframe, { height: 900, kind: "height", sessionId: 1 });
+ expect(frame?.style.height).toBe("720px");
+
+ themeState.resolvedTheme = "dark";
+ document.documentElement.style.setProperty("--background", "black");
+ await render(root, );
+
+ expect(container.querySelector("iframe")).toBe(iframe);
+ expect(iframe.title).toBe("Counter widget");
+ expect(iframe.getAttribute("srcdoc")).toBe(initialDocument);
+ expect(postMessage).toHaveBeenLastCalledWith(
+ {
+ kind: "theme",
+ sessionId: 1,
+ source: WIDGET_SANDBOX_HOST_SOURCE,
+ theme: "dark",
+ tokens: expect.objectContaining({ "--background": "black" }),
+ },
+ "*",
+ );
+
+ await render(root, );
+ expect(frame?.style.height).toBe("120px");
+ expect(iframe.getAttribute("srcdoc")).toContain("var SESSION=2");
+ await sendFrameMessage(iframe, { height: 500, kind: "height", sessionId: 1 });
+ expect(frame?.style.height).toBe("120px");
+ });
+
+ it("lets a load error size itself instead of reloading broken source", async () => {
+ await render(root, );
+ const iframe = getIframe(container);
+ const frame = iframe.parentElement;
+
+ await sendFrameMessage(iframe, {
+ kind: "error",
+ message: "Unexpected token",
+ sessionId: 1,
+ });
+
+ expect(frame?.style.height).toBe("");
+ expect(frame?.querySelector("iframe")).toBeNull();
+ expect(frame?.querySelector("button")).toBeNull();
+ });
+
+ it("keeps a ready widget mounted after a runtime error", async () => {
+ await render(root, );
+ const iframe = getIframe(container);
+ const frame = iframe.parentElement;
+
+ await sendFrameMessage(iframe, { kind: "ready", sessionId: 1 });
+ await sendFrameMessage(iframe, { kind: "error", message: "Click failed", sessionId: 1 });
+
+ expect(frame?.querySelector("iframe")).toBe(iframe);
+ await clickButton(frame, "Dismiss");
+ expect(frame?.querySelector('[role="alert"]')).toBeNull();
+ expect(frame?.querySelector("iframe")).toBe(iframe);
+ });
+});
+
+async function render(root: Root, element: ReactNode) {
+ await act(async () => root.render(element));
+}
+
+function getIframe(container: HTMLElement) {
+ const iframe = container.querySelector("iframe");
+ if (!iframe?.contentWindow) throw new Error("Expected the sandbox iframe window");
+ return iframe as HTMLIFrameElement & { contentWindow: Window };
+}
+
+async function sendFrameMessage(
+ iframe: HTMLIFrameElement,
+ message:
+ | { kind: "ready"; sessionId: number }
+ | { height: number; kind: "height"; sessionId: number }
+ | { kind: "error"; message: string; sessionId: number },
+) {
+ await act(async () => {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { ...message, source: WIDGET_SANDBOX_FRAME_SOURCE },
+ source: iframe.contentWindow,
+ }),
+ );
+ });
+}
+
+async function clickButton(container: HTMLElement | null, label: string) {
+ const button = [...(container?.querySelectorAll("button") ?? [])].find(
+ (candidate) => candidate.textContent === label,
+ );
+ await act(async () => button?.click());
+}
diff --git a/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.tsx b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.tsx
new file mode 100644
index 000000000..cc6efd094
--- /dev/null
+++ b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.tsx
@@ -0,0 +1,206 @@
+import { useEffect, useRef, useState } from "react";
+
+import { useTheme } from "#/components/theme-provider";
+import { Button } from "#/components/ui/button";
+import {
+ buildWidgetSandboxDocument,
+ isWidgetSandboxFrameMessage,
+ type WidgetSandboxHostMessage,
+ type WidgetSandboxTheme,
+ WIDGET_SANDBOX_MAX_HEIGHT,
+ WIDGET_SANDBOX_MIN_HEIGHT,
+ WIDGET_SANDBOX_HOST_SOURCE,
+ WIDGET_SANDBOX_TOKENS,
+} from "#/features/workspaces/components/widget/workspace-widget-sandbox-document";
+import { cn } from "#/lib/utils";
+
+type WorkspaceWidgetSandboxProps = {
+ html: string;
+ className?: string;
+ label?: string;
+ /**
+ * Called with the runtime error text when the user asks the AI to fix a
+ * crashed widget. Omit to hide the affordance.
+ */
+ onAskAiToFix?: (error: string) => void;
+};
+
+type WidgetSandboxError = {
+ message: string;
+ preserveFrame: boolean;
+};
+
+/**
+ * Renders untrusted, AI-authored widget HTML inside an opaque-origin sandbox
+ * iframe. The frame cannot reach the parent's cookies, storage, or DOM. Host
+ * design tokens are injected so the widget looks native in both themes, and
+ * runtime errors surface a muted banner (with an optional "Ask AI to fix"
+ * action) instead of a blank frame.
+ */
+export function WorkspaceWidgetSandbox({
+ html,
+ className,
+ label,
+ onAskAiToFix,
+}: WorkspaceWidgetSandboxProps) {
+ const { resolvedTheme } = useTheme();
+ const iframeRef = useRef(null);
+ const renderedHtmlRef = useRef(null);
+ const sessionIdRef = useRef(0);
+ const readySessionIdRef = useRef(null);
+ const themeRef = useRef(null);
+ const [srcDoc, setSrcDoc] = useState(null);
+ const [error, setError] = useState(null);
+ const [height, setHeight] = useState(WIDGET_SANDBOX_MIN_HEIGHT);
+
+ // Build a fresh document only when authored HTML changes. Theme changes send
+ // a full token snapshot into the existing frame, preserving its JS state.
+ //
+ // Everything happens in a frame callback, which also keeps the effect
+ // render-safe. The tokens have to be read there rather than here: the theme
+ // provider applies the `.dark` class in its own effect, and React runs a
+ // child's effects before its parents', so reading now would take the previous
+ // theme's values and pair them with the new theme's class.
+ useEffect(() => {
+ const frame = requestAnimationFrame(() => {
+ const theme = readWidgetSandboxTheme(resolvedTheme);
+ themeRef.current = theme;
+
+ if (renderedHtmlRef.current !== html) {
+ renderedHtmlRef.current = html;
+ sessionIdRef.current += 1;
+ readySessionIdRef.current = null;
+ setHeight(WIDGET_SANDBOX_MIN_HEIGHT);
+ setSrcDoc(
+ buildWidgetSandboxDocument({
+ html,
+ ...theme,
+ origin: window.location.origin,
+ sessionId: sessionIdRef.current,
+ }),
+ );
+ setError(null);
+ return;
+ }
+
+ if (readySessionIdRef.current === sessionIdRef.current) {
+ postWidgetSandboxTheme(iframeRef.current, sessionIdRef.current, theme);
+ }
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [html, resolvedTheme]);
+
+ useEffect(() => {
+ function onMessage(event: MessageEvent) {
+ if (event.source !== iframeRef.current?.contentWindow) {
+ return;
+ }
+ if (!isWidgetSandboxFrameMessage(event.data)) {
+ return;
+ }
+ if (event.data.sessionId !== sessionIdRef.current) {
+ return;
+ }
+ if (event.data.kind === "error") {
+ setError({
+ message: event.data.message,
+ preserveFrame: readySessionIdRef.current === event.data.sessionId,
+ });
+ return;
+ }
+ if (event.data.kind === "height") {
+ setHeight(
+ Math.min(
+ WIDGET_SANDBOX_MAX_HEIGHT,
+ Math.max(WIDGET_SANDBOX_MIN_HEIGHT, event.data.height),
+ ),
+ );
+ return;
+ }
+ readySessionIdRef.current = event.data.sessionId;
+ if (themeRef.current) {
+ postWidgetSandboxTheme(iframeRef.current, event.data.sessionId, themeRef.current);
+ }
+ }
+
+ window.addEventListener("message", onMessage);
+ return () => window.removeEventListener("message", onMessage);
+ }, []);
+
+ const showFrame = !error || error.preserveFrame;
+
+ return (
+ // Height comes from the frame's own content, so the block takes the room
+ // the widget needs instead of a number chosen here.
+
',
),
).document;
const html = await serializeTiptapDocumentToAiHtml(document);
- expect(html).toMatch(/^
Notes<\/h1>/);
+ expect(html).toMatch(
+ /^
Notes<\/h1>/,
+ );
expect(html).toContain("bold");
expect(html).toContain('data-type="inline-math"');
expect(html).toContain('data-type="taskItem"');
expect(parseDocumentAiHtml(html)).toMatchObject({ type: "doc" });
});
- it("ignores refs supplied in model-authored HTML", async () => {
+ it("converts sub/sup into inline math instead of failing the write", async () => {
+ // Models write `CH4` in prose by habit — evals showed it even
+ // with an explicit instruction not to. This used to throw and lose the
+ // entire document rather than the two characters it could not represent.
const html = await serializeTiptapDocumentToAiHtml(
- ensureTiptapDocumentAiRefs(parseDocumentAiHtml('
',
),
@@ -60,11 +77,33 @@ describe("document AI HTML", () => {
expect(html).toContain(">Unsafe");
});
- it("rejects elements outside the supported document schema", () => {
- expect(() => parseDocumentAiHtml("
Unsupported
")).toThrow(
- DocumentAiHtmlError,
+ it("degrades unsupported markup instead of losing the whole write", async () => {
+ // Rejecting cost the entire document to save formatting we can flatten:
+ // a probe of realistic model markup found 12 of 20 snippets refused.
+ const html = await serializeTiptapDocumentToAiHtml(
+ ensureTiptapDocumentBlockIds(
+ parseDocumentAiHtml(
+ '
`,
+ ),
+ );
+}
+
+describe("document preview text", () => {
+ it("previews a widget by title rather than by its source", () => {
+ expect(extractDocumentPreviewText(createDocumentWithWidget("Sine explorer"))).toBe(
+ "Waves intro\nSine explorer\nAfter",
+ );
+ });
+
+ // Search indexes the markdown projection, so keeping widgets out of it is
+ // what keeps their source out of the full-text index.
+ it("keeps widget source out of the markdown search projection", () => {
+ const markdown = serializeTiptapDocumentToMarkdown(
+ parseDocumentAiHtml(
+ `
Waves intro
${widgetSource.replaceAll("<", "<")}
`,
+ ),
+ );
+
+ expect(markdown).toContain("Waves intro");
+ expect(markdown).not.toContain("SOURCE");
+ expect(markdown).not.toContain("Sine explorer");
+ });
+});
diff --git a/src/features/workspaces/documents/document-preview-text.ts b/src/features/workspaces/documents/document-preview-text.ts
index 105122a21..f7086921a 100644
--- a/src/features/workspaces/documents/document-preview-text.ts
+++ b/src/features/workspaces/documents/document-preview-text.ts
@@ -12,6 +12,10 @@ export const WORKSPACE_DOCUMENT_PREVIEW_TEXT_MAX_LENGTH = 500;
const documentPreviewTextSerializers: Record = {
blockMath: ({ node }) => getMathLatex(node.attrs.latex),
inlineMath: ({ node }) => getMathLatex(node.attrs.latex),
+ // A widget holds its source as text content, which is markup rather than
+ // prose. Preview its title so a widget-only document still reads as
+ // something, and never its source.
+ widget: ({ node }) => (typeof node.attrs.title === "string" ? node.attrs.title : ""),
};
export function getWorkspaceDocumentPreviewText(item: Pick) {
diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts
index 88ae52c04..8cc614109 100644
--- a/src/features/workspaces/documents/document-session.ts
+++ b/src/features/workspaces/documents/document-session.ts
@@ -14,7 +14,15 @@ import {
type DocumentAiEditFailureCode,
type DocumentAiEditResultStatus,
} from "#/features/workspaces/documents/document-ai-edits";
-import { ensureProseMirrorDocumentAiRefs } from "#/features/workspaces/documents/document-ai-html";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+
+import {
+ createDocumentAiBlockSnapshot,
+ type DocumentAiBlockSnapshot,
+ ensureProseMirrorDocumentBlockIds,
+ parseDocumentAiEditRef,
+ readTiptapNodeBlockId,
+} from "#/features/workspaces/documents/document-ai-html";
import {
type DocumentHtmlChunkReadInput,
type DocumentHtmlChunkReadResult,
@@ -343,6 +351,38 @@ export class DocumentSession extends YServer {
return chunk ? { ...chunk, revision, status: "ready" } : { status: "invalid_offset" };
}
+ /**
+ * One block in full, addressed by an editRef from an earlier read.
+ *
+ * Document reads elide a widget's source to keep prose in the chunk, so this
+ * is how the assistant fetches it before editing — and it works for any block
+ * that is easier to read alone than to page to.
+ */
+ async readBlock(input: {
+ editRef: string;
+ }): Promise<(DocumentAiBlockSnapshot & { status: "ready" }) | { status: "edit_ref_not_found" }> {
+ this.assertActive();
+ const { document } = await this.getReferencedDocumentSnapshot();
+ const blockId = parseDocumentAiEditRef(input.editRef);
+ if (!blockId) {
+ return { status: "edit_ref_not_found" };
+ }
+
+ let found: ProseMirrorNode | null = null;
+ document.forEach((node) => {
+ if (!found && readTiptapNodeBlockId(node) === blockId) {
+ found = node;
+ }
+ });
+
+ return found
+ ? {
+ ...(await createDocumentAiBlockSnapshot(found)),
+ status: "ready",
+ }
+ : { status: "edit_ref_not_found" };
+ }
+
async purgeForDeletion(): Promise {
// Deliberately does not hydrate the document: this only wipes durable
// storage, and onLoad could otherwise reseed from the deleted item.
@@ -453,7 +493,7 @@ export class DocumentSession extends YServer {
}
private async getReferencedDocumentSnapshot() {
- const refs = ensureProseMirrorDocumentAiRefs(this.getCurrentProseMirrorDocument());
+ const refs = ensureProseMirrorDocumentBlockIds(this.getCurrentProseMirrorDocument());
if (refs.changed) {
this.reconcileCurrentDocument(coerceTiptapDocumentJson(refs.document.toJSON()));
await this.persistYDoc();
diff --git a/src/features/workspaces/documents/document-widget-extension.ts b/src/features/workspaces/documents/document-widget-extension.ts
new file mode 100644
index 000000000..63fe2a8b3
--- /dev/null
+++ b/src/features/workspaces/documents/document-widget-extension.ts
@@ -0,0 +1,13 @@
+import { ReactNodeViewRenderer } from "@tiptap/react";
+
+import { DocumentWidgetView } from "#/features/workspaces/documents/document-widget-node";
+import { Widget } from "#/features/workspaces/documents/tiptap-schema";
+
+/** Adds the sandboxed React view to the shared widget schema node. */
+export const DocumentWidget = Widget.extend({
+ addNodeView() {
+ return ReactNodeViewRenderer(DocumentWidgetView, {
+ ignoreMutation: ({ mutation }) => mutation.type !== "selection",
+ });
+ },
+});
diff --git a/src/features/workspaces/documents/document-widget-node.tsx b/src/features/workspaces/documents/document-widget-node.tsx
new file mode 100644
index 000000000..db0f9ab30
--- /dev/null
+++ b/src/features/workspaces/documents/document-widget-node.tsx
@@ -0,0 +1,84 @@
+import type { NodeViewProps } from "@tiptap/react";
+import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
+import { Shapes } from "lucide-react";
+import { createContext, type ReactNode, use } from "react";
+
+import {
+ CodeBlockHeader,
+ CodeBlockLabel,
+ CodeBlockTitle,
+} from "#/components/code-block/code-block-chrome";
+import { WorkspaceWidgetSandbox } from "#/features/workspaces/components/widget/WorkspaceWidgetSandbox";
+
+const DocumentWidgetActionContext = createContext<((error: string) => void) | null>(null);
+
+export function DocumentWidgetActionProvider({
+ children,
+ onAskAiToFix,
+}: {
+ children: ReactNode;
+ onAskAiToFix?: (error: string) => void;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The editor view for authored widget HTML running in its sandboxed frame.
+ *
+ * The frame is a separate document, so its own events never reach the editor and
+ * there is nothing to suppress. What does reach the editor is a click or drag on
+ * the header, and those have to get through: they are how the widget is selected,
+ * deleted and moved. Tiptap's default `stopEvent` already lets exactly those
+ * through for a selectable, draggable node, so overriding it only breaks them.
+ *
+ * `contentEditable={false}` still stops a click being read as a text selection,
+ * and `ignoreMutation` stops the editor re-parsing when React re-renders the
+ * header — selection mutations pass through so clicking away behaves normally.
+ */
+export function DocumentWidgetView({ node, selected }: NodeViewProps) {
+ const html = node.textContent;
+ const title = typeof node.attrs.title === "string" ? node.attrs.title : "";
+ const onAskAiToFix = use(DocumentWidgetActionContext);
+ const label = title || "Widget";
+ const askAiToFix = onAskAiToFix
+ ? (error: string) => onAskAiToFix(title ? `the "${title}" widget: ${error}` : error)
+ : undefined;
+
+ return (
+
+ {/* Shared block chrome, as the code block and Mermaid diagram use, so
+ every embedded block in a document reads as the same kind of thing.
+ It doubles as the drag handle: without it there is no way to pick the
+ widget up or delete it, since clicks inside go to the frame. */}
+
+
+
+ {label}
+
+
+
+ {/* A widget's source is its text content, so ProseMirror needs somewhere
+ to render it. Without this element Tiptap appends one itself and the
+ raw source shows up as prose in the document. It stays hidden: the
+ source is the frame's input, not something to read in the page. */}
+
+
+ );
+}
diff --git a/src/features/workspaces/documents/tiptap-extensions.ts b/src/features/workspaces/documents/tiptap-extensions.ts
index 08fc457a9..68c2469c7 100644
--- a/src/features/workspaces/documents/tiptap-extensions.ts
+++ b/src/features/workspaces/documents/tiptap-extensions.ts
@@ -5,6 +5,7 @@ import "katex/dist/katex.min.css";
import "katex/contrib/mhchem";
import { CodeBlockShiki } from "#/features/workspaces/documents/code-block-shiki";
+import { DocumentWidget } from "#/features/workspaces/documents/document-widget-extension";
import { DocumentCitation } from "#/features/workspaces/documents/document-citation-node";
import {
getTiptapDocumentSchemaExtensions,
@@ -16,9 +17,10 @@ export { tiptapDocumentYjsField };
export function getTiptapDocumentBaseExtensions() {
return [
...getTiptapDocumentSchemaExtensions({
- // Both extend the node spec the server uses, adding only how it draws.
+ // All three extend the node spec the server uses, adding only how it draws.
citation: DocumentCitation,
codeBlock: CodeBlockShiki,
+ widget: DocumentWidget,
}),
Placeholder.configure({
placeholder: ({ node }) => (node.type.name === "heading" ? "Untitled" : "Write something..."),
diff --git a/src/features/workspaces/documents/tiptap-schema.ts b/src/features/workspaces/documents/tiptap-schema.ts
index 523e695d1..90abf8896 100644
--- a/src/features/workspaces/documents/tiptap-schema.ts
+++ b/src/features/workspaces/documents/tiptap-schema.ts
@@ -1,9 +1,10 @@
-import { type AnyExtension, Extension, getSchema, Node } from "@tiptap/core";
+import { type AnyExtension, Extension, getSchema, mergeAttributes, Node } from "@tiptap/core";
import CodeBlock from "@tiptap/extension-code-block";
import Highlight from "@tiptap/extension-highlight";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Link from "@tiptap/extension-link";
import { TaskItem, TaskList } from "@tiptap/extension-list";
+import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details";
import { Mathematics } from "@tiptap/extension-mathematics";
import { TableKit } from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
@@ -51,6 +52,49 @@ export const Citation = Node.create({
];
},
});
+/**
+ * An interactive widget: a self-contained HTML fragment the assistant writes,
+ * rendered in a sandboxed iframe by the client's node view.
+ *
+ * The source is the node's text content rather than an attribute. A widget runs
+ * to several kilobytes of markup and script, and escaping all of that into an
+ * attribute is exactly the kind of thing a model gets subtly wrong — this is the
+ * same reason a code block holds its code as content. `atom` means the editor
+ * treats it as one unit: there is content, but it is not typed into directly.
+ * `code` keeps input rules and smart typography from rewriting the source.
+ */
+export const Widget = Node.create({
+ name: "widget",
+ group: "block",
+ content: "text*",
+ marks: "",
+ code: true,
+ atom: true,
+ defining: true,
+ selectable: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ title: {
+ default: "",
+ parseHTML: (el) => el.getAttribute("title") ?? "",
+ renderHTML: (attrs) => (attrs.title ? { title: String(attrs.title) } : {}),
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="widget"]', preserveWhitespace: "full" as const }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ // Merge rather than replace: global attributes land here, and data-edit-ref is
+ // how the assistant addresses this block for reads and edits.
+ return ["div", mergeAttributes(HTMLAttributes, { "data-type": "widget" }), 0];
+ },
+});
+
export const tiptapDocumentAiRefAttribute = "aiRef";
const DocumentAiRef = Extension.create({
@@ -77,7 +121,7 @@ const DocumentAiRef = Extension.create({
parseHTML: () => null,
renderHTML: (attributes: Record) => {
const ref = attributes[tiptapDocumentAiRefAttribute];
- return typeof ref === "string" && ref ? { "data-ref": ref } : {};
+ return typeof ref === "string" && ref ? { "data-edit-ref": ref } : {};
},
},
},
@@ -96,9 +140,11 @@ export const tiptapDocumentKernelCodeBlock = CodeBlock;
export function getTiptapDocumentSchemaExtensions({
citation = Citation,
codeBlock = tiptapDocumentKernelCodeBlock,
+ widget = Widget,
}: {
citation?: AnyExtension;
codeBlock?: AnyExtension;
+ widget?: AnyExtension;
} = {}) {
return [
DocumentAiRef,
@@ -114,6 +160,7 @@ export function getTiptapDocumentSchemaExtensions({
undoRedo: false,
}),
codeBlock,
+ widget,
HorizontalRule,
UnderlineExtension,
Highlight,
@@ -130,6 +177,12 @@ export function getTiptapDocumentSchemaExtensions({
TextAlign.configure({
types: ["heading", "paragraph"],
}),
+ // Collapsible sections. Registered for content only — the AI writes
+ // naturally and it used to be flattened away; there is no
+ // toolbar affordance for inserting one by hand.
+ Details,
+ DetailsSummary,
+ DetailsContent,
TaskList,
TaskItem.configure({
nested: true,
diff --git a/src/features/workspaces/model/tree.ts b/src/features/workspaces/model/tree.ts
index b8a904ed8..5aa4cff1e 100644
--- a/src/features/workspaces/model/tree.ts
+++ b/src/features/workspaces/model/tree.ts
@@ -1,4 +1,5 @@
import type { WorkspaceItem } from "#/features/workspaces/model/types";
+import { joinWorkspacePathSegment } from "#/features/workspaces/kernel/workspace-kernel-paths";
interface WorkspaceTreeItem {
id: string;
@@ -127,6 +128,18 @@ export function getWorkspaceBreadcrumbItems(
return [...ancestors, item];
}
+export function getWorkspaceItemPath(
+ item: WorkspaceItem,
+ itemsById: ReadonlyMap,
+) {
+ const relativePath = getWorkspaceBreadcrumbItems(item, itemsById).reduce(
+ (path, entry) => joinWorkspacePathSegment(path, entry.name),
+ "",
+ );
+
+ return `/${relativePath}`;
+}
+
export function getWorkspaceItemMeta(item: WorkspaceItem, allItems: WorkspaceItem[]) {
if (item.type !== "folder") {
return item.meta;
diff --git a/src/features/workspaces/model/workspace-ai-context-reference.ts b/src/features/workspaces/model/workspace-ai-context-reference.ts
index f2d73c1de..f5fd6f5f8 100644
--- a/src/features/workspaces/model/workspace-ai-context-reference.ts
+++ b/src/features/workspaces/model/workspace-ai-context-reference.ts
@@ -1,7 +1,6 @@
import { getWorkspaceItemTypeMeta } from "#/features/workspaces/defaults";
-import { joinWorkspacePathSegment } from "#/features/workspaces/kernel/workspace-kernel-paths";
import type { WorkspaceTab } from "#/features/workspaces/model/tab-types";
-import { getWorkspaceBreadcrumbItems } from "#/features/workspaces/model/tree";
+import { getWorkspaceItemPath } from "#/features/workspaces/model/tree";
import type { WorkspaceItem } from "#/features/workspaces/model/types";
import { getWorkspaceAiContextItemViewState } from "#/features/workspaces/model/workspace-item-view-state";
import type { WorkspacePane } from "#/features/workspaces/state/workspace-ui-store";
@@ -22,7 +21,7 @@ export function getWorkspaceAiContextItemReference(input: {
return {
name: item.name,
- path: getWorkspaceAiContextItemPath(item, context.itemsById),
+ path: getWorkspaceItemPath(item, context.itemsById),
type: getWorkspaceItemTypeMeta(item.type),
state: {
activeVisible: isVisible,
@@ -76,16 +75,3 @@ export function getOpenTabItemIds(tabs: WorkspaceTab[]) {
return itemTabTitles;
}
-
-function getWorkspaceAiContextItemPath(
- item: WorkspaceItem,
- itemsById: ReadonlyMap,
-) {
- const breadcrumbItems = getWorkspaceBreadcrumbItems(item, itemsById);
- const relativePath = breadcrumbItems.reduce(
- (path, entry) => joinWorkspacePathSegment(path, entry.name),
- "",
- );
-
- return `/${relativePath}`;
-}
diff --git a/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap b/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap
index 8352173cc..ab3e09e77 100644
--- a/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap
+++ b/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap
@@ -29,11 +29,12 @@ exports[`workspace tool surface > system prompt is stable 1`] = `
# Output Format
- Format final answers as GitHub-flavored Markdown. Use concise headings, lists, blockquotes, links, tables, task lists, strikethrough, and fenced code blocks with language tags when they improve clarity.
-- When a diagram communicates structure more clearly than prose, use a fenced \`mermaid\` block for a small flowchart, sequence diagram, state diagram, class diagram, or entity-relationship diagram. Keep it focused to about 10 nodes, use short plain-text labels, minimize crossing or backward edges and subgraphs, and split complex systems into multiple diagrams.
+- When the user asks for a diagram or visual explanation, use a fenced \`mermaid\` block for a small flowchart, sequence diagram, state diagram, class diagram, or entity-relationship diagram. Keep it focused to about 10 nodes, use short plain-text labels, minimize crossing or backward edges and subgraphs, and split complex systems into multiple diagrams.
- Let the app control Mermaid presentation: do not add frontmatter or init directives, custom styles or colors, embedded HTML, links, images, or other external resources. Include a concise \`accTitle\` and \`accDescr\` describing the diagram.
- When writing Markdown with math, use \`$...$\` for inline math and \`$$...$$\` on separate lines for block math. Do not use \`\\(...\\)\` or \`\\[...\\]\`; our renderer only understands dollar-sign delimiters.
- Chemistry renders with \`\\ce{...}\` (e.g. \`$\\ce{CH4 + 2 O2 -> CO2 + 2 H2O}$\`) and quantities with units render with \`\\pu{...}\`.
-- CRITICAL: every literal dollar sign — every price, cost, salary, or currency figure — MUST be escaped as \`\\$\`. Write \`\\$5\`, not \`$5\`. Write \`\\$1,000 − \\$200 = \\$800\`, not \`$1,000 − $200 = $800\`. A missed escape turns the price into broken math on screen.
+- CRITICAL, in chat replies only: every literal dollar sign — every price, cost, salary, or currency figure — MUST be escaped as \`\\$\`. Write \`\\$5\`, not \`$5\`. Write \`\\$1,000 − \\$200 = \\$800\`, not \`$1,000 − $200 = $800\`. A missed escape turns the price into broken math on screen.
+- That escape is a Markdown rule and applies to chat replies only. Document and widget content is HTML, where \`\\$\` renders as a visible backslash — write money plainly there, as \`$30\`.
# Memory
- Use memory only for durable preferences, workspace goals, thread goals, and decisions. Do not store transient requests, secrets, full documents, item bodies, or actual workspace state."
@@ -200,14 +201,20 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"additionalProperties": false,
"properties": {
"edits": {
- "description": "Ordered structural HTML edits, at most 40. For targeted operations, copy data-ref into the "ref" field; there is no "target" field. These refs are local to this document and are not workspace citation refs.",
+ "description": "Ordered edits, at most 40. Target a block with the exact editRef from a document or block read. A block read returns the exact content that replace_text matches. Use overwrite only to discard the entire document and write a new one.",
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
+ "editRef": {
+ "description": "Exact editRef from a recent document or block read.",
+ "maxLength": 64,
+ "minLength": 1,
+ "type": "string",
+ },
"html": {
- "description": "Schema-constrained HTML fragment. Model-supplied data-ref attributes are ignored.",
+ "description": "Schema-constrained HTML fragment. Model-supplied data-edit-ref attributes are ignored.",
"maxLength": 512000,
"type": "string",
},
@@ -219,56 +226,83 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
],
"type": "string",
},
- "ref": {
- "description": "Exact data-ref from a recent HTML read. Put it in the "ref" field, never "target".",
+ },
+ "required": [
+ "editRef",
+ "html",
+ "op",
+ ],
+ "type": "object",
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "editRef": {
+ "description": "Exact editRef from a recent document or block read.",
"maxLength": 64,
"minLength": 1,
"type": "string",
},
+ "op": {
+ "const": "delete",
+ "type": "string",
+ },
},
"required": [
- "html",
+ "editRef",
"op",
- "ref",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
- "op": {
- "const": "delete",
+ "html": {
+ "description": "Schema-constrained HTML fragment. Model-supplied data-edit-ref attributes are ignored.",
+ "maxLength": 512000,
"type": "string",
},
- "ref": {
- "description": "Exact data-ref from a recent HTML read. Put it in the "ref" field, never "target".",
- "maxLength": 64,
- "minLength": 1,
+ "op": {
+ "const": "overwrite",
"type": "string",
},
},
"required": [
+ "html",
"op",
- "ref",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
- "html": {
- "description": "Schema-constrained HTML fragment. Model-supplied data-ref attributes are ignored.",
+ "editRef": {
+ "description": "Exact editRef from a recent document or block read.",
+ "maxLength": 64,
+ "minLength": 1,
+ "type": "string",
+ },
+ "find": {
+ "description": "Exact text to replace inside the target block, copied from a read. It must appear exactly once in that block; if it matches more than once the edit fails instead of replacing every occurrence.",
"maxLength": 512000,
+ "minLength": 1,
"type": "string",
},
"op": {
- "const": "replace_all",
+ "const": "replace_text",
+ "type": "string",
+ },
+ "replace": {
+ "description": "Replacement text. May be empty to delete the matched text.",
+ "maxLength": 512000,
"type": "string",
},
},
"required": [
- "html",
+ "editRef",
+ "find",
"op",
+ "replace",
],
"type": "object",
},
@@ -479,6 +513,32 @@ exports[`workspace tool surface > workspace_read_items input schema is stable 1`
],
"type": "object",
},
+ {
+ "additionalProperties": false,
+ "properties": {
+ "editRef": {
+ "description": "editRef of one block from an earlier document read. The result returns the block in full with its current editRef.",
+ "maxLength": 64,
+ "minLength": 1,
+ "type": "string",
+ },
+ "mode": {
+ "const": "block",
+ "type": "string",
+ },
+ "path": {
+ "description": "Absolute path of the workspace item to read.",
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "path",
+ "editRef",
+ "mode",
+ ],
+ "type": "object",
+ },
],
},
"maxItems": 20,
diff --git a/src/features/workspaces/operations/workspace-tool-definitions.ts b/src/features/workspaces/operations/workspace-tool-definitions.ts
index a3427b1f0..307b8048f 100644
--- a/src/features/workspaces/operations/workspace-tool-definitions.ts
+++ b/src/features/workspaces/operations/workspace-tool-definitions.ts
@@ -149,7 +149,7 @@ export const workspaceToolDefinitions = [
name: "workspace_read_items",
access: "read",
description:
- "Read ThinkEx documents and extracted files by absolute path. Documents return bounded HTML block chunks; each top-level data-ref is an item-local edit target, not a citation ref. Files support explicit physical-page selections. Continue either kind with nextCursor. Uploaded files may still be extracting; each result carries any needed handling guidance.",
+ "Read ThinkEx documents and extracted files by absolute path. Document chunks give each top-level block an editRef. Widgets come back as an empty placeholder, so read one with mode block to get its full content and current editRef before editing it. Files support explicit physical-page selections. Continue either kind with nextCursor. Uploaded files may still be extracting; each result carries any needed handling guidance.",
inputSchema: workspaceReadItemsInputSchema,
inputExamples: workspaceReadItemsInputExamples,
outputSchema: workspaceReadItemsOutputSchema,
@@ -244,7 +244,7 @@ export const workspaceToolDefinitions = [
defineWorkspaceTool({
name: "workspace_edit_item",
access: "write",
- description: `Edit one actual ThinkEx workspace document by absolute path using structural HTML operations. Read first for targeted edits; replace_all can rewrite the whole document without a read. Use workspace_link_items to add relationships. ${workspaceDocumentHtmlInstruction}`,
+ description: `Edit one actual ThinkEx workspace document by absolute path. Read it first, then target blocks with their exact editRef. A block read returns the exact content that replace_text matches. Only overwrite replaces the whole document, and only it works without a read. Use workspace_link_items to add relationships. ${workspaceDocumentHtmlInstruction}`,
inputSchema: workspaceEditItemInputSchema,
inputExamples: workspaceEditItemInputExamples,
outputSchema: workspaceEditItemOutputSchema,
diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts
index 26d3a91fb..4d9247c92 100644
--- a/src/features/workspaces/operations/workspace-tool-schemas.ts
+++ b/src/features/workspaces/operations/workspace-tool-schemas.ts
@@ -34,8 +34,21 @@ export {
workspaceSearchOutputSchema,
};
-export const workspaceDocumentHtmlInstruction =
- 'Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. For math, use or — this is the only math that renders, so never write $...$, $$...$$, or \\(...\\) in the HTML, and never put dollar signs around the data-latex value. Put every subscript and superscript (exponents like 10^8, indices like x_1) inside math, not or tags, which are unsupported and reject the whole write. Chemistry renders with \\ce{...} (e.g. \\ce{CH4 + 2 O2 -> CO2 + 2 H2O}) and quantities with units render with \\pu{...} (e.g. \\pu{9.81 m/s^2}), both inside data-latex. Write literal money as plain text ($30, never \\$30) — this is HTML, not Markdown, so a backslash before a dollar sign shows on screen. For checkboxes, use
Item
. Documents cannot hold images: never use or , and describe the visual in words instead. Cite workspace sources in documents exactly as in a chat reply, with placed after the claim it supports.';
+/**
+ * Math, chemistry, and money for the HTML surfaces. Documents and widgets are
+ * both HTML, so they share one rule and the model tracks "Markdown or HTML?"
+ * rather than three per-surface dialects — chat keeps the `$…$` Markdown form.
+ */
+const workspaceHtmlMathInstruction =
+ 'This is HTML, so math is markup rather than delimiters: use or , and keep dollar signs out of the data-latex value. Put every subscript and superscript (exponents like 10^8, indices like x_1) inside math rather than / tags. Chemistry renders with \\ce{...} (e.g. \\ce{CH4 + 2 O2 -> CO2 + 2 H2O}) and quantities with units render with \\pu{...} (e.g. \\pu{9.81 m/s^2}), both inside data-latex. Write literal money as plain text ($30, never \\$30) — a backslash before a dollar sign shows on screen in HTML.';
+
+/**
+ * Keep discovery and serialization beside the document tool. The activated
+ * skill owns the authoring contract so the two prompts cannot drift apart.
+ */
+const workspaceWidgetHtmlInstruction = `A widget is one interactive block inside a document. Use one when the user explicitly asks for a widget, asks for interaction or live computation, or wants a document visual that ordinary blocks cannot express. Keep ordinary content in ordinary blocks. Before authoring or editing widget source, activate the "widget-authoring" skill and follow its HTML, sandbox, layout, and editing contract. Serialize the result as
…HTML-escaped fragment…
.`;
+
+export const workspaceDocumentHtmlInstruction = `Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. ${workspaceHtmlMathInstruction} For checkboxes, use
Item
. Documents cannot hold images: never use or , and describe the visual in words instead. Cite workspace sources in documents exactly as in a chat reply, with placed after the claim it supports. ${workspaceWidgetHtmlInstruction}`;
const workspacePathSchema = z.string().min(1);
const workspaceIndexSchema = z.number().int().nonnegative();
@@ -124,7 +137,7 @@ export const workspaceEditItemInputSchema = z.object({
.min(1)
.max(40)
.describe(
- 'Ordered structural HTML edits, at most 40. For targeted operations, copy data-ref into the "ref" field; there is no "target" field. These refs are local to this document and are not workspace citation refs.',
+ "Ordered edits, at most 40. Target a block with the exact editRef from a document or block read. A block read returns the exact content that replace_text matches. Use overwrite only to discard the entire document and write a new one.",
),
});
@@ -226,6 +239,15 @@ export const workspaceReadItemsInputExamples = createInputExamples<
},
],
},
+ {
+ requests: [
+ {
+ editRef: "b_JQrkL4Neurv2.r_6sNqkQxDdy",
+ mode: "block",
+ path: "/Demo Folder/Demo Document",
+ },
+ ],
+ },
);
export const workspaceSearchInputExamples = createInputExamples<
@@ -292,8 +314,8 @@ export const workspaceEditItemInputExamples = createInputExamples<
path: "/Demo Folder/Demo Document",
edits: [
{
+ editRef: "b_JQrkL4Neurv2.r_6sNqkQxDdy",
op: "replace",
- ref: "b_JQrkL4Neurv2.r_6sNqkQxDdy",
html: "