From 28675ac37ebae4f1e314f76b1c38eee2edb86061 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:46:54 -0400 Subject: [PATCH 1/9] feat(workspaces): add flashcard study items --- drizzle-postgres/0003_glorious_wolf_cub.sql | 13 + drizzle-postgres/meta/0003_snapshot.json | 2478 +++++++++++++++++ drizzle-postgres/meta/_journal.json | 7 + src/db/schema.ts | 20 +- .../ai/ai-thread-orchestration-contract.ts | 3 +- .../ai/ai-thread-orchestration.worker.test.ts | 29 + .../ai/ai-thread-tool-ui-metadata.test.ts | 21 + .../ai/ai-thread-tool-ui-metadata.ts | 2 +- .../ai/workspace-tool-result-adapters.ts | 2 +- .../components/WorkspaceContent.tsx | 10 +- .../components/WorkspaceCreateMenu.tsx | 33 +- .../components/WorkspaceImageViewer.tsx | 27 +- .../components/WorkspaceItemToolbarSlot.tsx | 184 +- .../workspaces/components/WorkspaceLayout.tsx | 36 +- .../components/WorkspacePdfViewer.tsx | 44 +- .../components/ai-chat/AiChatPromptInput.tsx | 25 +- .../components/ai-chat/AiChatThreadView.tsx | 45 +- .../ai-chat-document-edit-actions.test.ts | 38 + .../ai-chat/ai-chat-document-edit-actions.ts | 6 +- .../ai-chat/ai-chat-tool-receipts.ts | 6 +- .../document-editor/DocumentEditorSurface.tsx | 30 +- .../flashcards/CreateFlashcardsDialog.tsx | 98 + .../flashcards/FlashcardToolbar.tsx | 204 ++ .../components/flashcards/FlashcardViewer.tsx | 851 ++++++ .../flashcards/flashcard-viewer.css | 73 + .../widget/WorkspaceAddWidgetDialog.tsx | 31 +- .../composer/workspace-composer-actions.ts | 20 +- .../workspace-content-contract.test.ts | 2 + .../content/workspace-content-contract.ts | 45 +- .../content/workspace-content-cursor.ts | 7 + .../content/workspace-content-reader.test.ts | 122 +- .../content/workspace-content-reader.ts | 153 +- .../content/workspace-read-references.test.ts | 71 + .../content/workspace-read-references.ts | 49 +- src/features/workspaces/contracts.ts | 5 +- .../workspaces/documents/document-ai-html.ts | 8 + .../documents/document-item-content.ts | 35 +- .../export/workspace-export-archive.test.ts | 16 + .../export/workspace-export-archive.ts | 18 +- .../workspaces/export/workspace-export.ts | 17 + .../flashcards/flashcard-content.test.ts | 42 + .../flashcards/flashcard-content.ts | 146 + .../flashcards/flashcard-edits.test.ts | 50 + .../workspaces/flashcards/flashcard-edits.ts | 114 + .../flashcards/flashcard-functions.ts | 38 + .../flashcards/flashcard-persistence.ts | 102 + .../flashcards/flashcard-queries.ts | 101 + .../flashcards/flashcard-study-persistence.ts | 151 + .../flashcard-study-session.test.ts | 52 + .../flashcards/flashcard-study-session.ts | 30 + .../flashcards/flashcard-study-state.test.ts | 77 + .../flashcards/flashcard-study-state.ts | 76 + .../workspace-location-context.test.tsx | 72 + .../locations/workspace-location-context.tsx | 50 +- .../locations/workspace-location.test.ts | 11 + .../locations/workspace-location.ts | 9 + src/features/workspaces/model/item-display.ts | 37 +- .../model/workspace-ai-context-prompt.ts | 4 +- .../model/workspace-ai-context-reference.ts | 35 +- .../workspace-ai-context-snapshot.test.ts | 47 + .../model/workspace-ai-context-snapshot.ts | 8 + .../model/workspace-ai-context-types.ts | 2 +- .../workspace-ai-context-validation.test.ts | 46 + .../model/workspace-ai-context-validation.ts | 12 +- .../model/workspace-item-create-bootstrap.ts | 31 + .../model/workspace-item-view-state.test.ts | 26 + .../model/workspace-item-view-state.ts | 90 +- .../workspaces/model/workspace-page.ts | 2 +- src/features/workspaces/model/workspace-ui.ts | 9 + .../workspace-tool-surface.test.ts.snap | 486 +++- .../operations/create-items.test.ts | 39 + .../workspaces/operations/create-items.ts | 52 +- .../workspaces/operations/edit-item.ts | 45 +- .../workspaces/operations/read-items.ts | 7 + .../workspace-operation-failure-codes.ts | 2 + .../operations/workspace-tool-definitions.ts | 16 +- .../operations/workspace-tool-schemas.ts | 132 +- .../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 +- .../workspaces/state/workspace-ui-store.ts | 26 +- .../workspaces/workspace-item-registry.ts | 20 +- 83 files changed, 6618 insertions(+), 676 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/components/ai-chat/ai-chat-document-edit-actions.test.ts 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.tsx create mode 100644 src/features/workspaces/components/flashcards/flashcard-viewer.css 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 create mode 100644 src/features/workspaces/locations/workspace-location-context.test.tsx create mode 100644 src/features/workspaces/model/workspace-ai-context-snapshot.test.ts create mode 100644 src/features/workspaces/model/workspace-item-create-bootstrap.ts create mode 100644 src/features/workspaces/model/workspace-item-view-state.test.ts create mode 100644 src/features/workspaces/state/workspace-ai-composer-draft-store.test.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..47db548b8 --- /dev/null +++ b/drizzle-postgres/0003_glorious_wolf_cub.sql @@ -0,0 +1,13 @@ +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 +CREATE INDEX "workspace_item_user_states_item_id_idx" ON "workspace_item_user_states" USING btree ("item_id");--> 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..e5cfe8275 --- /dev/null +++ b/drizzle-postgres/meta/0003_snapshot.json @@ -0,0 +1,2478 @@ +{ + "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": { + "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', '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..ffc550e61 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,24 @@ 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] }), + index("workspace_item_user_states_item_id_idx").on(table.itemId), + ], +); + export const workspaceFileAssets = pgTable("workspace_file_assets", { itemId: text("item_id") .primaryKey() 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/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/WorkspaceContent.tsx b/src/features/workspaces/components/WorkspaceContent.tsx index 96df09153..0982e6ccc 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"; @@ -42,7 +43,6 @@ import { type WorkspaceItem, type WorkspaceItemType, type WorkspaceSummary, - getWorkspaceItemContentKind, } from "#/features/workspaces/contracts"; import { getWorkspaceItemDisplay } from "#/features/workspaces/model/item-display"; import { @@ -500,7 +500,7 @@ function WorkspaceItemView({ }) { const viewCapabilities = useWorkspaceViewCapabilities(); - if (getWorkspaceItemContentKind(item.type) === "document") { + if (item.type === "document") { return ( ; + } + const { Icon: ItemIcon, iconClassName, surfaceClassName } = getWorkspaceItemDisplay(item); const itemViewContent = (
diff --git a/src/features/workspaces/components/WorkspaceCreateMenu.tsx b/src/features/workspaces/components/WorkspaceCreateMenu.tsx index 0e979d149..ebe5196af 100644 --- a/src/features/workspaces/components/WorkspaceCreateMenu.tsx +++ b/src/features/workspaces/components/WorkspaceCreateMenu.tsx @@ -22,10 +22,7 @@ import { WorkspaceViewerRoleBadge, } from "#/features/workspaces/components/workspace-viewer-ui"; import type { WorkspaceItemType } from "#/features/workspaces/contracts"; -import { - workspaceItemAcquisitionActions, - workspaceItemPrimaryCreateActions, -} from "#/features/workspaces/model/item-display"; +import { workspaceCreateMenuActionGroups } from "#/features/workspaces/model/item-display"; interface WorkspaceCreateMenuProps { parentId: string | null; @@ -96,24 +93,16 @@ function getWorkspaceCreateMenuActions({ }: WorkspaceCreateMenuProps & { onUploadFile: (parentId: string | null) => void; }) { - return [ - ...workspaceItemPrimaryCreateActions.map(({ type, label, Icon, iconClassName }) => ({ + return workspaceCreateMenuActionGroups.flatMap((group, index) => [ + ...(index > 0 ? [{ kind: "separator" as const, id: `create-${group.id}` }] : []), + ...group.actions.map((action) => ({ kind: "item" as const, - id: type, - label, - leading: , - onSelect: () => onCreateItem({ type, parentId }), + id: action.kind === "item" ? action.type : action.id, + label: action.label, + leading: , + ...(action.kind === "item" + ? { onSelect: () => onCreateItem({ type: action.type, parentId }) } + : { onSelect: () => onUploadFile(parentId) }), })), - ...workspaceItemAcquisitionActions.map( - ({ id, label, description, Icon, iconClassName, disabled }) => ({ - kind: "item" as const, - id, - label, - trailing: description, - disabled, - leading: , - onSelect: id === "upload-file" ? () => onUploadFile(parentId) : undefined, - }), - ), - ]; + ]); } diff --git a/src/features/workspaces/components/WorkspaceImageViewer.tsx b/src/features/workspaces/components/WorkspaceImageViewer.tsx index ab9ec7d2e..2240c93de 100644 --- a/src/features/workspaces/components/WorkspaceImageViewer.tsx +++ b/src/features/workspaces/components/WorkspaceImageViewer.tsx @@ -1,11 +1,12 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Spinner } from "#/components/ui/spinner"; import { useWorkspaceImageViewerTransform } from "#/features/workspaces/components/use-workspace-image-viewer-transform"; import { WorkspaceCaptureShortcuts, WorkspaceCaptureViewerFrame, } from "#/features/workspaces/components/WorkspaceCaptureChrome"; -import { useFileItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; +import { WorkspaceFileToolbar } from "#/features/workspaces/components/WorkspaceFileToolbar"; +import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; import { WorkspaceImageRegionCaptureOverlay } from "#/features/workspaces/components/WorkspaceRegionCaptureOverlay"; import { useWorkspaceViewCapabilities } from "#/features/workspaces/components/workspace-view-policy"; import { renderImageRegionCapture } from "#/features/workspaces/components/workspace-image-capture"; @@ -66,17 +67,19 @@ function WorkspaceImageViewerContent({ isCaptureActive: captureActive, }); - useFileItemToolbar({ - capture: enableFileCapture - ? { - isActive: captureActive, - onToggle: toggleCapture, + const toolbar = useMemo( + () => ( + + ), + [captureActive, enableFileCapture, fileUrl, item.name, toggleCapture], + ); + useWorkspaceItemToolbar(viewInstanceId, toolbar); const handleImageLoad = useCallback(() => { setStatus("ready"); diff --git a/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx b/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx index c32f44c16..05acd2f2b 100644 --- a/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx +++ b/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx @@ -1,4 +1,3 @@ -import type { Editor } from "@tiptap/react"; import { createContext, type Dispatch, @@ -6,33 +5,16 @@ 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 { WorkspaceFileToolbar } from "#/features/workspaces/components/WorkspaceFileToolbar"; -type WorkspaceItemToolbarRegistration = - | { - canEdit: boolean; - documentPath: string; - editor: Editor | null; - itemId: string; - kind: "document"; - slotId: string; - workspaceId: string; - } - | { - capture?: { - isActive: boolean; - onToggle: () => void; - }; - fileName: string; - fileUrl: string; - kind: "file"; - slotId: string; - }; +interface WorkspaceItemToolbarRegistration { + content: ReactNode; + slotId: string; +} interface WorkspaceItemToolbarContextValue { registrationsBySlotId: Record; @@ -53,141 +35,29 @@ export function WorkspaceItemToolbarProvider({ children }: { children: ReactNode ); } -export function useDocumentEditorToolbar({ - canEdit, - documentPath, - editor, - itemId, - slotId, - workspaceId, -}: { - canEdit: boolean; - documentPath: string; - editor: Editor | null; - itemId: string; - 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]); -} - -export function useFileItemToolbar({ - capture, - fileName, - fileUrl, - slotId, -}: { - capture?: { - isActive: boolean; - onToggle: () => void; - }; - fileName: string; - fileUrl: string; - slotId: string; -}) { +export function useWorkspaceItemToolbar(slotId: string, content: ReactNode) { + const registration = useMemo(() => ({ content, slotId }), [content, slotId]); const context = use(WorkspaceItemToolbarContext); const setRegistration = context?.setRegistration; - const captureIsActive = capture?.isActive; - const captureOnToggle = capture?.onToggle; useEffect(() => { - if (!setRegistration) { - return; - } + 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, - }; - }); + 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({ @@ -200,29 +70,11 @@ export function WorkspaceItemToolbarSlot({ ? context?.registrationsBySlotId[activeToolbarSlotId] : null; - if (!activeToolbarSlotId || !registration) { - return null; - } + if (!registration) return null; return (
- - {registration.kind === "document" ? ( - - ) : ( - - )} - + {registration.content}
); } diff --git a/src/features/workspaces/components/WorkspaceLayout.tsx b/src/features/workspaces/components/WorkspaceLayout.tsx index 6ba7c40c3..2475fe0fc 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,15 +33,19 @@ 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 { + getActiveWorkspaceViewInstanceId, + getWorkspaceMobileChatSurfaceMode, +} from "#/features/workspaces/model/workspace-ui"; import { useWorkspaceNavigation } from "#/features/workspaces/navigation/useWorkspaceNavigation"; import { useWorkspaceRealtime } from "#/features/workspaces/realtime/use-workspace-presence"; import { useWorkspacePersistedStoresHydrated } from "#/features/workspaces/state/persisted-store-hydration"; import { useWorkspaceAiComposerDraftQuotes } from "#/features/workspaces/state/workspace-ai-composer-draft-store"; import { useWorkspaceSelectionItemIds } from "#/features/workspaces/state/workspace-selection-store"; import { - useWorkspaceItemViewStates, + useWorkspaceItemViewStatesByViewInstance, useWorkspaceUiSession, useWorkspaceUiStore, } from "#/features/workspaces/state/workspace-ui-store"; @@ -68,9 +73,10 @@ 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); + const itemViewStatesByViewInstanceId = useWorkspaceItemViewStatesByViewInstance(workspace.id); const selectedQuotes = useWorkspaceAiComposerDraftQuotes(workspace.id); const setChatSurfaceMode = useWorkspaceUiStore((state) => state.setChatSurfaceMode); const toggleChatPanel = useWorkspaceUiStore((state) => state.toggleChatPanel); @@ -132,6 +138,11 @@ export function WorkspaceShell({ return; } + if (input.type === "flashcard") { + setFlashcardParentId(input.parentId); + return; + } + createWorkspaceItemMutation.mutate({ id: crypto.randomUUID(), workspaceId: workspace.id, @@ -178,7 +189,7 @@ export function WorkspaceShell({ const aiContextScope = { activeItem: isWorkspaceItemView(activeItem) ? activeItem : undefined, activeTabId: activeTab.id, - itemViewStatesByItemId, + itemViewStatesByViewInstanceId, itemsById, presentation, selectedItemIds, @@ -193,7 +204,7 @@ export function WorkspaceShell({ workspace={workspace} activeItem={activeItem} itemsById={itemsById} - toolbarSlotId={activeTab.id} + toolbarSlotId={getActiveWorkspaceViewInstanceId(presentation, activeTab.id)} onCreateItem={createWorkspaceItem} onCloseItemView={isWorkspaceItemView(activeItem) ? closeItemView : undefined} onNavigateToRoot={openWorkspaceRoot} @@ -290,6 +301,11 @@ export function WorkspaceShell({ ); + const flashcardParent = flashcardParentId ? itemsById.get(flashcardParentId) : undefined; + const flashcardDialogOpen = flashcardParentId === null || flashcardParent !== undefined; + const flashcardParentPath = flashcardParent + ? getWorkspaceItemPath(flashcardParent, itemsById) + : "/"; return ( @@ -298,6 +314,14 @@ export function WorkspaceShell({ {workspaceInteractionContent} + { + if (!open) setFlashcardParentId(undefined); + }} + /> diff --git a/src/features/workspaces/components/WorkspacePdfViewer.tsx b/src/features/workspaces/components/WorkspacePdfViewer.tsx index a0e897905..8b9210a68 100644 --- a/src/features/workspaces/components/WorkspacePdfViewer.tsx +++ b/src/features/workspaces/components/WorkspacePdfViewer.tsx @@ -38,13 +38,14 @@ import { import { TilingLayer, TilingPluginPackage } from "@embedpdf/plugin-tiling/react"; import { Viewport, ViewportPluginPackage } from "@embedpdf/plugin-viewport/react"; import { ZoomGestureWrapper, ZoomMode, ZoomPluginPackage } from "@embedpdf/plugin-zoom/react"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { Spinner } from "#/components/ui/spinner"; import { WorkspaceCaptureShortcuts, WorkspaceCaptureViewerFrame, } from "#/features/workspaces/components/WorkspaceCaptureChrome"; -import { useFileItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; +import { WorkspaceFileToolbar } from "#/features/workspaces/components/WorkspaceFileToolbar"; +import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; import { useWorkspacePaneHotkey, useWorkspacePaneRuntime, @@ -127,17 +128,19 @@ export default function WorkspacePdfViewer({ setIsCaptureActive((current) => !current); }, []); - useFileItemToolbar({ - capture: enableFileCapture - ? { - isActive: captureActive, - onToggle: toggleCapture, + const toolbar = useMemo( + () => ( + + ), + [captureActive, enableFileCapture, fileUrl, item.name, toggleCapture], + ); + useWorkspaceItemToolbar(viewInstanceId, toolbar); return (
@@ -315,6 +318,7 @@ function WorkspacePdfDocumentLoader({ itemId={itemId} onCaptureModeExit={onCaptureModeExit} onCaptureModeToggle={onCaptureModeToggle} + viewInstanceId={viewInstanceId} workspaceId={workspaceId} {...props} /> @@ -335,6 +339,7 @@ function WorkspacePdfDocumentContent({ itemId, onCaptureModeExit, onCaptureModeToggle, + viewInstanceId, workspaceId, }: { documentId: string; @@ -348,6 +353,7 @@ function WorkspacePdfDocumentContent({ itemId: string; onCaptureModeExit: () => void; onCaptureModeToggle: () => void; + viewInstanceId: string; workspaceId: string; }) { const [selectionPoint, setSelectionPoint] = useState(null); @@ -391,6 +397,7 @@ function WorkspacePdfDocumentContent({ @@ -540,10 +547,12 @@ function WorkspacePdfPage({ function WorkspacePdfItemViewStateReporter({ documentId, itemId, + viewInstanceId, workspaceId, }: { documentId: string; itemId: string; + viewInstanceId: string; workspaceId: string; }) { const { @@ -553,18 +562,17 @@ function WorkspacePdfItemViewStateReporter({ const setItemViewState = useWorkspaceUiStore((state) => state.setItemViewState); useEffect(() => { - setItemViewState(workspaceId, { - kind: "pdf-page", + setItemViewState(workspaceId, viewInstanceId, { itemId, - pageNumber: currentPage, + label: `p. ${currentPage}`, }); - }, [currentPage, itemId, setItemViewState, workspaceId]); + }, [currentPage, itemId, setItemViewState, viewInstanceId, workspaceId]); useEffect(() => { return () => { - clearItemViewState(workspaceId, itemId); + clearItemViewState(workspaceId, viewInstanceId); }; - }, [clearItemViewState, itemId, workspaceId]); + }, [clearItemViewState, viewInstanceId, workspaceId]); return null; } 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..11db88254 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,10 @@ export default function AiChatThreadView({ const clearDraftArtifacts = useWorkspaceAiComposerDraftStore( (state) => state.clearDraftArtifacts, ); + const directPrompt = useWorkspaceAiDirectPrompt(threadId); + const setDraftText = useWorkspaceAiComposerDraftStore((state) => state.setText); + const takeDirectPrompt = useWorkspaceAiComposerDraftStore((state) => state.takeDirectPrompt); + const { isBlocked } = useWorkspaceAiAllowance(modelId); useEffect(() => { onRecoveringChange?.(presentation.isRecovering); @@ -71,7 +79,7 @@ export default function AiChatThreadView({ } }; - const sendMessage = (message: PromptInputMessage) => { + const sendMessage = (message: PromptInputMessage, clearDraft = true) => { const chatMessage = getChatMessageFromPrompt(message, generateId()); if (!chatMessage) { @@ -84,8 +92,35 @@ 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) return; + if (isBlocked || connectionError) { + const text = takeDirectPrompt(threadId, directPrompt.id); + if (text) { + queueMicrotask(() => + setDraftText(threadId, (current) => (current.trim() ? `${current}\n\n${text}` : text)), + ); + } + return; + } + if (!canSend || inputStatus !== "ready") return; + const text = takeDirectPrompt(threadId, directPrompt.id); + if (text) queueMicrotask(() => sendDirectPrompt(text)); + }, [ + canSend, + connectionError, + directPrompt, + inputStatus, + isBlocked, + setDraftText, + takeDirectPrompt, + threadId, + ]); return (
@@ -109,7 +144,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/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/components/document-editor/DocumentEditorSurface.tsx b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx index 238136206..f1bb20dbd 100644 --- a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx +++ b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx @@ -2,13 +2,14 @@ import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCaret from "@tiptap/extension-collaboration-caret"; import { EditorContent, useEditor } from "@tiptap/react"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMemo, 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 { DocumentToolbar } from "#/features/workspaces/components/document-editor/DocumentToolbar"; import { DocumentWordCount } from "#/features/workspaces/components/document-editor/DocumentWordCount"; -import { useDocumentEditorToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; +import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; import { useWorkspacePaneRuntime } from "#/features/workspaces/components/WorkspacePaneRuntime"; import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access"; import { DocumentEditReviewExtension } from "#/features/workspaces/documents/document-edit-review-extension"; @@ -107,14 +108,19 @@ function DocumentEditorInstance({ }, }); - useDocumentEditorToolbar({ - canEdit: capabilities.canMutateContent, - documentPath, - editor: capabilities.canMutateContent ? editor : null, - itemId: item.id, - slotId: viewInstanceId, - workspaceId, - }); + const toolbar = useMemo( + () => ( + + ), + [capabilities.canMutateContent, documentPath, editor, item.id, workspaceId], + ); + useWorkspaceItemToolbar(viewInstanceId, toolbar); useDocumentEditReviewOverlay({ canEdit: capabilities.canMutateContent, editor, @@ -135,7 +141,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..b113d21f7 --- /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 exactly ${count} cards ${describeFlashcardLocation(parentPath)}. Cover: ${topic}`, + ) + ) + return; + onOpenChange(false); + }} + > + + + + AI will create the set in your current chat. + + + + What should the cards cover? +