From 9b115b34130da588beb84b02af6d1c5ea71f36eb Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:52:12 -0400 Subject: [PATCH 01/21] refactor(workspaces): generalize item creation and editing --- .../ai/ai-thread-orchestration-contract.ts | 3 +- .../ai/ai-thread-orchestration.worker.test.ts | 29 ++ .../components/ai-chat/AiChatPromptInput.tsx | 25 +- .../components/ai-chat/AiChatThreadView.tsx | 26 +- .../ai-chat-document-edit-actions.test.ts | 38 ++ .../ai-chat/ai-chat-document-edit-actions.ts | 6 +- .../widget/WorkspaceAddWidgetDialog.tsx | 31 +- .../composer/workspace-composer-actions.ts | 20 +- .../workspaces/documents/document-ai-html.ts | 8 + .../documents/document-item-content.ts | 35 +- .../model/workspace-item-create-bootstrap.ts | 30 ++ .../workspace-tool-surface.test.ts.snap | 460 +++++++++++++----- .../operations/create-items.test.ts | 39 ++ .../workspaces/operations/create-items.ts | 52 +- .../workspaces/operations/edit-item.ts | 45 +- .../workspaces/operations/read-items.ts | 3 + .../workspace-operation-failure-codes.ts | 2 + .../operations/workspace-tool-definitions.ts | 16 +- .../operations/workspace-tool-schemas.ts | 129 +++-- .../operations/workspace-tool-surface.test.ts | 18 + .../workspaces/persistence/workspace-items.ts | 8 +- .../workspace-ai-composer-draft-store.test.ts | 21 + .../workspace-ai-composer-draft-store.ts | 68 ++- 23 files changed, 802 insertions(+), 310 deletions(-) create mode 100644 src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.test.ts create mode 100644 src/features/workspaces/model/workspace-item-create-bootstrap.ts create mode 100644 src/features/workspaces/state/workspace-ai-composer-draft-store.test.ts diff --git a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts index 11cccd3c7..d9d499ac2 100644 --- a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts +++ b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts @@ -267,7 +267,8 @@ function getDocumentEditAction(call: z.output const receiptId = getDocumentEditReceiptMetadata(call.result); const lineChanges = asRecord(result.lineChanges); - return itemId && path && applied > 0 && receiptId + const isDocument = result.itemType === "document"; + return isDocument && itemId && path && applied > 0 && receiptId ? { itemId, kind: "document-edit" as const, diff --git a/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts b/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts index c6749f8cc..134d43fa5 100644 --- a/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts +++ b/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts @@ -143,6 +143,7 @@ describe("AI thread orchestration", () => { applied: 1, failed: [], itemId: "document-1", + itemType: "document", path: "/Notes", __thinkexUi: { documentEditReceiptId: "receipt-secret" }, }, @@ -170,6 +171,34 @@ describe("AI thread orchestration", () => { expect(JSON.stringify(telemetryOutput)).not.toContain("receipt-secret"); }); + it("does not turn flashcard edits into document review actions", () => { + const output = normalizeAIThreadOrchestrationOutput({ + status: "completed", + executionId: "execution-flashcard-edit", + result: null, + calls: [ + { + seq: 1, + connector: "tools", + method: "workspace_edit_item", + state: "applied", + requiresApproval: false, + args: { path: "/Biology", type: "flashcard" }, + result: { + applied: 1, + failed: [], + itemId: "flashcard-1", + itemType: "flashcard", + path: "/Biology", + __thinkexUi: { documentEditReceiptId: "should-not-be-used" }, + }, + }, + ], + }); + + expect(output.calls[0]).not.toHaveProperty("action"); + }); + it("fails closed when a completed runtime result contains a malformed child call", () => { const output = normalizeAIThreadOrchestrationOutput({ status: "completed", diff --git a/src/features/workspaces/components/ai-chat/AiChatPromptInput.tsx b/src/features/workspaces/components/ai-chat/AiChatPromptInput.tsx index 630d0c2ab..c3431078e 100644 --- a/src/features/workspaces/components/ai-chat/AiChatPromptInput.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatPromptInput.tsx @@ -1,5 +1,5 @@ import { Mic, Paperclip } from "lucide-react"; -import { type SetStateAction, useCallback, useEffect, useRef } from "react"; +import { type SetStateAction, useCallback, useRef } from "react"; import { type AttachmentsContext, @@ -41,7 +41,6 @@ import { useWorkspaceAiComposerDraftFiles, useWorkspaceAiComposerDraftStore, useWorkspaceAiComposerDraftText, - useWorkspaceAiComposerFocusRequest, } from "#/features/workspaces/state/workspace-ai-composer-draft-store"; import { cn } from "#/lib/utils"; @@ -98,8 +97,6 @@ export default function AiChatPromptInput({ (value: SetStateAction) => setDraftText(activeThreadId, value), [activeThreadId, setDraftText], ); - const focusRequest = useWorkspaceAiComposerFocusRequest(activeThreadId); - const clearFocusRequest = useWorkspaceAiComposerDraftStore((state) => state.clearFocusRequest); const dictation = useAiChatDictation({ input, setInput }); const draftFiles = useWorkspaceAiComposerDraftFiles(activeThreadId); const attachmentsReady = @@ -130,26 +127,6 @@ export default function AiChatPromptInput({ setInput, textareaRef, }); - useEffect(() => { - if (focusRequest === 0) { - return; - } - - const frame = requestAnimationFrame(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - - textarea.focus(); - const caret = textarea.value.length; - textarea.setSelectionRange(caret, caret); - clearFocusRequest(activeThreadId, focusRequest); - }); - - return () => cancelAnimationFrame(frame); - }, [activeThreadId, clearFocusRequest, focusRequest]); - const attachments: Omit = { add: addFiles, composerReady: canType, diff --git a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx index 7a66db476..4cf11eb72 100644 --- a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx @@ -1,5 +1,5 @@ import { generateId } from "ai"; -import { useEffect, useState } from "react"; +import { useEffect, useEffectEvent, useState } from "react"; import type { PromptInputMessage } from "#/features/workspaces/components/ai-chat/ai-chat-prompt-input"; import type { AIThreadSummary } from "#/features/workspaces/ai/user-ai-agents"; @@ -12,9 +12,13 @@ import type { AiChatSendMessage, } from "#/features/workspaces/components/ai-chat/types"; import { useWorkspaceAiChat } from "#/features/workspaces/components/ai-chat/useWorkspaceAiChat"; +import { useWorkspaceAiAllowance } from "#/features/workspaces/ai/use-workspace-ai-allowance"; import type { WorkspaceAiContextScope } from "#/features/workspaces/model/workspace-ai-context-types"; import { buildWorkspaceAiContextSnapshot } from "#/features/workspaces/model/workspace-ai-context-snapshot"; -import { useWorkspaceAiComposerDraftStore } from "#/features/workspaces/state/workspace-ai-composer-draft-store"; +import { + useWorkspaceAiComposerDraftStore, + useWorkspaceAiDirectPrompt, +} from "#/features/workspaces/state/workspace-ai-composer-draft-store"; export default function AiChatThreadView({ context, @@ -47,6 +51,9 @@ export default function AiChatThreadView({ const clearDraftArtifacts = useWorkspaceAiComposerDraftStore( (state) => state.clearDraftArtifacts, ); + const directPrompt = useWorkspaceAiDirectPrompt(threadId); + const takeDirectPrompt = useWorkspaceAiComposerDraftStore((state) => state.takeDirectPrompt); + const { isBlocked } = useWorkspaceAiAllowance(modelId); useEffect(() => { onRecoveringChange?.(presentation.isRecovering); @@ -71,7 +78,7 @@ export default function AiChatThreadView({ } }; - const sendMessage = (message: PromptInputMessage) => { + const sendMessage = (message: PromptInputMessage, clearDraft = true) => { const chatMessage = getChatMessageFromPrompt(message, generateId()); if (!chatMessage) { @@ -84,8 +91,17 @@ export default function AiChatThreadView({ }, }); setSentMessageAnimationId(chatMessage.id); - clearDraftArtifacts(context.workspaceId, threadId); + if (clearDraft) clearDraftArtifacts(context.workspaceId, threadId); }; + const sendDirectPrompt = useEffectEvent((text: string) => { + sendMessage({ files: [], text }, false); + }); + + useEffect(() => { + if (!directPrompt || !canSend || inputStatus !== "ready" || isBlocked) return; + const text = takeDirectPrompt(threadId, directPrompt.id); + if (text) queueMicrotask(() => sendDirectPrompt(text)); + }, [canSend, directPrompt, inputStatus, isBlocked, takeDirectPrompt, threadId]); return (
@@ -109,7 +125,7 @@ export default function AiChatThreadView({ modelId={modelId} status={inputStatus} onModelChange={onModelChange} - onSubmit={sendMessage} + onSubmit={(message) => sendMessage(message)} onStop={() => { stopChatAndBrowser(); }} diff --git a/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.test.ts b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.test.ts new file mode 100644 index 000000000..8d6df945f --- /dev/null +++ b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { getAiChatDocumentEditGroups } from "#/features/workspaces/components/ai-chat/ai-chat-document-edit-actions"; +import type { AiChatRenderablePart } from "#/features/workspaces/components/ai-chat/ai-chat-display-state"; + +describe("AI chat document edit actions", () => { + it("does not offer document review for an applied flashcard edit", () => { + expect( + getAiChatDocumentEditGroups([ + editPart("flashcard", "call-flashcard"), + editPart("document", "call-document"), + ]), + ).toEqual([ + { + itemId: "item-document", + lineChanges: { added: 2, removed: 1 }, + path: "/Document", + receiptIds: ["call-document"], + }, + ]); + }); +}); + +function editPart(itemType: "document" | "flashcard", toolCallId: string) { + return { + input: {}, + output: { + applied: 1, + itemId: `item-${itemType}`, + itemType, + lineChanges: { added: 2, removed: 1 }, + path: itemType === "document" ? "/Document" : "/Flashcards", + }, + state: "output-available", + toolCallId, + type: "tool-workspace_edit_item", + } as AiChatRenderablePart; +} diff --git a/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts index bb67e51fb..59dbea208 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts @@ -44,10 +44,12 @@ export function getAiChatDocumentEditGroups( const path = typeof output.path === "string" ? output.path : null; const itemId = typeof output.itemId === "string" ? output.itemId : null; const applied = typeof output.applied === "number" ? output.applied : 0; - if (itemId && path && applied > 0) { + const lineChanges = readLineChanges(output.lineChanges); + const isDocument = output.itemType === "document"; + if (isDocument && itemId && path && applied > 0) { addToGroup(groupsByItemId, seenReceiptIds, { itemId, - lineChanges: readLineChanges(output.lineChanges), + lineChanges, path, receiptId: part.toolCallId, }); diff --git a/src/features/workspaces/components/widget/WorkspaceAddWidgetDialog.tsx b/src/features/workspaces/components/widget/WorkspaceAddWidgetDialog.tsx index 203187018..fa7e60e42 100644 --- a/src/features/workspaces/components/widget/WorkspaceAddWidgetDialog.tsx +++ b/src/features/workspaces/components/widget/WorkspaceAddWidgetDialog.tsx @@ -11,17 +11,12 @@ import { } from "#/components/ui/dialog"; import { Field, FieldGroup, FieldLabel } from "#/components/ui/field"; import { Textarea } from "#/components/ui/textarea"; -import { stageComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; +import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; /** * Adding a widget is an AI-authoring kickoff, not a blank block. Rather than * insert an empty widget the user would have to fill in by hand, we collect a - * description and hand it to the AI by prefilling the composer (via the shared - * `stageComposerPrompt` primitive). The AI writes the widget into the document. - * - * The prompt names the document by path because it is staged, not sent: the - * user may switch views before sending, which would leave "this document" - * pointing somewhere else. + * description and send one explicit request in the current AI thread. */ export function WorkspaceAddWidgetDialog({ documentPath, @@ -50,19 +45,20 @@ export function WorkspaceAddWidgetDialog({ return; } - stageComposerPrompt( - workspaceId, - `Add an interactive widget to ${documentPath}: ${description}`, - ); + if ( + !sendComposerPrompt( + workspaceId, + `Add an interactive widget to ${documentPath}: ${description}`, + ) + ) + return; onOpenChange(false); }} > Add a widget - A widget is an interactive tool that lives in this document, such as a simulation, - calculator, diagram, or visualization. Describe what you want, and the AI will build - it for you. + Describe the interactive tool you want in this document. @@ -74,15 +70,18 @@ export function WorkspaceAddWidgetDialog({ rows={4} required autoFocus - placeholder="e.g. An interactive unit circle that shows sine and cosine as I drag the angle" + placeholder="A calculator, interactive diagram, or simulation" /> +

+ This sends in your current chat and builds the widget automatically. +

- + diff --git a/src/features/workspaces/composer/workspace-composer-actions.ts b/src/features/workspaces/composer/workspace-composer-actions.ts index 3554ec807..41a34c745 100644 --- a/src/features/workspaces/composer/workspace-composer-actions.ts +++ b/src/features/workspaces/composer/workspace-composer-actions.ts @@ -18,20 +18,22 @@ type StageComposerQuoteOptions = { revealChat?: boolean; }; -/** - * Drop editable text into the AI composer from anywhere in the workspace (e.g. - * an item viewer's "Ask AI to fix" button) and reveal the chat. - * The text is staged as an editable draft — it is never auto-sent, so the user - * can review or amend it before sending. Reusable across item types. - */ -export function stageComposerPrompt(workspaceId: string, text: string) { +/** Send a generated action prompt in the current thread without touching its draft. */ +export function sendComposerPrompt(workspaceId: string, text: string) { const trimmed = text.trim(); if (!trimmed) { - return; + return false; } - useWorkspaceAiComposerDraftStore.getState().stageText(getComposerThreadId(workspaceId), trimmed); + const queued = useWorkspaceAiComposerDraftStore + .getState() + .queueDirectPrompt(getComposerThreadId(workspaceId), trimmed); + if (!queued) { + toast.info("Another AI action is already starting."); + return false; + } revealComposer(workspaceId); + return true; } export function stageComposerQuote( diff --git a/src/features/workspaces/documents/document-ai-html.ts b/src/features/workspaces/documents/document-ai-html.ts index 9ab0e92ce..b5d258e79 100644 --- a/src/features/workspaces/documents/document-ai-html.ts +++ b/src/features/workspaces/documents/document-ai-html.ts @@ -68,6 +68,14 @@ export async function serializeTiptapDocumentToAiHtml(document: TiptapDocumentJs ).join(""); } +/** Serialize rich text without document edit refs for non-document surfaces. */ +export function serializeTiptapDocumentToHtml(document: TiptapDocumentJson) { + const node = getTiptapDocumentSchema().nodeFromJSON(document); + return Array.from({ length: node.childCount }, (_, index) => + serializeTiptapNodeToEditableAiHtml(node.child(index)), + ).join(""); +} + export async function serializeTiptapNodeToAiHtml(node: ProseMirrorNode) { // The editRef is hashed from the full node, so a widget source change still // invalidates it. Only the serialized source is elided. diff --git a/src/features/workspaces/documents/document-item-content.ts b/src/features/workspaces/documents/document-item-content.ts index 222f696f9..49fce34ab 100644 --- a/src/features/workspaces/documents/document-item-content.ts +++ b/src/features/workspaces/documents/document-item-content.ts @@ -1,13 +1,5 @@ -import { - type JsonValue, - type WorkspaceItemType, - getWorkspaceItemContentKind, -} from "#/features/workspaces/contracts"; +import { type JsonValue } from "#/features/workspaces/contracts"; import { withDocumentPreviewMetadata } from "#/features/workspaces/documents/document-preview-text"; -import { - createInitialTiptapDocumentJson, - stringifyTiptapDocumentJson, -} from "#/features/workspaces/documents/tiptap-document"; export function prepareDocumentItemMetadata( metadataJson: Record, @@ -15,28 +7,3 @@ export function prepareDocumentItemMetadata( ) { return withDocumentPreviewMetadata(metadataJson, content); } - -/** Shared create-time content + metadata for persistence writes and optimistic UI. */ -export function buildWorkspaceItemCreateBootstrap(input: { - type: WorkspaceItemType; - metadataJson?: Record; - initialContent?: string; -}) { - const initialContent = input.initialContent ?? getInitialWorkspaceContent(input.type); - const metadataJson = - getWorkspaceItemContentKind(input.type) === "document" - ? prepareDocumentItemMetadata(input.metadataJson ?? {}, initialContent) - : (input.metadataJson ?? {}); - - return { initialContent, metadataJson }; -} - -function getInitialWorkspaceContent(type: WorkspaceItemType) { - switch (getWorkspaceItemContentKind(type)) { - case "document": - return stringifyTiptapDocumentJson(createInitialTiptapDocumentJson()); - case "file": - case "none": - return ""; - } -} diff --git a/src/features/workspaces/model/workspace-item-create-bootstrap.ts b/src/features/workspaces/model/workspace-item-create-bootstrap.ts new file mode 100644 index 000000000..2e3c6ee3b --- /dev/null +++ b/src/features/workspaces/model/workspace-item-create-bootstrap.ts @@ -0,0 +1,30 @@ +import { + type JsonValue, + type WorkspaceItemType, + getWorkspaceItemContentKind, +} from "#/features/workspaces/contracts"; +import { prepareDocumentItemMetadata } from "#/features/workspaces/documents/document-item-content"; +import { + createInitialTiptapDocumentJson, + stringifyTiptapDocumentJson, +} from "#/features/workspaces/documents/tiptap-document"; + +/** Content and metadata shared by persistence writes and optimistic UI. */ +export function buildWorkspaceItemCreateBootstrap(input: { + type: WorkspaceItemType; + metadataJson?: Record; + initialContent?: string; +}) { + const contentKind = getWorkspaceItemContentKind(input.type); + const initialContent = + input.initialContent ?? + (contentKind === "document" + ? stringifyTiptapDocumentJson(createInitialTiptapDocumentJson()) + : ""); + const metadataJson = + contentKind === "document" + ? prepareDocumentItemMetadata(input.metadataJson ?? {}, initialContent) + : (input.metadataJson ?? {}); + + return { initialContent, metadataJson }; +} 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 1fea4e91c..c6dd03212 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 @@ -45,9 +45,9 @@ exports[`workspace tool surface > workspace_create_items input schema is stable "additionalProperties": false, "properties": { "items": { - "description": "One or more folders or documents to create in order, at most 20. Parent folders must already exist or be created earlier in the same request.", + "description": "One or more folders, documents, or flashcard sets to create in order, at most 20. Parent folders must already exist or be created earlier in the same request.", "items": { - "anyOf": [ + "oneOf": [ { "additionalProperties": false, "properties": { @@ -102,9 +102,10 @@ exports[`workspace tool surface > workspace_create_items input schema is stable }, { "additionalProperties": false, + "description": "Document creation recipe.", "properties": { "initialContent": { - "description": "Optional initial HTML content for the document.", + "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…
.", "maxLength": 512000, "type": "string", }, @@ -157,6 +158,88 @@ exports[`workspace tool surface > workspace_create_items input schema is stable ], "type": "object", }, + { + "additionalProperties": false, + "description": "Flashcard creation recipe.", + "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.", + "items": { + "additionalProperties": false, + "properties": { + "back": { + "description": "HTML shown after the card flips.", + "maxLength": 8000, + "minLength": 1, + "type": "string", + }, + "front": { + "description": "HTML shown before the card flips.", + "maxLength": 8000, + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "front", + "back", + ], + "type": "object", + }, + "maxItems": 100, + "minItems": 1, + "type": "array", + }, + "path": { + "description": "Final absolute path for the flashcard set.", + "minLength": 1, + "type": "string", + }, + "relations": { + "description": "Optional relationships from this set 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": "flashcard", + "type": "string", + }, + }, + "required": [ + "type", + "path", + "cards", + ], + "type": "object", + }, ], }, "maxItems": 20, @@ -197,131 +280,284 @@ exports[`workspace tool surface > workspace_delete_items input schema is stable exports[`workspace tool surface > workspace_edit_item input schema is stable 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "properties": { - "edits": { - "description": "Ordered edits, at most 40. Target a block with the exact editRef from a document or block read. A block read returns the exact content that replace_text matches. Use overwrite only to discard the entire document and write a new one.", - "items": { - "anyOf": [ - { - "additionalProperties": false, - "properties": { - "editRef": { - "description": "Exact editRef from a recent document or block read.", - "maxLength": 64, - "minLength": 1, - "type": "string", - }, - "html": { - "description": "Schema-constrained HTML fragment. Model-supplied data-edit-ref attributes are ignored.", - "maxLength": 512000, - "type": "string", - }, - "op": { - "enum": [ - "insert_after", - "insert_before", - "replace", + "oneOf": [ + { + "additionalProperties": false, + "description": "Document edit recipe.", + "properties": { + "edits": { + "description": "Ordered document edits. Target blocks with exact editRefs from a read. Use overwrite only to replace the whole document. 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…
.", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "editRef": { + "description": "Exact editRef from a recent document or block read.", + "maxLength": 64, + "minLength": 1, + "type": "string", + }, + "html": { + "description": "Schema-constrained HTML fragment. Model-supplied data-edit-ref attributes are ignored.", + "maxLength": 512000, + "type": "string", + }, + "op": { + "enum": [ + "insert_after", + "insert_before", + "replace", + ], + "type": "string", + }, + }, + "required": [ + "editRef", + "html", + "op", ], - "type": "string", + "type": "object", }, - }, - "required": [ - "editRef", - "html", - "op", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "editRef": { - "description": "Exact editRef from a recent document or block read.", - "maxLength": 64, - "minLength": 1, - "type": "string", - }, - "op": { - "const": "delete", - "type": "string", + { + "additionalProperties": false, + "properties": { + "editRef": { + "description": "Exact editRef from a recent document or block read.", + "maxLength": 64, + "minLength": 1, + "type": "string", + }, + "op": { + "const": "delete", + "type": "string", + }, + }, + "required": [ + "editRef", + "op", + ], + "type": "object", }, - }, - "required": [ - "editRef", - "op", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "html": { - "description": "Schema-constrained HTML fragment. Model-supplied data-edit-ref attributes are ignored.", - "maxLength": 512000, - "type": "string", + { + "additionalProperties": false, + "properties": { + "html": { + "description": "Schema-constrained HTML fragment. Model-supplied data-edit-ref attributes are ignored.", + "maxLength": 512000, + "type": "string", + }, + "op": { + "const": "overwrite", + "type": "string", + }, + }, + "required": [ + "html", + "op", + ], + "type": "object", }, - "op": { - "const": "overwrite", - "type": "string", + { + "additionalProperties": false, + "properties": { + "editRef": { + "description": "Exact editRef from a recent document or block read.", + "maxLength": 64, + "minLength": 1, + "type": "string", + }, + "find": { + "description": "Exact text to replace inside the target block, copied from a read. It must appear exactly once in that block; if it matches more than once the edit fails instead of replacing every occurrence.", + "maxLength": 512000, + "minLength": 1, + "type": "string", + }, + "op": { + "const": "replace_text", + "type": "string", + }, + "replace": { + "description": "Replacement text. May be empty to delete the matched text.", + "maxLength": 512000, + "type": "string", + }, + }, + "required": [ + "editRef", + "find", + "op", + "replace", + ], + "type": "object", }, - }, - "required": [ - "html", - "op", ], - "type": "object", }, - { - "additionalProperties": false, - "properties": { - "editRef": { - "description": "Exact editRef from a recent document or block read.", - "maxLength": 64, - "minLength": 1, - "type": "string", + "maxItems": 40, + "minItems": 1, + "type": "array", + }, + "path": { + "description": "Absolute path of one actual ThinkEx document to edit.", + "minLength": 1, + "type": "string", + }, + "type": { + "const": "document", + "type": "string", + }, + }, + "required": [ + "type", + "path", + "edits", + ], + "type": "object", + }, + { + "additionalProperties": false, + "description": "Flashcard edit recipe.", + "properties": { + "edits": { + "description": "Ordered flashcard edits. Use stable card IDs from a read. Omit beforeCardId and afterCardId to place a card at the end. 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.", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "afterCardId": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "back": { + "maxLength": 8000, + "minLength": 1, + "type": "string", + }, + "beforeCardId": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "front": { + "maxLength": 8000, + "minLength": 1, + "type": "string", + }, + "op": { + "const": "insert_card", + "type": "string", + }, + }, + "required": [ + "op", + "front", + "back", + ], + "type": "object", }, - "find": { - "description": "Exact text to replace inside the target block, copied from a read. It must appear exactly once in that block; if it matches more than once the edit fails instead of replacing every occurrence.", - "maxLength": 512000, - "minLength": 1, - "type": "string", + { + "additionalProperties": false, + "properties": { + "back": { + "maxLength": 8000, + "minLength": 1, + "type": "string", + }, + "cardId": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "front": { + "maxLength": 8000, + "minLength": 1, + "type": "string", + }, + "op": { + "const": "update_card", + "type": "string", + }, + }, + "required": [ + "op", + "cardId", + ], + "type": "object", }, - "op": { - "const": "replace_text", - "type": "string", + { + "additionalProperties": false, + "properties": { + "afterCardId": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "beforeCardId": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "cardId": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "op": { + "const": "move_card", + "type": "string", + }, + }, + "required": [ + "op", + "cardId", + ], + "type": "object", }, - "replace": { - "description": "Replacement text. May be empty to delete the matched text.", - "maxLength": 512000, - "type": "string", + { + "additionalProperties": false, + "properties": { + "cardId": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "op": { + "const": "delete_card", + "type": "string", + }, + }, + "required": [ + "op", + "cardId", + ], + "type": "object", }, - }, - "required": [ - "editRef", - "find", - "op", - "replace", ], - "type": "object", }, - ], + "maxItems": 100, + "minItems": 1, + "type": "array", + }, + "path": { + "description": "Absolute path of one actual ThinkEx flashcard set to edit.", + "minLength": 1, + "type": "string", + }, + "type": { + "const": "flashcard", + "type": "string", + }, }, - "maxItems": 40, - "minItems": 1, - "type": "array", + "required": [ + "type", + "path", + "edits", + ], + "type": "object", }, - "path": { - "description": "Absolute path of one actual ThinkEx workspace item to edit.", - "minLength": 1, - "type": "string", - }, - }, - "required": [ - "path", - "edits", ], - "type": "object", } `; diff --git a/src/features/workspaces/operations/create-items.test.ts b/src/features/workspaces/operations/create-items.test.ts index 52213ba02..9d0f1d41e 100644 --- a/src/features/workspaces/operations/create-items.test.ts +++ b/src/features/workspaces/operations/create-items.test.ts @@ -14,8 +14,11 @@ vi.mock("#/features/workspaces/persistence/workspace-items", () => ({ resolveWorkspacePaths: persistence.resolveWorkspacePaths, })); +vi.mock("cloudflare:workers", () => ({ env: {} })); + import { createWorkspaceItemsOperation } from "#/features/workspaces/operations/create-items"; import { createWorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; +import { parseFlashcardSetContent } from "#/features/workspaces/flashcards/flashcard-content"; describe("createWorkspaceItemsOperation", () => { beforeEach(() => { @@ -57,4 +60,40 @@ describe("createWorkspaceItemsOperation", () => { }); expect(persistence.createWorkspaceItem).not.toHaveBeenCalled(); }); + + it("creates flashcards as structured content", async () => { + persistence.createWorkspaceItem.mockResolvedValue({ + status: "applied", + command: { result: { name: "Cell biology" }, revision: 1 }, + }); + + const result = await createWorkspaceItemsOperation( + createWorkspaceAccessContext({ + operationId: "create-flashcards", + scopes: ["workspace:write"], + userId: "user-1", + workspaceId: "workspace-1", + }), + { + items: [ + { + cards: [{ front: "

What is ATP?

", back: "

Cellular energy.

" }], + path: "/Cell biology", + type: "flashcard", + }, + ], + }, + ); + + const createInput = persistence.createWorkspaceItem.mock.calls[0]?.[1]; + expect(result).toMatchObject({ + failed: [], + items: [{ path: "/Cell biology", type: "flashcard" }], + }); + expect(createInput).toMatchObject({ type: "flashcard" }); + expect(parseFlashcardSetContent(createInput?.initialContent).cards[0]).toMatchObject({ + front: { type: "doc" }, + back: { type: "doc" }, + }); + }); }); diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index 777dafb87..ac46d0b87 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -16,10 +16,11 @@ import { } from "#/features/workspaces/documents/document-ai-html"; import { resolveDocumentCitations } from "#/features/workspaces/operations/document-citations"; import { stringifyTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; +import { isWorkspaceItemContainer } from "#/features/workspaces/contracts"; import { - getWorkspaceItemContentKind, - isWorkspaceItemContainer, -} from "#/features/workspaces/contracts"; + createFlashcardSetFromHtml, + stringifyFlashcardSetContent, +} from "#/features/workspaces/flashcards/flashcard-content"; import { createWorkspaceReferenceRecords, type WorkspaceReferenceRecord, @@ -32,12 +33,20 @@ import { WorkspacePathError, } from "#/features/workspaces/model/workspace-paths"; -export interface CreateWorkspaceItemOperationInput { - type: "document" | "folder"; - path: string; - initialContent?: string; - relations?: WorkspaceRelationInput[]; -} +export type CreateWorkspaceItemOperationInput = + | { type: "folder"; path: string; relations?: WorkspaceRelationInput[] } + | { + type: "document"; + path: string; + initialContent?: string; + relations?: WorkspaceRelationInput[]; + } + | { + type: "flashcard"; + path: string; + cards: Array<{ front: string; back: string }>; + relations?: WorkspaceRelationInput[]; + }; export interface CreateWorkspaceItemsOperationInput { items: CreateWorkspaceItemOperationInput[]; @@ -55,7 +64,7 @@ export interface CreateWorkspaceItemsFailure { export interface CreatedWorkspaceItem { itemId: string; path: string; - type: "document" | "folder"; + type: "document" | "flashcard" | "folder"; } export interface CreateWorkspaceItemsOperationResult { @@ -121,8 +130,7 @@ export async function createWorkspaceItemsOperation( } const initialContent = getCreateWorkspaceItemInitialContent( - getWorkspaceItemContentKind(itemInput.type) === "document" && - itemInput.initialContent !== undefined + itemInput.type === "document" && itemInput.initialContent !== undefined ? { ...itemInput, initialContent: await resolveDocumentCitations({ @@ -292,10 +300,22 @@ function getCreateWorkspaceItemInitialContent(input: CreateWorkspaceItemOperatio detail?: string; status: "failed"; } { - if ( - getWorkspaceItemContentKind(input.type) !== "document" || - input.initialContent === undefined - ) { + if (input.type === "flashcard") { + try { + return { + content: stringifyFlashcardSetContent(createFlashcardSetFromHtml(input.cards)), + status: "ready", + }; + } catch (error) { + return { + code: "invalid_initial_content", + ...(error instanceof Error && error.message ? { detail: error.message } : {}), + status: "failed", + }; + } + } + + if (input.type !== "document" || input.initialContent === undefined) { return { status: "ready" }; } diff --git a/src/features/workspaces/operations/edit-item.ts b/src/features/workspaces/operations/edit-item.ts index eff700360..925a33f6a 100644 --- a/src/features/workspaces/operations/edit-item.ts +++ b/src/features/workspaces/operations/edit-item.ts @@ -5,18 +5,21 @@ import { } from "#/features/workspaces/operations/workspace-operation-context"; import { resolveWorkspacePaths } from "#/features/workspaces/persistence/workspace-items"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; -import { type DocumentAiEdit } from "#/features/workspaces/documents/document-ai-edits"; +import type { DocumentAiEdit } from "#/features/workspaces/documents/document-ai-edits"; import { editWorkspaceItemFailureCodes } from "#/features/workspaces/operations/workspace-operation-failure-codes"; import type { DocumentEditLineChanges } from "#/features/workspaces/documents/document-edit-receipt"; import { resolveDocumentCitations } from "#/features/workspaces/operations/document-citations"; -import { getWorkspaceItemContentKind } from "#/features/workspaces/contracts"; +import { + applyFlashcardEdits, + type FlashcardEdit, +} from "#/features/workspaces/flashcards/flashcard-edits"; +import { updateFlashcardSet } from "#/features/workspaces/flashcards/flashcard-persistence"; type EditWorkspaceItemFailureCode = (typeof editWorkspaceItemFailureCodes)[number]; -export interface EditWorkspaceItemOperationInput { - edits: DocumentAiEdit[]; - path: string; -} +export type EditWorkspaceItemOperationInput = + | { edits: DocumentAiEdit[]; path: string; type: "document" } + | { edits: FlashcardEdit[]; path: string; type: "flashcard" }; interface EditWorkspaceItemFailure { code: EditWorkspaceItemFailureCode; @@ -28,6 +31,7 @@ export interface EditWorkspaceItemOperationResult { applied: number; failed: EditWorkspaceItemFailure[]; itemId?: string; + itemType?: "document" | "flashcard"; lineChanges?: DocumentEditLineChanges; path: string; } @@ -60,14 +64,36 @@ export async function editWorkspaceItemOperation( ...failedWorkspaceEditResult(resolution.failure.code, failureCount), }; } - - if (getWorkspaceItemContentKind(resolution.item.type) !== "document") { + if (resolution.item.type !== input.type) { return { path: resolution.path, ...failedWorkspaceEditResult("unsupported_item_type", edits.length), }; } + if (input.type === "flashcard") { + const { env } = await import("cloudflare:workers"); + const result = await updateFlashcardSet( + env, + { + actorUserId: accessContext.actor.userId, + itemId: resolution.item.id, + workspaceId: accessContext.workspaceId, + }, + (content) => { + const applied = applyFlashcardEdits(content, input.edits); + return { changed: applied.applied > 0, content: applied.content, result: applied }; + }, + ); + return { + applied: result.applied, + failed: result.failed, + itemId: resolution.item.id, + itemType: "flashcard", + path: resolution.path, + }; + } + const documentSession = await getDocumentSession({ itemId: resolution.item.id, workspaceId: accessContext.workspaceId, @@ -75,7 +101,7 @@ export async function editWorkspaceItemOperation( const result = await documentSession.applyEdits({ edits: await Promise.all( - edits.map(async (edit) => + input.edits.map(async (edit) => "html" in edit ? { ...edit, @@ -94,6 +120,7 @@ export async function editWorkspaceItemOperation( applied: result.applied, failed: result.failures, itemId: resolution.item.id, + itemType: "document", ...(result.lineChanges ? { lineChanges: result.lineChanges } : {}), path: resolution.path, }; diff --git a/src/features/workspaces/operations/read-items.ts b/src/features/workspaces/operations/read-items.ts index 46e31524a..d34449486 100644 --- a/src/features/workspaces/operations/read-items.ts +++ b/src/features/workspaces/operations/read-items.ts @@ -8,6 +8,7 @@ import { readWorkspaceContent } from "#/features/workspaces/content/workspace-co 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 { readFlashcardSet } from "#/features/workspaces/flashcards/flashcard-persistence"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; import { authorizeWorkspaceOperation } from "#/features/workspaces/operations/workspace-operation-context"; @@ -30,6 +31,8 @@ export async function readWorkspaceItemsOperation( itemId, workspaceId: accessContext.workspaceId, }), + readFlashcardSet: (itemId) => + readFlashcardSet({ itemId, workspaceId: accessContext.workspaceId }), requests: input.requests, workspaceId: accessContext.workspaceId, }); diff --git a/src/features/workspaces/operations/workspace-operation-failure-codes.ts b/src/features/workspaces/operations/workspace-operation-failure-codes.ts index 6ab3e6c71..2d6f236c7 100644 --- a/src/features/workspaces/operations/workspace-operation-failure-codes.ts +++ b/src/features/workspaces/operations/workspace-operation-failure-codes.ts @@ -28,6 +28,8 @@ export const editWorkspaceItemFailureCodes = [ "path_not_absolute", "path_not_found", "unsupported_item_type", + "card_not_found", + "invalid_card_content", ...documentAiEditFailureCodes, "content_changed", "operation_id_conflict", diff --git a/src/features/workspaces/operations/workspace-tool-definitions.ts b/src/features/workspaces/operations/workspace-tool-definitions.ts index f28596e46..783c4e7bf 100644 --- a/src/features/workspaces/operations/workspace-tool-definitions.ts +++ b/src/features/workspaces/operations/workspace-tool-definitions.ts @@ -15,7 +15,6 @@ import { workspaceDeleteItemsInputExamples, workspaceDeleteItemsInputSchema, workspaceDeleteItemsOutputSchema, - workspaceDocumentHtmlInstruction, workspaceEditItemInputExamples, workspaceEditItemInputSchema, workspaceEditItemOutputSchema, @@ -145,7 +144,7 @@ export const workspaceToolDefinitions = [ name: "workspace_read_items", access: "read", description: - "Read ThinkEx documents and extracted files by absolute path. Document chunks give each top-level block an editRef. Widgets come back as an empty placeholder, so read one with mode block to get its full content and current editRef before editing it. Files support explicit physical-page selections. Continue either kind with nextCursor. Uploaded files may still be extracting; each result carries any needed handling guidance.", + "Read ThinkEx documents, flashcard sets, and extracted files by absolute path. Document chunks give each top-level block an editRef. Flashcard reads return every card with its stable cardId and HTML front and back. Widgets come back as an empty placeholder, so read one with mode block to get its full content and current editRef before editing it. Files support physical-page selections. Continue documents or files with nextCursor.", inputSchema: workspaceReadItemsInputSchema, inputExamples: workspaceReadItemsInputExamples, outputSchema: workspaceReadItemsOutputSchema, @@ -192,7 +191,8 @@ export const workspaceToolDefinitions = [ defineWorkspaceTool({ name: "workspace_create_items", access: "write", - description: `Create one or more folders or documents at exact absolute paths. If a path already exists, creation fails instead of renaming. ${workspaceDocumentHtmlInstruction}`, + description: + "Create folders, documents, or flashcard sets at exact absolute paths. Set type and follow that branch's recipe. If a path already exists, creation fails instead of renaming.", inputSchema: workspaceCreateItemsInputSchema, inputExamples: workspaceCreateItemsInputExamples, outputSchema: workspaceCreateItemsOutputSchema, @@ -222,17 +222,15 @@ export const workspaceToolDefinitions = [ defineWorkspaceTool({ name: "workspace_edit_item", access: "write", - description: `Edit one actual ThinkEx workspace document by absolute path. Read it first, then target blocks with their exact editRef. A block read returns the exact content that replace_text matches. Only overwrite replaces the whole document, and only it works without a read. Use workspace_link_items to add relationships. ${workspaceDocumentHtmlInstruction}`, + description: + "Edit one document or flashcard set by absolute path. Set type to match the item and follow only that branch's recipe. Read the item first. Flashcard edits apply immediately; document edits retain their review flow. Use workspace_link_items for item-level relationships.", inputSchema: workspaceEditItemInputSchema, inputExamples: workspaceEditItemInputExamples, outputSchema: workspaceEditItemOutputSchema, summarizeResult: summarizeWorkspaceAppliedResult, effects: { destructive: true, idempotent: false }, - execute: async ({ path, edits }, context) => { - return await editWorkspaceItemOperation(context, { - path, - edits, - }); + execute: async (input, context) => { + return await editWorkspaceItemOperation(context, input); }, }), defineWorkspaceTool({ diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts index 25f88e8e0..677fe49e6 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -23,6 +23,8 @@ import { documentAiHtmlSchema, } from "#/features/workspaces/documents/document-ai-edits"; import { workspaceFileAssetKindSchema } from "#/features/workspaces/model/workspace-file"; +import { flashcardEditSchema } from "#/features/workspaces/flashcards/flashcard-edits"; +import { flashcardSideHtmlSchema } from "#/features/workspaces/flashcards/flashcard-content"; export { workspaceReadItemsInputSchema, workspaceReadItemsOutputSchema }; @@ -42,6 +44,8 @@ const workspaceWidgetHtmlInstruction = `A widget is one interactive block inside 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 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.`; + const workspacePathSchema = z.string().min(1); const workspaceIndexSchema = z.number().int().nonnegative(); @@ -120,16 +124,37 @@ export const workspaceListItemsInputSchema = z.object({ .describe("Include nested descendants. Defaults to false for immediate children only."), }); -export const workspaceEditItemInputSchema = z.object({ - path: z.string().min(1).describe("Absolute path of one actual ThinkEx workspace item to edit."), - edits: z - .array(documentAiEditSchema) - .min(1) - .max(40) - .describe( - "Ordered edits, at most 40. Target a block with the exact editRef from a document or block read. A block read returns the exact content that replace_text matches. Use overwrite only to discard the entire document and write a new one.", - ), -}); +export const workspaceEditItemInputSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("document"), + path: z.string().min(1).describe("Absolute path of one actual ThinkEx document to edit."), + edits: z + .array(documentAiEditSchema) + .min(1) + .max(40) + .describe( + `Ordered document edits. Target blocks with exact editRefs from a read. Use overwrite only to replace the whole document. ${workspaceDocumentHtmlInstruction}`, + ), + }) + .describe("Document edit recipe."), + z + .object({ + type: z.literal("flashcard"), + path: z + .string() + .min(1) + .describe("Absolute path of one actual ThinkEx flashcard set to edit."), + edits: z + .array(flashcardEditSchema) + .min(1) + .max(100) + .describe( + `Ordered flashcard edits. Use stable card IDs from a read. Omit beforeCardId and afterCardId to place a card at the end. ${workspaceFlashcardHtmlInstruction}`, + ), + }) + .describe("Flashcard edit recipe."), +]); export const workspaceLinkItemsInputSchema = z.object({ path: z.string().min(1).describe("Absolute path of the workspace item to link from."), @@ -165,7 +190,7 @@ export const workspaceMoveItemsInputSchema = z.object({ export const workspaceCreateItemsInputSchema = z.object({ items: z .array( - z.union([ + z.discriminatedUnion("type", [ z.object({ type: z.literal("folder"), path: z.string().min(1).describe("Final absolute path for the folder to create."), @@ -177,28 +202,49 @@ export const workspaceCreateItemsInputSchema = z.object({ "Optional relationships from this new folder to other workspace items, at most 20.", ), }), - z.object({ - type: z.literal("document"), - path: z.string().min(1).describe("Final absolute path for the document to create."), - relations: z - .array(workspaceRelationInputSchema) - .max(20) - .optional() - .describe( - "Optional relationships from this new document to other workspace items, at most 20.", - ), - initialContent: documentAiHtmlSchema - // Doc HTML rules live in the create tool description (see - // workspace-tool-definitions), so they are not repeated here. - .describe("Optional initial HTML content for the document.") - .optional(), - }), + z + .object({ + type: z.literal("document"), + path: z.string().min(1).describe("Final absolute path for the document to create."), + relations: z + .array(workspaceRelationInputSchema) + .max(20) + .optional() + .describe( + "Optional relationships from this new document to other workspace items, at most 20.", + ), + initialContent: documentAiHtmlSchema + .describe(`Optional initial HTML content. ${workspaceDocumentHtmlInstruction}`) + .optional(), + }) + .describe("Document creation recipe."), + z + .object({ + type: z.literal("flashcard"), + path: z.string().min(1).describe("Final absolute path for the flashcard set."), + cards: z + .array( + z.object({ + front: flashcardSideHtmlSchema.describe("HTML shown before the card flips."), + back: flashcardSideHtmlSchema.describe("HTML shown after the card flips."), + }), + ) + .min(1) + .max(100) + .describe(`Ordered cards. ${workspaceFlashcardHtmlInstruction}`), + relations: z + .array(workspaceRelationInputSchema) + .max(20) + .optional() + .describe("Optional relationships from this set to source items, at most 20."), + }) + .describe("Flashcard creation recipe."), ]), ) .min(1) .max(20) .describe( - "One or more folders or documents to create in order, at most 20. Parent folders must already exist or be created earlier in the same request.", + "One or more folders, documents, or flashcard sets to create in order, at most 20. Parent folders must already exist or be created earlier in the same request.", ), }); @@ -225,6 +271,9 @@ export const workspaceReadItemsInputExamples = createInputExamples< { requests: [{ mode: "start", path: "/Demo Folder/Demo Document" }], }, + { + requests: [{ mode: "start", path: "/Demo Folder/Demo Flashcards" }], + }, { requests: [ { @@ -280,6 +329,11 @@ export const workspaceCreateItemsInputExamples = createInputExamples< }, ], }, + { + type: "flashcard", + path: "/Demo Folder/Demo Flashcards", + cards: [{ front: "

What is ATP?

", back: "

The cell's main energy carrier.

" }], + }, ], }); @@ -293,6 +347,7 @@ export const workspaceEditItemInputExamples = createInputExamples< z.input >( { + type: "document", path: "/Demo Folder/Demo Document", edits: [ { @@ -303,6 +358,7 @@ export const workspaceEditItemInputExamples = createInputExamples< ], }, { + type: "document", path: "/Demo Folder/Demo Document", edits: [ { @@ -312,6 +368,7 @@ export const workspaceEditItemInputExamples = createInputExamples< ], }, { + type: "document", path: "/Demo Folder/Demo Document", edits: [ { @@ -322,6 +379,17 @@ export const workspaceEditItemInputExamples = createInputExamples< }, ], }, + { + type: "flashcard", + path: "/Demo Folder/Demo Flashcards", + edits: [ + { + op: "update_card", + cardId: "f67080f9-0158-4565-86a9-4c90ed6809d2", + back: "

The cell's main energy carrier.

", + }, + ], + }, ); export const workspaceLinkItemsInputExamples = createInputExamples< @@ -352,9 +420,7 @@ export const workspaceListItemsOutputSchema = z.object({ export const workspaceCreateItemsOutputSchema = createWorkspaceItemsResultSchema({ itemSchema: workspacePathItemSchema.extend({ itemId: z.string().min(1), - // Creation makes these two and nothing else; the shared item type covers - // files and study items this tool cannot produce. - type: z.enum(["document", "folder"]), + type: z.enum(["document", "flashcard", "folder"]), }), failureSchema: createFailureSchema(createWorkspaceItemsFailureCodes).extend({ detail: z.string().optional().describe("Why this item was refused, when the reason is known."), @@ -386,6 +452,7 @@ export const workspaceEditItemOutputSchema = z.object({ path: workspacePathSchema, applied: z.number().int().min(0), itemId: z.string().optional(), + itemType: z.enum(["document", "flashcard"]).optional(), lineChanges: z .object({ added: z.number().int().min(0), removed: z.number().int().min(0) }) .optional(), diff --git a/src/features/workspaces/operations/workspace-tool-surface.test.ts b/src/features/workspaces/operations/workspace-tool-surface.test.ts index 4869319aa..4e123367e 100644 --- a/src/features/workspaces/operations/workspace-tool-surface.test.ts +++ b/src/features/workspaces/operations/workspace-tool-surface.test.ts @@ -43,4 +43,22 @@ describe("workspace tool surface", () => { expect(z.toJSONSchema(schema)).toMatchSnapshot(); }); } + + it("keeps document and flashcard edit recipes unambiguous", () => { + const cardId = "f67080f9-0158-4565-86a9-4c90ed6809d2"; + expect( + workspaceEditItemInputSchema.safeParse({ + type: "flashcard", + path: "/Biology", + edits: [{ op: "delete_card", cardId }], + }).success, + ).toBe(true); + expect( + workspaceEditItemInputSchema.safeParse({ + type: "document", + path: "/Biology", + edits: [{ op: "delete_card", cardId }], + }).success, + ).toBe(false); + }); }); diff --git a/src/features/workspaces/persistence/workspace-items.ts b/src/features/workspaces/persistence/workspace-items.ts index 25579f382..eaa327b74 100644 --- a/src/features/workspaces/persistence/workspace-items.ts +++ b/src/features/workspaces/persistence/workspace-items.ts @@ -8,7 +8,7 @@ import { workspaceItemTypeSchema, workspaceRelationKindSchema, } from "#/features/workspaces/contracts"; -import { buildWorkspaceItemCreateBootstrap } from "#/features/workspaces/documents/document-item-content"; +import { buildWorkspaceItemCreateBootstrap } from "#/features/workspaces/model/workspace-item-create-bootstrap"; import { getWorkspaceItemNameKey, WORKSPACE_ITEM_SORT_STEP } from "#/features/workspaces/defaults"; import { listWorkspaceTreeItems as formatWorkspaceTreeItems } from "#/features/workspaces/model/workspace-tree-list"; import { @@ -232,7 +232,11 @@ export async function createWorkspaceItem( metadata: bootstrap.metadataJson, sortOrder: await getNextWorkspaceSortOrder(transaction, input.workspaceId, parentId), }); - if (getWorkspaceItemContentKind(type) === "document") { + const contentKind = getWorkspaceItemContentKind(type); + if (contentKind === "document" || contentKind === "structured") { + if (!bootstrap.initialContent) { + throw new Error("Workspace item content is required."); + } await transaction.insert(workspaceItemContents).values({ itemId: input.id, content: bootstrap.initialContent, diff --git a/src/features/workspaces/state/workspace-ai-composer-draft-store.test.ts b/src/features/workspaces/state/workspace-ai-composer-draft-store.test.ts new file mode 100644 index 000000000..aa176202f --- /dev/null +++ b/src/features/workspaces/state/workspace-ai-composer-draft-store.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { useWorkspaceAiComposerDraftStore } from "#/features/workspaces/state/workspace-ai-composer-draft-store"; + +describe("workspace AI direct prompts", () => { + it("sends one action prompt without replacing the user's draft", () => { + const threadId = crypto.randomUUID(); + const store = useWorkspaceAiComposerDraftStore.getState(); + store.setText(threadId, "My unfinished question"); + + expect(store.queueDirectPrompt(threadId, "Create flashcards")).toBe(true); + expect(store.queueDirectPrompt(threadId, "Create something else")).toBe(false); + const prompt = useWorkspaceAiComposerDraftStore.getState().directPromptByThreadId[threadId]; + expect(prompt).toBeDefined(); + expect(store.takeDirectPrompt(threadId, "wrong-id")).toBeNull(); + expect(store.takeDirectPrompt(threadId, prompt!.id)).toBe("Create flashcards"); + expect(useWorkspaceAiComposerDraftStore.getState().textByThreadId[threadId]).toBe( + "My unfinished question", + ); + }); +}); diff --git a/src/features/workspaces/state/workspace-ai-composer-draft-store.ts b/src/features/workspaces/state/workspace-ai-composer-draft-store.ts index b380a7c42..4dcd59493 100644 --- a/src/features/workspaces/state/workspace-ai-composer-draft-store.ts +++ b/src/features/workspaces/state/workspace-ai-composer-draft-store.ts @@ -28,8 +28,8 @@ type WorkspaceAiComposerDraftFileError = { }; interface WorkspaceAiComposerDraftState { + directPromptByThreadId: Record; filesByThreadId: Record; - focusRequestByThreadId: Record; quotesByWorkspaceId: Record; textByThreadId: Record; addFiles: ( @@ -41,12 +41,12 @@ interface WorkspaceAiComposerDraftState { addQuote: (workspaceId: string, quote: WorkspaceSelectedQuote) => void; clearDraftArtifacts: (workspaceId: string, threadId: string) => void; clearFiles: (threadId: string) => void; - clearFocusRequest: (threadId: string, request: number) => void; clearQuotes: (workspaceId: string) => void; removeFile: (threadId: string, fileId: string) => void; removeQuote: (workspaceId: string, quoteId: string) => void; setText: (threadId: string, value: SetStateAction) => void; - stageText: (threadId: string, text: string) => void; + queueDirectPrompt: (threadId: string, text: string) => boolean; + takeDirectPrompt: (threadId: string, promptId: string) => string | null; } const EMPTY_DRAFT_FILES: WorkspaceAiComposerDraftFile[] = []; @@ -132,19 +132,6 @@ export const useWorkspaceAiComposerDraftStore = create set((state) => clearDraftArtifacts(state, workspaceId, threadId)), clearFiles: (threadId) => set((state) => clearFilesForThread(state, threadId)), - clearFocusRequest: (threadId, request) => - set((state) => { - if (state.focusRequestByThreadId[threadId] !== request) { - return state; - } - - return { - focusRequestByThreadId: { - ...state.focusRequestByThreadId, - [threadId]: undefined, - }, - }; - }), clearQuotes: (workspaceId) => set((state) => { const current = state.quotesByWorkspaceId[workspaceId] ?? EMPTY_DRAFT_QUOTES; @@ -159,8 +146,8 @@ export const useWorkspaceAiComposerDraftStore = create { const file = get().filesByThreadId[threadId]?.find((item) => item.id === fileId); @@ -200,27 +187,28 @@ export const useWorkspaceAiComposerDraftStore = create - set((state) => { - const stagedText = text.trim(); - if (!stagedText) { - return state; - } - - const current = state.textByThreadId[threadId] ?? ""; - const next = current.trim() ? `${current.trimEnd()}\n\n${stagedText}` : stagedText; - - return { - focusRequestByThreadId: { - ...state.focusRequestByThreadId, - [threadId]: (state.focusRequestByThreadId[threadId] ?? 0) + 1, - }, - textByThreadId: { - ...state.textByThreadId, - [threadId]: next, - }, - }; - }), + queueDirectPrompt: (threadId, text) => { + const trimmed = text.trim(); + if (!trimmed || get().directPromptByThreadId[threadId]) return false; + set((state) => ({ + directPromptByThreadId: { + ...state.directPromptByThreadId, + [threadId]: { id: nanoid(), text: trimmed }, + }, + })); + return true; + }, + takeDirectPrompt: (threadId, promptId) => { + const prompt = get().directPromptByThreadId[threadId]; + if (!prompt || prompt.id !== promptId) return null; + set((state) => ({ + directPromptByThreadId: { + ...state.directPromptByThreadId, + [threadId]: undefined, + }, + })); + return prompt.text; + }, textByThreadId: {}, }), ); @@ -254,10 +242,10 @@ export function useWorkspaceAiComposerDraftText(threadId: string) { ); } -export function useWorkspaceAiComposerFocusRequest(threadId: string) { +export function useWorkspaceAiDirectPrompt(threadId: string) { return useWorkspaceAiComposerDraftStore( useMemo( - () => (state: WorkspaceAiComposerDraftState) => state.focusRequestByThreadId[threadId] ?? 0, + () => (state: WorkspaceAiComposerDraftState) => state.directPromptByThreadId[threadId], [threadId], ), ); From 1273ff2ee0b988b7ee4455f4bfb1825401e0669f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:52:27 -0400 Subject: [PATCH 02/21] feat(workspaces): add flashcard content and study state --- drizzle-postgres/0003_glorious_wolf_cub.sql | 12 + drizzle-postgres/meta/0003_snapshot.json | 2462 +++++++++++++++++ drizzle-postgres/meta/_journal.json | 7 + src/db/schema.ts | 17 +- .../ai/ai-thread-tool-ui-metadata.test.ts | 21 + .../ai/ai-thread-tool-ui-metadata.ts | 2 +- .../ai/workspace-tool-result-adapters.ts | 2 +- .../ai-chat/ai-chat-tool-receipts.ts | 6 +- .../content/workspace-content-contract.ts | 15 + .../content/workspace-content-reader.test.ts | 31 + .../content/workspace-content-reader.ts | 38 +- .../content/workspace-read-references.test.ts | 57 + .../content/workspace-read-references.ts | 49 +- src/features/workspaces/contracts.ts | 13 +- .../export/workspace-export-archive.test.ts | 16 + .../export/workspace-export-archive.ts | 18 +- .../workspaces/export/workspace-export.ts | 17 + .../flashcards/flashcard-content.test.ts | 30 + .../flashcards/flashcard-content.ts | 135 + .../flashcards/flashcard-edits.test.ts | 36 + .../workspaces/flashcards/flashcard-edits.ts | 112 + .../flashcards/flashcard-functions.ts | 38 + .../flashcards/flashcard-persistence.ts | 102 + .../flashcards/flashcard-queries.ts | 101 + .../flashcards/flashcard-study-persistence.ts | 138 + .../flashcard-study-session.test.ts | 52 + .../flashcards/flashcard-study-session.ts | 29 + .../flashcards/flashcard-study-state.test.ts | 52 + .../flashcards/flashcard-study-state.ts | 43 + src/features/workspaces/model/item-display.ts | 7 +- .../workspaces/model/workspace-page.ts | 2 +- .../workspaces/workspace-item-registry.ts | 14 +- 32 files changed, 3657 insertions(+), 17 deletions(-) create mode 100644 drizzle-postgres/0003_glorious_wolf_cub.sql create mode 100644 drizzle-postgres/meta/0003_snapshot.json create mode 100644 src/features/workspaces/ai/ai-thread-tool-ui-metadata.test.ts create mode 100644 src/features/workspaces/flashcards/flashcard-content.test.ts create mode 100644 src/features/workspaces/flashcards/flashcard-content.ts create mode 100644 src/features/workspaces/flashcards/flashcard-edits.test.ts create mode 100644 src/features/workspaces/flashcards/flashcard-edits.ts create mode 100644 src/features/workspaces/flashcards/flashcard-functions.ts create mode 100644 src/features/workspaces/flashcards/flashcard-persistence.ts create mode 100644 src/features/workspaces/flashcards/flashcard-queries.ts create mode 100644 src/features/workspaces/flashcards/flashcard-study-persistence.ts create mode 100644 src/features/workspaces/flashcards/flashcard-study-session.test.ts create mode 100644 src/features/workspaces/flashcards/flashcard-study-session.ts create mode 100644 src/features/workspaces/flashcards/flashcard-study-state.test.ts create mode 100644 src/features/workspaces/flashcards/flashcard-study-state.ts diff --git a/drizzle-postgres/0003_glorious_wolf_cub.sql b/drizzle-postgres/0003_glorious_wolf_cub.sql new file mode 100644 index 000000000..082ba3c89 --- /dev/null +++ b/drizzle-postgres/0003_glorious_wolf_cub.sql @@ -0,0 +1,12 @@ +CREATE TABLE "workspace_item_user_states" ( + "user_id" text NOT NULL, + "item_id" text NOT NULL, + "state" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_item_user_states_user_id_item_id_pk" PRIMARY KEY("user_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_items" DROP CONSTRAINT "workspace_items_type_check";--> statement-breakpoint +ALTER TABLE "workspace_item_user_states" ADD CONSTRAINT "workspace_item_user_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_item_user_states" ADD CONSTRAINT "workspace_item_user_states_item_id_workspace_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."workspace_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD CONSTRAINT "workspace_items_type_check" CHECK ("workspace_items"."type" in ('folder', 'document', 'flashcard', 'file')); diff --git a/drizzle-postgres/meta/0003_snapshot.json b/drizzle-postgres/meta/0003_snapshot.json new file mode 100644 index 000000000..292b491e6 --- /dev/null +++ b/drizzle-postgres/meta/0003_snapshot.json @@ -0,0 +1,2462 @@ +{ + "id": "25aa9ff5-8b2a-4423-8d78-053fd64107c5", + "prevId": "2b13358a-fc28-4689-a006-e4dd005e868e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_id_idx": { + "name": "oauth_access_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_consent_user_id_idx": { + "name": "oauth_consent_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_id_idx": { + "name": "oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_assets": { + "name": "workspace_file_assets", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_object_key": { + "name": "source_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "preview_object_key": { + "name": "preview_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_size_bytes": { + "name": "preview_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_assets_item_id_workspace_items_id_fk": { + "name": "workspace_file_assets_item_id_workspace_items_id_fk", + "tableFrom": "workspace_file_assets", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_assets_source_object_key_unique": { + "name": "workspace_file_assets_source_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "source_object_key" + ] + }, + "workspace_file_assets_preview_object_key_unique": { + "name": "workspace_file_assets_preview_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "preview_object_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_pending_link_per_role": { + "name": "workspace_invites_pending_link_per_role", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'link' and \"workspace_invites\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_pending_email_per_workspace": { + "name": "workspace_invites_pending_email_per_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'email' and \"workspace_invites\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_created_by_user_id_idx": { + "name": "workspace_invites_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_created_by_user_id_user_id_fk": { + "name": "workspace_invites_created_by_user_id_user_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_invites_role_check": { + "name": "workspace_invites_role_check", + "value": "\"workspace_invites\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + }, + "workspace_invites_type_check": { + "name": "workspace_invites_type_check", + "value": "\"workspace_invites\".\"type\" in ('email', 'link')" + }, + "workspace_invites_status_check": { + "name": "workspace_invites_status_check", + "value": "\"workspace_invites\".\"status\" in ('pending', 'accepted', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_contents": { + "name": "workspace_item_contents", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_item_contents_item_id_workspace_items_id_fk": { + "name": "workspace_item_contents_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_contents", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_extractions": { + "name": "workspace_item_extractions", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_mode": { + "name": "provider_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_extractions_status_idx": { + "name": "workspace_item_extractions_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_extractions_item_fk": { + "name": "workspace_item_extractions_item_fk", + "tableFrom": "workspace_item_extractions", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_extractions_status_check": { + "name": "workspace_item_extractions_status_check", + "value": "\"workspace_item_extractions\".\"status\" in ('processing', 'ready', 'failed')" + }, + "workspace_item_extractions_tier_check": { + "name": "workspace_item_extractions_tier_check", + "value": "\"workspace_item_extractions\".\"tier\" is null or \"workspace_item_extractions\".\"tier\" in ('fast', 'enhanced')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_pages": { + "name": "workspace_item_pages", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "markdown_bytes": { + "name": "markdown_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_item_pages_item_id_workspace_items_id_fk": { + "name": "workspace_item_pages_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_pages", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_pages_item_id_page_number_pk": { + "name": "workspace_item_pages_item_id_page_number_pk", + "columns": [ + "item_id", + "page_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_pages_number_check": { + "name": "workspace_item_pages_number_check", + "value": "\"workspace_item_pages\".\"page_number\" > 0" + }, + "workspace_item_pages_bytes_check": { + "name": "workspace_item_pages_bytes_check", + "value": "\"workspace_item_pages\".\"markdown_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_relations": { + "name": "workspace_item_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_item_id": { + "name": "from_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_item_id": { + "name": "to_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_relations_unique": { + "name": "workspace_item_relations_unique", + "columns": [ + { + "expression": "from_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "note", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_item_relations_from_idx": { + "name": "workspace_item_relations_from_idx", + "columns": [ + { + "expression": "from_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_item_relations_to_idx": { + "name": "workspace_item_relations_to_idx", + "columns": [ + { + "expression": "to_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_relations_from_fk": { + "name": "workspace_item_relations_from_fk", + "tableFrom": "workspace_item_relations", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "from_item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_item_relations_to_fk": { + "name": "workspace_item_relations_to_fk", + "tableFrom": "workspace_item_relations", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "to_item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_relations_kind_check": { + "name": "workspace_item_relations_kind_check", + "value": "\"workspace_item_relations\".\"kind\" in ('derived_from', 'references')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_user_states": { + "name": "workspace_item_user_states", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_item_user_states_user_id_user_id_fk": { + "name": "workspace_item_user_states_user_id_user_id_fk", + "tableFrom": "workspace_item_user_states", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_item_user_states_item_id_workspace_items_id_fk": { + "name": "workspace_item_user_states_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_user_states", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_user_states_user_id_item_id_pk": { + "name": "workspace_item_user_states_user_id_item_id_pk", + "columns": [ + "user_id", + "item_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_items": { + "name": "workspace_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_key": { + "name": "name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_items_tree_idx": { + "name": "workspace_items_tree_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_type_idx": { + "name": "workspace_items_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_root_name_unique": { + "name": "workspace_items_root_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_items\".\"parent_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_parent_name_unique": { + "name": "workspace_items_parent_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_items\".\"parent_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_items_workspace_id_workspaces_id_fk": { + "name": "workspace_items_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_items_parent_fk": { + "name": "workspace_items_parent_fk", + "tableFrom": "workspace_items", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "parent_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_items_workspace_id_id_unique": { + "name": "workspace_items_workspace_id_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_items_type_check": { + "name": "workspace_items_type_check", + "value": "\"workspace_items\".\"type\" in ('folder', 'document', 'flashcard', 'file')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_user_unique": { + "name": "workspace_members_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_last_opened_at_idx": { + "name": "workspace_members_user_last_opened_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_members_role_check": { + "name": "workspace_members_role_check", + "value": "\"workspace_members\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspaces_owner_id_idx": { + "name": "workspaces_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_user_id_fk": { + "name": "workspaces_owner_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle-postgres/meta/_journal.json b/drizzle-postgres/meta/_journal.json index 29d845db7..a7d5371d6 100644 --- a/drizzle-postgres/meta/_journal.json +++ b/drizzle-postgres/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1786634339603, "tag": "0002_wild_kat_farrell", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1786643511996, + "tag": "0003_glorious_wolf_cub", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 0ccdd65f8..529c069c3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,11 +14,11 @@ import { unique, uniqueIndex, } from "drizzle-orm/pg-core"; +import { WORKSPACE_ITEM_TYPES } from "#/features/workspaces/workspace-item-registry"; const WORKSPACE_ROLES = ["owner", "admin", "editor", "viewer"] as const; const WORKSPACE_INVITE_TYPES = ["email", "link"] as const; const WORKSPACE_INVITE_STATUSES = ["pending", "accepted", "revoked", "expired"] as const; -const WORKSPACE_ITEM_TYPES = ["folder", "document", "file"] as const; const WORKSPACE_RELATION_KINDS = ["derived_from", "references"] as const; const WORKSPACE_EXTRACTION_STATUSES = ["processing", "ready", "failed"] as const; const WORKSPACE_EXTRACTION_TIERS = ["fast", "enhanced"] as const; @@ -297,6 +297,21 @@ export const workspaceItemContents = pgTable("workspace_item_contents", { content: text("content").notNull(), }); +export const workspaceItemUserStates = pgTable( + "workspace_item_user_states", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + itemId: text("item_id") + .notNull() + .references(() => workspaceItems.id, { onDelete: "cascade" }), + state: jsonb("state").$type>().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [primaryKey({ columns: [table.userId, table.itemId] })], +); + export const workspaceFileAssets = pgTable("workspace_file_assets", { itemId: text("item_id") .primaryKey() diff --git a/src/features/workspaces/ai/ai-thread-tool-ui-metadata.test.ts b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.test.ts new file mode 100644 index 000000000..716423e5f --- /dev/null +++ b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { attachDocumentEditReceiptMetadata } from "#/features/workspaces/ai/ai-thread-tool-ui-metadata"; + +describe("AI thread edit metadata", () => { + it("adds review metadata only to document edits", () => { + const document = attachDocumentEditReceiptMetadata( + { applied: 1, itemType: "document" }, + "receipt-1", + ); + const flashcards = attachDocumentEditReceiptMetadata( + { applied: 1, itemType: "flashcard" }, + "receipt-2", + ); + + expect(document).toMatchObject({ + __thinkexUi: { documentEditReceiptId: "receipt-1" }, + }); + expect(flashcards).not.toHaveProperty("__thinkexUi"); + }); +}); diff --git a/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts index 69a01d732..add4c4b47 100644 --- a/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts +++ b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts @@ -7,7 +7,7 @@ interface AIThreadToolUiMetadata { } export function attachDocumentEditReceiptMetadata(output: unknown, receiptId: string) { - if (!isRecord(output)) { + if (!isRecord(output) || output.itemType !== "document") { return output; } diff --git a/src/features/workspaces/ai/workspace-tool-result-adapters.ts b/src/features/workspaces/ai/workspace-tool-result-adapters.ts index 0e029feb5..7d99d493f 100644 --- a/src/features/workspaces/ai/workspace-tool-result-adapters.ts +++ b/src/features/workspaces/ai/workspace-tool-result-adapters.ts @@ -49,7 +49,7 @@ const workspaceCreateItemsResultAdapter = defineWorkspaceToolResultAdapter({ z.object({ itemId: z.string(), path: z.string(), - type: z.enum(["document", "folder"]), + type: z.enum(["document", "flashcard", "folder"]), }), ), references: z.array(workspaceReferenceRecordSchema), diff --git a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts index 8a2eeeceb..7f605c3e9 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts @@ -166,8 +166,10 @@ export function getFinishedToolReceipt(input: { return summarizeWorkspaceBatch(input.output, { failureVerb: "create", successVerb: "Created", - typeFromItem: (item) => - getString(asRecord(item).type) === "folder" ? "folder" : "document", + typeFromItem: (item) => { + const type = getString(asRecord(item).type); + return type === "folder" ? "folder" : type === "flashcard" ? "flashcard set" : "document"; + }, }); case "workspace_delete_items": return summarizeWorkspaceBatch(input.output, { diff --git a/src/features/workspaces/content/workspace-content-contract.ts b/src/features/workspaces/content/workspace-content-contract.ts index c3b8f8686..f5b5f903b 100644 --- a/src/features/workspaces/content/workspace-content-contract.ts +++ b/src/features/workspaces/content/workspace-content-contract.ts @@ -101,6 +101,21 @@ const workspaceContentReadResultSchema = z.union([ status: z.literal("ready"), type: z.literal("document"), }), + z.object({ + cards: z.array( + z.object({ + cardId: z.uuid(), + front: z.string(), + back: z.string(), + }), + ), + format: z.literal("html"), + itemId: z.string().min(1), + path: workspacePathSchema, + relations: workspaceReadRelationsSchema.optional(), + status: z.literal("ready"), + type: z.literal("flashcard"), + }), z.object({ assetKind: workspaceFileAssetKindSchema, content: z.string(), diff --git a/src/features/workspaces/content/workspace-content-reader.test.ts b/src/features/workspaces/content/workspace-content-reader.test.ts index dcde1f78f..8dfa115f4 100644 --- a/src/features/workspaces/content/workspace-content-reader.test.ts +++ b/src/features/workspaces/content/workspace-content-reader.test.ts @@ -14,6 +14,7 @@ import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap- import type { WorkspacePathResolution } from "#/features/workspaces/persistence/workspace-persistence-types"; import { readWorkspaceContent } from "#/features/workspaces/content/workspace-content-reader"; import { encodeWorkspaceContentCursor } from "#/features/workspaces/content/workspace-content-cursor"; +import { createFlashcardSetFromHtml } from "#/features/workspaces/flashcards/flashcard-content"; const persistence = vi.hoisted(() => ({ getWorkspaceItemPaths: vi.fn(), @@ -40,7 +41,33 @@ const documentItem: WorkspaceItem = { updatedAt: "2026-01-01T00:00:00.000Z", }; +const flashcardItem: WorkspaceItem = { + ...documentItem, + id: "flashcard-1", + type: "flashcard", + name: "Biology cards", +}; + describe("WorkspaceContentReader", () => { + it("reads a complete flashcard set with stable card IDs", async () => { + const set = createFlashcardSetFromHtml([{ front: "

Question

", back: "

Answer

" }]); + const read = createReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => createDocumentSession({ html: "

", revision: "unused" }), + item: flashcardItem, + readFlashcardSet: async () => set, + }); + + await expect(read([{ mode: "start", path: "/Biology cards" }])).resolves.toMatchObject([ + { + cards: [{ cardId: set.cards[0]!.id, front: "

Question

", back: "

Answer

" }], + format: "html", + status: "ready", + type: "flashcard", + }, + ]); + }); + it("continues a large live document with a revision-guarded cursor", async () => { const html = Array.from({ length: 20_000 }, (_, index) => `

line ${index + 1}

`).join(""); const session = createDocumentSession({ html, revision: "revision-1" }); @@ -320,6 +347,9 @@ function createReader(input: { bucket: R2Bucket; getDocumentSession: (itemId: string) => ReturnType; item?: WorkspaceItem; + readFlashcardSet?: () => + | ReturnType + | Promise>; resolvePaths?: typeof persistence.resolveWorkspacePaths; }) { const item = input.item ?? documentItem; @@ -335,6 +365,7 @@ function createReader(input: { readWorkspaceContent({ bucket: input.bucket, getDocumentSession: input.getDocumentSession, + readFlashcardSet: input.readFlashcardSet ?? (async () => ({ version: 1, cards: [] })), requests, workspaceId: "workspace-1", }); diff --git a/src/features/workspaces/content/workspace-content-reader.ts b/src/features/workspaces/content/workspace-content-reader.ts index cf05c0343..5d3895d32 100644 --- a/src/features/workspaces/content/workspace-content-reader.ts +++ b/src/features/workspaces/content/workspace-content-reader.ts @@ -30,6 +30,10 @@ import { decodeWorkspaceContentCursor, encodeWorkspaceContentCursor, } from "#/features/workspaces/content/workspace-content-cursor"; +import { + serializeFlashcardSetToHtml, + type FlashcardSetContent, +} from "#/features/workspaces/flashcards/flashcard-content"; const maxWorkspaceContentBatchBytes = 2 * 1024 * 1024 + 64 * 1024; @@ -49,6 +53,7 @@ interface PendingReadyResult { export async function readWorkspaceContent(input: { bucket: R2Bucket; getDocumentSession: (itemId: string) => DocumentContentReader | Promise; + readFlashcardSet: (itemId: string) => FlashcardSetContent | Promise; requests: WorkspaceContentReadRequest[]; workspaceId: string; }): Promise { @@ -109,7 +114,9 @@ export async function readWorkspaceContent(input: { results.push(read); continue; } - const contentBytes = encoder.encode(read.content).byteLength; + const contentBytes = encoder.encode( + "content" in read ? read.content : JSON.stringify(read.cards), + ).byteLength; if (returnedContentBytes + contentBytes > maxWorkspaceContentBatchBytes) { readBudgetExhausted = true; results.push(readBudgetFailure); @@ -143,6 +150,7 @@ export async function readWorkspaceContent(input: { async function readWorkspaceItem(input: { bucket: R2Bucket; getDocumentSession: (itemId: string) => DocumentContentReader | Promise; + readFlashcardSet: (itemId: string) => FlashcardSetContent | Promise; item: WorkspaceItem; path: string; request: WorkspaceContentReadRequest; @@ -162,7 +170,35 @@ async function readWorkspaceItem(input: { : readFile(input); case "none": return { code: "unsupported_item_type", path: input.path, status: "failed" }; + case "structured": + return readFlashcards(input); + } +} + +async function readFlashcards(input: { + item: WorkspaceItem; + path: string; + readFlashcardSet: (itemId: string) => FlashcardSetContent | Promise; + request: WorkspaceContentReadRequest; + workspaceId: string; +}): Promise { + if (input.item.type !== "flashcard" || input.request.mode !== "start") { + return { code: "invalid_selection", path: input.path, status: "failed" }; } + + const content = await input.readFlashcardSet(input.item.id); + return { + cards: serializeFlashcardSetToHtml(content).map((card) => ({ + cardId: card.id, + front: card.front, + back: card.back, + })), + format: "html", + itemId: input.item.id, + path: input.path, + status: "ready", + type: "flashcard", + }; } /** diff --git a/src/features/workspaces/content/workspace-read-references.test.ts b/src/features/workspaces/content/workspace-read-references.test.ts index f1493d254..f51015c06 100644 --- a/src/features/workspaces/content/workspace-read-references.test.ts +++ b/src/features/workspaces/content/workspace-read-references.test.ts @@ -54,6 +54,43 @@ describe("workspace read references", () => { expect(JSON.stringify(modelOutput)).not.toContain("file-1"); }); + it("gives every flashcard side its own navigable ref", () => { + const results = [flashcardResult()] satisfies WorkspaceContentReadResult[]; + const references = createWorkspaceReadReferences(results); + const modelOutput = createWorkspaceReadItemsModelOutput({ references, results }); + + expect(references.map(({ location }) => location)).toEqual([ + { + cardId: "f67080f9-0158-4565-86a9-4c90ed6809d2", + itemId: "flashcard-1", + kind: "flashcard-side", + side: "front", + version: 1, + }, + { + cardId: "f67080f9-0158-4565-86a9-4c90ed6809d2", + itemId: "flashcard-1", + kind: "flashcard-side", + side: "back", + version: 1, + }, + ]); + expect(modelOutput.results[0]).toMatchObject({ + cards: [ + { + back: "

Paris

", + backReference: expect.stringMatching(/^wr_[0-9A-Za-z]{8}$/), + cardId: "f67080f9-0158-4565-86a9-4c90ed6809d2", + front: "

Capital of France?

", + frontReference: expect.stringMatching(/^wr_[0-9A-Za-z]{8}$/), + }, + ], + path: "/Geography", + type: "flashcard", + }); + expect(JSON.stringify(modelOutput)).not.toContain("flashcard-1"); + }); + it("deduplicates repeated reads of the same durable location in one result", () => { const references = createWorkspaceReadReferences([ documentResult(), @@ -205,3 +242,23 @@ function fileResult(): Extract { + return { + cards: [ + { + back: "

Paris

", + cardId: "f67080f9-0158-4565-86a9-4c90ed6809d2", + front: "

Capital of France?

", + }, + ], + format: "html", + itemId: "flashcard-1", + path: "/Geography", + status: "ready", + type: "flashcard", + }; +} diff --git a/src/features/workspaces/content/workspace-read-references.ts b/src/features/workspaces/content/workspace-read-references.ts index 5950fa6a3..07df644de 100644 --- a/src/features/workspaces/content/workspace-read-references.ts +++ b/src/features/workspaces/content/workspace-read-references.ts @@ -14,7 +14,7 @@ import type { * Allocates durable-location records for every ready workspace read. * * Documents and images receive one item-level ref. PDFs receive one ref per - * physical page so the model never has to cite an imprecise page range. + * physical page, and flashcards one per side, so citations remain exact. * * @param results - Ordered workspace read results. * @returns Deduplicated reference records for the rich tool result. @@ -28,6 +28,27 @@ export function createWorkspaceReadReferences( if (result.status !== "ready") { continue; } + if (result.type === "flashcard") { + for (const card of result.cards) { + locations.push( + { + itemId: result.itemId, + kind: "flashcard-side", + cardId: card.cardId, + side: "front", + version: 1, + }, + { + itemId: result.itemId, + kind: "flashcard-side", + cardId: card.cardId, + side: "back", + version: 1, + }, + ); + } + continue; + } if (result.type !== "file" || result.assetKind !== "pdf") { locations.push({ @@ -135,6 +156,32 @@ export function createWorkspaceReadItemsModelOutput(output: WorkspaceReadItemsOu if (result.status === "failed") { return result; } + if (result.type === "flashcard") { + return { + ...omitWorkspaceReadItemId(result), + cards: result.cards.map((card) => ({ + ...card, + frontReference: refsByLocation.get( + getWorkspaceLocationKey({ + itemId: result.itemId, + kind: "flashcard-side", + cardId: card.cardId, + side: "front", + version: 1, + }), + ), + backReference: refsByLocation.get( + getWorkspaceLocationKey({ + itemId: result.itemId, + kind: "flashcard-side", + cardId: card.cardId, + side: "back", + version: 1, + }), + ), + })), + }; + } if (result.type !== "file" || result.assetKind !== "pdf") { const ref = refsByLocation.get( diff --git a/src/features/workspaces/contracts.ts b/src/features/workspaces/contracts.ts index e9623e2d6..03a884c5d 100644 --- a/src/features/workspaces/contracts.ts +++ b/src/features/workspaces/contracts.ts @@ -382,13 +382,22 @@ export const createWorkspaceItemInputSchema = z initialContent: z.string().optional(), }) .superRefine((input, context) => { + const contentKind = getWorkspaceItemContentKind(input.type); if ( input.initialContent !== undefined && - getWorkspaceItemContentKind(input.type) !== "document" + contentKind !== "document" && + contentKind !== "structured" ) { context.addIssue({ code: "custom", - message: "Initial content can only be provided for documents.", + message: "Initial content can only be provided for content-backed items.", + path: ["initialContent"], + }); + } + if (contentKind === "structured" && input.initialContent === undefined) { + context.addIssue({ + code: "custom", + message: "Initial content is required for structured items.", path: ["initialContent"], }); } diff --git a/src/features/workspaces/export/workspace-export-archive.test.ts b/src/features/workspaces/export/workspace-export-archive.test.ts index 04e2f8c50..01b138362 100644 --- a/src/features/workspaces/export/workspace-export-archive.test.ts +++ b/src/features/workspaces/export/workspace-export-archive.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { WorkspaceItem } from "#/features/workspaces/contracts"; import { createWorkspaceExportStream } from "#/features/workspaces/export/workspace-export-archive"; +import { createFlashcardSetFromHtml } from "#/features/workspaces/flashcards/flashcard-content"; const baseItem = { workspaceId: "workspace-1", @@ -23,6 +24,13 @@ describe("workspace export archive", () => { type: "folder", name: "Research", }, + { + ...baseItem, + id: "flashcards", + parentId: "folder", + type: "flashcard", + name: "Key terms", + }, { ...baseItem, id: "document", @@ -46,6 +54,11 @@ describe("workspace export archive", () => { content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }], }), readFile: vi.fn().mockResolvedValue(new Blob(["PDF bytes"]).stream()), + readFlashcards: vi + .fn() + .mockReturnValue( + createFlashcardSetFromHtml([{ front: "

Term

", back: "

Definition

" }]), + ), }), ).arrayBuffer(); const files = unzipSync(new Uint8Array(archive)); @@ -53,10 +66,12 @@ describe("workspace export archive", () => { expect(Object.keys(files).sort()).toEqual([ "Empty/", "Research/", + "Research/Key terms.md", "Research/Notes.md", "Research/source.pdf", ]); expect(strFromU8(files["Research/Notes.md"]!)).toBe("Hello\n"); + expect(strFromU8(files["Research/Key terms.md"]!)).toContain("## Card 1\n\nTerm"); expect(strFromU8(files["Research/source.pdf"]!)).toBe("PDF bytes"); }); @@ -88,6 +103,7 @@ describe("workspace export archive", () => { createWorkspaceExportStream(items, { readDocument: vi.fn().mockReturnValue({ type: "doc" }), readFile: vi.fn().mockResolvedValue(new Blob(["file"]).stream()), + readFlashcards: vi.fn(), }), ).arrayBuffer(); diff --git a/src/features/workspaces/export/workspace-export-archive.ts b/src/features/workspaces/export/workspace-export-archive.ts index 97edbf444..9c5620260 100644 --- a/src/features/workspaces/export/workspace-export-archive.ts +++ b/src/features/workspaces/export/workspace-export-archive.ts @@ -7,6 +7,7 @@ import { } from "#/features/workspaces/contracts"; import { serializeTiptapDocumentToMarkdown } from "#/features/workspaces/documents/document-markdown"; import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; +import type { FlashcardSetContent } from "#/features/workspaces/flashcards/flashcard-content"; import { buildWorkspaceItemPathIndex } from "#/features/workspaces/model/workspace-paths"; const emptyBytes = new Uint8Array(); @@ -14,6 +15,7 @@ const textEncoder = new TextEncoder(); interface WorkspaceExportReaders { readDocument: (item: WorkspaceItem) => TiptapDocumentJson; + readFlashcards: (item: WorkspaceItem) => FlashcardSetContent; readFile: (item: WorkspaceItem) => Promise>; } @@ -63,6 +65,11 @@ async function writeWorkspaceExport( await addZipBytes(zip, path, textEncoder.encode(`${markdown}\n`), () => output); continue; } + if (item.type === "flashcard") { + const markdown = serializeFlashcardsToMarkdown(item, readers.readFlashcards(item)); + await addZipBytes(zip, path, textEncoder.encode(markdown), () => output); + continue; + } if (getWorkspaceItemContentKind(item.type) === "file") { await addZipStream(zip, path, await readers.readFile(item), () => output); } @@ -95,7 +102,7 @@ function buildArchivePathIndex( } for (const item of items) { - if (getWorkspaceItemContentKind(item.type) !== "document") { + if (getWorkspaceItemContentKind(item.type) !== "document" && item.type !== "flashcard") { continue; } const workspacePath = workspacePaths.get(item.id)?.slice(1); @@ -114,6 +121,15 @@ function buildArchivePathIndex( return archivePaths; } +function serializeFlashcardsToMarkdown(item: WorkspaceItem, set: FlashcardSetContent) { + const cards = set.cards.map((card, index) => { + const front = serializeTiptapDocumentToMarkdown(card.front); + const back = serializeTiptapDocumentToMarkdown(card.back); + return `## Card ${index + 1}\n\n${front}\n\n**Answer**\n\n${back}`; + }); + return `# ${item.name}\n\n${cards.join("\n\n---\n\n")}\n`; +} + function reserveArchivePath(path: string, reservedPaths: ReadonlySet) { if (!reservedPaths.has(path.toLowerCase())) { return path; diff --git a/src/features/workspaces/export/workspace-export.ts b/src/features/workspaces/export/workspace-export.ts index 24e67a665..ae5fe3153 100644 --- a/src/features/workspaces/export/workspace-export.ts +++ b/src/features/workspaces/export/workspace-export.ts @@ -7,8 +7,10 @@ import { type TiptapDocumentJson, } from "#/features/workspaces/documents/tiptap-document"; import { createWorkspaceExportStream } from "#/features/workspaces/export/workspace-export-archive"; +import { type FlashcardSetContent } from "#/features/workspaces/flashcards/flashcard-content"; import { canExportWorkspaceEstimate } from "#/features/workspaces/export/workspace-export-limit"; import { readWorkspaceDocumentCheckpoint } from "#/features/workspaces/persistence/workspace-document-checkpoints"; +import { readFlashcardSet } from "#/features/workspaces/flashcards/flashcard-persistence"; import { readWorkspaceFileSource } from "#/features/workspaces/persistence/workspace-files"; import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; import { getWorkspacePageForUser } from "#/features/workspaces/server/queries"; @@ -25,6 +27,7 @@ export class WorkspaceExportTooLargeError extends Error { interface PreparedWorkspaceExport { documents: Map; + flashcards: Map; fileName: string; fileObjectKeys: Map; items: WorkspaceItem[]; @@ -43,6 +46,11 @@ export async function createWorkspaceExport(input: { workspaceId: string; userId } return document; }, + readFlashcards: (item) => { + const set = prepared.flashcards.get(item.id); + if (!set) throw new Error(`Workspace flashcards were not prepared for ${item.name}.`); + return set; + }, readFile: async (item) => { const objectKey = prepared.fileObjectKeys.get(item.id); if (!objectKey) { @@ -86,6 +94,7 @@ async function prepareWorkspaceExport(input: { workspaceId: string; userId: stri } const documents = new Map(); + const flashcards = new Map(); const fileObjectKeys = new Map(); let estimatedBytes = page.items.length * 512; @@ -112,6 +121,13 @@ async function prepareWorkspaceExport(input: { workspaceId: string; userId: stri } estimatedBytes += object.size; fileObjectKeys.set(item.id, source.objectKey); + continue; + } + if (item.type === "flashcard") { + const set = await readFlashcardSet({ itemId: item.id, workspaceId: input.workspaceId }); + const serialized = JSON.stringify(set); + estimatedBytes += textEncoder.encode(serialized).byteLength; + flashcards.set(item.id, set); } } @@ -121,6 +137,7 @@ async function prepareWorkspaceExport(input: { workspaceId: string; userId: stri return { documents, + flashcards, fileName: `${normalizeWorkspaceItemName(page.workspace.name, "Workspace")}-${new Date().toISOString().slice(0, 10)}.zip`, fileObjectKeys, items: page.items, diff --git a/src/features/workspaces/flashcards/flashcard-content.test.ts b/src/features/workspaces/flashcards/flashcard-content.test.ts new file mode 100644 index 000000000..69c3a546d --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-content.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + createFlashcardSetFromHtml, + parseFlashcardSetContent, + serializeFlashcardSetToHtml, + stringifyFlashcardSetContent, +} from "#/features/workspaces/flashcards/flashcard-content"; + +describe("flashcard content", () => { + it("stores stable IDs and Tiptap JSON while exposing HTML", () => { + const set = createFlashcardSetFromHtml([ + { front: "

What is ATP?

", back: "

Cellular energy.

" }, + ]); + const parsed = parseFlashcardSetContent(stringifyFlashcardSetContent(set)); + + expect(parsed.cards[0]?.id).toBe(set.cards[0]?.id); + expect(parsed.cards[0]?.front.type).toBe("doc"); + expect(serializeFlashcardSetToHtml(parsed)[0]).toMatchObject({ + front: "

What is ATP?

", + back: "

Cellular energy.

", + }); + }); + + it("rejects document-only nodes", () => { + expect(() => + createFlashcardSetFromHtml([{ front: "

Heading

", back: "

A

" }]), + ).toThrow("Flashcards do not support heading content yet."); + }); +}); diff --git a/src/features/workspaces/flashcards/flashcard-content.ts b/src/features/workspaces/flashcards/flashcard-content.ts new file mode 100644 index 000000000..eebeb13ab --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-content.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; + +import { + parseDocumentAiHtml, + serializeTiptapDocumentToHtml, +} from "#/features/workspaces/documents/document-ai-html"; +import { + coerceTiptapDocumentJson, + type TiptapDocumentJson, +} from "#/features/workspaces/documents/tiptap-document"; +import { isRecord } from "#/lib/record"; + +export const FLASHCARD_SET_VERSION = 1; +export const flashcardSideHtmlSchema = z.string().trim().min(1).max(8_000); +const flashcardIdSchema = z.uuid(); + +export interface Flashcard { + id: string; + front: TiptapDocumentJson; + back: TiptapDocumentJson; +} + +export interface FlashcardSetContent { + version: typeof FLASHCARD_SET_VERSION; + cards: Flashcard[]; +} + +interface FlashcardHtmlCard { + id: string; + front: string; + back: string; +} + +const allowedNodeTypes = new Set([ + "doc", + "paragraph", + "text", + "bulletList", + "orderedList", + "listItem", + "codeBlock", + "hardBreak", + "inlineMath", + "blockMath", +]); + +const allowedMarkTypes = new Set(["bold", "italic", "strike", "code", "link", "underline"]); + +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 { + version: FLASHCARD_SET_VERSION, + cards: cards.map((card) => ({ + id: crypto.randomUUID(), + front: parseFlashcardSideHtml(card.front), + back: parseFlashcardSideHtml(card.back), + })), + } satisfies FlashcardSetContent; +} + +export function parseFlashcardSideHtml(html: string) { + const document = parseDocumentAiHtml(html); + assertFlashcardRichText(document); + return document; +} + +export function parseFlashcardSetContent(content: string | null): FlashcardSetContent { + if (!content?.trim()) { + throw new Error("Flashcard content is missing."); + } + + const value: unknown = JSON.parse(content); + if (!isRecord(value) || value.version !== FLASHCARD_SET_VERSION || !Array.isArray(value.cards)) { + throw new Error("Flashcard content has an unsupported format."); + } + + const seenIds = new Set(); + const cards = value.cards.map((card) => { + if (!isRecord(card)) { + throw new Error("Flashcard content contains an invalid card ID."); + } + const cardId = flashcardIdSchema.safeParse(card.id); + if (!cardId.success) { + throw new Error("Flashcard content contains an invalid card ID."); + } + if (seenIds.has(cardId.data)) { + throw new Error("Flashcard content contains a duplicate card ID."); + } + seenIds.add(cardId.data); + + const front = coerceTiptapDocumentJson(card.front); + const back = coerceTiptapDocumentJson(card.back); + assertFlashcardRichText(front); + assertFlashcardRichText(back); + return { id: cardId.data, front, back }; + }); + + return { version: FLASHCARD_SET_VERSION, cards }; +} + +export function stringifyFlashcardSetContent(content: FlashcardSetContent) { + return `${JSON.stringify(content)}\n`; +} + +export function serializeFlashcardSetToHtml(content: FlashcardSetContent): FlashcardHtmlCard[] { + return content.cards.map((card) => ({ + id: card.id, + front: serializeTiptapDocumentToHtml(card.front), + back: serializeTiptapDocumentToHtml(card.back), + })); +} + +function assertFlashcardRichText(value: unknown) { + visitRichTextValue(value); +} + +function visitRichTextValue(value: unknown) { + if (Array.isArray(value)) { + for (const entry of value) visitRichTextValue(entry); + return; + } + if (!isRecord(value)) return; + + if (typeof value.type === "string" && !allowedNodeTypes.has(value.type)) { + throw new Error(`Flashcards do not support ${value.type} content yet.`); + } + 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("Flashcard content contains an unsupported text mark."); + } + } + } + if ("content" in value) visitRichTextValue(value.content); +} diff --git a/src/features/workspaces/flashcards/flashcard-edits.test.ts b/src/features/workspaces/flashcards/flashcard-edits.test.ts new file mode 100644 index 000000000..8cb854a74 --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-edits.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { createFlashcardSetFromHtml } from "#/features/workspaces/flashcards/flashcard-content"; +import { applyFlashcardEdits } from "#/features/workspaces/flashcards/flashcard-edits"; + +describe("applyFlashcardEdits", () => { + it("inserts, moves, updates, and deletes by stable card ID", () => { + const set = createFlashcardSetFromHtml([ + { front: "

A

", back: "

1

" }, + { front: "

B

", back: "

2

" }, + ]); + const [first, second] = set.cards; + const result = applyFlashcardEdits(set, [ + { op: "insert_card", beforeCardId: second!.id, front: "

C

", back: "

3

" }, + { op: "move_card", cardId: first!.id, afterCardId: second!.id }, + { op: "update_card", cardId: second!.id, back: "

Two

" }, + { op: "delete_card", cardId: first!.id }, + ]); + + expect(result.applied).toBe(4); + expect(result.failed).toEqual([]); + expect(result.content.cards).toHaveLength(2); + expect(result.content.cards[1]?.id).toBe(second!.id); + }); + + it("keeps processing after a missing target", () => { + const set = createFlashcardSetFromHtml([{ front: "

A

", back: "

1

" }]); + const result = applyFlashcardEdits(set, [ + { op: "delete_card", cardId: crypto.randomUUID() }, + { op: "update_card", cardId: set.cards[0]!.id, front: "

Updated

" }, + ]); + + expect(result.applied).toBe(1); + expect(result.failed).toMatchObject([{ code: "card_not_found", index: 0 }]); + }); +}); diff --git a/src/features/workspaces/flashcards/flashcard-edits.ts b/src/features/workspaces/flashcards/flashcard-edits.ts new file mode 100644 index 000000000..af6cf48d6 --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-edits.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +import { + parseFlashcardSideHtml, + flashcardSideHtmlSchema, + type FlashcardSetContent, +} from "#/features/workspaces/flashcards/flashcard-content"; + +const cardIdSchema = z.uuid(); +const placementSchema = z + .object({ + beforeCardId: cardIdSchema.optional(), + afterCardId: cardIdSchema.optional(), + }) + .refine((value) => !(value.beforeCardId && value.afterCardId), { + message: "Choose beforeCardId or afterCardId, not both.", + }); + +export const flashcardEditSchema = z.union([ + placementSchema.extend({ + op: z.literal("insert_card"), + front: flashcardSideHtmlSchema, + back: flashcardSideHtmlSchema, + }), + z + .object({ + op: z.literal("update_card"), + cardId: cardIdSchema, + front: flashcardSideHtmlSchema.optional(), + back: flashcardSideHtmlSchema.optional(), + }) + .refine((value) => value.front !== undefined || value.back !== undefined, { + message: "Provide a new front or back.", + }), + placementSchema.extend({ + op: z.literal("move_card"), + cardId: cardIdSchema, + }), + z.object({ + op: z.literal("delete_card"), + cardId: cardIdSchema, + }), +]); + +export type FlashcardEdit = z.output; +export type FlashcardEditFailureCode = "card_not_found" | "invalid_card_content"; + +export function applyFlashcardEdits(content: FlashcardSetContent, edits: FlashcardEdit[]) { + const cards = [...content.cards]; + const failed: Array<{ code: FlashcardEditFailureCode; detail?: string; index: number }> = []; + let applied = 0; + + for (const [index, edit] of edits.entries()) { + try { + if (edit.op === "insert_card") { + const insertionIndex = getPlacementIndex(cards, edit); + if (insertionIndex === null) throw new CardNotFoundError(); + cards.splice(insertionIndex, 0, { + id: crypto.randomUUID(), + front: parseFlashcardSideHtml(edit.front), + back: parseFlashcardSideHtml(edit.back), + }); + } else if (edit.op === "update_card") { + const cardIndex = cards.findIndex((card) => card.id === edit.cardId); + if (cardIndex < 0) throw new CardNotFoundError(); + const card = cards[cardIndex]!; + cards[cardIndex] = { + ...card, + ...(edit.front === undefined ? {} : { front: parseFlashcardSideHtml(edit.front) }), + ...(edit.back === undefined ? {} : { back: parseFlashcardSideHtml(edit.back) }), + }; + } else if (edit.op === "delete_card") { + const cardIndex = cards.findIndex((card) => card.id === edit.cardId); + if (cardIndex < 0) throw new CardNotFoundError(); + cards.splice(cardIndex, 1); + } else { + const cardIndex = cards.findIndex((card) => card.id === edit.cardId); + if (cardIndex < 0) throw new CardNotFoundError(); + const targetId = edit.beforeCardId ?? edit.afterCardId; + if (targetId === edit.cardId || (targetId && !cards.some((card) => card.id === targetId))) { + throw new CardNotFoundError(); + } + const [card] = cards.splice(cardIndex, 1); + const insertionIndex = getPlacementIndex(cards, edit); + if (!card || insertionIndex === null) throw new CardNotFoundError(); + cards.splice(insertionIndex, 0, card); + } + applied += 1; + } catch (error) { + failed.push({ + code: error instanceof CardNotFoundError ? "card_not_found" : "invalid_card_content", + ...(error instanceof Error && error.message ? { detail: error.message } : {}), + index, + }); + } + } + + return { applied, failed, content: { ...content, cards } }; +} + +function getPlacementIndex( + cards: FlashcardSetContent["cards"], + placement: { beforeCardId?: string; afterCardId?: string }, +) { + const targetId = placement.beforeCardId ?? placement.afterCardId; + if (!targetId) return cards.length; + const targetIndex = cards.findIndex((card) => card.id === targetId); + if (targetIndex < 0) return null; + return placement.beforeCardId ? targetIndex : targetIndex + 1; +} + +class CardNotFoundError extends Error {} diff --git a/src/features/workspaces/flashcards/flashcard-functions.ts b/src/features/workspaces/flashcards/flashcard-functions.ts new file mode 100644 index 000000000..14983bcba --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-functions.ts @@ -0,0 +1,38 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + +import { + readFlashcardViewer, + recordFlashcardStudyRating, + resetFlashcardStudyProgress, +} from "#/features/workspaces/flashcards/flashcard-study-persistence"; +import { flashcardStudyRatingSchema } from "#/features/workspaces/flashcards/flashcard-study-state"; +import { getCurrentUserId } from "#/features/workspaces/server/permissions"; + +const flashcardItemInputSchema = z.object({ + itemId: z.string().min(1), + workspaceId: z.string().min(1), +}); + +export const getFlashcardViewerFn = createServerFn({ method: "GET" }) + .validator(flashcardItemInputSchema) + .handler(async ({ data }) => { + return await readFlashcardViewer({ ...data, userId: await getCurrentUserId() }); + }); + +export const recordFlashcardStudyRatingFn = createServerFn({ method: "POST" }) + .validator( + flashcardItemInputSchema.extend({ + cardId: z.uuid(), + rating: flashcardStudyRatingSchema, + }), + ) + .handler(async ({ data }) => + recordFlashcardStudyRating({ ...data, userId: await getCurrentUserId() }), + ); + +export const resetFlashcardStudyProgressFn = createServerFn({ method: "POST" }) + .validator(flashcardItemInputSchema) + .handler(async ({ data }) => + resetFlashcardStudyProgress({ ...data, userId: await getCurrentUserId() }), + ); diff --git a/src/features/workspaces/flashcards/flashcard-persistence.ts b/src/features/workspaces/flashcards/flashcard-persistence.ts new file mode 100644 index 000000000..168d2e3de --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-persistence.ts @@ -0,0 +1,102 @@ +import { and, eq } from "drizzle-orm"; + +import { workspaceItemContents, workspaceItems } from "#/db/schema"; +import { withDb } from "#/db/server"; +import { + parseFlashcardSetContent, + stringifyFlashcardSetContent, + type FlashcardSetContent, +} from "#/features/workspaces/flashcards/flashcard-content"; +import { + lockWorkspaceForActor, + nextWorkspaceRevision, + requireActiveWorkspaceItem, + withWorkspaceTransaction, +} from "#/features/workspaces/persistence/workspace-postgres-support"; +import { notifyWorkspaceRoom } from "#/features/workspaces/realtime/workspace-room-notifier"; +import { assertCanReadWorkspace } from "#/features/workspaces/server/permissions"; + +export async function readFlashcardSet(input: { + itemId: string; + workspaceId: string; + userId?: string; +}) { + return await withDb(async (db) => { + if (input.userId) { + await assertCanReadWorkspace(db, { workspaceId: input.workspaceId, userId: input.userId }); + } + const [row] = await db + .select({ content: workspaceItemContents.content }) + .from(workspaceItems) + .innerJoin(workspaceItemContents, eq(workspaceItems.id, workspaceItemContents.itemId)) + .where( + and( + eq(workspaceItems.id, input.itemId), + eq(workspaceItems.workspaceId, input.workspaceId), + eq(workspaceItems.type, "flashcard"), + ), + ) + .limit(1); + if (!row) throw new Error("Workspace item is not a flashcard set."); + return parseFlashcardSetContent(row.content); + }); +} + +export async function updateFlashcardSet( + env: Cloudflare.Env, + input: { + actorUserId?: string | null; + itemId: string; + workspaceId: string; + }, + update: (content: FlashcardSetContent) => { + changed: boolean; + content: FlashcardSetContent; + result: T; + }, +) { + const command = await withWorkspaceTransaction(async (transaction) => { + await lockWorkspaceForActor(transaction, input.workspaceId, input.actorUserId); + const [row] = await transaction + .select({ content: workspaceItemContents.content }) + .from(workspaceItems) + .innerJoin(workspaceItemContents, eq(workspaceItems.id, workspaceItemContents.itemId)) + .where( + and( + eq(workspaceItems.id, input.itemId), + eq(workspaceItems.workspaceId, input.workspaceId), + eq(workspaceItems.type, "flashcard"), + ), + ) + .limit(1); + if (!row) throw new Error("Workspace item is not a flashcard set."); + const updated = update(parseFlashcardSetContent(row.content)); + if (!updated.changed) return { item: null, revision: null, result: updated.result }; + const now = new Date(); + await transaction + .update(workspaceItemContents) + .set({ content: stringifyFlashcardSetContent(updated.content) }) + .where(eq(workspaceItemContents.itemId, input.itemId)); + await transaction + .update(workspaceItems) + .set({ updatedAt: now }) + .where( + and(eq(workspaceItems.workspaceId, input.workspaceId), eq(workspaceItems.id, input.itemId)), + ); + return { + item: await requireActiveWorkspaceItem(transaction, input.workspaceId, input.itemId), + revision: await nextWorkspaceRevision(transaction, input.workspaceId), + result: updated.result, + }; + }); + + if (command.item && command.revision !== null) { + await notifyWorkspaceRoom(env, { + type: "workspace.items.upserted", + workspaceId: input.workspaceId, + revision: command.revision, + items: [command.item], + }); + } + return command.result; +} diff --git a/src/features/workspaces/flashcards/flashcard-queries.ts b/src/features/workspaces/flashcards/flashcard-queries.ts new file mode 100644 index 000000000..9366af456 --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-queries.ts @@ -0,0 +1,101 @@ +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { + getFlashcardViewerFn, + recordFlashcardStudyRatingFn, + resetFlashcardStudyProgressFn, +} from "#/features/workspaces/flashcards/flashcard-functions"; +import { + applyFlashcardStudyRating, + createEmptyFlashcardStudyState, + type FlashcardStudyRating, +} from "#/features/workspaces/flashcards/flashcard-study-state"; + +export function flashcardViewerQueryOptions(input: { + itemId: string; + updatedAt: string; + workspaceId: string; +}) { + return queryOptions({ + queryKey: ["workspace-flashcards", input.workspaceId, input.itemId, input.updatedAt], + queryFn: () => + getFlashcardViewerFn({ + data: { itemId: input.itemId, workspaceId: input.workspaceId }, + }), + }); +} + +export function useResetFlashcardStudyProgress(input: { + itemId: string; + updatedAt: string; + workspaceId: string; +}) { + const queryClient = useQueryClient(); + const viewerQuery = flashcardViewerQueryOptions(input); + return useMutation({ + scope: { id: `flashcard-study:${input.itemId}` }, + mutationFn: () => + resetFlashcardStudyProgressFn({ + data: { itemId: input.itemId, workspaceId: input.workspaceId }, + }), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: viewerQuery.queryKey }); + const previous = queryClient.getQueryData(viewerQuery.queryKey); + queryClient.setQueryData(viewerQuery.queryKey, (current) => + current ? { ...current, studyState: createEmptyFlashcardStudyState() } : current, + ); + return { previous }; + }, + onSuccess: (studyState) => { + queryClient.setQueryData(viewerQuery.queryKey, (current) => + current ? { ...current, studyState } : current, + ); + }, + onError: (_error, _variables, context) => { + queryClient.setQueryData(viewerQuery.queryKey, context?.previous); + toast.error("Your study progress could not be reset."); + }, + }); +} + +export function useRecordFlashcardStudyRating(input: { + itemId: string; + updatedAt: string; + workspaceId: string; +}) { + const queryClient = useQueryClient(); + const viewerQuery = flashcardViewerQueryOptions(input); + return useMutation({ + scope: { id: `flashcard-study:${input.itemId}` }, + mutationFn: (rating: { cardId: string; rating: FlashcardStudyRating }) => + recordFlashcardStudyRatingFn({ + data: { itemId: input.itemId, workspaceId: input.workspaceId, ...rating }, + }), + onMutate: async (rating) => { + await queryClient.cancelQueries({ queryKey: viewerQuery.queryKey }); + const previous = queryClient.getQueryData(viewerQuery.queryKey); + queryClient.setQueryData(viewerQuery.queryKey, (current) => + current + ? { + ...current, + studyState: applyFlashcardStudyRating(current.studyState, { + ...rating, + reviewedAt: new Date().toISOString(), + }), + } + : current, + ); + return { previous }; + }, + onSuccess: (studyState) => { + queryClient.setQueryData(viewerQuery.queryKey, (current) => + current ? { ...current, studyState } : current, + ); + }, + onError: (_error, _rating, context) => { + queryClient.setQueryData(viewerQuery.queryKey, context?.previous); + toast.error("Your study progress could not be saved."); + }, + }); +} diff --git a/src/features/workspaces/flashcards/flashcard-study-persistence.ts b/src/features/workspaces/flashcards/flashcard-study-persistence.ts new file mode 100644 index 000000000..908a21488 --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-study-persistence.ts @@ -0,0 +1,138 @@ +import { and, eq } from "drizzle-orm"; + +import { workspaceItemContents, workspaceItems, workspaceItemUserStates } from "#/db/schema"; +import { withDb } from "#/db/server"; +import { parseFlashcardSetContent } from "#/features/workspaces/flashcards/flashcard-content"; +import { + applyFlashcardStudyRating, + createEmptyFlashcardStudyState, + parseFlashcardStudyState, + type FlashcardStudyRating, +} from "#/features/workspaces/flashcards/flashcard-study-state"; +import { assertCanReadWorkspace } from "#/features/workspaces/server/permissions"; + +export async function readFlashcardViewer(input: { + itemId: string; + userId: string; + workspaceId: string; +}) { + return await withDb(async (db) => { + await assertCanReadWorkspace(db, input); + const [row] = await db + .select({ content: workspaceItemContents.content, state: workspaceItemUserStates.state }) + .from(workspaceItems) + .innerJoin(workspaceItemContents, eq(workspaceItems.id, workspaceItemContents.itemId)) + .leftJoin( + workspaceItemUserStates, + and( + eq(workspaceItemUserStates.itemId, workspaceItems.id), + eq(workspaceItemUserStates.userId, input.userId), + ), + ) + .where( + and( + eq(workspaceItems.id, input.itemId), + eq(workspaceItems.workspaceId, input.workspaceId), + eq(workspaceItems.type, "flashcard"), + ), + ) + .limit(1); + if (!row) throw new Error("Flashcard set not found."); + return { + cards: parseFlashcardSetContent(row.content).cards, + studyState: parseFlashcardStudyState(row.state), + }; + }); +} + +export async function recordFlashcardStudyRating(input: { + cardId: string; + itemId: string; + rating: FlashcardStudyRating; + userId: string; + workspaceId: string; +}) { + return await withDb((db) => + db.transaction(async (transaction) => { + await assertCanReadWorkspace(transaction, input); + const [item] = await transaction + .select({ content: workspaceItemContents.content }) + .from(workspaceItems) + .innerJoin(workspaceItemContents, eq(workspaceItems.id, workspaceItemContents.itemId)) + .where( + and( + eq(workspaceItems.id, input.itemId), + eq(workspaceItems.workspaceId, input.workspaceId), + eq(workspaceItems.type, "flashcard"), + ), + ) + .limit(1); + if (!item) throw new Error("Flashcard set not found."); + if (!parseFlashcardSetContent(item.content).cards.some((card) => card.id === input.cardId)) { + throw new Error("Flashcard not found."); + } + + const [currentRow] = await transaction + .select({ state: workspaceItemUserStates.state }) + .from(workspaceItemUserStates) + .where( + and( + eq(workspaceItemUserStates.userId, input.userId), + eq(workspaceItemUserStates.itemId, input.itemId), + ), + ) + .limit(1); + const state = currentRow + ? parseFlashcardStudyState(currentRow.state) + : createEmptyFlashcardStudyState(); + const nextState = applyFlashcardStudyRating(state, { + cardId: input.cardId, + rating: input.rating, + reviewedAt: new Date().toISOString(), + }); + + await transaction + .insert(workspaceItemUserStates) + .values({ itemId: input.itemId, userId: input.userId, state: nextState }) + .onConflictDoUpdate({ + target: [workspaceItemUserStates.userId, workspaceItemUserStates.itemId], + set: { state: nextState, updatedAt: new Date() }, + }); + return nextState; + }), + ); +} + +export async function resetFlashcardStudyProgress(input: { + itemId: string; + userId: string; + workspaceId: string; +}) { + return await withDb((db) => + db.transaction(async (transaction) => { + await assertCanReadWorkspace(transaction, input); + const [item] = await transaction + .select({ id: workspaceItems.id }) + .from(workspaceItems) + .where( + and( + eq(workspaceItems.id, input.itemId), + eq(workspaceItems.workspaceId, input.workspaceId), + eq(workspaceItems.type, "flashcard"), + ), + ) + .limit(1); + if (!item) throw new Error("Flashcard set not found."); + + await transaction + .delete(workspaceItemUserStates) + .where( + and( + eq(workspaceItemUserStates.userId, input.userId), + eq(workspaceItemUserStates.itemId, input.itemId), + ), + ); + return createEmptyFlashcardStudyState(); + }), + ); +} diff --git a/src/features/workspaces/flashcards/flashcard-study-session.test.ts b/src/features/workspaces/flashcards/flashcard-study-session.test.ts new file mode 100644 index 000000000..57187a017 --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-study-session.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import type { Flashcard } from "#/features/workspaces/flashcards/flashcard-content"; +import { createFlashcardStudyQueue } from "#/features/workspaces/flashcards/flashcard-study-session"; +import type { FlashcardStudyState } from "#/features/workspaces/flashcards/flashcard-study-state"; + +const cards = [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", +].map((id) => ({ id }) as Flashcard); + +const studyState: FlashcardStudyState = { + kind: "flashcard", + cards: { + [cards[0]!.id]: { + lastRating: "again", + lastReviewedAt: "2026-08-13T12:00:00.000Z", + reviewCount: 1, + }, + [cards[1]!.id]: { + lastRating: "good", + lastReviewedAt: "2026-08-13T12:00:00.000Z", + reviewCount: 1, + }, + }, +}; + +describe("flashcard study queue", () => { + it("keeps authored order for an unshuffled all-cards session", () => { + expect(createFlashcardStudyQueue({ cards, mode: "all", shuffled: false, studyState })).toEqual( + cards.map((card) => card.id), + ); + }); + + it("starts a missed-only session from cards whose latest rating is Again", () => { + expect( + createFlashcardStudyQueue({ cards, mode: "missed", shuffled: false, studyState }), + ).toEqual([cards[0]!.id]); + }); + + it("shuffles a session without mutating authored order", () => { + const authoredOrder = cards.map((card) => card.id); + const queue = createFlashcardStudyQueue( + { cards, mode: "all", shuffled: true, studyState }, + () => 0, + ); + + expect(queue).toEqual([authoredOrder[1], authoredOrder[2], authoredOrder[0]]); + expect(cards.map((card) => card.id)).toEqual(authoredOrder); + }); +}); diff --git a/src/features/workspaces/flashcards/flashcard-study-session.ts b/src/features/workspaces/flashcards/flashcard-study-session.ts new file mode 100644 index 000000000..3fc416794 --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-study-session.ts @@ -0,0 +1,29 @@ +import type { Flashcard } from "#/features/workspaces/flashcards/flashcard-content"; +import type { FlashcardStudyState } from "#/features/workspaces/flashcards/flashcard-study-state"; + +export type FlashcardStudyMode = "all" | "missed"; + +/** Builds one stable study queue without changing the set's authored order. */ +export function createFlashcardStudyQueue( + input: { + cards: Flashcard[]; + mode: FlashcardStudyMode; + shuffled: boolean; + studyState: FlashcardStudyState; + }, + random: () => number = Math.random, +) { + const cardIds = input.cards + .filter( + (card) => input.mode === "all" || input.studyState.cards[card.id]?.lastRating === "again", + ) + .map((card) => card.id); + + if (!input.shuffled) return cardIds; + + for (let index = cardIds.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [cardIds[index], cardIds[swapIndex]] = [cardIds[swapIndex]!, cardIds[index]!]; + } + return cardIds; +} diff --git a/src/features/workspaces/flashcards/flashcard-study-state.test.ts b/src/features/workspaces/flashcards/flashcard-study-state.test.ts new file mode 100644 index 000000000..f83e8ef4d --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-study-state.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + applyFlashcardStudyRating, + createEmptyFlashcardStudyState, + parseFlashcardStudyState, +} from "#/features/workspaces/flashcards/flashcard-study-state"; + +describe("flashcard study state", () => { + it("keeps valid private review history", () => { + const cardId = crypto.randomUUID(); + const state = { + kind: "flashcard" as const, + cards: { + [cardId]: { + lastRating: "easy" as const, + lastReviewedAt: "2026-08-13T12:00:00.000Z", + reviewCount: 2, + }, + }, + }; + + expect(parseFlashcardStudyState(state)).toEqual(state); + }); + + it("treats unknown future or damaged state as empty", () => { + expect(parseFlashcardStudyState({ kind: "quiz", cards: {} })).toEqual( + createEmptyFlashcardStudyState(), + ); + }); + + it("records the latest rating and increments the review count", () => { + const cardId = crypto.randomUUID(); + const first = applyFlashcardStudyRating(createEmptyFlashcardStudyState(), { + cardId, + rating: "again", + reviewedAt: "2026-08-13T12:00:00.000Z", + }); + + expect( + applyFlashcardStudyRating(first, { + cardId, + rating: "good", + reviewedAt: "2026-08-13T12:01:00.000Z", + }).cards[cardId], + ).toEqual({ + lastRating: "good", + lastReviewedAt: "2026-08-13T12:01:00.000Z", + reviewCount: 2, + }); + }); +}); diff --git a/src/features/workspaces/flashcards/flashcard-study-state.ts b/src/features/workspaces/flashcards/flashcard-study-state.ts new file mode 100644 index 000000000..14bc2c552 --- /dev/null +++ b/src/features/workspaces/flashcards/flashcard-study-state.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export const flashcardStudyRatingSchema = z.enum(["again", "hard", "good", "easy"]); +export type FlashcardStudyRating = z.output; + +const flashcardReviewSchema = z.object({ + lastRating: flashcardStudyRatingSchema, + lastReviewedAt: z.string(), + reviewCount: z.number().int().nonnegative(), +}); + +export const flashcardStudyStateSchema = z.object({ + kind: z.literal("flashcard"), + cards: z.record(z.uuid(), flashcardReviewSchema), +}); + +export type FlashcardStudyState = z.output; + +export function createEmptyFlashcardStudyState(): FlashcardStudyState { + return { kind: "flashcard", cards: {} }; +} + +export function parseFlashcardStudyState(value: unknown): FlashcardStudyState { + const parsed = flashcardStudyStateSchema.safeParse(value); + return parsed.success ? parsed.data : createEmptyFlashcardStudyState(); +} + +export function applyFlashcardStudyRating( + state: FlashcardStudyState, + input: { cardId: string; rating: FlashcardStudyRating; reviewedAt: string }, +): FlashcardStudyState { + return { + ...state, + cards: { + ...state.cards, + [input.cardId]: { + lastRating: input.rating, + lastReviewedAt: input.reviewedAt, + reviewCount: (state.cards[input.cardId]?.reviewCount ?? 0) + 1, + }, + }, + }; +} diff --git a/src/features/workspaces/model/item-display.ts b/src/features/workspaces/model/item-display.ts index a1ce5c901..799d2bb9f 100644 --- a/src/features/workspaces/model/item-display.ts +++ b/src/features/workspaces/model/item-display.ts @@ -1,4 +1,4 @@ -import { FilePen, Folder, Paperclip, Upload } from "lucide-react"; +import { FilePen, Folder, Layers3, Paperclip, Upload } from "lucide-react"; import { type WorkspaceItem, @@ -13,6 +13,7 @@ import { getWorkspaceItemPalette } from "#/features/workspaces/model/workspace-i const workspaceItemIcons = { document: FilePen, file: Paperclip, + flashcard: Layers3, folder: Folder, } satisfies Record; @@ -46,12 +47,12 @@ export function getWorkspaceItemDisplay(item: WorkspaceItem) { }; } -const workspaceItemPrimaryCreateActionOrder = ["document", "folder"] as const; +const workspaceItemPrimaryCreateActionOrder = ["document", "flashcard", "folder"] as const; export const workspaceItemPrimaryCreateActions = workspaceItemPrimaryCreateActionOrder.map(createWorkspaceItemAction); -function createWorkspaceItemAction(type: "document" | "folder") { +function createWorkspaceItemAction(type: "document" | "flashcard" | "folder") { const display = getWorkspaceItemTypeDisplay(type); return { type, diff --git a/src/features/workspaces/model/workspace-page.ts b/src/features/workspaces/model/workspace-page.ts index fad1debd2..027d61ce1 100644 --- a/src/features/workspaces/model/workspace-page.ts +++ b/src/features/workspaces/model/workspace-page.ts @@ -8,7 +8,7 @@ import { getAvailableWorkspaceItemName, WORKSPACE_ITEM_SORT_STEP, } from "#/features/workspaces/defaults"; -import { buildWorkspaceItemCreateBootstrap } from "#/features/workspaces/documents/document-item-content"; +import { buildWorkspaceItemCreateBootstrap } from "#/features/workspaces/model/workspace-item-create-bootstrap"; import { getWorkspaceRootItems, getWorkspaceSubtreeItemIds, diff --git a/src/features/workspaces/workspace-item-registry.ts b/src/features/workspaces/workspace-item-registry.ts index 815d88ac0..9ccacd7af 100644 --- a/src/features/workspaces/workspace-item-registry.ts +++ b/src/features/workspaces/workspace-item-registry.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -const workspaceItemTypes = ["folder", "document", "file"] as const; -export const workspaceItemTypeSchema = z.enum(workspaceItemTypes); +export const WORKSPACE_ITEM_TYPES = ["folder", "document", "flashcard", "file"] as const; +export const workspaceItemTypeSchema = z.enum(WORKSPACE_ITEM_TYPES); export type WorkspaceItemType = z.infer; /** @@ -10,7 +10,7 @@ export type WorkspaceItemType = z.infer; * the same question: a future type can share `document` storage without being a * document, and `none` covers every item whose body is the tree itself. */ -type WorkspaceItemContentKind = "document" | "file" | "none"; +type WorkspaceItemContentKind = "document" | "file" | "none" | "structured"; interface WorkspaceItemRegistryEntry { color: "amber" | "emerald" | "rose" | "sky" | "violet"; @@ -43,6 +43,14 @@ const workspaceItemRegistry = { label: "Document", menuLabel: "Document", }, + flashcard: { + color: "violet", + contentKind: "structured", + defaultName: "New flashcards", + isContainer: false, + label: "Flashcards", + menuLabel: "Flashcards", + }, file: { color: "rose", contentKind: "file", From c37aac18e35c663658ce658f4b8c25518411f2cb Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:52:37 -0400 Subject: [PATCH 03/21] feat(workspaces): add flashcard study viewer --- .../components/WorkspaceContent.tsx | 5 + .../components/WorkspaceItemToolbarSlot.tsx | 254 ++++---- .../workspaces/components/WorkspaceLayout.tsx | 22 +- .../document-editor/DocumentEditorSurface.tsx | 4 +- .../flashcards/CreateFlashcardsDialog.tsx | 98 +++ .../flashcards/FlashcardToolbar.tsx | 206 ++++++ .../flashcards/FlashcardViewer.test.tsx | 126 ++++ .../components/flashcards/FlashcardViewer.tsx | 612 ++++++++++++++++++ .../flashcards/flashcard-viewer.css | 82 +++ .../locations/workspace-location-context.tsx | 47 +- .../locations/workspace-location.test.ts | 11 + .../locations/workspace-location.ts | 9 + .../model/workspace-ai-context-prompt.ts | 5 +- .../workspace-ai-context-validation.test.ts | 55 ++ .../model/workspace-ai-context-validation.ts | 12 +- .../model/workspace-item-view-state.ts | 142 +++- 16 files changed, 1541 insertions(+), 149 deletions(-) create mode 100644 src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx create mode 100644 src/features/workspaces/components/flashcards/FlashcardToolbar.tsx create mode 100644 src/features/workspaces/components/flashcards/FlashcardViewer.test.tsx create mode 100644 src/features/workspaces/components/flashcards/FlashcardViewer.tsx create mode 100644 src/features/workspaces/components/flashcards/flashcard-viewer.css diff --git a/src/features/workspaces/components/WorkspaceContent.tsx b/src/features/workspaces/components/WorkspaceContent.tsx index 96df09153..d2252de35 100644 --- a/src/features/workspaces/components/WorkspaceContent.tsx +++ b/src/features/workspaces/components/WorkspaceContent.tsx @@ -10,6 +10,7 @@ import { EmptyTitle, } from "#/components/ui/empty"; import { DocumentEditorSurface } from "#/features/workspaces/components/document-editor/DocumentEditorSurface"; +import { FlashcardViewer } from "#/features/workspaces/components/flashcards/FlashcardViewer"; import { WorkspaceClipboardIntakeDialog } from "#/features/workspaces/components/WorkspaceClipboardIntakeDialog"; import WorkspaceClickableEmptyState from "#/features/workspaces/components/WorkspaceClickableEmptyState"; import { useWorkspaceClipboardIntake } from "#/features/workspaces/components/useWorkspaceClipboardIntake"; @@ -524,6 +525,10 @@ function WorkspaceItemView({ ); } + if (item.type === "flashcard") { + return ; + } + const { Icon: ItemIcon, iconClassName, surfaceClassName } = getWorkspaceItemDisplay(item); const itemViewContent = (
diff --git a/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx b/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx index c32f44c16..c4a3478e9 100644 --- a/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx +++ b/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx @@ -6,12 +6,15 @@ import { type SetStateAction, use, useEffect, + useMemo, useState, } from "react"; import { TooltipProvider } from "#/components/ui/tooltip"; import { DocumentToolbar } from "#/features/workspaces/components/document-editor/DocumentToolbar"; +import { FlashcardToolbar } from "#/features/workspaces/components/flashcards/FlashcardToolbar"; import { WorkspaceFileToolbar } from "#/features/workspaces/components/WorkspaceFileToolbar"; +import type { FlashcardStudyMode } from "#/features/workspaces/flashcards/flashcard-study-session"; type WorkspaceItemToolbarRegistration = | { @@ -32,6 +35,18 @@ type WorkspaceItemToolbarRegistration = fileUrl: string; kind: "file"; slotId: string; + } + | { + canReset: boolean; + isResetting: boolean; + kind: "flashcard"; + missedCount: number; + mode: FlashcardStudyMode; + onModeChange: (mode: FlashcardStudyMode) => void; + onReset: () => void; + onShuffleToggle: () => void; + shuffled: boolean; + slotId: string; }; interface WorkspaceItemToolbarContextValue { @@ -68,56 +83,20 @@ export function useDocumentEditorToolbar({ slotId: string; workspaceId: string; }) { - const context = use(WorkspaceItemToolbarContext); - const setRegistration = context?.setRegistration; - - useEffect(() => { - if (!setRegistration) { - return; - } - - const registration = { - canEdit, - documentPath, - editor, - itemId, - kind: "document" as const, - slotId, - workspaceId, - }; - setRegistration((current) => { - const existing = current[slotId]; - if ( - existing?.kind === "document" && - existing.canEdit === canEdit && - existing.documentPath === documentPath && - existing.editor === editor && - existing.itemId === itemId && - existing.slotId === slotId && - existing.workspaceId === workspaceId - ) { - return current; - } - - return { - ...current, - [slotId]: registration, - }; - }); - - return () => { - setRegistration((current) => { - if (current[slotId] !== registration) { - return current; - } - - const next = { ...current }; - delete next[slotId]; - - return next; - }); - }; - }, [canEdit, documentPath, editor, itemId, slotId, workspaceId, setRegistration]); + useWorkspaceItemToolbarRegistration( + useMemo( + () => ({ + canEdit, + documentPath, + editor, + itemId, + kind: "document" as const, + slotId, + workspaceId, + }), + [canEdit, documentPath, editor, itemId, slotId, workspaceId], + ), + ); } export function useFileItemToolbar({ @@ -134,60 +113,96 @@ export function useFileItemToolbar({ fileUrl: string; slotId: string; }) { - const context = use(WorkspaceItemToolbarContext); - const setRegistration = context?.setRegistration; const captureIsActive = capture?.isActive; const captureOnToggle = capture?.onToggle; + useWorkspaceItemToolbarRegistration( + useMemo( + () => ({ + capture: captureOnToggle + ? { isActive: Boolean(captureIsActive), onToggle: captureOnToggle } + : undefined, + fileName, + fileUrl, + kind: "file" as const, + slotId, + }), + [captureIsActive, captureOnToggle, fileName, fileUrl, slotId], + ), + ); +} + +export function useFlashcardItemToolbar({ + canReset, + isResetting, + missedCount, + mode, + onModeChange, + onReset, + onShuffleToggle, + shuffled, + slotId, +}: { + canReset: boolean; + isResetting: boolean; + missedCount: number; + mode: FlashcardStudyMode; + onModeChange: (mode: FlashcardStudyMode) => void; + onReset: () => void; + onShuffleToggle: () => void; + shuffled: boolean; + slotId: string; +}) { + useWorkspaceItemToolbarRegistration( + useMemo( + () => ({ + canReset, + isResetting, + kind: "flashcard" as const, + missedCount, + mode, + onModeChange, + onReset, + onShuffleToggle, + shuffled, + slotId, + }), + [ + canReset, + isResetting, + missedCount, + mode, + onModeChange, + onReset, + onShuffleToggle, + shuffled, + slotId, + ], + ), + ); +} + +function useWorkspaceItemToolbarRegistration(registration: WorkspaceItemToolbarRegistration) { + const context = use(WorkspaceItemToolbarContext); + const setRegistration = context?.setRegistration; useEffect(() => { - if (!setRegistration) { - return; - } - - const registeredCapture = captureOnToggle - ? { - isActive: Boolean(captureIsActive), - onToggle: captureOnToggle, - } - : undefined; - const registration = { - capture: registeredCapture, - fileName, - fileUrl, - kind: "file" as const, - slotId, - }; - setRegistration((current) => { - const existing = current[slotId]; - if ( - existing?.kind === "file" && - existing.fileName === fileName && - existing.fileUrl === fileUrl && - existing.capture?.isActive === registeredCapture?.isActive && - existing.capture?.onToggle === registeredCapture?.onToggle - ) { - return current; - } - - return { - ...current, - [slotId]: registration, - }; - }); + if (!setRegistration) return; + + setRegistration((current) => + current[registration.slotId] === registration + ? current + : { ...current, [registration.slotId]: registration }, + ); return () => { setRegistration((current) => { - if (current[slotId] !== registration) { - return current; - } - + if (current[registration.slotId] !== registration) return current; const next = { ...current }; - delete next[slotId]; - + delete next[registration.slotId]; return next; }); }; - }, [captureIsActive, captureOnToggle, fileName, fileUrl, slotId, setRegistration]); + }, [registration, setRegistration]); } export function WorkspaceItemToolbarSlot({ @@ -206,23 +221,42 @@ export function WorkspaceItemToolbarSlot({ return (
- - {registration.kind === "document" ? ( - - ) : ( - - )} - + {renderWorkspaceItemToolbar(registration)}
); } + +function renderWorkspaceItemToolbar(registration: WorkspaceItemToolbarRegistration) { + if (registration.kind === "document") { + return ( + + ); + } + if (registration.kind === "file") { + return ( + + ); + } + return ( + + ); +} diff --git a/src/features/workspaces/components/WorkspaceLayout.tsx b/src/features/workspaces/components/WorkspaceLayout.tsx index 6ba7c40c3..b6b8ebbc9 100644 --- a/src/features/workspaces/components/WorkspaceLayout.tsx +++ b/src/features/workspaces/components/WorkspaceLayout.tsx @@ -1,9 +1,10 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { workspacePageQueryKey } from "#/features/workspaces/cache-keys"; import { applyWorkspacePageDeltaToCache } from "#/features/workspaces/cache-page"; import AiChatPanel from "#/features/workspaces/components/AiChatPanel"; import WorkspaceChatLayout from "#/features/workspaces/components/WorkspaceChatLayout"; +import { CreateFlashcardsDialog } from "#/features/workspaces/components/flashcards/CreateFlashcardsDialog"; import WorkspaceContextBar from "#/features/workspaces/components/WorkspaceContextBar"; import { hasActiveWorkspaceCapture } from "#/features/workspaces/components/WorkspaceCaptureChrome"; import WorkspaceDragProvider from "#/features/workspaces/components/WorkspaceDragProvider"; @@ -32,6 +33,7 @@ import type { WorkspaceLocation } from "#/features/workspaces/locations/workspac import { WorkspaceLocationProvider } from "#/features/workspaces/locations/workspace-location-context"; import { DocumentEditReviewProvider } from "#/features/workspaces/documents/document-edit-review-context"; import { isWorkspaceItemView } from "#/features/workspaces/model/view"; +import { getWorkspaceItemPath } from "#/features/workspaces/model/tree"; import { workspaceItemRequiresHeavyViewerRuntime } from "#/features/workspaces/model/workspace-file"; import { getWorkspaceMobileChatSurfaceMode } from "#/features/workspaces/model/workspace-ui"; import { useWorkspaceNavigation } from "#/features/workspaces/navigation/useWorkspaceNavigation"; @@ -68,6 +70,7 @@ export function WorkspaceShell({ const queryClient = useQueryClient(); const createWorkspaceItemMutation = useCreateWorkspaceItemMutation(); const moveWorkspaceItemsMutation = useMoveWorkspaceItemsMutation(); + const [flashcardParentId, setFlashcardParentId] = useState(); const persistedStoresHydrated = useWorkspacePersistedStoresHydrated(); const ensureWorkspaceUiSession = useWorkspaceUiStore((state) => state.ensureWorkspaceSession); const itemViewStatesByItemId = useWorkspaceItemViewStates(workspace.id); @@ -132,6 +135,11 @@ export function WorkspaceShell({ return; } + if (input.type === "flashcard") { + setFlashcardParentId(input.parentId); + return; + } + createWorkspaceItemMutation.mutate({ id: crypto.randomUUID(), workspaceId: workspace.id, @@ -290,6 +298,10 @@ export function WorkspaceShell({ ); + const flashcardParent = flashcardParentId ? itemsById.get(flashcardParentId) : undefined; + const flashcardParentPath = flashcardParent + ? getWorkspaceItemPath(flashcardParent, itemsById) + : "/"; return ( @@ -298,6 +310,14 @@ export function WorkspaceShell({ {workspaceInteractionContent} + { + if (!open) setFlashcardParentId(undefined); + }} + /> diff --git a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx index 238136206..d043db65c 100644 --- a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx +++ b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx @@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { Skeleton } from "#/components/ui/skeleton"; -import { stageComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; +import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; import { DocumentAskSelectionMenu } from "#/features/workspaces/components/document-editor/DocumentAskSelectionMenu"; import { DocumentWordCount } from "#/features/workspaces/components/document-editor/DocumentWordCount"; import { useDocumentEditorToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; @@ -135,7 +135,7 @@ function DocumentEditorInstance({ onAskAiToFix={ capabilities.canMutateContent ? (error) => - stageComposerPrompt( + sendComposerPrompt( workspaceId, `A widget in ${documentPath} hit this error. Please fix it:\n\n${error}`, ) diff --git a/src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx b/src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx new file mode 100644 index 000000000..4604b8013 --- /dev/null +++ b/src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx @@ -0,0 +1,98 @@ +import { Layers3 } from "lucide-react"; +import { useId } from "react"; + +import { Button } from "#/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/ui/dialog"; +import { Field, FieldGroup, FieldLabel } from "#/components/ui/field"; +import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select"; +import { Textarea } from "#/components/ui/textarea"; +import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; + +export function CreateFlashcardsDialog({ + open, + parentPath, + workspaceId, + onOpenChange, +}: { + open: boolean; + parentPath: string; + workspaceId: string; + onOpenChange: (open: boolean) => void; +}) { + const topicId = useId(); + const countId = useId(); + + return ( + + {open ? ( + +
{ + const rawTopic = formData.get("topic"); + const topic = typeof rawTopic === "string" ? rawTopic.trim() : ""; + const count = Number(formData.get("count")); + if (!topic || !Number.isInteger(count)) return; + if ( + !sendComposerPrompt( + workspaceId, + `Create a flashcard set with about ${count} cards ${describeFlashcardLocation(parentPath)}. Cover: ${topic}`, + ) + ) + return; + onOpenChange(false); + }} + > + + + + AI will create the set in your current chat. + + + + What should the cards cover? +