From fb4ce32d2748bd4db3aef697e0017b6d6d2ba8df Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:34:05 -0400 Subject: [PATCH 01/14] feat(workspaces): add quiz study items Multiple-choice quizzes as a first-class item: the model authors question + correctAnswer + distractors + explanation, the server shuffles the answer in and stores the option order, and a first-pick- wins immediate-feedback viewer persists per-user answers. Extracts the shared ordered-entry edit engine and entry rich-text dialect from flashcards so both study types run on one mechanism, and generalizes the study toolbar and creation dialog. Part 1 of a stacked series; builds together with the address refactor it was developed alongside. --- drizzle-postgres/0004_charming_doomsday.sql | 2 + drizzle-postgres/meta/0004_snapshot.json | 2478 +++++++++++++++++ eval/datasets/workspace-tools.cases.ts | 9 + .../components/WorkspaceContent.tsx | 5 + .../workspaces/components/WorkspaceLayout.tsx | 32 +- .../ai-chat/ai-chat-tool-receipts.ts | 8 +- .../components/flashcards/FlashcardViewer.tsx | 12 +- .../components/quizzes/QuizViewer.tsx | 458 +++ .../CreateStudyItemDialog.tsx} | 43 +- .../StudyToolbar.tsx} | 150 +- .../workspace-presentation-model.ts | 2 +- .../workspaces/content/entry-rich-text.ts | 91 + .../workspaces/content/ordered-entry-edits.ts | 131 + .../export/workspace-export-archive.test.ts | 10 +- .../export/workspace-export-archive.ts | 20 +- .../workspaces/export/workspace-export.ts | 61 +- .../flashcards/flashcard-content.ts | 87 +- .../flashcards/flashcard-edits.test.ts | 4 +- .../workspaces/flashcards/flashcard-edits.ts | 188 +- .../flashcards/flashcard-functions.ts | 4 +- .../flashcards/flashcard-study-state.ts | 4 +- src/features/workspaces/model/item-display.ts | 7 +- .../workspace-operation-failure-codes.ts | 4 +- .../workspaces/quizzes/quiz-content.test.ts | 75 + .../workspaces/quizzes/quiz-content.ts | 240 ++ .../workspaces/quizzes/quiz-edits.test.ts | 159 ++ src/features/workspaces/quizzes/quiz-edits.ts | 192 ++ .../workspaces/quizzes/quiz-functions.ts | 37 + .../workspaces/quizzes/quiz-persistence.ts | 100 + .../workspaces/quizzes/quiz-queries.ts | 110 + .../quizzes/quiz-study-persistence.ts | 163 ++ .../workspaces/quizzes/quiz-study-session.ts | 58 + .../quizzes/quiz-study-state.test.ts | 67 + .../workspaces/quizzes/quiz-study-state.ts | 102 + .../workspaces/workspace-item-registry.ts | 10 +- 35 files changed, 4786 insertions(+), 337 deletions(-) create mode 100644 drizzle-postgres/0004_charming_doomsday.sql create mode 100644 drizzle-postgres/meta/0004_snapshot.json create mode 100644 src/features/workspaces/components/quizzes/QuizViewer.tsx rename src/features/workspaces/components/{flashcards/CreateFlashcardsDialog.tsx => study/CreateStudyItemDialog.tsx} (63%) rename src/features/workspaces/components/{flashcards/FlashcardToolbar.tsx => study/StudyToolbar.tsx} (51%) create mode 100644 src/features/workspaces/content/entry-rich-text.ts create mode 100644 src/features/workspaces/content/ordered-entry-edits.ts create mode 100644 src/features/workspaces/quizzes/quiz-content.test.ts create mode 100644 src/features/workspaces/quizzes/quiz-content.ts create mode 100644 src/features/workspaces/quizzes/quiz-edits.test.ts create mode 100644 src/features/workspaces/quizzes/quiz-edits.ts create mode 100644 src/features/workspaces/quizzes/quiz-functions.ts create mode 100644 src/features/workspaces/quizzes/quiz-persistence.ts create mode 100644 src/features/workspaces/quizzes/quiz-queries.ts create mode 100644 src/features/workspaces/quizzes/quiz-study-persistence.ts create mode 100644 src/features/workspaces/quizzes/quiz-study-session.ts create mode 100644 src/features/workspaces/quizzes/quiz-study-state.test.ts create mode 100644 src/features/workspaces/quizzes/quiz-study-state.ts diff --git a/drizzle-postgres/0004_charming_doomsday.sql b/drizzle-postgres/0004_charming_doomsday.sql new file mode 100644 index 00000000..dfc9e468 --- /dev/null +++ b/drizzle-postgres/0004_charming_doomsday.sql @@ -0,0 +1,2 @@ +ALTER TABLE "workspace_items" DROP CONSTRAINT "workspace_items_type_check";--> statement-breakpoint +ALTER TABLE "workspace_items" ADD CONSTRAINT "workspace_items_type_check" CHECK ("workspace_items"."type" in ('folder', 'document', 'flashcard', 'quiz', 'file')); \ No newline at end of file diff --git a/drizzle-postgres/meta/0004_snapshot.json b/drizzle-postgres/meta/0004_snapshot.json new file mode 100644 index 00000000..3b9dbe68 --- /dev/null +++ b/drizzle-postgres/meta/0004_snapshot.json @@ -0,0 +1,2478 @@ +{ + "id": "cff9bd5b-3d3d-4cc3-86d9-7236e8cef04d", + "prevId": "25aa9ff5-8b2a-4423-8d78-053fd64107c5", + "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": { + "workspace_item_user_states_item_id_idx": { + "name": "workspace_item_user_states_item_id_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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', 'quiz', '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": {} + } +} \ No newline at end of file diff --git a/eval/datasets/workspace-tools.cases.ts b/eval/datasets/workspace-tools.cases.ts index 6c249279..07b0d5c8 100644 --- a/eval/datasets/workspace-tools.cases.ts +++ b/eval/datasets/workspace-tools.cases.ts @@ -37,6 +37,15 @@ export const workspaceToolCases: WorkspaceToolCase[] = [ expectedTools: ["workspace_read_items", "workspace_edit_item"], requiresTargetedEditFromRead: true, }, + { + name: "create a quiz without leaking the answer key into the stems", + input: { + prompt: + "Create a quiz at /Study/Photosynthesis Quiz with 3 multiple-choice questions about photosynthesis basics.", + }, + expectedTools: ["workspace_create_items"], + forbiddenTools: ["workspace_delete_items"], + }, { name: "respects a read-only turn", input: { diff --git a/src/features/workspaces/components/WorkspaceContent.tsx b/src/features/workspaces/components/WorkspaceContent.tsx index 7fbfc8f7..7baecb4a 100644 --- a/src/features/workspaces/components/WorkspaceContent.tsx +++ b/src/features/workspaces/components/WorkspaceContent.tsx @@ -11,6 +11,7 @@ import { } from "#/components/ui/empty"; import { DocumentEditorSurface } from "#/features/workspaces/components/document-editor/DocumentEditorSurface"; import { FlashcardViewer } from "#/features/workspaces/components/flashcards/FlashcardViewer"; +import { QuizViewer } from "#/features/workspaces/components/quizzes/QuizViewer"; import { WorkspaceClipboardIntakeDialog } from "#/features/workspaces/components/WorkspaceClipboardIntakeDialog"; import WorkspaceClickableEmptyState from "#/features/workspaces/components/WorkspaceClickableEmptyState"; import { useWorkspaceClipboardIntake } from "#/features/workspaces/components/useWorkspaceClipboardIntake"; @@ -525,6 +526,10 @@ function WorkspaceItemView({ return ; } + if (item.type === "quiz") { + return ; + } + const { Icon: ItemIcon, iconClassName, surfaceClassName } = getWorkspaceItemDisplay(item); const itemViewContent = (
diff --git a/src/features/workspaces/components/WorkspaceLayout.tsx b/src/features/workspaces/components/WorkspaceLayout.tsx index dfc6f5fe..bd7238d3 100644 --- a/src/features/workspaces/components/WorkspaceLayout.tsx +++ b/src/features/workspaces/components/WorkspaceLayout.tsx @@ -4,7 +4,10 @@ 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 { + CreateStudyItemDialog, + type StudyItemDialogType, +} from "#/features/workspaces/components/study/CreateStudyItemDialog"; import WorkspaceContextBar from "#/features/workspaces/components/WorkspaceContextBar"; import { hasActiveWorkspaceCapture } from "#/features/workspaces/components/WorkspaceCaptureChrome"; import WorkspaceDragProvider from "#/features/workspaces/components/WorkspaceDragProvider"; @@ -70,7 +73,10 @@ export function WorkspaceShell({ const queryClient = useQueryClient(); const createWorkspaceItemMutation = useCreateWorkspaceItemMutation(); const moveWorkspaceItemsMutation = useMoveWorkspaceItemsMutation(); - const [flashcardParentId, setFlashcardParentId] = useState(); + const [studyDraft, setStudyDraft] = useState<{ + type: StudyItemDialogType; + parentId: string | null; + }>(); const persistedStoresHydrated = useWorkspacePersistedStoresHydrated(); const ensureWorkspaceUiSession = useWorkspaceUiStore((state) => state.ensureWorkspaceSession); const itemViewStatesByViewInstanceId = useWorkspaceItemViewStatesByViewInstance(workspace.id); @@ -135,8 +141,8 @@ export function WorkspaceShell({ return; } - if (input.type === "flashcard") { - setFlashcardParentId(input.parentId); + if (input.type === "flashcard" || input.type === "quiz") { + setStudyDraft({ type: input.type, parentId: input.parentId }); return; } createWorkspaceItemMutation.mutate({ @@ -297,11 +303,10 @@ export function WorkspaceShell({ ); - const flashcardParent = flashcardParentId ? itemsById.get(flashcardParentId) : undefined; - const flashcardDialogOpen = flashcardParentId === null || flashcardParent !== undefined; - const flashcardParentPath = flashcardParent - ? getWorkspaceItemPath(flashcardParent, itemsById) - : "/"; + const studyParent = studyDraft?.parentId ? itemsById.get(studyDraft.parentId) : undefined; + const studyDialogOpen = + studyDraft !== undefined && (studyDraft.parentId === null || studyParent !== undefined); + const studyParentPath = studyParent ? getWorkspaceItemPath(studyParent, itemsById) : "/"; return ( @@ -310,12 +315,13 @@ export function WorkspaceShell({ {workspaceInteractionContent} - { - if (!open) setFlashcardParentId(undefined); + if (!open) setStudyDraft(undefined); }} /> 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 6cd3e183..285a9eb4 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,7 +166,13 @@ export function getFinishedToolReceipt(input: { successVerb: "Created", typeFromItem: (item) => { const type = getString(asRecord(item).type); - return type === "folder" ? "folder" : type === "flashcard" ? "flashcard set" : "document"; + return type === "folder" + ? "folder" + : type === "flashcard" + ? "flashcard set" + : type === "quiz" + ? "quiz" + : "document"; }, }); case "workspace_delete_items": diff --git a/src/features/workspaces/components/flashcards/FlashcardViewer.tsx b/src/features/workspaces/components/flashcards/FlashcardViewer.tsx index 81edcf8c..d7fbaf52 100644 --- a/src/features/workspaces/components/flashcards/FlashcardViewer.tsx +++ b/src/features/workspaces/components/flashcards/FlashcardViewer.tsx @@ -8,7 +8,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react import { Button } from "#/components/ui/button"; import { Skeleton } from "#/components/ui/skeleton"; import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; -import { FlashcardToolbar } from "#/features/workspaces/components/flashcards/FlashcardToolbar"; +import { StudyToolbar } from "#/features/workspaces/components/study/StudyToolbar"; import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; import { getTiptapDocumentBaseExtensions } from "#/features/workspaces/documents/tiptap-extensions"; import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; @@ -178,9 +178,17 @@ function FlashcardStudySession({ }, [resetProgress, startSession]); const toolbar = useMemo( () => ( - 0} isResetting={isResetting} + labels={{ + allLabel: "All cards", + mobileLabel: "Flashcard study options", + resetAriaLabel: "Reset flashcard progress", + resetDescription: + "This clears every saved response for this set. The cards themselves will not change.", + resetTitle: "Reset flashcard progress?", + }} missedCount={studyProgress.missedCount} mode={mode} shuffled={shuffled} diff --git a/src/features/workspaces/components/quizzes/QuizViewer.tsx b/src/features/workspaces/components/quizzes/QuizViewer.tsx new file mode 100644 index 00000000..5deac9a0 --- /dev/null +++ b/src/features/workspaces/components/quizzes/QuizViewer.tsx @@ -0,0 +1,458 @@ +import { useQuery } from "@tanstack/react-query"; +import type { JSONContent } from "@tiptap/core"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { Check, ChevronLeft, ChevronRight, Lightbulb, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Button } from "#/components/ui/button"; +import { Skeleton } from "#/components/ui/skeleton"; +import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; +import { StudyToolbar } from "#/features/workspaces/components/study/StudyToolbar"; +import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; +import { getTiptapDocumentBaseExtensions } from "#/features/workspaces/documents/tiptap-extensions"; +import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; +import type { WorkspaceItem } from "#/features/workspaces/contracts"; +import { useWorkspaceRevealRequest } from "#/features/workspaces/locations/workspace-location-context"; +import type { QuizQuestion } from "#/features/workspaces/quizzes/quiz-content"; +import { + quizViewerQueryOptions, + useRecordQuizAnswer, + useResetQuizStudyProgress, +} from "#/features/workspaces/quizzes/quiz-queries"; +import { + createQuizStudyQueue, + getQuizStudyViewState, + type QuizStudyMode, +} from "#/features/workspaces/quizzes/quiz-study-session"; +import { + getQuizAnswer, + summarizeQuizStudyProgress, + type QuizStudyState, +} from "#/features/workspaces/quizzes/quiz-study-state"; +import { useWorkspaceUiStore } from "#/features/workspaces/state/workspace-ui-store"; +import { cn } from "#/lib/utils"; + +interface QuizSessionState { + questionIds: string[]; + currentIndex: number; + mode: QuizStudyMode; + shuffled: boolean; +} + +export function QuizViewer({ + itemPath, + item, + viewInstanceId, +}: { + itemPath: string; + item: WorkspaceItem; + viewInstanceId: string; +}) { + const { data, error, isPending } = useQuery( + quizViewerQueryOptions({ + itemId: item.id, + updatedAt: item.updatedAt, + workspaceId: item.workspaceId, + }), + ); + + if (isPending) return ; + if (error || !data) { + return ( +
+ Could not load this quiz. +
+ ); + } + return ( + + ); +} + +function QuizStudySession({ + questions, + itemPath, + studyState, + item, + viewInstanceId, +}: { + questions: QuizQuestion[]; + itemPath: string; + studyState: QuizStudyState; + item: WorkspaceItem; + viewInstanceId: string; +}) { + const [session, setSession] = useState(() => ({ + questionIds: createQuizStudyQueue({ questions, mode: "all", shuffled: false, studyState }), + currentIndex: 0, + mode: "all", + shuffled: false, + })); + const { questionIds, currentIndex, mode, shuffled } = session; + const questionsById = useMemo( + () => new Map(questions.map((question) => [question.id, question])), + [questions], + ); + const studyQuestions = useMemo( + () => + questionIds.flatMap((questionId) => { + const question = questionsById.get(questionId); + return question ? [question] : []; + }), + [questionIds, questionsById], + ); + const currentQuestion = studyQuestions[currentIndex]; + const sourceQuestionNumber = currentQuestion + ? questions.findIndex((question) => question.id === currentQuestion.id) + 1 + : 0; + const answer = currentQuestion ? getQuizAnswer(currentQuestion, studyState) : undefined; + const quizProgress = useMemo( + () => summarizeQuizStudyProgress(questions, studyState), + [questions, studyState], + ); + const clearItemViewState = useWorkspaceUiStore((state) => state.clearItemViewState); + const setItemViewState = useWorkspaceUiStore((state) => state.setItemViewState); + const { complete: completeRevealRequest, request: revealRequest } = useWorkspaceRevealRequest( + viewInstanceId, + "quiz-question", + ); + const queryInput = { + itemId: item.id, + updatedAt: item.updatedAt, + workspaceId: item.workspaceId, + }; + const recordAnswer = useRecordQuizAnswer(queryInput); + const { isPending: isResetting, mutate: resetProgress } = useResetQuizStudyProgress(queryInput); + const startSession = useCallback( + (nextMode: QuizStudyMode, nextShuffled: boolean) => { + setSession({ + questionIds: createQuizStudyQueue({ + questions, + mode: nextMode, + shuffled: nextShuffled, + studyState, + }), + currentIndex: 0, + mode: nextMode, + shuffled: nextShuffled, + }); + }, + [questions, studyState], + ); + const goTo = useCallback((index: number) => { + setSession((current) => ({ + ...current, + currentIndex: Math.min(Math.max(index, 0), current.questionIds.length - 1), + })); + }, []); + const resetStudyProgress = useCallback(() => { + startSession("all", false); + resetProgress(); + }, [resetProgress, startSession]); + const toolbar = useMemo( + () => ( + 0} + isResetting={isResetting} + labels={{ + allLabel: "All questions", + mobileLabel: "Quiz options", + resetAriaLabel: "Reset quiz progress", + resetDescription: + "This clears every saved answer for this quiz. The questions themselves will not change.", + resetTitle: "Reset quiz progress?", + }} + missedCount={quizProgress.incorrectCount} + mode={mode} + shuffled={shuffled} + onModeChange={(nextMode) => startSession(nextMode, shuffled)} + onReset={resetStudyProgress} + onShuffleToggle={() => startSession(mode, !shuffled)} + /> + ), + [isResetting, mode, quizProgress, resetStudyProgress, shuffled, startSession], + ); + useWorkspaceItemToolbar(viewInstanceId, toolbar); + useEffect(() => { + if (!currentQuestion) return; + setItemViewState(item.workspaceId, viewInstanceId, { + itemId: item.id, + ...getQuizStudyViewState({ + answered: answer !== undefined, + ...(answer ? { correct: answer.selectedOptionId === currentQuestion.correctOptionId } : {}), + mode, + progress: quizProgress, + sessionPosition: currentIndex + 1, + sessionTotal: studyQuestions.length, + shuffled, + sourceQuestionNumber, + }), + }); + }, [ + answer, + currentIndex, + currentQuestion, + item.id, + item.workspaceId, + mode, + quizProgress, + setItemViewState, + shuffled, + sourceQuestionNumber, + studyQuestions.length, + viewInstanceId, + ]); + useEffect( + () => () => clearItemViewState(item.workspaceId, viewInstanceId), + [clearItemViewState, item.workspaceId, viewInstanceId], + ); + useEffect(() => { + if (!revealRequest) return; + let nextQuestionIds = questionIds; + let questionIndex = nextQuestionIds.indexOf(revealRequest.location.questionId); + if (questionIndex < 0 && questionsById.has(revealRequest.location.questionId)) { + nextQuestionIds = createQuizStudyQueue({ questions, mode: "all", shuffled, studyState }); + questionIndex = nextQuestionIds.indexOf(revealRequest.location.questionId); + } + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + if (questionIndex >= 0) { + setSession((current) => ({ + ...current, + questionIds: nextQuestionIds, + currentIndex: questionIndex, + mode: nextQuestionIds === questionIds ? current.mode : "all", + })); + } + completeRevealRequest(revealRequest, questionIndex >= 0); + }); + return () => { + cancelled = true; + }; + }, [ + completeRevealRequest, + questionIds, + questions, + questionsById, + revealRequest, + shuffled, + studyState, + ]); + + if (questions.length === 0) { + return ( +
+
+

No questions yet

+

Ask AI to add questions to this quiz.

+
+
+ ); + } + if (studyQuestions.length === 0) { + return ( +
+
+
+

No missed questions

+

Nothing needs another try right now.

+
+ +
+
+ ); + } + if (!currentQuestion) return null; + + const isCorrect = answer?.selectedOptionId === currentQuestion.correctOptionId; + + return ( +
{ + if (event.key === "ArrowLeft") goTo(currentIndex - 1); + if (event.key === "ArrowRight") goTo(currentIndex + 1); + }} + > +
+ + Question {sourceQuestionNumber} of {questions.length} + + + {quizProgress.correctCount}/{quizProgress.answeredCount} correct + +
+ +
+ + {answer === undefined ? ( + + ) : null} +
+ +
+ {currentQuestion.options.map((option, optionIndex) => { + const isSelected = answer?.selectedOptionId === option.id; + const isCorrectOption = option.id === currentQuestion.correctOptionId; + const graded = answer !== undefined; + return ( + + ); + })} +
+ + {answer !== undefined ? ( +
+
+ + {isCorrect ? "Correct" : "Incorrect"} + + +
+ +
+ ) : null} + +
+ + {quizProgress.answeredCount} of {quizProgress.totalQuestions} answered + +
+ + + {currentIndex + 1} / {studyQuestions.length} + + +
+
+
+ ); +} + +function QuizRichText({ content, compact }: { content: TiptapDocumentJson; compact?: boolean }) { + const editor = useEditor( + { + content: content as unknown as JSONContent, + editable: false, + immediatelyRender: false, + extensions: getTiptapDocumentBaseExtensions(), + editorProps: { + attributes: { + class: cn("workspace-document-prose outline-none", compact && "text-sm"), + }, + }, + }, + [content], + ); + + return ; +} + +function QuizViewerSkeleton() { + return ( +
+ + + + + + +
+ ); +} diff --git a/src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx b/src/features/workspaces/components/study/CreateStudyItemDialog.tsx similarity index 63% rename from src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx rename to src/features/workspaces/components/study/CreateStudyItemDialog.tsx index 37d7db68..549d21a9 100644 --- a/src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx +++ b/src/features/workspaces/components/study/CreateStudyItemDialog.tsx @@ -1,4 +1,4 @@ -import { Layers3 } from "lucide-react"; +import { Layers3, ListChecks } from "lucide-react"; import { useId } from "react"; import { Button } from "#/components/ui/button"; @@ -15,12 +15,36 @@ 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({ +export type StudyItemDialogType = "flashcard" | "quiz"; + +const studyItemDialogConfigs = { + flashcard: { + Icon: Layers3, + iconClassName: "text-violet-500", + title: "Create flashcards", + countLabel: "Number of cards", + prompt: (count: number, locationPhrase: string, topic: string) => + `Create a flashcard set with exactly ${count} cards ${locationPhrase}. Cover: ${topic}`, + }, + quiz: { + Icon: ListChecks, + iconClassName: "text-emerald-500", + title: "Create a quiz", + countLabel: "Number of questions", + prompt: (count: number, locationPhrase: string, topic: string) => + `Create a quiz with exactly ${count} multiple-choice questions ${locationPhrase}. Cover: ${topic}`, + }, +} as const satisfies Record; + +/** Collects a topic and size, then hands the AI composer the create request. */ +export function CreateStudyItemDialog({ + type, open, parentPath, workspaceId, onOpenChange, }: { + type: StudyItemDialogType; open: boolean; parentPath: string; workspaceId: string; @@ -28,6 +52,7 @@ export function CreateFlashcardsDialog({ }) { const topicId = useId(); const countId = useId(); + const config = studyItemDialogConfigs[type]; return ( @@ -43,7 +68,7 @@ export function CreateFlashcardsDialog({ if ( !sendComposerPrompt( workspaceId, - `Create a flashcard set with exactly ${count} cards ${describeFlashcardLocation(parentPath)}. Cover: ${topic}`, + config.prompt(count, describeStudyItemLocation(parentPath), topic), ) ) return; @@ -52,14 +77,14 @@ export function CreateFlashcardsDialog({ > - - AI will create the set in your current chat. + AI will create it in your current chat. - What should the cards cover? + What should it cover?