",
- study: {
- lastRating: "good",
- lastReviewedAt: "2026-08-13T12:00:00.000Z",
- reviewCount: 1,
- },
- },
- ],
- format: "html",
- itemId: "flashcard-1",
- location: { kind: "cards", returned: [1], total: 1 },
- path: "/Geography",
- progress: {
- gotItCount: 1,
- missedCount: 0,
- reviewedCount: 1,
- totalCards: 1,
- unreviewedCount: 0,
- },
- status: "ready",
- type: "flashcard",
- };
-}
diff --git a/src/features/workspaces/content/workspace-read-references.ts b/src/features/workspaces/content/workspace-read-references.ts
deleted file mode 100644
index 29cf6ad9..00000000
--- a/src/features/workspaces/content/workspace-read-references.ts
+++ /dev/null
@@ -1,268 +0,0 @@
-import {
- createWorkspaceReferenceRecords,
- getWorkspaceLocationKey,
- type WorkspaceLocation,
- type WorkspaceReference,
- type WorkspaceReferenceRecord,
-} from "#/features/workspaces/locations/workspace-location";
-import {
- parseDocumentAiRef,
- readDocumentAiRefs,
- readDocumentAiRefRevision,
-} from "#/features/workspaces/documents/document-ai-html";
-import type {
- WorkspaceContentReadResult,
- WorkspaceReadItemsOutput,
-} from "#/features/workspaces/content/workspace-content-contract";
-
-/**
- * Allocates durable-location records for every ready workspace read.
- *
- * Editable blocks and cards retain a revision with their durable location.
- * PDFs receive one location per physical page; other files use the item.
- *
- * @param results - Ordered workspace read results.
- * @returns Deduplicated reference records for the rich tool result.
- */
-export function createWorkspaceReadReferences(
- results: readonly WorkspaceContentReadResult[],
-): WorkspaceReferenceRecord[] {
- const targets: Array = [];
-
- for (const result of results) {
- if (result.status !== "ready") {
- continue;
- }
- if (result.type === "flashcard") {
- for (const card of result.cards) {
- targets.push({
- location: {
- itemId: result.itemId,
- kind: "flashcard",
- cardId: card.cardId,
- version: 1,
- },
- revision: card.revision,
- });
- }
- continue;
- }
- if (result.type === "document" || result.type === "block") {
- const contentRefs =
- result.type === "block" ? [result.contentRef] : readDocumentAiRefs(result.content);
- for (const contentRef of contentRefs) {
- const blockId = parseDocumentAiRef(contentRef);
- const revision = readDocumentAiRefRevision(contentRef);
- if (!blockId || !revision)
- throw new Error("Document read returned an invalid content ref.");
- targets.push({
- location: { blockId, itemId: result.itemId, kind: "document-block", version: 1 },
- revision,
- });
- }
- continue;
- }
-
- if (result.type !== "file" || result.assetKind !== "pdf") {
- targets.push({
- itemId: result.itemId,
- kind: "item",
- version: 1,
- });
- continue;
- }
-
- for (const pageNumber of result.location.returned) {
- targets.push({
- itemId: result.itemId,
- kind: "pdf-page",
- pageNumber,
- version: 1,
- });
- }
- }
-
- return createWorkspaceReferenceRecords(targets);
-}
-
-/**
- * Guidance for read outcomes the model has to react to, keyed by situation.
- *
- * These live here rather than in the tool description because none of them
- * change how a read is issued, and background extraction states are rare enough
- * that every request should not carry the instructions for handling them.
- */
-const workspaceReadGuidance = {
- pending:
- "Some paths are still extracting. Never sleep, poll, or otherwise stall waiting for them, including inside compute, sandbox_bash, or orchestrate. If an original file is attached, use it and do not read that path again in this reply. Otherwise, either do other work and read pending paths again later in this reply, or tell the user they are still processing and to ask again in about retryAfterSeconds. Never read the same pending path more than twice in one reply.",
- unrecoverable:
- "Extraction will not finish for some paths. Report the code and any message to the user; do not retry those reads and do not suggest re-uploading the file.",
- transient:
- "Some paths failed on a transient storage problem. One repeat read is reasonable; if it fails again, tell the user.",
- provisional:
- "Some content came from a fast first pass. Pages listed in emptyPages are still extracting, so read them again later rather than reporting them as blank.",
-} as const;
-
-/**
- * Collects the handling guidance a set of read results calls for.
- *
- * Emitted once per situation rather than once per result so a batch of pending
- * reads does not repeat the same paragraph for every path.
- *
- * @param results - Ordered workspace read results.
- * @returns Guidance lines for the situations present, most actionable first.
- */
-function createWorkspaceReadGuidance(results: readonly WorkspaceContentReadResult[]): string[] {
- const situations = new Set();
-
- for (const result of results) {
- if (result.status === "pending") {
- situations.add("pending");
- continue;
- }
-
- if (result.status === "failed") {
- if (result.code === "extraction_failed" || result.code === "extraction_stalled") {
- situations.add("unrecoverable");
- } else if (result.code === "projection_failed") {
- situations.add("transient");
- }
- continue;
- }
-
- // Gate on provisional only: emptyPages on a final (non-provisional) read
- // describes genuinely blank pages, which must not be reported as still
- // extracting or the model retries a completed read forever.
- if (result.type === "file" && result.provisional) {
- situations.add("provisional");
- }
- }
-
- return (Object.keys(workspaceReadGuidance) as Array)
- .filter((situation) => situations.has(situation))
- .map((situation) => workspaceReadGuidance[situation]);
-}
-
-/**
- * Projects a rich workspace read result into compact model-visible JSON.
- *
- * Raw item IDs and durable locations remain in the persisted tool result.
- * Model-visible content receives only opaque refs next to the content they
- * identify, plus guidance for any outcome that needs handling.
- *
- * @param output - Validated rich workspace read output.
- * @returns JSON-safe results annotated with short workspace refs.
- */
-export function createWorkspaceReadItemsModelOutput(output: WorkspaceReadItemsOutput) {
- const refsByLocation = new Map(
- output.references.map((record) => [getWorkspaceLocationKey(record.location), record.ref]),
- );
- const refFor = (location: WorkspaceLocation) =>
- refsByLocation.get(getWorkspaceLocationKey(location));
- const guidance = createWorkspaceReadGuidance(output.results);
-
- return {
- ...(guidance.length > 0 ? { guidance } : {}),
- results: output.results.map((result) => {
- if (result.status === "pending") {
- return omitWorkspaceReadItemId(result);
- }
-
- if (result.status === "failed") {
- return result;
- }
- if (result.type === "flashcard") {
- return {
- ...omitWorkspaceReadItemId(result),
- cards: result.cards.map(({ cardId, revision: _revision, ...card }) => ({
- ...card,
- ref: refFor({ itemId: result.itemId, kind: "flashcard", cardId, version: 1 }),
- })),
- };
- }
- if (result.type === "document") {
- return {
- ...omitWorkspaceReadItemId(result),
- content: replaceDocumentContentRefs(result, refsByLocation),
- };
- }
- if (result.type === "block") {
- const blockId = parseDocumentAiRef(result.contentRef);
- const ref = blockId
- ? refFor({ blockId, itemId: result.itemId, kind: "document-block", version: 1 })
- : undefined;
- const { contentRef: _contentRef, itemId: _itemId, ...modelResult } = result;
- return { ...modelResult, ...(ref ? { ref } : {}) };
- }
-
- if (result.type !== "file" || result.assetKind !== "pdf") {
- const ref = refFor({ itemId: result.itemId, kind: "item", version: 1 });
-
- return {
- ...omitWorkspaceReadItemId(result),
- ...(ref ? { ref } : {}),
- };
- }
-
- const references = result.location.returned.flatMap((pageNumber) => {
- const ref = refFor({
- itemId: result.itemId,
- kind: "pdf-page",
- pageNumber,
- version: 1,
- });
-
- return ref ? [{ pageNumber, ref }] : [];
- });
-
- return {
- ...omitWorkspaceReadItemId(result),
- content: annotateWorkspaceReadPageHeadings(result.content, references),
- };
- }),
- };
-}
-
-function replaceDocumentContentRefs(
- result: Extract,
- refsByLocation: ReadonlyMap,
-) {
- return result.content.replace(/data-ref="([^"]+)"/g, (attribute, contentRef: string) => {
- const blockId = parseDocumentAiRef(contentRef);
- if (!blockId) return attribute;
- const ref = refsByLocation.get(
- getWorkspaceLocationKey({
- blockId,
- itemId: result.itemId,
- kind: "document-block",
- version: 1,
- }),
- );
- return ref ? `data-ref="${ref}"` : attribute;
- });
-}
-
-function omitWorkspaceReadItemId(
- result: T,
-): Omit {
- const { itemId: _itemId, ...modelResult } = result;
-
- return modelResult;
-}
-
-function annotateWorkspaceReadPageHeadings(
- content: string,
- references: readonly { readonly pageNumber: number; readonly ref: WorkspaceReference }[],
-) {
- const refsByPage = new Map(references.map(({ pageNumber, ref }) => [pageNumber, ref]));
-
- return content.replace(/^## Page (\d+)[ \t]*$/gm, (heading, pageNumberText: string) => {
- const pageNumber = Number(pageNumberText);
- const ref = refsByPage.get(pageNumber);
- if (!ref) {
- return heading;
- }
-
- return `${heading} [ref: ${ref}]`;
- });
-}
diff --git a/src/features/workspaces/documents/document-ai-edits.test.ts b/src/features/workspaces/documents/document-ai-edits.test.ts
index b96e55cb..ff169c92 100644
--- a/src/features/workspaces/documents/document-ai-edits.test.ts
+++ b/src/features/workspaces/documents/document-ai-edits.test.ts
@@ -103,7 +103,7 @@ describe("document AI edits", () => {
expect(rewritten.document.content?.at(-1)).toMatchObject({ type: "paragraph" });
expect(await serializeTiptapDocumentToAiHtml(rewritten.document)).toMatch(
- /<\/ul>
<\/p>$/,
+ /<\/ul>
<\/p>$/,
);
});
diff --git a/src/features/workspaces/documents/document-ai-edits.ts b/src/features/workspaces/documents/document-ai-edits.ts
index ce6b67fa..56dc7e6b 100644
--- a/src/features/workspaces/documents/document-ai-edits.ts
+++ b/src/features/workspaces/documents/document-ai-edits.ts
@@ -19,9 +19,9 @@ import {
type TiptapDocumentJson,
} from "#/features/workspaces/documents/tiptap-document";
import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema";
-import { workspaceReferenceInputSchema } from "#/features/workspaces/locations/workspace-location";
+import { workspaceUnitRefInputSchema } from "#/features/workspaces/locations/workspace-location";
-const documentRefSchema = workspaceReferenceInputSchema.describe(
+const documentRefSchema = workspaceUnitRefInputSchema.describe(
"Exact ref from a recent document or block read.",
);
export const documentAiHtmlSchema = z
diff --git a/src/features/workspaces/documents/document-ai-html.test.ts b/src/features/workspaces/documents/document-ai-html.test.ts
index ffa56fc4..06c3fc0e 100644
--- a/src/features/workspaces/documents/document-ai-html.test.ts
+++ b/src/features/workspaces/documents/document-ai-html.test.ts
@@ -19,7 +19,7 @@ describe("document AI HTML", () => {
).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"');
@@ -47,7 +47,7 @@ describe("document AI HTML", () => {
);
expect(html).not.toContain("b_modelchosen1");
- expect(html).toMatch(/data-ref="b_[A-Za-z0-9_-]{12}\.r_[A-Za-z0-9_-]{10}"/);
+ expect(html).toMatch(/data-ref="b_[A-Za-z0-9_-]{12}\.r_[A-Za-z0-9_-]{6}"/);
});
it("normalizes malformed but recoverable HTML", () => {
diff --git a/src/features/workspaces/documents/document-ai-html.ts b/src/features/workspaces/documents/document-ai-html.ts
index cb22531a..6eeafeff 100644
--- a/src/features/workspaces/documents/document-ai-html.ts
+++ b/src/features/workspaces/documents/document-ai-html.ts
@@ -16,7 +16,7 @@ import { sha256Base64UrlText } from "#/lib/binary";
const TEXT_NODE = 3;
const documentBlockIdPattern = /^b_[A-Za-z0-9_-]{12}$/;
-const documentAiRefPattern = /^(b_[A-Za-z0-9_-]{12})\.r_([A-Za-z0-9_-]{10})$/;
+const documentAiRefPattern = /^(b_[A-Za-z0-9_-]{12})\.r_([A-Za-z0-9_-]{6})$/;
export class DocumentAiHtmlError extends Error {}
export class WidgetScriptSyntaxError extends DocumentAiHtmlError {}
@@ -108,7 +108,7 @@ export async function createDocumentAiRef(node: ProseMirrorNode) {
// second DOM pass per block per read, and the ref only has to change when
// the block's content does.
const content = JSON.stringify(withTiptapNodeAiRef(node, null).toJSON());
- const revision = (await sha256Base64UrlText(content)).slice(0, 10);
+ const revision = (await sha256Base64UrlText(content)).slice(0, 6);
return `${blockId}.r_${revision}`;
}
@@ -116,16 +116,6 @@ export function parseDocumentAiRef(ref: string) {
return documentAiRefPattern.exec(ref)?.[1] ?? null;
}
-export function readDocumentAiRefRevision(ref: string) {
- return documentAiRefPattern.exec(ref)?.[2] ?? null;
-}
-
-export function readDocumentAiRefs(html: string) {
- return Array.from(html.matchAll(/\sdata-ref="([^"]+)"/g), (match) => match[1]!).filter((ref) =>
- documentAiRefPattern.test(ref),
- );
-}
-
export function ensureTiptapDocumentBlockIds(document: TiptapDocumentJson): {
changed: boolean;
document: TiptapDocumentJson;
@@ -375,6 +365,9 @@ export function applyDocumentCitationLocations(
case "flashcard":
element.setAttribute("data-card-id", location.cardId);
break;
+ case "quiz-question":
+ element.setAttribute("data-question-id", location.questionId);
+ break;
}
}
diff --git a/src/features/workspaces/documents/document-ai-html.worker.test.ts b/src/features/workspaces/documents/document-ai-html.worker.test.ts
index 2741508f..2b755a80 100644
--- a/src/features/workspaces/documents/document-ai-html.worker.test.ts
+++ b/src/features/workspaces/documents/document-ai-html.worker.test.ts
@@ -13,7 +13,7 @@ describe("document AI HTML in Workers", () => {
).document;
expect(await serializeTiptapDocumentToAiHtml(document)).toMatch(
- /^
Worker<\/h2>
Schema-safe HTML<\/p>$/,
+ /^
Worker<\/h2>
Schema-safe HTML<\/p>$/,
);
});
});
diff --git a/src/features/workspaces/documents/document-html-chunk.ts b/src/features/workspaces/documents/document-html-chunk.ts
index 0810bda1..9d1991ce 100644
--- a/src/features/workspaces/documents/document-html-chunk.ts
+++ b/src/features/workspaces/documents/document-html-chunk.ts
@@ -17,18 +17,19 @@ export interface DocumentHtmlChunk {
}
export interface DocumentHtmlChunkReadInput {
- expectedRevision?: string;
offset: number;
+ /** Stop after this many blocks even if the character budget has room. */
+ maxBlocks?: number;
}
export type DocumentHtmlChunkReadResult =
- | { status: "content_changed" }
| { status: "invalid_offset" }
- | ({ revision: string; status: "ready" } & DocumentHtmlChunk);
+ | ({ status: "ready" } & DocumentHtmlChunk);
export async function readDocumentHtmlChunk(
document: ProseMirrorNode,
offset: number,
+ maxBlocks?: number,
): Promise {
if (offset < 0 || offset >= document.childCount) {
return undefined;
@@ -38,6 +39,7 @@ export async function readDocumentHtmlChunk(
let characters = 0;
let endOffset = offset;
while (endOffset < document.childCount) {
+ if (maxBlocks !== undefined && endOffset - offset >= maxBlocks) break;
const block = await serializeTiptapNodeToAiHtml(document.child(endOffset));
const separatorCharacters = content.length > 0 ? 1 : 0;
if (
diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts
index 3b8c63c6..98049c23 100644
--- a/src/features/workspaces/documents/document-session.ts
+++ b/src/features/workspaces/documents/document-session.ts
@@ -51,7 +51,7 @@ import {
commitWorkspaceDocumentCheckpoint,
readWorkspaceDocumentCheckpoint,
} from "#/features/workspaces/persistence/workspace-document-checkpoints";
-import { sha256Base64Url, sha256Base64UrlText } from "#/lib/binary";
+import { sha256Base64UrlText } from "#/lib/binary";
const persistedYDocUpdateKey = "document-session:yjs-update";
const latestDocumentEditReceiptKey = "document-session:ai-edit-receipt:latest";
@@ -354,14 +354,10 @@ export class DocumentSession extends YServer {
async readHtmlChunk(input: DocumentHtmlChunkReadInput): Promise {
this.assertActive();
- const { document, stateVector } = await this.getReferencedDocumentSnapshot();
- const revision = await sha256Base64Url(stateVector);
- if (input.expectedRevision && input.expectedRevision !== revision) {
- return { status: "content_changed" };
- }
+ const { document } = await this.getReferencedDocumentSnapshot();
- const chunk = await readDocumentHtmlChunk(document, input.offset);
- return chunk ? { ...chunk, revision, status: "ready" } : { status: "invalid_offset" };
+ const chunk = await readDocumentHtmlChunk(document, input.offset, input.maxBlocks);
+ return chunk ? { ...chunk, status: "ready" } : { status: "invalid_offset" };
}
/**
diff --git a/src/features/workspaces/documents/tiptap-schema.ts b/src/features/workspaces/documents/tiptap-schema.ts
index fe794b81..fdf78a85 100644
--- a/src/features/workspaces/documents/tiptap-schema.ts
+++ b/src/features/workspaces/documents/tiptap-schema.ts
@@ -34,6 +34,7 @@ export const Citation = Node.create({
itemId: { default: null, parseHTML: (el) => el.getAttribute("data-item-id") },
blockId: { default: null, parseHTML: (el) => el.getAttribute("data-block-id") },
cardId: { default: null, parseHTML: (el) => el.getAttribute("data-card-id") },
+ questionId: { default: null, parseHTML: (el) => el.getAttribute("data-question-id") },
pageNumber: {
default: null,
parseHTML: (el) => {
@@ -55,6 +56,7 @@ export const Citation = Node.create({
"data-item-id": node.attrs.itemId,
...(node.attrs.blockId ? { "data-block-id": String(node.attrs.blockId) } : {}),
...(node.attrs.cardId ? { "data-card-id": String(node.attrs.cardId) } : {}),
+ ...(node.attrs.questionId ? { "data-question-id": String(node.attrs.questionId) } : {}),
...(node.attrs.pageNumber ? { "data-page": String(node.attrs.pageNumber) } : {}),
},
];
@@ -67,13 +69,15 @@ export function getWorkspaceCitationLocation(
const itemId = typeof attrs.itemId === "string" ? attrs.itemId : null;
if (!itemId) return undefined;
- const location = attrs.cardId
- ? { cardId: attrs.cardId, itemId, kind: "flashcard", version: 1 }
- : attrs.blockId
- ? { blockId: attrs.blockId, itemId, kind: "document-block", version: 1 }
- : attrs.pageNumber
- ? { itemId, kind: "pdf-page", pageNumber: attrs.pageNumber, version: 1 }
- : { itemId, kind: "item", version: 1 };
+ const location = attrs.questionId
+ ? { itemId, kind: "quiz-question", questionId: attrs.questionId, version: 1 }
+ : attrs.cardId
+ ? { cardId: attrs.cardId, itemId, kind: "flashcard", version: 1 }
+ : attrs.blockId
+ ? { blockId: attrs.blockId, itemId, kind: "document-block", version: 1 }
+ : attrs.pageNumber
+ ? { itemId, kind: "pdf-page", pageNumber: attrs.pageNumber, version: 1 }
+ : { itemId, kind: "item", version: 1 };
const parsed = workspaceLocationSchema.safeParse(location);
return parsed.success ? parsed.data : undefined;
diff --git a/src/features/workspaces/locations/workspace-location-context.test.tsx b/src/features/workspaces/locations/workspace-location-context.test.tsx
index f8dad677..6c80feeb 100644
--- a/src/features/workspaces/locations/workspace-location-context.test.tsx
+++ b/src/features/workspaces/locations/workspace-location-context.test.tsx
@@ -30,6 +30,7 @@ describe("WorkspaceLocationProvider", () => {
type: "file",
name: "Book.pdf",
color: null,
+ refKey: "ref-file-1",
metadataJson: {},
sortOrder: 1,
createdAt: "2026-08-13T00:00:00.000Z",
diff --git a/src/features/workspaces/locations/workspace-location-context.tsx b/src/features/workspaces/locations/workspace-location-context.tsx
index fceaae93..c59ea90f 100644
--- a/src/features/workspaces/locations/workspace-location-context.tsx
+++ b/src/features/workspaces/locations/workspace-location-context.tsx
@@ -2,7 +2,11 @@ import { FileQuestion, type LucideIcon } from "lucide-react";
import { createContext, type ReactNode, use, useCallback, useState } from "react";
import { toast } from "sonner";
-import type { WorkspaceLocation } from "#/features/workspaces/locations/workspace-location";
+import {
+ parseWorkspaceAddress,
+ resolveWorkspaceAddressLocation,
+ type WorkspaceLocation,
+} from "#/features/workspaces/locations/workspace-location";
import { getWorkspaceItemDisplay } from "#/features/workspaces/model/item-display";
import type { WorkspaceItem } from "#/features/workspaces/contracts";
@@ -24,6 +28,8 @@ type WorkspaceLocationContextValue = {
completeRevealRequest: (request: WorkspaceLocationRevealRequest, revealed: boolean) => void;
getItem: (itemId: string) => WorkspaceItem | undefined;
getPresentation: (location: WorkspaceLocation) => WorkspaceLocationPresentation;
+ /** Resolves a self-describing address like `Xk7p2Qa9/p5` against the live workspace. */
+ resolveAddress: (ref: unknown) => WorkspaceLocation | undefined;
reveal: (location: WorkspaceLocation) => boolean;
revealRequest: WorkspaceLocationRevealRequest | null;
};
@@ -56,6 +62,16 @@ export function WorkspaceLocationProvider({
getItem(itemId) {
return itemsById.get(itemId);
},
+ resolveAddress(ref) {
+ const address = typeof ref === "string" ? parseWorkspaceAddress(ref) : undefined;
+ if (!address) return undefined;
+ for (const item of itemsById.values()) {
+ if (item.refKey === address.refKey) {
+ return resolveWorkspaceAddressLocation(item, address);
+ }
+ }
+ return undefined;
+ },
getPresentation(location) {
const item = itemsById.get(location.itemId);
const itemName = item?.name ?? "Source unavailable";
@@ -95,6 +111,9 @@ function getWorkspaceLocatorLabel(location: WorkspaceLocation) {
return `p. ${location.pageNumber}`;
case "document-block":
case "flashcard":
+ case "quiz-question":
+ // Content-positional labels ("Card 3", "Question 3") need the item's
+ // current content, so the citation component supplies them.
return undefined;
}
}
diff --git a/src/features/workspaces/locations/workspace-location.test.ts b/src/features/workspaces/locations/workspace-location.test.ts
index 8ae8010d..85325813 100644
--- a/src/features/workspaces/locations/workspace-location.test.ts
+++ b/src/features/workspaces/locations/workspace-location.test.ts
@@ -1,108 +1,95 @@
import { describe, expect, it } from "vitest";
import {
- createWorkspaceReferenceRecords,
- getWorkspaceLocationKey,
- indexWorkspaceReferenceRecords,
- parseWorkspaceReference,
+ parseWorkspaceAddress,
+ parseWorkspaceUnitRef,
+ resolveWorkspaceAddressLocation,
workspaceLocationSchema,
} from "#/features/workspaces/locations/workspace-location";
-describe("workspace location", () => {
- it.each([
- [{ itemId: "item-1", kind: "item", version: 1 }, "1:item:item-1"],
- [{ itemId: "item-1", kind: "pdf-page", pageNumber: 12, version: 1 }, "1:pdf-page:item-1:12"],
- [
- {
- itemId: "item-1",
- kind: "flashcard",
- cardId: "f67080f9-0158-4565-86a9-4c90ed6809d2",
- version: 1,
- },
- "1:flashcard:item-1:f67080f9-0158-4565-86a9-4c90ed6809d2",
- ],
- [
- { blockId: "b_abcdefghijkl", itemId: "item-1", kind: "document-block", version: 1 },
- "1:document-block:item-1:b_abcdefghijkl",
- ],
- ])("parses and keys %o", (input, expectedKey) => {
- const result = workspaceLocationSchema.safeParse(input);
-
- expect(result.success).toBe(true);
- if (result.success) {
- expect(getWorkspaceLocationKey(result.data)).toBe(expectedKey);
- }
- });
-
- it.each([
- { itemId: "", kind: "item", version: 1 },
- { itemId: "item-1", kind: "pdf-page", pageNumber: 0, version: 1 },
- { cardId: "not-a-uuid", itemId: "item-1", kind: "flashcard", version: 1 },
- { blockId: "block-1", itemId: "item-1", kind: "document-block", version: 1 },
- { itemId: "item-1", kind: "item", version: 2 },
- { extra: true, itemId: "item-1", kind: "item", version: 1 },
- ])("rejects invalid location %o", (input) => {
- expect(workspaceLocationSchema.safeParse(input).success).toBe(false);
- });
-});
-
-describe("workspace reference", () => {
- it("creates an 11-character opaque reference", () => {
- const location = { itemId: "item-1", kind: "item", version: 1 } as const;
- const [{ ref }] = createWorkspaceReferenceRecords([location]);
-
- expect(ref).toMatch(/^wr_[0-9A-Za-z]{8}$/);
- expect(ref).toHaveLength(11);
- expect(parseWorkspaceReference(ref)).toBe(ref);
- });
-
- it("reuses one ref for the same durable location", () => {
- const location = { itemId: "item-1", kind: "pdf-page", pageNumber: 7, version: 1 } as const;
- const records = createWorkspaceReferenceRecords([location, { ...location }], {
- createCandidate: () => "wr_AAAAAAAA",
+describe("workspace addresses", () => {
+ it("parses item, unit, and revisioned forms", () => {
+ expect(parseWorkspaceAddress("Xk7p2Qa9")).toEqual({ refKey: "Xk7p2Qa9" });
+ expect(parseWorkspaceAddress("Xk7p2Qa9/p12")).toEqual({ refKey: "Xk7p2Qa9", unit: "p12" });
+ expect(parseWorkspaceAddress("Xk7p2Qa9/b_x7Kp2Qa9x8Lm.r_4f2a1b")).toEqual({
+ refKey: "Xk7p2Qa9",
+ unit: "b_x7Kp2Qa9x8Lm",
+ revision: "4f2a1b",
});
-
- expect(records).toEqual([{ location, ref: "wr_AAAAAAAA" }]);
- });
-
- it("retries a colliding candidate", () => {
- const candidates = ["wr_AAAAAAAA", "wr_AAAAAAAA", "wr_BBBBBBBB"];
- const records = createWorkspaceReferenceRecords(
- [
- { itemId: "item-1", kind: "item", version: 1 },
- { itemId: "item-2", kind: "item", version: 1 },
- ],
- {
- createCandidate: () => candidates.shift() ?? "wr_CCCCCCCC",
- },
- );
-
- expect(records.map(({ ref }) => ref)).toEqual(["wr_AAAAAAAA", "wr_BBBBBBBB"]);
+ expect(parseWorkspaceAddress("not an address")).toBeUndefined();
+ expect(parseWorkspaceAddress("wr_7Kp2Qa9x")).toBeUndefined();
});
- it("retains editable revisions and makes conflicting transcript refs unusable", () => {
- const location = {
- blockId: "b_abcdefghijkl",
+ it("interprets units against the item's type", () => {
+ const address = parseWorkspaceAddress("Xk7p2Qa9/p12")!;
+ expect(resolveWorkspaceAddressLocation({ id: "item-1", type: "file" }, address)).toEqual({
itemId: "item-1",
- kind: "document-block",
+ kind: "pdf-page",
+ pageNumber: 12,
version: 1,
- } as const;
- const [record] = createWorkspaceReferenceRecords([{ location, revision: "0123456789" }], {
- createCandidate: () => "wr_AAAAAAAA",
});
+ // The same unit means nothing on a document.
+ expect(
+ resolveWorkspaceAddressLocation({ id: "item-1", type: "document" }, address),
+ ).toBeUndefined();
- expect(record).toEqual({ location, ref: "wr_AAAAAAAA", revision: "0123456789" });
expect(
- indexWorkspaceReferenceRecords([record!, { ...record!, revision: "9876543210" }]).get(
- record!.ref,
+ resolveWorkspaceAddressLocation(
+ { id: "item-1", type: "flashcard" },
+ parseWorkspaceAddress("Xk7p2Qa9/c_9xKp2Qab")!,
+ ),
+ ).toEqual({ cardId: "c_9xKp2Qab", itemId: "item-1", kind: "flashcard", version: 1 });
+ expect(
+ resolveWorkspaceAddressLocation(
+ { id: "item-1", type: "quiz" },
+ parseWorkspaceAddress("Xk7p2Qa9/f67080f9-0158-4565-86a9-4c90ed6809d2")!,
+ ),
+ ).toEqual({
+ itemId: "item-1",
+ kind: "quiz-question",
+ questionId: "f67080f9-0158-4565-86a9-4c90ed6809d2",
+ version: 1,
+ });
+ expect(
+ resolveWorkspaceAddressLocation(
+ { id: "item-1", type: "document" },
+ parseWorkspaceAddress("Xk7p2Qa9")!,
),
- ).toBeNull();
+ ).toEqual({ itemId: "item-1", kind: "item", version: 1 });
});
- it.each(["", "wr_short", "xx_AAAAAAAA", "wr_!!!!!!!!", "wr_AAAAAAAAA"])(
- "rejects malformed ref %s",
- (input) => {
- expect(parseWorkspaceReference(input)).toBeUndefined();
- },
- );
+ it("parses edit unit refs with their revision", () => {
+ expect(parseWorkspaceUnitRef("b_x7Kp2Qa9x8Lm.r_4f2a1b")).toEqual({
+ unit: "b_x7Kp2Qa9x8Lm",
+ revision: "4f2a1b",
+ });
+ expect(parseWorkspaceUnitRef("b_x7Kp2Qa9x8Lm")).toBeUndefined();
+ });
+
+ it("keeps persisted locations validating legacy and short entry ids", () => {
+ expect(
+ workspaceLocationSchema.safeParse({
+ cardId: "f67080f9-0158-4565-86a9-4c90ed6809d2",
+ itemId: "item-1",
+ kind: "flashcard",
+ version: 1,
+ }).success,
+ ).toBe(true);
+ expect(
+ workspaceLocationSchema.safeParse({
+ cardId: "c_9xKp2Qab",
+ itemId: "item-1",
+ kind: "flashcard",
+ version: 1,
+ }).success,
+ ).toBe(true);
+ expect(
+ workspaceLocationSchema.safeParse({
+ cardId: "not-an-id",
+ itemId: "item-1",
+ kind: "flashcard",
+ version: 1,
+ }).success,
+ ).toBe(false);
+ });
});
diff --git a/src/features/workspaces/locations/workspace-location.ts b/src/features/workspaces/locations/workspace-location.ts
index 67f9dc81..89f94fdc 100644
--- a/src/features/workspaces/locations/workspace-location.ts
+++ b/src/features/workspaces/locations/workspace-location.ts
@@ -3,19 +3,30 @@ import { z } from "zod";
const WORKSPACE_REFERENCE_ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
-const WORKSPACE_REFERENCE_RANDOM_LENGTH = 8;
-const WORKSPACE_REFERENCE_COLLISION_ATTEMPTS = 32;
-const createRandomWorkspaceReferenceSuffix = customAlphabet(
- WORKSPACE_REFERENCE_ALPHABET,
- WORKSPACE_REFERENCE_RANDOM_LENGTH,
-);
+/** Mints the short, durable, model-facing handle a workspace item keeps for life. */
+export const createWorkspaceItemRefKey = customAlphabet(WORKSPACE_REFERENCE_ALPHABET, 8);
const workspaceLocationItemIdSchema = z.string().trim().min(1);
+const createWorkspaceEntryIdSuffix = customAlphabet(WORKSPACE_REFERENCE_ALPHABET, 8);
+
+/** Mints the id of one card or question, e.g. `c_9xKp2Qab`. */
+export function createWorkspaceEntryId(prefix: "c" | "q") {
+ return `${prefix}_${createWorkspaceEntryIdSuffix()}`;
+}
+
/**
- * Durable, versioned pointer to content inside a workspace.
- *
- * Short AI-facing references are aliases for this value and must never replace
- * it in persisted application state.
+ * One flashcard or quiz-question id. Early content minted UUIDs; everything
+ * since mints the short prefixed form, and both stay valid ids forever.
+ */
+export const workspaceEntryIdSchema = z.union([
+ z.string().regex(/^[cq]_[0-9A-Za-z]{8}$/),
+ z.uuid(),
+]);
+
+/**
+ * Durable, versioned pointer to content inside a workspace, as persisted by
+ * document and chat citations. Model-facing addresses parse into this against
+ * the item they name.
*/
export const workspaceLocationSchema = z.discriminatedUnion("kind", [
z.strictObject({
@@ -38,7 +49,13 @@ export const workspaceLocationSchema = z.discriminatedUnion("kind", [
z.strictObject({
itemId: workspaceLocationItemIdSchema,
kind: z.literal("flashcard"),
- cardId: z.uuid(),
+ cardId: workspaceEntryIdSchema,
+ version: z.literal(1),
+ }),
+ z.strictObject({
+ itemId: workspaceLocationItemIdSchema,
+ kind: z.literal("quiz-question"),
+ questionId: workspaceEntryIdSchema,
version: z.literal(1),
}),
]);
@@ -46,127 +63,89 @@ export const workspaceLocationSchema = z.discriminatedUnion("kind", [
/** A parsed durable workspace location. */
export type WorkspaceLocation = Readonly>;
-/** Schema for the exact short reference the model is allowed to copy. */
-export const workspaceReferenceInputSchema = z.string().regex(/^wr_[0-9A-Za-z]{8}$/);
-export const workspaceReferenceSchema = workspaceReferenceInputSchema.brand<"WorkspaceReference">();
-
-/** Short model-facing alias for a durable workspace location. */
-export type WorkspaceReference = z.output;
-
-/** Schema for a durable location retained behind a short reference. */
-export const workspaceReferenceRecordSchema = z.strictObject({
- location: workspaceLocationSchema,
- ref: workspaceReferenceSchema,
- revision: z
- .string()
- .regex(/^[A-Za-z0-9_-]{10}$/)
- .optional(),
-});
-
-/** Durable record retained behind a short workspace reference. */
-export type WorkspaceReferenceRecord = Readonly>;
-
/**
- * Produces the canonical in-memory key for a workspace location.
+ * The model-facing address grammar: `refKey[/unit][.r_revision]`.
*
- * @param location - Parsed workspace location.
- * @returns A collision-free key within location schema version 1.
+ * `refKey` is the item's durable 8-character handle. `unit` names one piece of
+ * it — `p5` a physical page, `b_…` a document block, a card or question id for
+ * study items — and is interpreted against the item's type, so the token never
+ * has to encode what kind of thing it names. `.r_…` carries the 6-character
+ * content revision an edit must present. The whole string is self-describing:
+ * nothing has to be minted, stored, or kept alive server-side for it to
+ * resolve, so an address from any point in a conversation keeps working.
*/
-export function getWorkspaceLocationKey(location: WorkspaceLocation) {
- switch (location.kind) {
- case "item":
- return `1:item:${location.itemId}`;
- case "pdf-page":
- return `1:pdf-page:${location.itemId}:${location.pageNumber}`;
- case "document-block":
- return `1:document-block:${location.itemId}:${location.blockId}`;
- case "flashcard":
- return `1:flashcard:${location.itemId}:${location.cardId}`;
- }
-}
+const workspaceAddressPattern =
+ /^([0-9A-Za-z]{8})(?:\/([A-Za-z0-9_-]{1,40}?))?(?:\.r_([A-Za-z0-9_-]{6}))?$/;
-/**
- * Parses an untrusted short workspace reference.
- *
- * @param input - Untrusted model or persisted value.
- * @returns A branded reference when valid.
- */
-export function parseWorkspaceReference(input: unknown) {
- const parsed = workspaceReferenceSchema.safeParse(input);
+export const workspaceAddressInputSchema = z.string().regex(workspaceAddressPattern);
- return parsed.success ? parsed.data : undefined;
+export interface WorkspaceAddress {
+ refKey: string;
+ unit?: string;
+ revision?: string;
}
-/** Index app-issued refs while making collisions unusable. */
-export function indexWorkspaceReferenceRecords(
- records: readonly WorkspaceReferenceRecord[],
-): ReadonlyMap {
- const index = new Map();
- for (const record of records) {
- const existing = index.get(record.ref);
- if (existing === undefined) {
- index.set(record.ref, record);
- } else if (
- existing &&
- (getWorkspaceLocationKey(existing.location) !== getWorkspaceLocationKey(record.location) ||
- existing.revision !== record.revision)
- ) {
- index.set(record.ref, null);
- }
- }
- return index;
+export function parseWorkspaceAddress(input: unknown): WorkspaceAddress | undefined {
+ if (typeof input !== "string") return undefined;
+ const match = workspaceAddressPattern.exec(input.trim());
+ if (!match) return undefined;
+ return {
+ refKey: match[1]!,
+ ...(match[2] ? { unit: match[2] } : {}),
+ ...(match[3] ? { revision: match[3] } : {}),
+ };
}
/**
- * Creates deduplicated, collision-checked refs for durable locations.
- *
- * The optional candidate source is an internal test seam. Production callers
- * should use the default cryptographically strong Nano ID source.
+ * Interprets an address's unit against the item it names.
*
- * @param locations - Durable locations in desired record order.
- * @param options - Optional candidate source for deterministic verification.
- * @returns One reference record per distinct location.
+ * @returns The durable location, or undefined when the unit cannot belong to
+ * an item of this type.
*/
-export function createWorkspaceReferenceRecords(
- targets: readonly (WorkspaceLocation | { location: WorkspaceLocation; revision: string })[],
- options: { readonly createCandidate?: () => string } = {},
-): WorkspaceReferenceRecord[] {
- const createCandidate =
- options.createCandidate ?? (() => `wr_${createRandomWorkspaceReferenceSuffix()}`);
- const locationKeys = new Set();
- const refs = new Set();
- const records: WorkspaceReferenceRecord[] = [];
-
- for (const target of targets) {
- const { location, revision } =
- "location" in target ? target : { location: target, revision: undefined };
- const locationKey = getWorkspaceLocationKey(location);
- if (locationKeys.has(locationKey)) {
- continue;
- }
-
- let allocatedRef: WorkspaceReference | undefined;
- for (let attempt = 0; attempt < WORKSPACE_REFERENCE_COLLISION_ATTEMPTS; attempt += 1) {
- const ref = parseWorkspaceReference(createCandidate());
- if (!ref) {
- throw new Error("Workspace reference candidate source returned an invalid value.");
- }
- if (refs.has(ref)) {
- continue;
- }
-
- allocatedRef = ref;
- break;
- }
+export function resolveWorkspaceAddressLocation(
+ item: { id: string; type: "document" | "file" | "flashcard" | "folder" | "quiz" },
+ address: WorkspaceAddress,
+): WorkspaceLocation | undefined {
+ if (address.unit === undefined) {
+ return { itemId: item.id, kind: "item", version: 1 };
+ }
+ if (item.type === "file") {
+ const page = /^p([1-9]\d{0,5})$/.exec(address.unit);
+ return page
+ ? { itemId: item.id, kind: "pdf-page", pageNumber: Number(page[1]), version: 1 }
+ : undefined;
+ }
+ if (item.type === "document") {
+ return /^b_[A-Za-z0-9_-]{12}$/.test(address.unit)
+ ? { blockId: address.unit, itemId: item.id, kind: "document-block", version: 1 }
+ : undefined;
+ }
+ if (item.type === "flashcard") {
+ const cardId = workspaceEntryIdSchema.safeParse(address.unit);
+ return cardId.success
+ ? { cardId: cardId.data, itemId: item.id, kind: "flashcard", version: 1 }
+ : undefined;
+ }
+ if (item.type === "quiz") {
+ const questionId = workspaceEntryIdSchema.safeParse(address.unit);
+ return questionId.success
+ ? { itemId: item.id, kind: "quiz-question", questionId: questionId.data, version: 1 }
+ : undefined;
+ }
+ return undefined;
+}
- if (!allocatedRef) {
- throw new Error("Unable to allocate a collision-free workspace reference.");
- }
+/**
+ * A unit ref as copied from a read into an edit: `unit.r_revision`. The item
+ * is already named by the edit call's path, so the ref stays item-relative.
+ */
+const workspaceUnitRefPattern = /^([A-Za-z0-9_-]{1,40}?)\.r_([A-Za-z0-9_-]{6})$/;
- locationKeys.add(locationKey);
- refs.add(allocatedRef);
- records.push({ location, ref: allocatedRef, ...(revision ? { revision } : {}) });
- }
+export const workspaceUnitRefInputSchema = z.string().regex(workspaceUnitRefPattern);
- return records;
+export function parseWorkspaceUnitRef(
+ input: string,
+): { unit: string; revision: string } | undefined {
+ const match = workspaceUnitRefPattern.exec(input);
+ return match ? { unit: match[1]!, revision: match[2]! } : undefined;
}
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 ef7c7572..5f94c410 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
@@ -105,7 +105,7 @@ exports[`workspace tool surface > workspace_create_items input schema is stable
"description": "Document to create.",
"properties": {
"initialContent": {
- "description": "Optional initial HTML content. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. 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. 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…
.",
+ "description": "Optional initial HTML content. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. 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 — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. 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…
.",
"maxLength": 512000,
"type": "string",
},
@@ -240,6 +240,107 @@ exports[`workspace tool surface > workspace_create_items input schema is stable
],
"type": "object",
},
+ {
+ "additionalProperties": false,
+ "description": "Quiz to create.",
+ "properties": {
+ "path": {
+ "description": "Final absolute path for the quiz.",
+ "minLength": 1,
+ "type": "string",
+ },
+ "questions": {
+ "description": "Ordered multiple-choice questions. Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a question. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "correctAnswer": {
+ "description": "HTML for the single correct option. Never mark it in the stem; its final position is shuffled server-side.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "distractors": {
+ "description": "HTML for each incorrect option: strictly wrong, plausible, and grounded in a specific misconception. Use 3 for a standard question, 1 for true/false.",
+ "items": {
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "maxItems": 4,
+ "minItems": 1,
+ "type": "array",
+ },
+ "explanation": {
+ "description": "Short HTML explanation of why the correct answer is right, touching on why the others are not.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "question": {
+ "description": "HTML question stem.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "question",
+ "correctAnswer",
+ "distractors",
+ "explanation",
+ ],
+ "type": "object",
+ },
+ "maxItems": 100,
+ "minItems": 1,
+ "type": "array",
+ },
+ "relations": {
+ "description": "Optional relationships from this quiz to source items, at most 20.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "description": "\`derived_from\` means this item was created or materially changed from the linked item. \`references\` means this item cites or points to the linked item.",
+ "enum": [
+ "derived_from",
+ "references",
+ ],
+ "type": "string",
+ },
+ "note": {
+ "description": "Optional short source detail, like pages 12-14 or section on photosynthesis.",
+ "maxLength": 240,
+ "type": "string",
+ },
+ "path": {
+ "description": "Absolute path of the related ThinkEx workspace item.",
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "kind",
+ "path",
+ ],
+ "type": "object",
+ },
+ "maxItems": 20,
+ "type": "array",
+ },
+ "type": {
+ "const": "quiz",
+ "type": "string",
+ },
+ },
+ "required": [
+ "type",
+ "path",
+ "questions",
+ ],
+ "type": "object",
+ },
],
},
"maxItems": 20,
@@ -286,7 +387,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"description": "Document edits.",
"properties": {
"edits": {
- "description": "Ordered document edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. update accepts exactly one top-level block; replace may replace one block with several. move requires exactly one of beforeRef or afterRef. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. 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. 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…
.",
+ "description": "Ordered document edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. update accepts exactly one top-level block; replace may replace one block with several. move requires exactly one of beforeRef or afterRef. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. 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 — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. 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…
.",
"items": {
"anyOf": [
{
@@ -308,7 +409,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
},
"ref": {
"description": "Exact ref from a recent document or block read.",
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -328,7 +429,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
},
"ref": {
"description": "Exact ref from a recent document or block read.",
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -353,7 +454,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
},
"ref": {
"description": "Exact ref from a recent document or block read.",
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
"replace": {
@@ -375,12 +476,12 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"properties": {
"afterRef": {
"description": "Exact ref from a recent document or block read.",
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
"beforeRef": {
"description": "Exact ref from a recent document or block read.",
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
"op": {
@@ -389,7 +490,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
},
"ref": {
"description": "Exact ref from a recent document or block read.",
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -451,7 +552,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"type": "string",
},
"ref": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -481,7 +582,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"type": "string",
},
"ref": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -509,7 +610,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"type": "string",
},
"ref": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -535,7 +636,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"type": "string",
},
"ref": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
"replace": {
@@ -564,11 +665,11 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"additionalProperties": false,
"properties": {
"afterRef": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
"beforeRef": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
"op": {
@@ -576,7 +677,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"type": "string",
},
"ref": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -594,7 +695,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"type": "string",
},
"ref": {
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
"type": "string",
},
},
@@ -627,6 +728,257 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
],
"type": "object",
},
+ {
+ "additionalProperties": false,
+ "description": "Quiz edits.",
+ "properties": {
+ "edits": {
+ "description": "Ordered quiz edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. insert and replace take a full authored question and reshuffle its options; update changes only the stem or explanation in place; replace_text requires question, options, or explanation as field and never reshuffles; move requires exactly one of beforeRef or afterRef. Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a question. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.",
+ "items": {
+ "anyOf": [
+ {
+ "additionalProperties": false,
+ "properties": {
+ "correctAnswer": {
+ "description": "HTML for the single correct option. Never mark it in the stem; its final position is shuffled server-side.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "distractors": {
+ "description": "HTML for each incorrect option: strictly wrong, plausible, and grounded in a specific misconception. Use 3 for a standard question, 1 for true/false.",
+ "items": {
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "maxItems": 4,
+ "minItems": 1,
+ "type": "array",
+ },
+ "explanation": {
+ "description": "Short HTML explanation of why the correct answer is right, touching on why the others are not.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "op": {
+ "enum": [
+ "insert_before",
+ "insert_after",
+ ],
+ "type": "string",
+ },
+ "question": {
+ "description": "HTML question stem.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "ref": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ },
+ "required": [
+ "op",
+ "ref",
+ "question",
+ "correctAnswer",
+ "distractors",
+ "explanation",
+ ],
+ "type": "object",
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "explanation": {
+ "description": "New HTML explanation.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "op": {
+ "const": "update",
+ "type": "string",
+ },
+ "question": {
+ "description": "New HTML question stem.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "ref": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ },
+ "required": [
+ "op",
+ "ref",
+ ],
+ "type": "object",
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "correctAnswer": {
+ "description": "HTML for the single correct option. Never mark it in the stem; its final position is shuffled server-side.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "distractors": {
+ "description": "HTML for each incorrect option: strictly wrong, plausible, and grounded in a specific misconception. Use 3 for a standard question, 1 for true/false.",
+ "items": {
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "maxItems": 4,
+ "minItems": 1,
+ "type": "array",
+ },
+ "explanation": {
+ "description": "Short HTML explanation of why the correct answer is right, touching on why the others are not.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "op": {
+ "const": "replace",
+ "type": "string",
+ },
+ "question": {
+ "description": "HTML question stem.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "ref": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ },
+ "required": [
+ "op",
+ "ref",
+ "question",
+ "correctAnswer",
+ "distractors",
+ "explanation",
+ ],
+ "type": "object",
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "field": {
+ "description": "Where to look: the stem, all options together, or the explanation.",
+ "enum": [
+ "question",
+ "options",
+ "explanation",
+ ],
+ "type": "string",
+ },
+ "find": {
+ "description": "Exact HTML text from the selected field. It must appear exactly once.",
+ "maxLength": 8000,
+ "minLength": 1,
+ "type": "string",
+ },
+ "op": {
+ "const": "replace_text",
+ "type": "string",
+ },
+ "ref": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ "replace": {
+ "description": "Replacement text. May be empty.",
+ "maxLength": 8000,
+ "type": "string",
+ },
+ },
+ "required": [
+ "op",
+ "ref",
+ "field",
+ "find",
+ "replace",
+ ],
+ "type": "object",
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "afterRef": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ "beforeRef": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ "op": {
+ "const": "move",
+ "type": "string",
+ },
+ "ref": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ },
+ "required": [
+ "op",
+ "ref",
+ ],
+ "type": "object",
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "op": {
+ "const": "delete",
+ "type": "string",
+ },
+ "ref": {
+ "pattern": "^([A-Za-z0-9_-]{1,40}?)\\.r_([A-Za-z0-9_-]{6})$",
+ "type": "string",
+ },
+ },
+ "required": [
+ "op",
+ "ref",
+ ],
+ "type": "object",
+ },
+ ],
+ },
+ "maxItems": 100,
+ "minItems": 1,
+ "type": "array",
+ },
+ "path": {
+ "description": "Absolute path of one actual ThinkEx quiz to edit.",
+ "minLength": 1,
+ "type": "string",
+ },
+ "type": {
+ "const": "quiz",
+ "type": "string",
+ },
+ },
+ "required": [
+ "type",
+ "path",
+ "edits",
+ ],
+ "type": "object",
+ },
],
}
`;
@@ -779,7 +1131,7 @@ exports[`workspace tool surface > workspace_read_items input schema is stable 1`
"type": "string",
},
"range": {
- "description": "Up to 20 physical pages from an extracted file, like 1, 3, 5-7, or 1,4-6. Defaults to page 1.",
+ "description": "Up to 20 physical pages from an extracted file, like 1, 3, 5-7, or 1,4-6.",
"minLength": 1,
"pattern": "^\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*$",
"type": "string",
@@ -796,7 +1148,7 @@ exports[`workspace tool surface > workspace_read_items input schema is stable 1`
"additionalProperties": false,
"properties": {
"mode": {
- "const": "cards",
+ "const": "entries",
"type": "string",
},
"path": {
@@ -805,7 +1157,7 @@ exports[`workspace tool surface > workspace_read_items input schema is stable 1`
"type": "string",
},
"range": {
- "description": "Up to 20 card numbers from a flashcard set, like 1, 3, 5-7, or 1,4-6.",
+ "description": "Up to 20 entry numbers — document blocks, flashcards, or quiz questions — like 1, 3, 5-7, or 1,4-6.",
"minLength": 1,
"pattern": "^\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*$",
"type": "string",
@@ -818,32 +1170,6 @@ exports[`workspace tool surface > workspace_read_items input schema is stable 1`
],
"type": "object",
},
- {
- "additionalProperties": false,
- "properties": {
- "cursor": {
- "description": "Opaque cursor returned by a previous read.",
- "maxLength": 4096,
- "minLength": 1,
- "type": "string",
- },
- "mode": {
- "const": "continue",
- "type": "string",
- },
- "path": {
- "description": "Absolute path of the workspace item to read.",
- "minLength": 1,
- "type": "string",
- },
- },
- "required": [
- "path",
- "cursor",
- "mode",
- ],
- "type": "object",
- },
{
"additionalProperties": false,
"properties": {
@@ -851,21 +1177,15 @@ exports[`workspace tool surface > workspace_read_items input schema is stable 1`
"const": "ref",
"type": "string",
},
- "path": {
- "description": "Absolute path of the workspace item to read.",
- "minLength": 1,
- "type": "string",
- },
"ref": {
- "description": "Ref from an earlier read. Returns the exact content it identifies with a current ref.",
- "pattern": "^wr_[0-9A-Za-z]{8}$",
+ "description": "Address from an earlier read or citation, like itemRef or itemRef/unit.",
+ "pattern": "^([0-9A-Za-z]{8})(?:\\/([A-Za-z0-9_-]{1,40}?))?(?:\\.r_([A-Za-z0-9_-]{6}))?$",
"type": "string",
},
},
"required": [
- "path",
- "ref",
"mode",
+ "ref",
],
"type": "object",
},
diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts
index ac46d0b8..8ba1336b 100644
--- a/src/features/workspaces/operations/create-items.ts
+++ b/src/features/workspaces/operations/create-items.ts
@@ -22,9 +22,10 @@ import {
stringifyFlashcardSetContent,
} from "#/features/workspaces/flashcards/flashcard-content";
import {
- createWorkspaceReferenceRecords,
- type WorkspaceReferenceRecord,
-} from "#/features/workspaces/locations/workspace-location";
+ createQuizSetFromInputs,
+ stringifyQuizSetContent,
+ type QuizQuestionInput,
+} from "#/features/workspaces/quizzes/quiz-content";
import {
getParentWorkspacePath,
getWorkspacePathName,
@@ -46,6 +47,12 @@ export type CreateWorkspaceItemOperationInput =
path: string;
cards: Array<{ front: string; back: string }>;
relations?: WorkspaceRelationInput[];
+ }
+ | {
+ type: "quiz";
+ path: string;
+ questions: QuizQuestionInput[];
+ relations?: WorkspaceRelationInput[];
};
export interface CreateWorkspaceItemsOperationInput {
@@ -64,13 +71,14 @@ export interface CreateWorkspaceItemsFailure {
export interface CreatedWorkspaceItem {
itemId: string;
path: string;
- type: "document" | "flashcard" | "folder";
+ /** The item's durable address, for citations and later reads. */
+ ref: string;
+ type: "document" | "flashcard" | "folder" | "quiz";
}
export interface CreateWorkspaceItemsOperationResult {
items: CreatedWorkspaceItem[];
failed: CreateWorkspaceItemsFailure[];
- references: WorkspaceReferenceRecord[];
}
type CreateWorkspaceItemPathResolution =
@@ -199,17 +207,12 @@ export async function createWorkspaceItemsOperation(
items.push({
itemId: id,
path: createdPath,
+ ref: command.result.refKey,
type: itemInput.type,
});
}
- return {
- items,
- failed,
- references: createWorkspaceReferenceRecords(
- items.map((item) => ({ itemId: item.itemId, kind: "item", version: 1 })),
- ),
- };
+ return { items, failed };
}
function resolveCreateWorkspaceItemParent(resolution: WorkspacePathResolution):
@@ -300,10 +303,13 @@ function getCreateWorkspaceItemInitialContent(input: CreateWorkspaceItemOperatio
detail?: string;
status: "failed";
} {
- if (input.type === "flashcard") {
+ if (input.type === "flashcard" || input.type === "quiz") {
try {
return {
- content: stringifyFlashcardSetContent(createFlashcardSetFromHtml(input.cards)),
+ content:
+ input.type === "flashcard"
+ ? stringifyFlashcardSetContent(createFlashcardSetFromHtml(input.cards))
+ : stringifyQuizSetContent(createQuizSetFromInputs(input.questions)),
status: "ready",
};
} catch (error) {
diff --git a/src/features/workspaces/operations/document-citations.ts b/src/features/workspaces/operations/document-citations.ts
index 209a9686..75bc78df 100644
--- a/src/features/workspaces/operations/document-citations.ts
+++ b/src/features/workspaces/operations/document-citations.ts
@@ -2,16 +2,21 @@ import {
applyDocumentCitationLocations,
readDocumentCitationRefs,
} from "#/features/workspaces/documents/document-ai-html";
-import { indexWorkspaceReferenceRecords } from "#/features/workspaces/locations/workspace-location";
+import {
+ parseWorkspaceAddress,
+ resolveWorkspaceAddressLocation,
+ type WorkspaceLocation,
+} from "#/features/workspaces/locations/workspace-location";
+import { getWorkspaceItemByRefKey } from "#/features/workspaces/persistence/workspace-items";
import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context";
/**
- * Turn the refs an assistant cited into the locations a document can keep.
+ * Turn the addresses an assistant cited into the locations a document keeps.
*
- * The assistant cites `wr_` refs, the same way it cites in a chat reply, but a
- * ref only means something inside the turn that produced it. Resolving here
- * lets the document store the item and page it points at; what that source is
- * called is read from the workspace when the citation is drawn.
+ * The assistant cites the same `refKey/unit` addresses it reads with. They
+ * resolve statelessly — refKey to item, unit against the item's type — so the
+ * document stores the durable location, and what that source is called is
+ * read from the workspace when the citation is drawn.
*/
export async function resolveDocumentCitations(input: {
context: WorkspaceAccessContext;
@@ -19,16 +24,21 @@ export async function resolveDocumentCitations(input: {
}): Promise {
const refs = readDocumentCitationRefs(input.html);
- if (refs.length === 0 || !input.context.resolveWorkspaceReferences) {
+ if (refs.length === 0) {
return input.html;
}
- const records = await input.context.resolveWorkspaceReferences(refs);
- const locations = new Map(
- [...indexWorkspaceReferenceRecords(records)].flatMap(([ref, record]) =>
- record ? [[ref, record.location] as const] : [],
- ),
- );
+ const locations = new Map();
+ for (const ref of new Set(refs)) {
+ const address = parseWorkspaceAddress(ref);
+ if (!address) continue;
+ const resolved = await getWorkspaceItemByRefKey({
+ refKey: address.refKey,
+ workspaceId: input.context.workspaceId,
+ });
+ const location = resolved ? resolveWorkspaceAddressLocation(resolved.item, address) : undefined;
+ if (location) locations.set(ref, location);
+ }
return applyDocumentCitationLocations(input.html, locations);
}
diff --git a/src/features/workspaces/operations/edit-item.ts b/src/features/workspaces/operations/edit-item.ts
index 30cde027..d9e65039 100644
--- a/src/features/workspaces/operations/edit-item.ts
+++ b/src/features/workspaces/operations/edit-item.ts
@@ -12,20 +12,22 @@ import { resolveDocumentCitations } from "#/features/workspaces/operations/docum
import {
applyFlashcardEdits,
type FlashcardEdit,
- type FlashcardEditTarget,
} from "#/features/workspaces/flashcards/flashcard-edits";
import { updateFlashcardSet } from "#/features/workspaces/flashcards/flashcard-persistence";
+import { applyQuizEdits, type QuizEdit } from "#/features/workspaces/quizzes/quiz-edits";
+import { updateQuizSet } from "#/features/workspaces/quizzes/quiz-persistence";
+import type { OrderedEntryEditTarget } from "#/features/workspaces/content/ordered-entry-edits";
import {
- indexWorkspaceReferenceRecords,
- parseWorkspaceReference,
- type WorkspaceReferenceRecord,
+ parseWorkspaceUnitRef,
+ workspaceEntryIdSchema,
} from "#/features/workspaces/locations/workspace-location";
type EditWorkspaceItemFailureCode = (typeof editWorkspaceItemFailureCodes)[number];
export type EditWorkspaceItemOperationInput =
| { edits: DocumentAiEdit[]; path: string; type: "document" }
- | { edits: FlashcardEdit[]; path: string; type: "flashcard" };
+ | { edits: FlashcardEdit[]; path: string; type: "flashcard" }
+ | { edits: QuizEdit[]; path: string; type: "quiz" };
interface EditWorkspaceItemFailure {
code: EditWorkspaceItemFailureCode;
@@ -37,7 +39,7 @@ export interface EditWorkspaceItemOperationResult {
applied: number;
failed: EditWorkspaceItemFailure[];
itemId?: string;
- itemType?: "document" | "flashcard";
+ itemType?: "document" | "flashcard" | "quiz";
lineChanges?: DocumentEditLineChanges;
path: string;
}
@@ -77,66 +79,51 @@ export async function editWorkspaceItemOperation(
};
}
- if (input.type === "flashcard") {
- const targets = await resolveEditTargets(
- accessContext,
- input.edits,
- (record) =>
- record.location.kind === "flashcard" &&
- record.location.itemId === resolution.item.id &&
- record.revision
- ? { cardId: record.location.cardId, revision: record.revision }
- : undefined,
- );
+ if (input.type === "flashcard" || input.type === "quiz") {
+ const itemId = resolution.item.id;
+ const targets = collectEntryEditTargets(input.edits);
const { env } = await import("cloudflare:workers");
- const result = await updateFlashcardSet(
- env,
- {
- actorUserId: accessContext.actor.userId,
- itemId: resolution.item.id,
- workspaceId: accessContext.workspaceId,
- },
- async (content) => {
- const applied = await applyFlashcardEdits(content, input.edits, targets);
- return { changed: applied.applied > 0, content: applied.content, result: applied };
- },
- );
+ const persistenceInput = {
+ actorUserId: accessContext.actor.userId,
+ itemId,
+ workspaceId: accessContext.workspaceId,
+ };
+ const result =
+ input.type === "flashcard"
+ ? await updateFlashcardSet(env, persistenceInput, async (content) => {
+ const applied = await applyFlashcardEdits(content, input.edits, targets);
+ return { changed: applied.applied > 0, content: applied.content, result: applied };
+ })
+ : await updateQuizSet(env, persistenceInput, async (content) => {
+ const applied = await applyQuizEdits(content, input.edits, targets);
+ return { changed: applied.applied > 0, content: applied.content, result: applied };
+ });
return {
applied: result.applied,
failed: result.failed,
- itemId: resolution.item.id,
- itemType: "flashcard",
+ itemId,
+ itemType: input.type,
path: resolution.path,
};
}
- const [targets, documentSession] = await Promise.all([
- resolveEditTargets(accessContext, input.edits, (record) =>
- record.location.kind === "document-block" &&
- record.location.itemId === resolution.item.id &&
- record.revision
- ? `${record.location.blockId}.r_${record.revision}`
- : undefined,
- ),
- getDocumentSession({
- itemId: resolution.item.id,
- workspaceId: accessContext.workspaceId,
- }),
- ]);
+ const documentSession = await getDocumentSession({
+ itemId: resolution.item.id,
+ workspaceId: accessContext.workspaceId,
+ });
const result = await documentSession.applyEdits({
edits: await Promise.all(
- input.edits.map(async (edit) => {
- const contentEdit = replacePublicRefs(edit, targets);
- return "html" in contentEdit
+ input.edits.map(async (edit) =>
+ "html" in edit
? {
- ...contentEdit,
+ ...edit,
html: await resolveDocumentCitations({
context: accessContext,
- html: contentEdit.html,
+ html: edit.html,
}),
}
- : contentEdit;
- }),
+ : edit,
+ ),
),
operationId: accessContext.operationId,
});
@@ -151,52 +138,27 @@ export async function editWorkspaceItemOperation(
};
}
-async function resolveEditTargets<
- TEdit extends { ref: string; afterRef?: string; beforeRef?: string },
- TTarget,
->(
- context: WorkspaceAccessContext,
- edits: readonly TEdit[],
- toTarget: (record: WorkspaceReferenceRecord) => TTarget | undefined,
+/**
+ * Parses every entry ref an edit batch mentions. A unit ref is self-contained
+ * — entry id plus content revision — so targeting needs no lookup; a ref whose
+ * unit is not an entry id simply resolves to nothing and the edit engine
+ * reports `ref_not_found` at the right index.
+ */
+function collectEntryEditTargets(
+ edits: readonly { ref: string; afterRef?: string; beforeRef?: string }[],
) {
- const refs = [...new Set(edits.flatMap(getReferencedRefs))];
- const records =
- refs.length > 0 && context.resolveWorkspaceReferences
- ? await context.resolveWorkspaceReferences(refs)
- : [];
- const recordsByRef = indexWorkspaceReferenceRecords(records);
- const targets = new Map();
- for (const ref of refs) {
- const parsedRef = parseWorkspaceReference(ref);
- const record = parsedRef ? recordsByRef.get(parsedRef) : undefined;
- const target = record ? toTarget(record) : undefined;
- if (target) targets.set(ref, target);
+ const targets = new Map();
+ for (const edit of edits) {
+ for (const ref of [edit.ref, edit.beforeRef, edit.afterRef]) {
+ if (!ref || targets.has(ref)) continue;
+ const parsed = parseWorkspaceUnitRef(ref);
+ if (!parsed || !workspaceEntryIdSchema.safeParse(parsed.unit).success) continue;
+ targets.set(ref, { entryId: parsed.unit, revision: parsed.revision });
+ }
}
-
return targets;
}
-function replacePublicRefs(
- edit: TEdit,
- targets: ReadonlyMap,
-): TEdit {
- // Unresolved refs stay invalid so the document engine reports them at their original edit index.
- return {
- ...edit,
- ref: targets.get(edit.ref) ?? edit.ref,
- ...(edit.beforeRef ? { beforeRef: targets.get(edit.beforeRef) ?? edit.beforeRef } : {}),
- ...(edit.afterRef ? { afterRef: targets.get(edit.afterRef) ?? edit.afterRef } : {}),
- };
-}
-
-function getReferencedRefs(edit: { ref: string; afterRef?: string; beforeRef?: string }) {
- return [
- edit.ref,
- ...(edit.beforeRef ? [edit.beforeRef] : []),
- ...(edit.afterRef ? [edit.afterRef] : []),
- ];
-}
-
async function getDocumentSession(input: { itemId: string; workspaceId: string }) {
const { env } = await import("cloudflare:workers");
diff --git a/src/features/workspaces/operations/read-items.ts b/src/features/workspaces/operations/read-items.ts
index fafde068..8f4ba239 100644
--- a/src/features/workspaces/operations/read-items.ts
+++ b/src/features/workspaces/operations/read-items.ts
@@ -6,13 +6,10 @@ import {
} from "#/features/workspaces/content/workspace-content-contract";
import { readWorkspaceContent } from "#/features/workspaces/content/workspace-content-reader";
import { recordWorkspaceFileReadOutcomes } from "#/features/workspaces/content/workspace-read-observability";
-import { createWorkspaceReadReferences } from "#/features/workspaces/content/workspace-read-references";
import { getDocumentSessionFromEnv } from "#/features/workspaces/document-session-access";
import { readFlashcardViewer } from "#/features/workspaces/flashcards/flashcard-study-persistence";
-import {
- indexWorkspaceReferenceRecords,
- parseWorkspaceReference,
-} from "#/features/workspaces/locations/workspace-location";
+import { readQuizViewer } from "#/features/workspaces/quizzes/quiz-study-persistence";
+import { getWorkspaceItemByRefKey } from "#/features/workspaces/persistence/workspace-items";
import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context";
import { authorizeWorkspaceOperation } from "#/features/workspaces/operations/workspace-operation-context";
@@ -28,14 +25,6 @@ export async function readWorkspaceItemsOperation(
access: "read",
context: accessContext,
});
- const requestedRefs = input.requests.flatMap((request) =>
- request.mode === "ref" ? [request.ref] : [],
- );
- const referenceTargets = indexWorkspaceReferenceRecords(
- requestedRefs.length > 0 && accessContext.resolveWorkspaceReferences
- ? await accessContext.resolveWorkspaceReferences(requestedRefs)
- : [],
- );
const results = await readWorkspaceContent({
bucket: env.WORKSPACE_FILES,
getDocumentSession: (itemId) =>
@@ -49,11 +38,14 @@ export async function readWorkspaceItemsOperation(
userId: accessContext.actor.userId,
workspaceId: accessContext.workspaceId,
}),
- resolveReference: (itemId, ref) => {
- const parsedRef = parseWorkspaceReference(ref);
- const record = parsedRef ? referenceTargets.get(parsedRef) : undefined;
- return record?.location.itemId === itemId ? record.location : undefined;
- },
+ readQuizItem: (itemId) =>
+ readQuizViewer({
+ itemId,
+ userId: accessContext.actor.userId,
+ workspaceId: accessContext.workspaceId,
+ }),
+ resolveRefKey: (refKey) =>
+ getWorkspaceItemByRefKey({ refKey, workspaceId: accessContext.workspaceId }),
requests: input.requests,
workspaceId: accessContext.workspaceId,
});
@@ -65,8 +57,5 @@ export async function readWorkspaceItemsOperation(
workspaceId: accessContext.workspaceId,
});
- return {
- references: createWorkspaceReadReferences(results),
- results,
- };
+ return { results };
}
diff --git a/src/features/workspaces/operations/workspace-access-context.ts b/src/features/workspaces/operations/workspace-access-context.ts
index 0078c27a..ad3970f3 100644
--- a/src/features/workspaces/operations/workspace-access-context.ts
+++ b/src/features/workspaces/operations/workspace-access-context.ts
@@ -1,4 +1,3 @@
-import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location";
import {
assertAccessScope,
createAccessActor,
@@ -11,27 +10,18 @@ export type WorkspaceAccessScope = (typeof workspaceAccessScopes)[number];
export interface WorkspaceAccessContext extends ScopedAccessContext {
operationId: string;
- /**
- * Resolves short refs retained in the chat transcript for reads, edits,
- * citations, and navigation. Absent outside a chat turn.
- */
- resolveWorkspaceReferences?: (refs: readonly string[]) => Promise;
workspaceId: string;
}
export function createWorkspaceAccessContext(input: {
scopes: readonly WorkspaceAccessScope[];
operationId: string;
- resolveWorkspaceReferences?: (refs: readonly string[]) => Promise;
userId: string;
workspaceId: string;
}): WorkspaceAccessContext {
return {
actor: createAccessActor(input),
operationId: input.operationId,
- ...(input.resolveWorkspaceReferences
- ? { resolveWorkspaceReferences: input.resolveWorkspaceReferences }
- : {}),
workspaceId: input.workspaceId,
};
}
diff --git a/src/features/workspaces/operations/workspace-tool-definitions.ts b/src/features/workspaces/operations/workspace-tool-definitions.ts
index 79c951f5..8f4bf8f1 100644
--- a/src/features/workspaces/operations/workspace-tool-definitions.ts
+++ b/src/features/workspaces/operations/workspace-tool-definitions.ts
@@ -144,7 +144,7 @@ export const workspaceToolDefinitions = [
name: "workspace_read_items",
access: "read",
description:
- "Read ThinkEx documents, flashcard sets, and extracted files by absolute path. Document chunks and flashcards include short, freshness-checked refs. Flashcard reads also return HTML fronts and backs plus the current user's study progress; again means missed, while hard, good, and easy mean got it. Use mode cards with a card-number range for targeted flashcard reads. Continue long documents, flashcard sets, or files with nextCursor. Use mode ref to read the exact content identified by an earlier ref; this returns elided widget source in full before editing. Files also support physical-page selections.",
+ "Read ThinkEx documents, flashcard sets, quizzes, and extracted files. Every item is an ordered list of units — document blocks, cards, questions, or physical pages — and every result names the numbered units it covered plus the total, so continue by requesting the next range: mode entries for blocks, cards, or questions; mode pages for file pages. mode start returns the first chunk. Each result carries the item's durable ref, and blocks, cards, and questions carry unit refs with a current .r_ revision — copy them exactly into edits, and cite as ref or ref/unit. Use mode ref with an address to read one unit in full; that is how elided widget source is fetched before editing. Flashcard reads include the current user's study progress (again means missed; hard, good, and easy mean got it). Quiz reads return options in user-visible order with the correct one marked, plus the user's answers and score.",
inputSchema: workspaceReadItemsInputSchema,
inputExamples: workspaceReadItemsInputExamples,
outputSchema: workspaceReadItemsOutputSchema,
@@ -192,7 +192,7 @@ export const workspaceToolDefinitions = [
name: "workspace_create_items",
access: "write",
description:
- "Create folders, documents, or flashcard sets at exact absolute paths. A slash separates folders, so use another character inside an item name. Set type and provide that branch's fields. If a path already exists, creation fails instead of renaming.",
+ "Create folders, documents, flashcard sets, or quizzes at exact absolute paths. A slash separates folders, so use another character inside an item name. Set type and provide that branch's fields. If a path already exists, creation fails instead of renaming.",
inputSchema: workspaceCreateItemsInputSchema,
inputExamples: workspaceCreateItemsInputExamples,
outputSchema: workspaceCreateItemsOutputSchema,
@@ -223,7 +223,7 @@ export const workspaceToolDefinitions = [
name: "workspace_edit_item",
access: "write",
description:
- "Edit one document or flashcard set by absolute path. Read it first, then copy the exact refs it returned. The same refs also work for citations and navigation. Flashcard edits apply immediately; document edits retain their review flow. Use workspace_link_items for item-level relationships.",
+ "Edit one document, flashcard set, or quiz by absolute path. Read it first, then copy each target's exact unit ref (like b_x7Kp2Qa9x8Lm.r_4f2a1b) from that read; the .r_ revision is how a stale edit fails loudly instead of hitting the wrong content. Flashcard and quiz edits apply immediately; document edits retain their review flow. Use workspace_link_items for item-level relationships.",
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 92d2587f..c6f02e4b 100644
--- a/src/features/workspaces/operations/workspace-tool-schemas.ts
+++ b/src/features/workspaces/operations/workspace-tool-schemas.ts
@@ -17,7 +17,6 @@ import {
workspaceItemTypeSchema,
workspaceRelationKindSchema,
} from "#/features/workspaces/contracts";
-import { workspaceReferenceRecordSchema } from "#/features/workspaces/locations/workspace-location";
import {
documentAiEditSchema,
documentAiHtmlSchema,
@@ -25,6 +24,7 @@ import {
import { workspaceFileAssetKindSchema } from "#/features/workspaces/model/workspace-file";
import { flashcardEditSchema } from "#/features/workspaces/flashcards/flashcard-edits";
import { flashcardSideHtmlSchema } from "#/features/workspaces/flashcards/flashcard-content";
+import { quizEditSchema, quizQuestionInputSchema } from "#/features/workspaces/quizzes/quiz-edits";
export { workspaceReadItemsInputSchema, workspaceReadItemsOutputSchema };
@@ -42,10 +42,12 @@ const workspaceHtmlMathInstruction =
*/
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}`;
+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 — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. ${workspaceWidgetHtmlInstruction}`;
export const workspaceFlashcardHtmlInstruction = `Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a card. Use item-level relations for sources.`;
+export const workspaceQuizHtmlInstruction = `Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a question. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.`;
+
const workspacePathSchema = z.string().min(1);
const workspaceIndexSchema = z.number().int().nonnegative();
@@ -154,6 +156,19 @@ export const workspaceEditItemInputSchema = z.discriminatedUnion("type", [
),
})
.describe("Flashcard edits."),
+ z
+ .object({
+ type: z.literal("quiz"),
+ path: z.string().min(1).describe("Absolute path of one actual ThinkEx quiz to edit."),
+ edits: z
+ .array(quizEditSchema)
+ .min(1)
+ .max(100)
+ .describe(
+ `Ordered quiz edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. insert and replace take a full authored question and reshuffle its options; update changes only the stem or explanation in place; replace_text requires question, options, or explanation as field and never reshuffles; move requires exactly one of beforeRef or afterRef. ${workspaceQuizHtmlInstruction}`,
+ ),
+ })
+ .describe("Quiz edits."),
]);
export const workspaceLinkItemsInputSchema = z.object({
@@ -239,6 +254,22 @@ export const workspaceCreateItemsInputSchema = z.object({
.describe("Optional relationships from this set to source items, at most 20."),
})
.describe("Flashcard set to create."),
+ z
+ .object({
+ type: z.literal("quiz"),
+ path: z.string().min(1).describe("Final absolute path for the quiz."),
+ questions: z
+ .array(quizQuestionInputSchema)
+ .min(1)
+ .max(100)
+ .describe(`Ordered multiple-choice questions. ${workspaceQuizHtmlInstruction}`),
+ relations: z
+ .array(workspaceRelationInputSchema)
+ .max(20)
+ .optional()
+ .describe("Optional relationships from this quiz to source items, at most 20."),
+ })
+ .describe("Quiz to create."),
]),
)
.min(1)
@@ -275,7 +306,10 @@ export const workspaceReadItemsInputExamples = createInputExamples<
requests: [{ mode: "start", path: "/Demo Folder/Demo Flashcards" }],
},
{
- requests: [{ mode: "cards", path: "/Demo Folder/Demo Flashcards", range: "1-3" }],
+ requests: [{ mode: "entries", path: "/Demo Folder/Demo Flashcards", range: "1-3" }],
+ },
+ {
+ requests: [{ mode: "start", path: "/Demo Folder/Demo Quiz" }],
},
{
requests: [
@@ -289,9 +323,8 @@ export const workspaceReadItemsInputExamples = createInputExamples<
{
requests: [
{
- ref: "wr_7Kp2Qa9x",
+ ref: "Xk7p2Qa9/b_x7Kp2Qa9x8Lm",
mode: "ref",
- path: "/Demo Folder/Demo Document",
},
],
},
@@ -337,6 +370,23 @@ export const workspaceCreateItemsInputExamples = createInputExamples<
path: "/Demo Folder/Demo Flashcards",
cards: [{ front: "
ATP transfers readily usable energy; fats store energy long-term, membranes get structure from lipids and proteins, and genetic information lives in DNA.
-
- {answer === undefined ? (
-
- sendComposerPrompt(
- item.workspaceId,
- `Give me a helpful hint for question ${sourceQuestionNumber} in “${itemPath}” without revealing the answer.`,
- )
- }
- >
-
- Hint
-
- ) : null}
-
+
+ {/* Keyed by question so a scrolled-down long question does not leave the
+ next one scrolled past its stem. pt-1 leaves the Hint button's focus
+ ring room inside the scroller, which clips both axes once
+ overflow-y is set. */}
+
+
+
+ {/* The slot keeps its height once grading removes the Hint, so the
+ options below do not jump up. */}
+
+ {!graded ? (
+
+ sendComposerPrompt(
+ item.workspaceId,
+ `Give me a helpful hint for question ${sourceQuestionNumber} in “${itemPath}” without revealing the answer.`,
+ )
+ }
+ />
+ ) : null}
+
+
);
}
function QuizRichText({ content, compact }: { content: TiptapDocumentJson; compact?: boolean }) {
- const editor = useEditor(
- {
- content: content as unknown as JSONContent,
- editable: false,
- immediatelyRender: false,
- extensions: getTiptapDocumentBaseExtensions(),
- editorProps: {
- attributes: {
- class: cn("workspace-document-prose outline-none", compact && "text-sm"),
- },
- },
- },
- [content],
+ return (
+
);
+}
- return ;
+/**
+ * Hairline between answer rows. Always in the flow and hidden by colour rather
+ * than by unmounting, so rows never shift as the outline moves between them.
+ */
+function QuizOptionRule({ visible }: { visible: boolean }) {
+ return ;
}
function QuizViewerSkeleton() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/styles.css b/src/styles.css
index 96ccc268..9791a59e 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -514,6 +514,15 @@
color: var(--foreground);
}
+ /*
+ * Prose inside a surface that already supplies its own measure and padding —
+ * a quiz question or answer row — where the reading-measure padding above
+ * would read as a stray indent.
+ */
+ .workspace-document-prose.is-flush {
+ padding-inline: 0;
+ }
+
/*
* Review marks follow word-processor tracked changes rather than a code diff:
* new text is underlined in the app's accent, removed text is struck through
@@ -607,6 +616,15 @@
line-height: 1.7;
}
+ /*
+ * That min-height is a click target for empty paragraphs while editing. In a
+ * read-only render it only adds space under the last line, which pushes the
+ * text off-centre against anything aligned beside it — a quiz option's letter.
+ */
+ .workspace-document-prose[contenteditable="false"] p {
+ min-height: 0;
+ }
+
.workspace-document-prose ul,
.workspace-document-prose ol {
padding-left: 1.35rem;
From 668945e87e46725926585767deee12f492d1a062 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 14:56:58 -0400
Subject: [PATCH 08/14] refactor(workspaces): validate entry rich text on the
way in only
The allowlist earns its keep at the model boundary: study viewers render
with the full document schema, so an unchecked widget really would embed
a sandboxed iframe inside a card face. It earns nothing on the way out,
where re-running it over content that already passed can only turn one
odd entry into an item that will not open.
Storage keeps the structural check and drops the allowlist. The parser
factory and its six message strings collapse into two functions taking
the item's label.
---
.../workspaces/content/entry-rich-text.ts | 60 +++++++++----------
.../flashcards/flashcard-content.test.ts | 2 +-
.../flashcards/flashcard-content.ts | 17 +++---
.../workspaces/quizzes/quiz-content.test.ts | 2 +-
.../workspaces/quizzes/quiz-content.ts | 27 ++++-----
5 files changed, 50 insertions(+), 58 deletions(-)
diff --git a/src/features/workspaces/content/entry-rich-text.ts b/src/features/workspaces/content/entry-rich-text.ts
index 13c18137..4393961f 100644
--- a/src/features/workspaces/content/entry-rich-text.ts
+++ b/src/features/workspaces/content/entry-rich-text.ts
@@ -29,55 +29,53 @@ const allowedNodeTypes = new Set([
const allowedMarkTypes = new Set(["bold", "italic", "strike", "code", "link", "underline"]);
-interface EntryRichTextMessages {
- /** e.g. "Flashcards do not support heading content yet." */
- unsupportedNode: (nodeType: string) => string;
- unsupportedMark: string;
- invalidStored: string;
+/**
+ * Parses one HTML fragment the model authored for an entry. `itemLabel` names
+ * the item type in errors — "Flashcard", "Quiz".
+ *
+ * The allowlist is not cosmetic: study viewers render with the full document
+ * schema, so an unchecked widget or table really would embed itself inside a
+ * card face or an answer row. Rejecting costs the model one retry, which is
+ * cheaper than the alternatives — silently dropping the node loses content the
+ * model believed it wrote, and coercing it needs a rule per node type.
+ */
+export function parseEntryRichTextHtml(html: string, itemLabel: string): TiptapDocumentJson {
+ const document = parseDocumentAiHtml(html);
+ visitRichTextValue(document, itemLabel);
+ return document;
}
/**
- * One parser per item type so validation failures name the item the model
- * (or a stored blob) actually violated.
+ * Reads back content this module wrote earlier. Structure still has to hold,
+ * but the allowlist is deliberately not re-applied: it guards what the model
+ * may author, and re-running it on storage would let one odd entry — written
+ * by an older build, or by a later one that allows more — make the whole item
+ * unopenable.
*/
-export function createEntryRichTextParser(messages: EntryRichTextMessages) {
- function assertEntryRichText(value: unknown) {
- visitRichTextValue(value, messages);
+export function parseStoredEntryRichText(value: unknown, itemLabel: string): TiptapDocumentJson {
+ const projection = coerceTiptapDocumentProjection(value);
+ if (projection.warnings.length > 0) {
+ throw new Error(`${itemLabel} content contains invalid rich text.`);
}
-
- return {
- parseEntryRichTextHtml(html: string): TiptapDocumentJson {
- const document = parseDocumentAiHtml(html);
- assertEntryRichText(document);
- return document;
- },
- parseStoredEntryRichText(value: unknown): TiptapDocumentJson {
- const projection = coerceTiptapDocumentProjection(value);
- if (projection.warnings.length > 0) {
- throw new Error(messages.invalidStored);
- }
- assertEntryRichText(projection.document);
- return projection.document;
- },
- };
+ return projection.document;
}
-function visitRichTextValue(value: unknown, messages: EntryRichTextMessages) {
+function visitRichTextValue(value: unknown, itemLabel: string) {
if (Array.isArray(value)) {
- for (const entry of value) visitRichTextValue(entry, messages);
+ for (const entry of value) visitRichTextValue(entry, itemLabel);
return;
}
if (!isRecord(value)) return;
if (typeof value.type === "string" && !allowedNodeTypes.has(value.type)) {
- throw new Error(messages.unsupportedNode(value.type));
+ throw new Error(`${itemLabel} content cannot contain ${value.type} nodes.`);
}
if (Array.isArray(value.marks)) {
for (const mark of value.marks) {
if (!isRecord(mark) || typeof mark.type !== "string" || !allowedMarkTypes.has(mark.type)) {
- throw new Error(messages.unsupportedMark);
+ throw new Error(`${itemLabel} content contains an unsupported text mark.`);
}
}
}
- if ("content" in value) visitRichTextValue(value.content, messages);
+ if ("content" in value) visitRichTextValue(value.content, itemLabel);
}
diff --git a/src/features/workspaces/flashcards/flashcard-content.test.ts b/src/features/workspaces/flashcards/flashcard-content.test.ts
index 8d0d9e8c..e2f4fb1b 100644
--- a/src/features/workspaces/flashcards/flashcard-content.test.ts
+++ b/src/features/workspaces/flashcards/flashcard-content.test.ts
@@ -25,7 +25,7 @@ describe("flashcard content", () => {
it("rejects document-only nodes", () => {
expect(() =>
createFlashcardSetFromHtml([{ front: "
Heading
", back: "
A
" }]),
- ).toThrow("Flashcards do not support heading content yet.");
+ ).toThrow("Flashcard content cannot contain heading nodes.");
});
it("rejects empty sets and malformed stored rich text", () => {
diff --git a/src/features/workspaces/flashcards/flashcard-content.ts b/src/features/workspaces/flashcards/flashcard-content.ts
index aa404eff..baed067d 100644
--- a/src/features/workspaces/flashcards/flashcard-content.ts
+++ b/src/features/workspaces/flashcards/flashcard-content.ts
@@ -1,4 +1,7 @@
-import { createEntryRichTextParser } from "#/features/workspaces/content/entry-rich-text";
+import {
+ parseEntryRichTextHtml,
+ parseStoredEntryRichText,
+} from "#/features/workspaces/content/entry-rich-text";
import { serializeTiptapDocumentToHtml } from "#/features/workspaces/documents/document-ai-html";
import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
import {
@@ -28,12 +31,6 @@ interface FlashcardHtmlCard {
back: string;
}
-const flashcardRichText = createEntryRichTextParser({
- invalidStored: "Flashcard content contains invalid rich text.",
- unsupportedMark: "Flashcard content contains an unsupported text mark.",
- unsupportedNode: (nodeType) => `Flashcards do not support ${nodeType} content yet.`,
-});
-
export function createFlashcardSetFromHtml(cards: Array<{ front: string; back: string }>) {
if (cards.length === 0) throw new Error("A flashcard set needs at least one card.");
return {
@@ -47,7 +44,7 @@ export function createFlashcardSetFromHtml(cards: Array<{ front: string; back: s
}
export function parseFlashcardSideHtml(html: string) {
- return flashcardRichText.parseEntryRichTextHtml(html);
+ return parseEntryRichTextHtml(html, "Flashcard");
}
export function parseFlashcardSetContent(content: string | null): FlashcardSetContent {
@@ -77,8 +74,8 @@ export function parseFlashcardSetContent(content: string | null): FlashcardSetCo
}
seenIds.add(cardId.data);
- const front = flashcardRichText.parseStoredEntryRichText(card.front);
- const back = flashcardRichText.parseStoredEntryRichText(card.back);
+ const front = parseStoredEntryRichText(card.front, "Flashcard");
+ const back = parseStoredEntryRichText(card.back, "Flashcard");
return { id: cardId.data, front, back };
});
diff --git a/src/features/workspaces/quizzes/quiz-content.test.ts b/src/features/workspaces/quizzes/quiz-content.test.ts
index f2aadd31..2c8fe8a0 100644
--- a/src/features/workspaces/quizzes/quiz-content.test.ts
+++ b/src/features/workspaces/quizzes/quiz-content.test.ts
@@ -49,7 +49,7 @@ describe("materializeQuizQuestion", () => {
it("rejects content outside the entry rich-text dialect", () => {
expect(() =>
materializeQuizQuestion({ ...atpQuestion, question: "
Heading stem
" }),
- ).toThrow("Quizzes do not support heading content yet.");
+ ).toThrow("Quiz content cannot contain heading nodes.");
});
});
diff --git a/src/features/workspaces/quizzes/quiz-content.ts b/src/features/workspaces/quizzes/quiz-content.ts
index f78fa9f9..f9428168 100644
--- a/src/features/workspaces/quizzes/quiz-content.ts
+++ b/src/features/workspaces/quizzes/quiz-content.ts
@@ -1,4 +1,7 @@
-import { createEntryRichTextParser } from "#/features/workspaces/content/entry-rich-text";
+import {
+ parseEntryRichTextHtml,
+ parseStoredEntryRichText,
+} from "#/features/workspaces/content/entry-rich-text";
import { serializeTiptapDocumentToHtml } from "#/features/workspaces/documents/document-ai-html";
import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
import {
@@ -52,14 +55,8 @@ export interface QuizQuestionInput {
explanation: string;
}
-const quizRichText = createEntryRichTextParser({
- invalidStored: "Quiz content contains invalid rich text.",
- unsupportedMark: "Quiz content contains an unsupported text mark.",
- unsupportedNode: (nodeType) => `Quizzes do not support ${nodeType} content yet.`,
-});
-
export function parseQuizRichTextHtml(html: string) {
- return quizRichText.parseEntryRichTextHtml(html);
+ return parseEntryRichTextHtml(html, "Quiz");
}
export function createQuizSetFromInputs(questions: QuizQuestionInput[]) {
@@ -87,13 +84,13 @@ export function materializeQuizQuestion(input: QuizQuestionInput): QuizQuestion
const correct = {
id: crypto.randomUUID(),
- text: quizRichText.parseEntryRichTextHtml(input.correctAnswer),
+ text: parseEntryRichTextHtml(input.correctAnswer, "Quiz"),
};
const options = [
correct,
...input.distractors.map((distractor) => ({
id: crypto.randomUUID(),
- text: quizRichText.parseEntryRichTextHtml(distractor),
+ text: parseEntryRichTextHtml(distractor, "Quiz"),
})),
];
assertDistinctOptions(options);
@@ -102,10 +99,10 @@ export function materializeQuizQuestion(input: QuizQuestionInput): QuizQuestion
return {
id: createWorkspaceEntryId("q"),
kind: "multiple_choice",
- question: quizRichText.parseEntryRichTextHtml(input.question),
+ question: parseEntryRichTextHtml(input.question, "Quiz"),
options,
correctOptionId: correct.id,
- explanation: quizRichText.parseEntryRichTextHtml(input.explanation),
+ explanation: parseEntryRichTextHtml(input.explanation, "Quiz"),
};
}
@@ -153,7 +150,7 @@ export function parseQuizSetContent(content: string | null): QuizSetContent {
throw new Error("Quiz content contains an invalid option ID.");
}
seenOptionIds.add(optionId.data);
- return { id: optionId.data, text: quizRichText.parseStoredEntryRichText(option.text) };
+ return { id: optionId.data, text: parseStoredEntryRichText(option.text, "Quiz") };
});
if (
typeof question.correctOptionId !== "string" ||
@@ -165,10 +162,10 @@ export function parseQuizSetContent(content: string | null): QuizSetContent {
return {
id: questionId.data,
kind: "multiple_choice" as const,
- question: quizRichText.parseStoredEntryRichText(question.question),
+ question: parseStoredEntryRichText(question.question, "Quiz"),
options,
correctOptionId: question.correctOptionId,
- explanation: quizRichText.parseStoredEntryRichText(question.explanation),
+ explanation: parseStoredEntryRichText(question.explanation, "Quiz"),
};
});
From 5f83e5c442e3e9cc6a7ada372fc909a43f400469 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 14:57:09 -0400
Subject: [PATCH 09/14] feat(workspaces): draw mermaid code blocks as diagrams
everywhere
Chat could already draw mermaid. Documents, flashcards, and quizzes
could not, which left diagrams out of exactly the surfaces that explain
things. A mermaid block is an ordinary code block, so cards and
questions get diagrams without opening their allowlist to widgets, which
stay documents-only.
AiChatMermaidDiagram becomes a shared MermaidDiagram taking isIncomplete
as a prop, since only chat can see a half-written fence, and caches
rendered SVGs so a card flipped back and forth does not re-render its
artwork. CodeBlockNodeView draws the diagram in place of the source and
selects the whole block on click, the way an image behaves. PDF export
drops mermaid blocks rather than printing authoring syntax.
Tool schemas gain one diagram instruction shared by all three item
types, plus a note that a diagram usually belongs in a quiz stem or
explanation, since one option taller than the rest hints at the answer.
---
.../code-block/mermaid-diagram.tsx} | 87 +++++++++++++++----
.../components/ai-chat/ai-chat-code-block.tsx | 11 ++-
.../code-block-shiki/CodeBlockNodeView.tsx | 40 ++++++++-
.../code-block-shiki/code-languages.ts | 10 +++
.../documents/code-block-shiki/highlighter.ts | 1 +
.../workspace-document-pdf-html.test.ts | 21 +++++
.../export/workspace-document-pdf-html.ts | 24 ++++-
.../workspace-tool-surface.test.ts.snap | 12 +--
.../operations/workspace-tool-schemas.ts | 14 ++-
src/styles.css | 15 ++++
10 files changed, 202 insertions(+), 33 deletions(-)
rename src/{features/workspaces/components/ai-chat/AiChatMermaidDiagram.tsx => components/code-block/mermaid-diagram.tsx} (80%)
diff --git a/src/features/workspaces/components/ai-chat/AiChatMermaidDiagram.tsx b/src/components/code-block/mermaid-diagram.tsx
similarity index 80%
rename from src/features/workspaces/components/ai-chat/AiChatMermaidDiagram.tsx
rename to src/components/code-block/mermaid-diagram.tsx
index 9519beb4..21a9723a 100644
--- a/src/features/workspaces/components/ai-chat/AiChatMermaidDiagram.tsx
+++ b/src/components/code-block/mermaid-diagram.tsx
@@ -1,6 +1,5 @@
import { Check, Copy, GitBranch, Maximize2, Minimize2, Minus, Plus } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
-import { useIsCodeFenceIncomplete } from "streamdown";
import { useTheme } from "#/components/theme-provider";
import {
@@ -28,6 +27,24 @@ type MermaidRenderResult =
let mermaidRenderQueue = Promise.resolve();
+/**
+ * Rendered SVGs keyed by theme and source. Study surfaces mount the same
+ * diagram over and over — flipping a card back and forth, stepping through
+ * questions, or any edit that rebuilds the editor — and re-running mermaid
+ * each time would flash a placeholder over artwork the reader just saw.
+ * Cleared wholesale past a generous ceiling: an eviction policy would cost
+ * more than the few kilobytes it saves.
+ */
+const mermaidImageCache = new Map();
+const MAX_CACHED_DIAGRAMS = 64;
+
+function cacheMermaidImage(requestKey: string, image: MermaidImage) {
+ if (mermaidImageCache.size >= MAX_CACHED_DIAGRAMS) {
+ mermaidImageCache.clear();
+ }
+ mermaidImageCache.set(requestKey, image);
+}
+
function enqueueMermaidRender(input: { darkMode: boolean; id: string; source: string }) {
const render = mermaidRenderQueue.then(async () => {
const { default: mermaid } = await import("mermaid");
@@ -146,17 +163,30 @@ function useNearViewport() {
return { containerRef, isNearViewport };
}
-export function AiChatMermaidDiagram({ source }: { source: string }) {
- const isIncomplete = useIsCodeFenceIncomplete();
+/**
+ * Renders one mermaid source block as a diagram. Shared by chat, where the
+ * source streams in, and by document code blocks, where it arrives whole —
+ * hence `isIncomplete` as a prop rather than a hook: only chat can observe a
+ * half-written fence, and a document must not wait for a stream that has
+ * already ended.
+ */
+export function MermaidDiagram({
+ isIncomplete = false,
+ source,
+}: {
+ isIncomplete?: boolean;
+ source: string;
+}) {
const { resolvedTheme } = useTheme();
const reactId = useId();
const { containerRef, isNearViewport } = useNearViewport();
const requestKey = `${resolvedTheme}:${source}`;
const sourceIsTooLarge = source.length > MAX_MERMAID_SOURCE_LENGTH;
+ const cachedImage = mermaidImageCache.get(requestKey);
const [result, setResult] = useState(null);
useEffect(() => {
- if (!isNearViewport || isIncomplete || sourceIsTooLarge) {
+ if (!isNearViewport || isIncomplete || sourceIsTooLarge || cachedImage) {
return;
}
@@ -174,13 +204,15 @@ export function AiChatMermaidDiagram({ source }: { source: string }) {
return;
}
- setResult({ image: prepareMermaidImage(svg), requestKey, status: "ready" });
+ const image = prepareMermaidImage(svg);
+ cacheMermaidImage(requestKey, image);
+ setResult({ image, requestKey, status: "ready" });
} catch (error: unknown) {
if (cancelled) {
return;
}
- console.warn("[AiChatMermaidDiagram] Failed to render diagram", error);
+ console.warn("[MermaidDiagram] Failed to render diagram", error);
setResult({ requestKey, status: "error" });
}
})();
@@ -188,16 +220,39 @@ export function AiChatMermaidDiagram({ source }: { source: string }) {
return () => {
cancelled = true;
};
- }, [isIncomplete, isNearViewport, reactId, requestKey, resolvedTheme, source, sourceIsTooLarge]);
-
- const state: MermaidRenderResult | { status: "loading" } = sourceIsTooLarge
- ? { requestKey, status: "error" }
- : isIncomplete || result?.requestKey !== requestKey
- ? { status: "loading" }
- : result;
+ }, [
+ cachedImage,
+ isIncomplete,
+ isNearViewport,
+ reactId,
+ requestKey,
+ resolvedTheme,
+ source,
+ sourceIsTooLarge,
+ ]);
+
+ // A cache hit is read straight through to the render rather than copied
+ // into state, so a remount paints finished artwork on the first frame.
+ // This render's own result still outranks it: an that failed to
+ // decode must not be answered with the cached image that just failed.
+ const state = ((): MermaidRenderResult | { status: "loading" } => {
+ if (sourceIsTooLarge) {
+ return { requestKey, status: "error" };
+ }
+ if (isIncomplete) {
+ return { status: "loading" };
+ }
+ if (result?.requestKey === requestKey) {
+ return result;
+ }
+ if (cachedImage) {
+ return { image: cachedImage, requestKey, status: "ready" };
+ }
+ return { status: "loading" };
+ })();
return (
-
This diagram couldn’t be displayed, but the rest of the response is unaffected.
+
This diagram couldn’t be displayed. Its source is unchanged.
View diagram source
@@ -260,7 +315,7 @@ function MermaidDiagramCard({
const [zoom, setZoom] = useState(1);
const { copied, copy } = useCopyToClipboard({
onError: (error) => {
- console.warn("[AiChatMermaidDiagram] Failed to copy source", error);
+ console.warn("[MermaidDiagram] Failed to copy source", error);
},
});
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx b/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx
index fad3fcd1..4a8d30e8 100644
--- a/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx
+++ b/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx
@@ -2,6 +2,7 @@ import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import { isValidElement, useEffect, useState } from "react";
import type { ThemedToken } from "shiki/core";
import { ClientOnly } from "@tanstack/react-router";
+import { useIsCodeFenceIncomplete } from "streamdown";
import {
CodeBlockActions,
CodeBlockCopyButton,
@@ -13,10 +14,11 @@ import {
import {
getCodeLanguageLabel,
highlightCodeTokens,
+ isMermaidCodeLanguage,
normalizeCodeLanguage,
type SupportedCodeLanguage,
} from "#/features/workspaces/documents/code-block-shiki/highlighter";
-import { AiChatMermaidDiagram } from "#/features/workspaces/components/ai-chat/AiChatMermaidDiagram";
+import { MermaidDiagram } from "#/components/code-block/mermaid-diagram";
import { cn } from "#/lib/utils.ts";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
@@ -358,6 +360,9 @@ export const MarkdownCodeBlock = ({
"data-block": dataBlock,
...props
}: MarkdownCodeBlockProps) => {
+ // Only chat can see a half-written fence, so the streaming state is read
+ // here and handed to the shared diagram rather than looked up inside it.
+ const isFenceIncomplete = useIsCodeFenceIncomplete();
const isBlock = dataBlock !== undefined;
if (!isBlock) {
@@ -375,10 +380,10 @@ export const MarkdownCodeBlock = ({
getLanguageFromClassName(className) ?? getLanguageFromClassName(node?.properties?.className);
const code = getTextContent(children).replace(/\n$/, "");
- if (rawLanguage?.toLowerCase() === "mermaid") {
+ if (isMermaidCodeLanguage(rawLanguage)) {
return (
-
+
);
}
diff --git a/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx b/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx
index 212ddfb7..cb7dac2c 100644
--- a/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx
+++ b/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx
@@ -13,18 +13,56 @@ import {
CodeBlockLanguageSelectorValue,
CodeBlockTitle,
} from "#/components/code-block/code-block-chrome";
+import { MermaidDiagram } from "#/components/code-block/mermaid-diagram";
import {
codeLanguageOptions,
getCodeLanguageLabel,
+ isMermaidCodeLanguage,
normalizeCodeLanguage,
type SupportedCodeLanguage,
} from "#/features/workspaces/documents/code-block-shiki/highlighter";
-export function CodeBlockNodeView({ node, updateAttributes }: ReactNodeViewProps) {
+export function CodeBlockNodeView({
+ editor,
+ getPos,
+ node,
+ selected,
+ updateAttributes,
+}: ReactNodeViewProps) {
const language = normalizeCodeLanguage(node.attrs.language as string | null);
const code = node.textContent;
const codeLanguage = language ?? "text";
+ /**
+ * A diagram is only ever the drawing, never the mermaid source that made
+ * it. Nobody hand-writes that syntax — the assistant authors and edits it —
+ * so a click selects the whole block to move or delete, the way an image
+ * behaves. The content stays mounted but hidden because ProseMirror still
+ * needs somewhere to keep the node's text.
+ */
+ if (isMermaidCodeLanguage(node.attrs.language as string | null)) {
+ return (
+ {
+ const position = getPos();
+ if (position !== undefined && editor.isEditable) {
+ // Focus first: a click on non-editable content leaves the
+ // editor blurred, and an unfocused selection ignores Backspace.
+ editor.chain().focus().setNodeSelection(position).run();
+ }
+ }}
+ >
+
+ {/* Same bargain as a widget's source: ProseMirror has to render the
+ node's text somewhere, and the drawing is its visible form. */}
+
+
+ );
+ }
+
return (
({ label, value }),
);
+/**
+ * Mermaid is deliberately not one of the languages above. It is never syntax
+ * highlighted, never offered in the language picker, and never read as source
+ * — every surface draws it instead — so registering a grammar for it would
+ * only ship a parser nothing looks at.
+ */
+export function isMermaidCodeLanguage(language: string | null | undefined) {
+ return language?.trim().toLowerCase() === "mermaid";
+}
+
export const supportedCodeFileExtensions = [
...new Set(codeLanguageDefinitionList.flatMap((definition) => definition.extensions ?? [])),
].sort((left, right) => left.localeCompare(right));
diff --git a/src/features/workspaces/documents/code-block-shiki/highlighter.ts b/src/features/workspaces/documents/code-block-shiki/highlighter.ts
index 26d4c3e7..4fe9a7a9 100644
--- a/src/features/workspaces/documents/code-block-shiki/highlighter.ts
+++ b/src/features/workspaces/documents/code-block-shiki/highlighter.ts
@@ -13,6 +13,7 @@ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
export {
codeLanguageOptions,
getCodeLanguageLabel,
+ isMermaidCodeLanguage,
normalizeCodeLanguage,
type CodeLanguageOption,
type SupportedCodeLanguage,
diff --git a/src/features/workspaces/export/workspace-document-pdf-html.test.ts b/src/features/workspaces/export/workspace-document-pdf-html.test.ts
index 34e8b2ee..c26eb347 100644
--- a/src/features/workspaces/export/workspace-document-pdf-html.test.ts
+++ b/src/features/workspaces/export/workspace-document-pdf-html.test.ts
@@ -70,4 +70,25 @@ describe("renderWorkspaceDocumentPdfHtml", () => {
expect(html).toContain("math-fallback");
expect(html).toContain("$\\definitelynotacommand{$");
});
+
+ // A PDF cannot draw the diagram, and its source is authoring material the
+ // reader never asked for, so the block leaves nothing behind.
+ it("drops a mermaid diagram rather than printing its source", async () => {
+ const html = await renderWorkspaceDocumentPdfHtml({
+ type: "doc",
+ content: [
+ {
+ type: "codeBlock",
+ attrs: { language: "mermaid" },
+ content: [{ type: "text", text: "flowchart TD; A[Start] --> B[End]" }],
+ },
+ { type: "paragraph", content: [{ type: "text", text: "Prose survives." }] },
+ ],
+ });
+
+ expect(html).toContain("Prose survives.");
+ expect(html).not.toContain("flowchart");
+ expect(html).not.toContain("Mermaid");
+ expect(html).not.toContain("language-mermaid");
+ });
});
diff --git a/src/features/workspaces/export/workspace-document-pdf-html.ts b/src/features/workspaces/export/workspace-document-pdf-html.ts
index 262387d9..d5360c73 100644
--- a/src/features/workspaces/export/workspace-document-pdf-html.ts
+++ b/src/features/workspaces/export/workspace-document-pdf-html.ts
@@ -5,6 +5,7 @@ import { parseHTML } from "linkedom";
import {
getCodeLanguageLabel,
highlightCodeTokens,
+ isMermaidCodeLanguage,
} from "#/features/workspaces/documents/code-block-shiki/highlighter";
import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema";
@@ -102,10 +103,28 @@ export async function renderWorkspaceDocumentPdfHtml(document: TiptapDocumentJso
return `${article.outerHTML}`;
}
+/**
+ * What a printed page cannot carry. A citation has nothing to link to; a
+ * widget is markup that has to run; a mermaid block is an instruction for
+ * drawing rather than the drawing. Falling back to source for the last two
+ * would put authoring material in front of a reader who asked for a document,
+ * so all three leave nothing behind.
+ */
function removeUnexportedNodes(article: Element) {
for (const node of article.querySelectorAll('citation, [data-type="widget"]')) {
node.remove();
}
+ for (const code of article.querySelectorAll("pre > code")) {
+ if (isMermaidCodeLanguage(readCodeBlockLanguage(code))) {
+ code.parentElement?.remove();
+ }
+ }
+}
+
+/** The language a code block declares, exactly as it was written. */
+function readCodeBlockLanguage(element: Element) {
+ const languageClass = Array.from(element.classList).find((name) => name.startsWith("language-"));
+ return languageClass?.slice("language-".length) || null;
}
function renderMath(article: Element) {
@@ -133,11 +152,8 @@ async function renderCodeBlocks(htmlDocument: Document, article: Element) {
const codeBlocks = Array.from(article.querySelectorAll("pre > code"));
const rendered = await Promise.all(
codeBlocks.map(async (element) => {
- const languageClass = Array.from(element.classList).find((name) =>
- name.startsWith("language-"),
- );
const code = element.textContent ?? "";
- const language = languageClass?.slice("language-".length) || null;
+ const language = readCodeBlockLanguage(element);
return {
code,
element,
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 916c1cb2..8039e895 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
@@ -105,7 +105,7 @@ exports[`workspace tool surface > workspace_create_items input schema is stable
"description": "Document to create.",
"properties": {
"initialContent": {
- "description": "Optional initial HTML content. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. 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 — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. 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…
.",
+ "description": "Optional initial HTML content. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. For checkboxes, use
Item
. Documents cannot hold images: never use or — draw a diagram or describe the visual in words instead. Draw a diagram as a mermaid code block:
flowchart TD; A[Start] --> B[End]
. It renders as a diagram wherever the item is read or studied, and is left out of a PDF export entirely, so never let a diagram carry a point the surrounding text does not also make. Keep it to about ten nodes with short labels, and include concise accTitle and accDescr lines — they become the diagram's alt text. No frontmatter, init directives, custom styles, HTML, links, or images inside the diagram. Cite workspace sources in documents exactly as in a chat reply, with placed after the claim it supports — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. 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…
.",
"maxLength": 512000,
"type": "string",
},
@@ -163,7 +163,7 @@ exports[`workspace tool surface > workspace_create_items input schema is stable
"description": "Flashcard set to create.",
"properties": {
"cards": {
- "description": "Ordered cards. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a card. Use item-level relations for sources.",
+ "description": "Ordered cards. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a card. Draw a diagram as a mermaid code block:
flowchart TD; A[Start] --> B[End]
. It renders as a diagram wherever the item is read or studied, and is left out of a PDF export entirely, so never let a diagram carry a point the surrounding text does not also make. Keep it to about ten nodes with short labels, and include concise accTitle and accDescr lines — they become the diagram's alt text. No frontmatter, init directives, custom styles, HTML, links, or images inside the diagram. Use item-level relations for sources.",
"items": {
"additionalProperties": false,
"properties": {
@@ -250,7 +250,7 @@ exports[`workspace tool surface > workspace_create_items input schema is stable
"type": "string",
},
"questions": {
- "description": "Ordered multiple-choice questions. Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a question. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.",
+ "description": "Ordered multiple-choice questions. Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a question. Draw a diagram as a mermaid code block:
flowchart TD; A[Start] --> B[End]
. It renders as a diagram wherever the item is read or studied, and is left out of a PDF export entirely, so never let a diagram carry a point the surrounding text does not also make. Keep it to about ten nodes with short labels, and include concise accTitle and accDescr lines — they become the diagram's alt text. No frontmatter, init directives, custom styles, HTML, links, or images inside the diagram. A diagram usually belongs in the stem or the explanation; if one option needs it, give them all one, since an option taller than the rest hints at the answer. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.",
"items": {
"additionalProperties": false,
"properties": {
@@ -387,7 +387,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"description": "Document edits.",
"properties": {
"edits": {
- "description": "Ordered document edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. update accepts exactly one top-level block; replace may replace one block with several. move requires exactly one of beforeRef or afterRef. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. 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 — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. 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…
.",
+ "description": "Ordered document edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. update accepts exactly one top-level block; replace may replace one block with several. move requires exactly one of beforeRef or afterRef. Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. 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. For checkboxes, use
Item
. Documents cannot hold images: never use or — draw a diagram or describe the visual in words instead. Draw a diagram as a mermaid code block:
flowchart TD; A[Start] --> B[End]
. It renders as a diagram wherever the item is read or studied, and is left out of a PDF export entirely, so never let a diagram carry a point the surrounding text does not also make. Keep it to about ten nodes with short labels, and include concise accTitle and accDescr lines — they become the diagram's alt text. No frontmatter, init directives, custom styles, HTML, links, or images inside the diagram. Cite workspace sources in documents exactly as in a chat reply, with placed after the claim it supports — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. 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…
.",
"items": {
"anyOf": [
{
@@ -528,7 +528,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"description": "Flashcard edits.",
"properties": {
"edits": {
- "description": "Ordered flashcard edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. replace changes both sides; replace_text requires front or back as side; move requires exactly one of beforeRef or afterRef. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a card. Use item-level relations for sources.",
+ "description": "Ordered flashcard edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. replace changes both sides; replace_text requires front or back as side; move requires exactly one of beforeRef or afterRef. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a card. Draw a diagram as a mermaid code block:
flowchart TD; A[Start] --> B[End]
. It renders as a diagram wherever the item is read or studied, and is left out of a PDF export entirely, so never let a diagram carry a point the surrounding text does not also make. Keep it to about ten nodes with short labels, and include concise accTitle and accDescr lines — they become the diagram's alt text. No frontmatter, init directives, custom styles, HTML, links, or images inside the diagram. Use item-level relations for sources.",
"items": {
"anyOf": [
{
@@ -733,7 +733,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`]
"description": "Quiz edits.",
"properties": {
"edits": {
- "description": "Ordered quiz edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. insert and replace take a full authored question and reshuffle its options; update changes only the stem or explanation in place; replace_text requires question, options, or explanation as field and never reshuffles; move requires exactly one of beforeRef or afterRef. Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a question. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.",
+ "description": "Ordered quiz edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. insert and replace take a full authored question and reshuffle its options; update changes only the stem or explanation in place; replace_text requires question, options, or explanation as field and never reshuffles; move requires exactly one of beforeRef or afterRef. Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. 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. Do not use headings, tables, images, widgets, task lists, or citations inside a question. Draw a diagram as a mermaid code block:
flowchart TD; A[Start] --> B[End]
. It renders as a diagram wherever the item is read or studied, and is left out of a PDF export entirely, so never let a diagram carry a point the surrounding text does not also make. Keep it to about ten nodes with short labels, and include concise accTitle and accDescr lines — they become the diagram's alt text. No frontmatter, init directives, custom styles, HTML, links, or images inside the diagram. A diagram usually belongs in the stem or the explanation; if one option needs it, give them all one, since an option taller than the rest hints at the answer. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.",
"items": {
"anyOf": [
{
diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts
index 249a88d6..d36db2e8 100644
--- a/src/features/workspaces/operations/workspace-tool-schemas.ts
+++ b/src/features/workspaces/operations/workspace-tool-schemas.ts
@@ -36,17 +36,25 @@ export { workspaceReadItemsInputSchema, workspaceReadItemsOutputSchema };
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.';
+/**
+ * The one diagram route every HTML surface shares. A mermaid block is an
+ * ordinary code block, so cards and questions get diagrams without opening
+ * their allowlist to widgets — which stay documents-only.
+ */
+const workspaceHtmlDiagramInstruction =
+ 'Draw a diagram as a mermaid code block:
flowchart TD; A[Start] --> B[End]
. It renders as a diagram wherever the item is read or studied, and is left out of a PDF export entirely, so never let a diagram carry a point the surrounding text does not also make. Keep it to about ten nodes with short labels, and include concise accTitle and accDescr lines — they become the diagram\'s alt text. No frontmatter, init directives, custom styles, HTML, links, or images inside the diagram.';
+
/**
* 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 — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. ${workspaceWidgetHtmlInstruction}`;
+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 — draw a diagram or describe the visual in words instead. ${workspaceHtmlDiagramInstruction} Cite workspace sources in documents exactly as in a chat reply, with placed after the claim it supports — the address is the item's ref, or ref/unit for a page, block, card, or question, with any .r_ suffix dropped. ${workspaceWidgetHtmlInstruction}`;
-export const workspaceFlashcardHtmlInstruction = `Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a card. Use item-level relations for sources.`;
+export const workspaceFlashcardHtmlInstruction = `Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a card. ${workspaceHtmlDiagramInstruction} Use item-level relations for sources.`;
-export const workspaceQuizHtmlInstruction = `Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a question. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.`;
+export const workspaceQuizHtmlInstruction = `Question stems, options, and explanations are HTML. Keep them concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a question. ${workspaceHtmlDiagramInstruction} A diagram usually belongs in the stem or the explanation; if one option needs it, give them all one, since an option taller than the rest hints at the answer. Use item-level relations for sources. Write correctAnswer as its own field and never hint at it in the stem or option order: the server shuffles the options and records which one is correct. Every distractor must be strictly wrong yet plausible, reflect a specific misconception, and match the correct answer's length and tone. Prefer 3 distractors; use 1 for true/false. Questions should test understanding from the source material, not trivia recall.`;
const workspacePathSchema = z.string().min(1);
const workspaceIndexSchema = z.number().int().nonnegative();
diff --git a/src/styles.css b/src/styles.css
index 9791a59e..5270eb54 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -745,6 +745,21 @@
display: none;
}
+ /* A mermaid diagram, drawn from a code block. Nobody edits the syntax by
+ hand, so the block behaves like an image: click to select, then move or
+ delete it whole. */
+ .workspace-document-prose .workspace-document-diagram[data-selected="true"] {
+ outline: 2px solid var(--ring);
+ outline-offset: 1px;
+ border-radius: var(--radius);
+ }
+
+ /* The mermaid source, which ProseMirror must render somewhere but the reader
+ should never see. The drawing is the visible form of this text. */
+ .workspace-document-prose .workspace-document-diagram-source {
+ display: none;
+ }
+
/* Collapsible sections. The Details node view renders a toggle button next to
the summary and hides the content div, so it only needs layout here — the
AI writes and there is no toolbar affordance for inserting one. */
From 4164e7e571ca57a30b48f5626f02fb004aa50204 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:00:20 -0400
Subject: [PATCH 10/14] fix(workspaces): match the quiz skeleton to the viewer
The skeleton still drew one big rounded card, which the quiz stopped
being when the answer rows moved out into a plain list. It now traces
the real shape: a stem line with the Hint slot beside it, four option
rows with their letter badges and hairlines, then the progress strip
and footer.
---
.../components/quizzes/QuizViewer.tsx | 28 +++++++++++++++----
1 file changed, 22 insertions(+), 6 deletions(-)
diff --git a/src/features/workspaces/components/quizzes/QuizViewer.tsx b/src/features/workspaces/components/quizzes/QuizViewer.tsx
index fce5c318..74054ef8 100644
--- a/src/features/workspaces/components/quizzes/QuizViewer.tsx
+++ b/src/features/workspaces/components/quizzes/QuizViewer.tsx
@@ -291,10 +291,9 @@ function QuizStudySession({
// outline. Wrong picks keep only the filled X — no row chrome — so they
// stay in the hairline list.
const outlinedOptionIds = new Set(
- [
- !graded ? selectedOptionId : null,
- graded ? currentQuestion.correctOptionId : null,
- ].filter(Boolean),
+ [!graded ? selectedOptionId : null, graded ? currentQuestion.correctOptionId : null].filter(
+ Boolean,
+ ),
);
const lastOption = currentQuestion.options.at(-1);
@@ -522,9 +521,26 @@ function QuizViewerSkeleton() {
return (
-
+
+
+
+
+
+ {/* Widths vary so the rows read as answers rather than a block. */}
+ {["w-3/5", "w-2/5", "w-1/2", "w-4/6"].map((width) => (
+
+
+
+
+
+
+ ))}
+
+
-
+
+
+
From f74ab16bcad7d4c4ec5b3ca3c19e6cfdd78d5cfb Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:28:42 -0400
Subject: [PATCH 11/14] fix(workspaces): keep study arrow keys alive across
navigation
Both viewers key the current entry's subtree so navigation remounts it,
and a remount that held the focused element drops focus to , dead
to the section's keydown handler. The section now reclaims focus when a
navigation strands it there.
---
.../components/flashcards/FlashcardViewer.tsx | 3 +++
.../components/quizzes/QuizViewer.tsx | 3 +++
.../components/study/StudySessionControls.tsx | 19 +++++++++++++++++++
3 files changed, 25 insertions(+)
diff --git a/src/features/workspaces/components/flashcards/FlashcardViewer.tsx b/src/features/workspaces/components/flashcards/FlashcardViewer.tsx
index efe4f5e1..4782c1e8 100644
--- a/src/features/workspaces/components/flashcards/FlashcardViewer.tsx
+++ b/src/features/workspaces/components/flashcards/FlashcardViewer.tsx
@@ -14,6 +14,7 @@ import { StudyRichText } from "#/features/workspaces/components/study/StudyRichT
import {
StudyAiActionButton,
StudyNavButton,
+ useStudySessionFocus,
} from "#/features/workspaces/components/study/StudySessionControls";
import { StudyToolbar } from "#/features/workspaces/components/study/StudyToolbar";
import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot";
@@ -421,6 +422,7 @@ function FlashcardStudySurface({
studyCards: Flashcard[];
studyState: FlashcardStudyState;
}) {
+ const sectionRef = useStudySessionFocus(currentCard.id);
const settling = ratingFeedback !== null;
const nextCard = ratingFeedback ? studyCards[currentIndex + 1] : undefined;
const isLastCardRating = ratingFeedback !== null && !nextCard;
@@ -455,6 +457,7 @@ function FlashcardStudySurface({
{
diff --git a/src/features/workspaces/components/quizzes/QuizViewer.tsx b/src/features/workspaces/components/quizzes/QuizViewer.tsx
index 74054ef8..3ac924e8 100644
--- a/src/features/workspaces/components/quizzes/QuizViewer.tsx
+++ b/src/features/workspaces/components/quizzes/QuizViewer.tsx
@@ -10,6 +10,7 @@ import { StudyRichText } from "#/features/workspaces/components/study/StudyRichT
import {
StudyAiActionButton,
StudyNavButton,
+ useStudySessionFocus,
} from "#/features/workspaces/components/study/StudySessionControls";
import { StudyToolbar } from "#/features/workspaces/components/study/StudyToolbar";
import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot";
@@ -119,6 +120,7 @@ function QuizStudySession({
? questions.findIndex((question) => question.id === currentQuestion.id) + 1
: 0;
const answer = currentQuestion ? getQuizAnswer(currentQuestion, studyState) : undefined;
+ const sectionRef = useStudySessionFocus(currentQuestion?.id);
const quizProgress = useMemo(
() => summarizeQuizStudyProgress(questions, studyState),
[questions, studyState],
@@ -299,6 +301,7 @@ function QuizStudySession({
return (
— out of reach of the section's
+ * keydown handler. Attach the returned ref to the section; when an entry change
+ * strands focus on , the section takes it back.
+ */
+export function useStudySessionFocus(entryKey: string | undefined) {
+ const sectionRef = useRef(null);
+ const previousKey = useRef(entryKey);
+ useEffect(() => {
+ if (previousKey.current === entryKey) return;
+ previousKey.current = entryKey;
+ if (document.activeElement === document.body) sectionRef.current?.focus();
+ }, [entryKey]);
+ return sectionRef;
+}
+
/**
* Asks the AI for help with the item on screen — a hint before answering, a
* fuller explanation after. Amber everywhere so "ask AI" reads the same in
From 2aa4b94454afd5f6225caac61c3dd75e3834593a Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:28:43 -0400
Subject: [PATCH 12/14] fix(workspaces): show a static language label in
read-only code blocks
The node view assumed its host was the editable document editor; study
viewers now render it read-only, where the language dropdown is editing
chrome with nothing to edit.
---
.../code-block-shiki/CodeBlockNodeView.tsx | 56 ++++++++++---------
1 file changed, 31 insertions(+), 25 deletions(-)
diff --git a/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx b/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx
index cb7dac2c..07b7215d 100644
--- a/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx
+++ b/src/features/workspaces/documents/code-block-shiki/CodeBlockNodeView.tsx
@@ -71,32 +71,38 @@ export function CodeBlockNodeView({
>
- {
- updateAttributes({
- language: value === "plain" ? null : (value as SupportedCodeLanguage),
- });
- }}
- >
-
-
- {(value: string | null) =>
- value === "plain" ? "Plain text" : getCodeLanguageLabel(value)
- }
-
-
-
-
- Plain text
-
- {codeLanguageOptions.map((option) => (
-
- {option.label}
+ {/* Study viewers render this node view read-only, where the
+ language is a fact to show, not a setting to change. */}
+ {editor.isEditable ? (
+ {
+ updateAttributes({
+ language: value === "plain" ? null : (value as SupportedCodeLanguage),
+ });
+ }}
+ >
+
+
+ {(value: string | null) =>
+ value === "plain" ? "Plain text" : getCodeLanguageLabel(value)
+ }
+
+
+
+
+ Plain text
- ))}
-
-
+ {codeLanguageOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ ) : (
+ getCodeLanguageLabel(language)
+ )}
From 149d6dd186f6d0caa2c2e0c2ff2a6e4cb5fed815 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:28:49 -0400
Subject: [PATCH 13/14] perf(workspaces): share one page read across refKey
resolutions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
getWorkspaceItemByRefKey read the whole workspace page per call, and
both callers called it in a loop — N citations or ref reads cost N
serial page reads. It is now getWorkspaceItemRefKeyIndex: one page
read, however many keys the operation resolves.
---
.../operations/document-citations.ts | 10 +++++-----
.../workspaces/operations/read-items.ts | 10 +++++++---
.../workspaces/persistence/workspace-items.ts | 19 +++++++++++++------
3 files changed, 25 insertions(+), 14 deletions(-)
diff --git a/src/features/workspaces/operations/document-citations.ts b/src/features/workspaces/operations/document-citations.ts
index 75bc78df..a97f547a 100644
--- a/src/features/workspaces/operations/document-citations.ts
+++ b/src/features/workspaces/operations/document-citations.ts
@@ -7,7 +7,7 @@ import {
resolveWorkspaceAddressLocation,
type WorkspaceLocation,
} from "#/features/workspaces/locations/workspace-location";
-import { getWorkspaceItemByRefKey } from "#/features/workspaces/persistence/workspace-items";
+import { getWorkspaceItemRefKeyIndex } from "#/features/workspaces/persistence/workspace-items";
import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context";
/**
@@ -28,14 +28,14 @@ export async function resolveDocumentCitations(input: {
return input.html;
}
+ const refKeyIndex = await getWorkspaceItemRefKeyIndex({
+ workspaceId: input.context.workspaceId,
+ });
const locations = new Map();
for (const ref of new Set(refs)) {
const address = parseWorkspaceAddress(ref);
if (!address) continue;
- const resolved = await getWorkspaceItemByRefKey({
- refKey: address.refKey,
- workspaceId: input.context.workspaceId,
- });
+ const resolved = refKeyIndex.get(address.refKey);
const location = resolved ? resolveWorkspaceAddressLocation(resolved.item, address) : undefined;
if (location) locations.set(ref, location);
}
diff --git a/src/features/workspaces/operations/read-items.ts b/src/features/workspaces/operations/read-items.ts
index 8f4ba239..9e1a9214 100644
--- a/src/features/workspaces/operations/read-items.ts
+++ b/src/features/workspaces/operations/read-items.ts
@@ -9,7 +9,7 @@ import { recordWorkspaceFileReadOutcomes } from "#/features/workspaces/content/w
import { getDocumentSessionFromEnv } from "#/features/workspaces/document-session-access";
import { readFlashcardViewer } from "#/features/workspaces/flashcards/flashcard-study-persistence";
import { readQuizViewer } from "#/features/workspaces/quizzes/quiz-study-persistence";
-import { getWorkspaceItemByRefKey } from "#/features/workspaces/persistence/workspace-items";
+import { getWorkspaceItemRefKeyIndex } from "#/features/workspaces/persistence/workspace-items";
import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context";
import { authorizeWorkspaceOperation } from "#/features/workspaces/operations/workspace-operation-context";
@@ -25,6 +25,8 @@ export async function readWorkspaceItemsOperation(
access: "read",
context: accessContext,
});
+ // Lazy so path-only batches skip it; shared so N ref requests cost one page read.
+ let refKeyIndex: ReturnType | undefined;
const results = await readWorkspaceContent({
bucket: env.WORKSPACE_FILES,
getDocumentSession: (itemId) =>
@@ -44,8 +46,10 @@ export async function readWorkspaceItemsOperation(
userId: accessContext.actor.userId,
workspaceId: accessContext.workspaceId,
}),
- resolveRefKey: (refKey) =>
- getWorkspaceItemByRefKey({ refKey, workspaceId: accessContext.workspaceId }),
+ resolveRefKey: async (refKey) => {
+ refKeyIndex ??= getWorkspaceItemRefKeyIndex({ workspaceId: accessContext.workspaceId });
+ return (await refKeyIndex).get(refKey);
+ },
requests: input.requests,
workspaceId: accessContext.workspaceId,
});
diff --git a/src/features/workspaces/persistence/workspace-items.ts b/src/features/workspaces/persistence/workspace-items.ts
index 49ee9328..32fd245c 100644
--- a/src/features/workspaces/persistence/workspace-items.ts
+++ b/src/features/workspaces/persistence/workspace-items.ts
@@ -124,13 +124,20 @@ export async function getWorkspaceItemPaths(input: WorkspaceScoped) {
+/**
+ * Resolves item address refKeys to live items and their paths. Costs one
+ * workspace page read however many refKeys the caller then looks up, so
+ * callers with several refs to resolve share a single index.
+ */
+export async function getWorkspaceItemRefKeyIndex(input: WorkspaceScoped