diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..b02d5aad4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +patches/*.patch whitespace=-space-before-tab 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/eval/datasets/workspace-tools.cases.ts b/eval/datasets/workspace-tools.cases.ts index 4d24835c4..6c2492794 100644 --- a/eval/datasets/workspace-tools.cases.ts +++ b/eval/datasets/workspace-tools.cases.ts @@ -9,7 +9,7 @@ export interface WorkspaceToolCase { forbiddenTools?: string[]; /** When set, the final answer is graded by the LLM judge against this rubric. */ qualityRubric?: string; - /** When true, the turn must produce a targeted edit whose editRef traces to the read fixture. */ + /** When true, the turn must produce a targeted edit whose ref traces to the read fixture. */ requiresTargetedEditFromRead?: boolean; } diff --git a/eval/support/harness.ts b/eval/support/harness.ts index 3b31ad02a..25a08c606 100644 --- a/eval/support/harness.ts +++ b/eval/support/harness.ts @@ -4,6 +4,7 @@ import type { z } from "zod"; import { getAIThreadSoulPrompt } from "#/features/workspaces/ai/ai-thread-soul-prompt"; import { createProviderCompatibleInputSchema } from "#/features/workspaces/ai/ai-thread-tool"; +import { getWorkspaceToolResultAdapter } from "#/features/workspaces/ai/workspace-tool-result-adapters"; import { getAIThreadSystemPromptForWorkspace, getWorkspaceAiGatewayProviderOptions, @@ -24,13 +25,15 @@ import { withTiptapNodeAiRef, } from "#/features/workspaces/documents/document-ai-html"; import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema"; +import type { WorkspaceContentReadResult } from "#/features/workspaces/content/workspace-content-contract"; +import { createWorkspaceReadReferences } from "#/features/workspaces/content/workspace-read-references"; /** A single tool call the model emitted, graded against the real zod schema. */ export interface WorkspaceAgentToolCall { name: string; input: unknown; - /** editRefs returned by completed read steps before this call, grouped by source path. */ - priorReadEditRefsByPath: Record; + /** Refs returned by completed read steps before this call, grouped by source path. */ + priorReadRefsByPath: Record; /** `input` satisfies the tool's real zod input schema. */ valid: boolean; /** Human-readable zod issues (`path: message`) when invalid. */ @@ -56,6 +59,7 @@ const STANDUP_PATH = "/Notes/Standup.md"; type EvalStandupFixture = { blocks: Map; content: string; + references: ReturnType; }; let evalStandupFixture: Promise | undefined; @@ -78,11 +82,20 @@ async function createEvalStandupFixture(): Promise { if (!blockId) throw new Error("Eval standup fixture has an unexpected block count."); const node = withTiptapNodeAiRef(document.child(index), blockId); const snapshot = await createDocumentAiBlockSnapshot(node); - blocks.set(snapshot.editRef, snapshot.content); + blocks.set(snapshot.contentRef, snapshot.content); content.push(await serializeTiptapNodeToAiHtml(node)); } - return { blocks, content: content.join("") }; + const result = { + content: content.join(""), + format: "html" as const, + itemId: "standup-document", + location: { endBlock: 2, kind: "blocks" as const, startBlock: 1, totalBlocks: 2 }, + path: STANDUP_PATH, + status: "ready" as const, + type: "document" as const, + }; + return { blocks, content: result.content, references: createWorkspaceReadReferences([result]) }; } // Per-tool stubbed outputs. Reads return the realistic item for the requested @@ -91,25 +104,26 @@ async function createEvalStandupFixture(): Promise { async function evalToolFixture(toolName: string, input: unknown): Promise { if (toolName === "workspace_read_items") { const fixture = await getEvalStandupFixture(); - const requests = ( - input as { - requests?: Array<{ editRef?: string; mode?: string; path?: string }>; - } - )?.requests; - const results = (requests ?? []).map((request) => { + const requests = (input as { requests?: Array<{ ref?: string; mode?: string; path?: string }> }) + ?.requests; + const results: WorkspaceContentReadResult[] = (requests ?? []).map((request) => { if (request.path !== STANDUP_PATH) { return { code: "path_not_found", path: request.path ?? "", status: "failed" }; } - if (request.mode === "block") { - const editRef = request.editRef ?? ""; - const content = fixture.blocks.get(editRef); + if (request.mode === "ref") { + const record = fixture.references.find(({ ref }) => ref === request.ref); + const contentRef = + record?.location.kind === "document-block" && record.revision + ? `${record.location.blockId}.r_${record.revision}` + : ""; + const content = fixture.blocks.get(contentRef); if (!content) { - return { code: "edit_ref_not_found", path: STANDUP_PATH, status: "failed" }; + return { code: "ref_not_found", path: STANDUP_PATH, status: "failed" }; } return { content, - editRef, + contentRef, format: "html", itemId: "standup-document", path: STANDUP_PATH, @@ -132,7 +146,7 @@ async function evalToolFixture(toolName: string, input: unknown): Promise ({ + type: "json" as const, + value: getWorkspaceToolResultAdapter(definition.name)!.projectOutput(output), + }), + } + : {}), execute: async (input: unknown) => evalToolFixture(definition.name, input), }), ]), @@ -191,7 +213,7 @@ export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise>(); + const priorReadRefsByPath = new Map>(); for (const step of result.steps) { for (const part of step.content) { if (part.type !== "tool-call") continue; @@ -200,8 +222,8 @@ export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise [path, [...refs]]), + priorReadRefsByPath: Object.fromEntries( + [...priorReadRefsByPath].map(([path, refs]) => [path, [...refs]]), ), valid: parsed ? parsed.success : false, issues: parsed @@ -215,7 +237,7 @@ export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise>) { - const results = (output as { results?: unknown[] })?.results; +function collectReadRefs(output: unknown, refsByPath: Map>) { + const projected = getWorkspaceToolResultAdapter("workspace_read_items")?.projectOutput(output); + const results = (projected as { results?: unknown[] })?.results; if (!Array.isArray(results)) { return; } @@ -234,9 +257,10 @@ function collectReadEditRefs(output: unknown, refsByPath: Map(); refsByPath.set(path, refs); } - if (typeof editRef === "string") { - refs.add(editRef); + if (typeof ref === "string") { + refs.add(ref); } if (typeof content === "string") { - for (const match of content.matchAll(/data-edit-ref="([^"]+)"/g)) { + for (const match of content.matchAll(/data-ref="([^"]+)"/g)) { if (match[1]) { refs.add(match[1]); } } } + if (Array.isArray(cards)) { + for (const card of cards) { + if (card && typeof card === "object" && "ref" in card && typeof card.ref === "string") { + refs.add(card.ref); + } + } + } } } diff --git a/eval/support/scorers.ts b/eval/support/scorers.ts index 8a045a8d8..52bd8045a 100644 --- a/eval/support/scorers.ts +++ b/eval/support/scorers.ts @@ -66,47 +66,41 @@ export function scoreNoForbiddenTools( /** * For a read→edit turn: the model must submit a *targeted* edit (replace / insert / - * delete) whose `editRef` came from a completed earlier read, not a fabricated - * target or a whole-document `overwrite`. + * delete) whose `ref` came from a completed earlier read, not a fabricated target. */ export function scoreTargetedEditProvenance(output: WorkspaceAgentOutput): ScoreResult { let targeted = 0; const fabricated: string[] = []; - let usedOverwrite = false; - for (const call of output.toolCalls) { if (call.name !== "workspace_edit_item") continue; const input = call.input as { - edits?: Array<{ editRef?: string; op?: string }>; + edits?: Array<{ ref?: string; op?: string }>; path?: string; }; if (!Array.isArray(input.edits)) continue; - const priorReadEditRefs = new Set( - typeof input.path === "string" && Object.hasOwn(call.priorReadEditRefsByPath, input.path) - ? call.priorReadEditRefsByPath[input.path] + const priorReadRefs = new Set( + typeof input.path === "string" && Object.hasOwn(call.priorReadRefsByPath, input.path) + ? call.priorReadRefsByPath[input.path] : [], ); for (const edit of input.edits) { - if (edit.op === "overwrite") { - usedOverwrite = true; - } else if (typeof edit.editRef === "string" && priorReadEditRefs.has(edit.editRef)) { + if (typeof edit.ref === "string" && priorReadRefs.has(edit.ref)) { targeted += 1; } else { - fabricated.push(edit.editRef ?? ""); + fabricated.push(edit.ref ?? ""); } } } - const pass = targeted > 0 && fabricated.length === 0 && !usedOverwrite; + const pass = targeted > 0 && fabricated.length === 0; const reasons: string[] = []; - if (targeted === 0) reasons.push("no targeted edit used an editRef from the read"); - if (fabricated.length > 0) reasons.push(`fabricated editRef(s): ${fabricated.join(", ")}`); - if (usedOverwrite) reasons.push("used overwrite instead of a targeted edit"); + if (targeted === 0) reasons.push("no targeted edit used a ref from the read"); + if (fabricated.length > 0) reasons.push(`fabricated ref(s): ${fabricated.join(", ")}`); return { score: pass ? 1 : 0, pass, - message: pass ? "targeted edit used an editRef from a prior read" : reasons.join("; "), + message: pass ? "targeted edit used a ref from a prior read" : reasons.join("; "), }; } diff --git a/eval/workspace-tools.eval.ts b/eval/workspace-tools.eval.ts index c0b05593c..be147293d 100644 --- a/eval/workspace-tools.eval.ts +++ b/eval/workspace-tools.eval.ts @@ -10,7 +10,7 @@ import { scoreToolInputsValid, } from "./support/scorers"; -const STANDUP_LIST_REF = "b_standupList1.r_bullet0001"; +const STANDUP_LIST_REF = "wr_AAAAAAAA"; const STANDUP_PATH = "/Notes/Standup.md"; describe("workspace eval scorers", () => { @@ -28,18 +28,18 @@ describe("workspace eval scorers", () => { readPath: STANDUP_PATH, refs: [STANDUP_LIST_REF], }, - ])("requires an editRef read from the edited path", ({ editPath, expected, readPath, refs }) => { + ])("requires a ref read from the edited path", ({ editPath, expected, readPath, refs }) => { const output: WorkspaceAgentOutput = { text: "", toolCalls: [ { input: { - edits: [{ editRef: STANDUP_LIST_REF, op: "insert_after" }], + edits: [{ ref: STANDUP_LIST_REF, op: "insert_after" }], path: editPath, }, issues: [], name: "workspace_edit_item", - priorReadEditRefsByPath: { [readPath]: refs }, + priorReadRefsByPath: { [readPath]: refs }, valid: true, }, ], diff --git a/patches/agents@0.19.0.patch b/patches/agents@0.19.0.patch index 262a64a88..cf1cd76e5 100644 --- a/patches/agents@0.19.0.patch +++ b/patches/agents@0.19.0.patch @@ -11,7 +11,7 @@ index 8d5b57034f927298d207d8a2bcbac927614780f5..9cf49a4471b9da862ddde11b818fd1ee } /** * For a single message, resolve its ID by matching toolCallId against server state. -@@ -1948,17 +1948,21 @@ function assistantContentKey(message, sanitize) { +@@ -1948,23 +1948,27 @@ function assistantContentKey(message, sanitize) { return JSON.stringify(sanitized.parts); } function mergeServerToolOutputs(incoming, serverMessages) { @@ -36,42 +36,52 @@ index 8d5b57034f927298d207d8a2bcbac927614780f5..9cf49a4471b9da862ddde11b818fd1ee let hasChanges = false; const updatedParts = msg.parts.map((part) => { const record = part; -@@ -1999,7 +2003,22 @@ function reconcileAssistantIds(incoming, serverMessages, sanitize) { +- if ("toolCallId" in record && "state" in record && (record.state === "input-available" || record.state === "approval-requested" || record.state === "approval-responded") && serverResolvedParts.has(record.toolCallId)) { +- hasChanges = true; +- const server = serverResolvedParts.get(record.toolCallId); ++ if ("toolCallId" in record && "state" in record && (record.state === "input-available" || record.state === "approval-requested" || record.state === "approval-responded") && serverResolvedParts.has(record.toolCallId)) { ++ hasChanges = true; ++ const server = serverResolvedParts.get(record.toolCallId); + const merged = { + ...part, + state: server.state +@@ -1999,7 +2003,23 @@ function reconcileAssistantIds(incoming, serverMessages, sanitize) { } return incoming.map((incomingMessage, incomingIdx) => { if (exactMatchMap.has(incomingIdx)) return incomingMessage; - if (incomingMessage.role !== "assistant" || hasToolCallPart(incomingMessage)) return incomingMessage; + if (incomingMessage.role !== "assistant") return incomingMessage; -+ const incomingToolCallIds = getToolCallIds(incomingMessage); -+ if (incomingToolCallIds.size > 0) { ++ const incomingToolCalls = getToolCalls(incomingMessage); ++ if (incomingToolCalls.length > 0) { ++ const matchingServerIndices = []; + for (let i = 0; i < serverMessages.length; i++) { + if (claimedServerIndices.has(i)) continue; + const serverMessage = serverMessages[i]; -+ if (serverMessage.role === "assistant" && serverMessage.parts.some((part) => "toolCallId" in part && typeof part.toolCallId === "string" && incomingToolCallIds.has(part.toolCallId))) { -+ claimedServerIndices.add(i); -+ return { -+ ...incomingMessage, -+ id: serverMessage.id -+ }; -+ } ++ if (serverMessage.role === "assistant" && incomingToolCalls.some((incomingPart) => serverMessage.parts.some((serverPart) => "toolCallId" in serverPart && serverPart.toolCallId === incomingPart.toolCallId && JSON.stringify(serverPart.input) === JSON.stringify(incomingPart.input)))) matchingServerIndices.push(i); + } -+ return incomingMessage; ++ if (matchingServerIndices.length !== 1) return incomingMessage; ++ const serverIndex = matchingServerIndices[0]; ++ claimedServerIndices.add(serverIndex); ++ return { ++ ...incomingMessage, ++ id: serverMessages[serverIndex].id ++ }; + } const incomingKey = assistantContentKey(incomingMessage, sanitize); if (!incomingKey) return incomingMessage; for (let i = 0; i < serverMessages.length; i++) { -@@ -2020,6 +2039,9 @@ function reconcileAssistantIds(incoming, serverMessages, sanitize) { +@@ -2020,6 +2040,9 @@ function reconcileAssistantIds(incoming, serverMessages, sanitize) { function hasToolCallPart(message) { return message.parts.some((part) => "toolCallId" in part); } -+function getToolCallIds(message) { -+ return new Set(message.parts.flatMap((part) => "toolCallId" in part && typeof part.toolCallId === "string" ? [part.toolCallId] : [])); ++function getToolCalls(message) { ++ return message.parts.filter((part) => "toolCallId" in part && typeof part.toolCallId === "string"); +} function findMessageByToolCallId(messages, toolCallId) { for (const msg of messages) { if (msg.role !== "assistant") continue; diff --git a/dist/chat/react.js b/dist/chat/react.js -index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2623a2b43 100644 +index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..44059d63d10b5f1ae192eb7a6e63f0df61ddf49e 100644 --- a/dist/chat/react.js +++ b/dist/chat/react.js @@ -253,7 +253,7 @@ var WebSocketChatTransport = class { @@ -83,23 +93,79 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 }; agent.addEventListener("message", onMessage, { signal: abortController.signal }); agent.addEventListener("close", onClose, { signal: abortController.signal }); -@@ -967,6 +967,7 @@ function useAgentChat(options) { +@@ -716,9 +716,35 @@ function prependMissingHydratedMessages(hydratedMessages, currentMessages) { + const missingHydratedMessages = hydratedMessages.filter((message) => !currentMessageIds.has(message.id)); + if (missingHydratedMessages.length === 0) return currentMessages; + return [...missingHydratedMessages, ...currentMessages]; + } ++function continuationPartsOverlap(baseline, current) { ++ if (baseline.type !== current.type) return false; ++ if ("toolCallId" in baseline || "toolCallId" in current) return baseline.toolCallId === current.toolCallId; ++ if ("id" in baseline && "id" in current && typeof baseline.id === "string" && typeof current.id === "string") return baseline.id === current.id; ++ if ((baseline.type === "text" || baseline.type === "reasoning") && "text" in baseline && "text" in current) return current.text.startsWith(baseline.text); ++ return JSON.stringify(baseline) === JSON.stringify(current); ++} ++function preserveContinuationBaseline(messages, baselineMessages) { ++ const baselineAssistant = findLastAssistantMessage(baselineMessages)?.message; ++ if (!baselineAssistant) return messages; ++ const assistantIndex = messages.findIndex((message) => message.id === baselineAssistant.id); ++ if (assistantIndex < 0) return messages; ++ const assistant = messages[assistantIndex]; ++ let overlapLength = Math.min(baselineAssistant.parts.length, assistant.parts.length); ++ while (overlapLength > 0 && !baselineAssistant.parts.slice(-overlapLength).every((part, index) => continuationPartsOverlap(part, assistant.parts[index]))) overlapLength--; ++ const missingParts = baselineAssistant.parts.slice(0, baselineAssistant.parts.length - overlapLength); ++ const missingMetadata = assistant.metadata == null && baselineAssistant.metadata != null; ++ if (missingParts.length === 0 && !missingMetadata) return messages; ++ const next = [...messages]; ++ next[assistantIndex] = { ++ ...assistant, ++ parts: [...structuredClone(missingParts), ...assistant.parts], ++ ...missingMetadata ? { metadata: structuredClone(baselineAssistant.metadata) } : {} ++ }; ++ return next; ++} + /** + * React hook for building AI chat interfaces using an Agent + * @param options Chat options including the agent connection + * @returns Chat interface controls and state with added clearHistory method + */ +@@ -967,6 +990,25 @@ function useAgentChat(options) { const [clientToolResults, setClientToolResults] = useState(/* @__PURE__ */ new Map()); const messagesRef = useRef(chatMessages); messagesRef.current = chatMessages; + const continuationStartMessagesRef = useRef(/* @__PURE__ */ new Map()); ++ const transportContinuationRequestIdRef = useRef(null); ++ const completedTransportContinuationRequestIdRef = useRef(null); ++ useEffect(() => { ++ const requestId = transportContinuationRequestIdRef.current; ++ if (!requestId) return; ++ const baseline = continuationStartMessagesRef.current.get(requestId); ++ if (!baseline) return; ++ const next = preserveContinuationBaseline(chatMessages, baseline); ++ if (next !== chatMessages) { ++ setMessages(next); ++ return; ++ } ++ if (completedTransportContinuationRequestIdRef.current === requestId) { ++ continuationStartMessagesRef.current.delete(requestId); ++ transportContinuationRequestIdRef.current = null; ++ completedTransportContinuationRequestIdRef.current = null; ++ } ++ }, [chatMessages, setMessages, status]); const initialMessagesRef = useRef(initialMessages); initialMessagesRef.current = initialMessages; const seededInitialMessagesKeyRef = useRef(null); -@@ -1088,6 +1089,7 @@ function useAgentChat(options) { +@@ -1088,6 +1130,9 @@ function useAgentChat(options) { pendingReplayResumeRequestIdsRef.current.clear(); fallbackAckedResumeRequestIdsRef.current.clear(); replayHydratedAssistantMessageIdsRef.current.clear(); + continuationStartMessagesRef.current.clear(); ++ transportContinuationRequestIdRef.current = null; ++ completedTransportContinuationRequestIdRef.current = null; protectedStreamingAssistantRef.current = null; }, [ markInitialMessagesSeeded, -@@ -1268,6 +1270,16 @@ function useAgentChat(options) { +@@ -1268,6 +1313,20 @@ function useAgentChat(options) { const [isRecovering, setIsRecovering] = useState(false); useEffect(() => { const localResponseIds = localResponseMessageIdsRef.current; @@ -107,6 +173,10 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 + const captureContinuationStart = (requestId) => { + if (!continuationStartMessages.has(requestId)) continuationStartMessages.set(requestId, structuredClone(messagesRef.current)); + }; ++ const completeContinuation = (requestId) => { ++ if (transportContinuationRequestIdRef.current === requestId) completedTransportContinuationRequestIdRef.current = requestId; ++ else continuationStartMessages.delete(requestId); ++ }; + const rewindContinuation = (requestId) => { + const baseline = continuationStartMessages.get(requestId); + if (!baseline) return; @@ -116,20 +186,25 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 /** * Unified message handler that parses JSON once and dispatches based on type. * Avoids duplicate parsing overhead from separate listeners. -@@ -1322,6 +1334,7 @@ function useAgentChat(options) { +@@ -1322,6 +1381,9 @@ function useAgentChat(options) { break; case "cf_agent_stream_resume_none": if (customTransport.handleStreamResumeNone(data) && data.reason === STREAM_RESUME_NONE_REASONS.IDLE && typeof data.probeId === "string") { + continuationStartMessages.clear(); ++ transportContinuationRequestIdRef.current = null; ++ completedTransportContinuationRequestIdRef.current = null; const result = transition(streamStateRef.current, { type: "clear" }); streamStateRef.current = result.state; setIsServerStreaming(result.isStreaming); -@@ -1337,9 +1350,14 @@ function useAgentChat(options) { +@@ -1337,9 +1399,17 @@ function useAgentChat(options) { if (!isEarlyToolContinuation) return; } if (!resumingToolContinuationRef.current) pendingReplayResumeRequestIdsRef.current.add(data.id); + if (customTransport.isAwaitingResume()) { + // AI SDK snapshots the last message as soon as this resolves. ++ captureContinuationStart(data.id); ++ transportContinuationRequestIdRef.current = data.id; ++ completedTransportContinuationRequestIdRef.current = null; + rewindContinuation(data.id); + } if (customTransport.handleStreamResuming(data)) return; @@ -139,7 +214,7 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 if (isEarlyToolContinuation) { pendingToolContinuationRef.current = false; observedToolContinuationRequestIdRef.current = data.id; -@@ -1369,6 +1387,7 @@ function useAgentChat(options) { +@@ -1369,6 +1439,7 @@ function useAgentChat(options) { const chunkData = JSON.parse(data.body); if (chunkData.type === "start" && typeof chunkData.messageId === "string") { localResponseIds.set(data.id, chunkData.messageId); @@ -147,7 +222,7 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 if (!data.continuation) { if (protectedStreamingAssistantRef.current?.assistantId !== chunkData.messageId) { const msgs = messagesRef.current; -@@ -1386,8 +1405,8 @@ function useAgentChat(options) { +@@ -1386,8 +1457,8 @@ function useAgentChat(options) { } } } catch {} @@ -158,11 +233,11 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 if (streamStateRef.current.status === "observing" && streamStateRef.current.streamId === data.id) { streamStateRef.current = { status: "idle" }; setIsServerStreaming(false); -@@ -1397,14 +1416,30 @@ function useAgentChat(options) { +@@ -1397,14 +1468,30 @@ function useAgentChat(options) { localResponseIds.delete(data.id); localRequestIdsRef.current.delete(data.id); fallbackAckedResumeRequestIdsRef.current.delete(data.id); -+ continuationStartMessages.delete(data.id); ++ completeContinuation(data.id); if (observedToolContinuationRequestIdRef.current === data.id) resetToolContinuation(); } return; @@ -173,7 +248,7 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 + pendingReplayResumeRequestIdsRef.current.delete(data.id); + customTransport.handleServerTurnCompleted(data.id); + fallbackAckedResumeRequestIdsRef.current.delete(data.id); -+ continuationStartMessages.delete(data.id); ++ completeContinuation(data.id); + setIsRecovering(false); + if (streamStateRef.current.status === "idle" || streamStateRef.current.streamId === data.id) { + const result = transition(streamStateRef.current, { type: "clear" }); @@ -189,14 +264,24 @@ index 51c24ed62b48d43a64a093b5d08e6ccb3aafcbf9..f9fa50a098030d9e99a48bcfe261d9e2 if (data.replay && !data.continuation && !resumingToolContinuationRef.current && observedToolContinuationRequestIdRef.current !== data.id && typeof chunkData.messageId === "string" && chunkData.type === "start") { pendingReplayResumeRequestIdsRef.current.delete(data.id); resetMatchingHydratedAssistantForReplay(chunkData.messageId); -@@ -1417,6 +1452,7 @@ function useAgentChat(options) { +@@ -1417,6 +1504,7 @@ function useAgentChat(options) { if (data.done) { customTransport.handleServerTurnCompleted(data.id); fallbackAckedResumeRequestIdsRef.current.delete(data.id); -+ continuationStartMessages.delete(data.id); ++ completeContinuation(data.id); setIsRecovering(false); } const completedObservedToolContinuation = data.done && observedToolContinuationRequestIdRef.current === data.id; +@@ -1493,7 +1585,9 @@ function useAgentChat(options) { + streamStateRef.current = { status: "idle" }; + setIsServerStreaming(false); + setIsRecovering(false); ++ transportContinuationRequestIdRef.current = null; ++ completedTransportContinuationRequestIdRef.current = null; + protectedStreamingAssistantRef.current = null; + localResponseIds.clear(); + customTransport.resetResumeState(); + invalidateResumeGeneration(); diff --git a/dist/wire-types-CU9rLoeS.js b/dist/wire-types-CU9rLoeS.js index a0f027305b8594ea5cec3edcbf4273cf878319e5..d565dbb7c92d0ac7ca0e4a14c51a2820834a07d0 100644 --- a/dist/wire-types-CU9rLoeS.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 353b1a4ac..dc4b35ba2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,7 @@ overrides: patchedDependencies: '@cloudflare/think@0.15.0': 1325490ec006d39225ccf20fdf92d9c0a193a58c0427100f2b4b3c1b95a82576 '@embedpdf/plugin-zoom@2.15.0': 7bdd44c58d2eec07af954b81e11e168032c753d87e339354df3b5974505330a9 - agents@0.19.0: b4925f176922f992a0781ffa357da123a7e05876e924b4ff009585baa101bf4d + agents@0.19.0: 94e6fbab6d4e07c1ff3fa762b5d57cf34ae0d63d4e3a969c632dbb6fb9afafdc importers: @@ -43,7 +43,7 @@ importers: version: 0.4.3(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(ai@7.0.64(zod@4.4.3))(zod@4.4.3) '@cloudflare/think': specifier: ^0.15.0 - version: 0.15.0(patch_hash=1325490ec006d39225ccf20fdf92d9c0a193a58c0427100f2b4b3c1b95a82576)(@ai-sdk/provider@4.0.7)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(agents@0.19.0(patch_hash=b4925f176922f992a0781ffa357da123a7e05876e924b4ff009585baa101bf4d)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3))(ai@7.0.64(zod@4.4.3))(react@19.2.8)(zod@4.4.3) + version: 0.15.0(patch_hash=1325490ec006d39225ccf20fdf92d9c0a193a58c0427100f2b4b3c1b95a82576)(@ai-sdk/provider@4.0.7)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(agents@0.19.0(patch_hash=94e6fbab6d4e07c1ff3fa762b5d57cf34ae0d63d4e3a969c632dbb6fb9afafdc)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3))(ai@7.0.64(zod@4.4.3))(react@19.2.8)(zod@4.4.3) '@content-collections/core': specifier: ^0.15.2 version: 0.15.2(typescript@7.0.2) @@ -214,7 +214,7 @@ importers: version: 8.18.0 agents: specifier: ^0.19.0 - version: 0.19.0(patch_hash=b4925f176922f992a0781ffa357da123a7e05876e924b4ff009585baa101bf4d)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3) + version: 0.19.0(patch_hash=94e6fbab6d4e07c1ff3fa762b5d57cf34ae0d63d4e3a969c632dbb6fb9afafdc)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3) ai: specifier: ^7.0.64 version: 7.0.64(zod@4.4.3) @@ -10138,13 +10138,13 @@ snapshots: - ai - zod - '@cloudflare/think@0.15.0(patch_hash=1325490ec006d39225ccf20fdf92d9c0a193a58c0427100f2b4b3c1b95a82576)(@ai-sdk/provider@4.0.7)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(agents@0.19.0(patch_hash=b4925f176922f992a0781ffa357da123a7e05876e924b4ff009585baa101bf4d)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3))(ai@7.0.64(zod@4.4.3))(react@19.2.8)(zod@4.4.3)': + '@cloudflare/think@0.15.0(patch_hash=1325490ec006d39225ccf20fdf92d9c0a193a58c0427100f2b4b3c1b95a82576)(@ai-sdk/provider@4.0.7)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(agents@0.19.0(patch_hash=94e6fbab6d4e07c1ff3fa762b5d57cf34ae0d63d4e3a969c632dbb6fb9afafdc)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3))(ai@7.0.64(zod@4.4.3))(react@19.2.8)(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 4.0.21(zod@4.4.3) '@ai-sdk/openai': 4.0.20(zod@4.4.3) '@cloudflare/codemode': 0.5.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(ai@7.0.64(zod@4.4.3))(zod@4.4.3) '@cloudflare/shell': 0.4.3(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(ai@7.0.64(zod@4.4.3))(zod@4.4.3) - agents: 0.19.0(patch_hash=b4925f176922f992a0781ffa357da123a7e05876e924b4ff009585baa101bf4d)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3) + agents: 0.19.0(patch_hash=94e6fbab6d4e07c1ff3fa762b5d57cf34ae0d63d4e3a969c632dbb6fb9afafdc)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3) ai: 7.0.64(zod@4.4.3) aywson: 0.0.16 chat: 4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3) @@ -13568,7 +13568,7 @@ snapshots: prompts: 2.4.2 yaml: 2.9.0 - agents@0.19.0(patch_hash=b4925f176922f992a0781ffa357da123a7e05876e924b4ff009585baa101bf4d)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3): + agents@0.19.0(patch_hash=94e6fbab6d4e07c1ff3fa762b5d57cf34ae0d63d4e3a969c632dbb6fb9afafdc)(@ai-sdk/react@4.0.67(react@19.2.8)(zod@4.4.3))(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260812.1)(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(ai@7.0.64(zod@4.4.3))(chat@4.31.0(ai@7.0.64(zod@4.4.3))(zod@4.4.3))(just-bash@3.0.2)(react@19.2.8)(rolldown@1.1.2)(zod@4.4.3): dependencies: '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@7.29.7) '@cfworker/json-schema': 4.1.1 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-runtime.ts b/src/features/workspaces/ai/ai-thread-runtime.ts index 0d8b500f9..5b7cf5f20 100644 --- a/src/features/workspaces/ai/ai-thread-runtime.ts +++ b/src/features/workspaces/ai/ai-thread-runtime.ts @@ -104,6 +104,7 @@ export function createAIThreadTurnToolConfig(input: { canMutate: boolean; onOrchestrationRuntime?: Parameters[0]["onRuntime"]; onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; timeZone?: string; }) { const toolCatalog = createAIThreadToolCatalog(input); 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/ai-thread.ts b/src/features/workspaces/ai/ai-thread.ts index 46f194e67..6b840f7b6 100644 --- a/src/features/workspaces/ai/ai-thread.ts +++ b/src/features/workspaces/ai/ai-thread.ts @@ -35,8 +35,8 @@ import { } from "#/features/workspaces/ai/ai-compaction"; import { collectWorkspaceReferenceRecords, - reconcileWorkspaceMessageCitations, -} from "#/features/workspaces/ai/workspace-citations"; + reconcileWorkspaceMessageReferences, +} from "#/features/workspaces/ai/workspace-references"; import { resolveChatAttachmentModelMessages } from "#/features/workspaces/ai/chat-attachment-model"; import { classifyAIThreadChatError } from "#/features/workspaces/ai/chat-error-classification"; import type { AIThreadContext } from "#/features/workspaces/ai/ai-thread-metadata"; @@ -339,7 +339,7 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { this.telemetry.recordTurnFinished(result); this._trackCompletedMessageUsage(result); if (result.status === "completed") { - await this._reconcileWorkspaceCitations(result.message); + await this._reconcileWorkspaceReferences(result.message); } if (!this._shouldSettleRunAfterResponse(result)) { await this._refreshSessionPromptIfNeeded(); @@ -404,6 +404,7 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { onWorkspaceReferences: (records) => { this._recordWorkspaceReferences(records); }, + resolveWorkspaceReferences: (refs) => this._resolveWorkspaceReferences(refs), timeZone, }); } @@ -566,13 +567,14 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { return records.filter((record) => wanted.has(record.ref)); } - private async _reconcileWorkspaceCitations(message: ChatResponseResult["message"]) { + private async _reconcileWorkspaceReferences(message: ChatResponseResult["message"]) { try { const transcriptReferences = collectWorkspaceReferenceRecords(await this.getMessages()); - const reconciled = reconcileWorkspaceMessageCitations(message, [ - ...transcriptReferences, - ...this.activeWorkspaceReferences, - ]); + const reconciled = reconcileWorkspaceMessageReferences( + message, + [...transcriptReferences, ...this.activeWorkspaceReferences], + this.activeWorkspaceReferences, + ); if (reconciled !== message) { await this.addMessages([reconciled], { mode: "upsert" }); diff --git a/src/features/workspaces/ai/skills/widget-authoring/SKILL.md b/src/features/workspaces/ai/skills/widget-authoring/SKILL.md index 339835698..aed0a1d64 100644 --- a/src/features/workspaces/ai/skills/widget-authoring/SKILL.md +++ b/src/features/workspaces/ai/skills/widget-authoring/SKILL.md @@ -42,9 +42,9 @@ Use the document tool's HTML math markup. The runtime renders matching math elem ## Edit -1. Read the document to locate the widget placeholder and its `data-edit-ref`. -2. Read that exact block with `{ mode: "block", path, editRef }` to obtain the current source and current `editRef`. +1. Read the document to locate the widget placeholder and its `data-ref`. +2. Read that exact content with `{ mode: "ref", path, ref }` to obtain the current source and current `ref`. 3. For a focused change, use `replace_text` with a `find` string copied exactly from the block read. Include enough surrounding text to make the match unique. -4. For a ground-up rebuild, read `references/starter.md`, then use `replace` on the widget's current `editRef`. Never use `overwrite`, which replaces the entire document. +4. For a ground-up rebuild, read `references/starter.md`, then use `replace` on the widget's current `ref`. -Complete the operation only when the source follows the contract, the main interaction or visual is immediately clear, the widget has no duplicate host chrome or unintended overflow, every visible control works, both themes remain readable, runtime failures remain reportable, and an edit changes only the intended widget using the latest block read's exact `editRef`. +Complete the operation only when the source follows the contract, the main interaction or visual is immediately clear, the widget has no duplicate host chrome or unintended overflow, every visible control works, both themes remain readable, runtime failures remain reportable, and an edit changes only the intended widget using the latest block read's exact `ref`. diff --git a/src/features/workspaces/ai/workspace-citations.test.ts b/src/features/workspaces/ai/workspace-references.test.ts similarity index 69% rename from src/features/workspaces/ai/workspace-citations.test.ts rename to src/features/workspaces/ai/workspace-references.test.ts index 4abc04851..a834bc936 100644 --- a/src/features/workspaces/ai/workspace-citations.test.ts +++ b/src/features/workspaces/ai/workspace-references.test.ts @@ -3,22 +3,22 @@ import { describe, expect, it } from "vitest"; import { collectWorkspaceReferenceRecords, - reconcileWorkspaceMessageCitations, + reconcileWorkspaceMessageReferences, stripWorkspaceCitationTags, -} from "#/features/workspaces/ai/workspace-citations"; +} from "#/features/workspaces/ai/workspace-references"; import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; const first = reference("wr_AAAAAAAA", "item-1"); const second = reference("wr_BBBBBBBB", "item-2"); -describe("workspace citations", () => { +describe("workspace references", () => { it("persists only known refs used by assistant text in first-use order", () => { const message = assistantMessage( `Alpha beta ` + ` gamma ` + ` again `, ); - const reconciled = reconcileWorkspaceMessageCitations(message, [first, second]); + const reconciled = reconcileWorkspaceMessageReferences(message, [first, second]); expect(collectWorkspaceReferenceRecords([reconciled])).toEqual([second, first]); }); @@ -29,19 +29,19 @@ describe("workspace citations", () => { expect( collectWorkspaceReferenceRecords([ - reconcileWorkspaceMessageCitations(message, [first, collision]), + reconcileWorkspaceMessageReferences(message, [first, collision]), ]), ).toEqual([]); }); it("is idempotent and removes stale normalized records", () => { const message = assistantMessage(`Alpha `); - const reconciled = reconcileWorkspaceMessageCitations(message, [first]); + const reconciled = reconcileWorkspaceMessageReferences(message, [first]); - expect(reconcileWorkspaceMessageCitations(reconciled, [first])).toBe(reconciled); + expect(reconcileWorkspaceMessageReferences(reconciled, [first])).toBe(reconciled); expect( collectWorkspaceReferenceRecords([ - reconcileWorkspaceMessageCitations( + reconcileWorkspaceMessageReferences( { ...reconciled, parts: [{ type: "text", text: "No citation" }, reconciled.parts[1]] }, [first], ), @@ -51,24 +51,51 @@ describe("workspace citations", () => { it("collapses duplicate citation data parts to one canonical part", () => { const message = assistantMessage(`Alpha `); - const reconciled = reconcileWorkspaceMessageCitations(message, [first]); + const reconciled = reconcileWorkspaceMessageReferences(message, [first]); const duplicated = { ...reconciled, parts: [...reconciled.parts, reconciled.parts[1]], }; - const repaired = reconcileWorkspaceMessageCitations(duplicated, [first]); + const repaired = reconcileWorkspaceMessageReferences(duplicated, [first]); - expect(repaired.parts.filter((part) => part.type === "data-workspace-citations")).toHaveLength( + expect(repaired.parts.filter((part) => part.type === "data-workspace-references")).toHaveLength( 1, ); }); + it("retains Code Mode refs that are not present in a direct tool result", () => { + const reconciled = reconcileWorkspaceMessageReferences(assistantMessage("Done"), [], [first]); + + expect(collectWorkspaceReferenceRecords([reconciled])).toEqual([first]); + }); + + it("does not duplicate refs already persisted by a direct tool result", () => { + const readOutput = { references: [first], results: [] }; + const message: UIMessage = { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-workspace_read_items", + toolCallId: "call-1", + state: "output-available", + input: { requests: [] }, + output: readOutput, + }, + ], + }; + const reconciled = reconcileWorkspaceMessageReferences(message, [first], [first]); + + expect(reconciled.parts).toHaveLength(1); + expect(collectWorkspaceReferenceRecords([reconciled])).toEqual([first]); + }); + it("collects references from direct workspace reads", () => { const readOutput = { references: [first], results: [ { - content: '

Notes

', + content: '

Notes

', format: "html", itemId: "item-1", location: { @@ -136,9 +163,9 @@ describe("workspace citations", () => { role: "user", parts: [ { - type: "data-workspace-citations", - id: "workspace-citations", - data: { citations: [first], version: 1 }, + type: "data-workspace-references", + id: "workspace-references", + data: { references: [first], version: 1 }, }, ], }, diff --git a/src/features/workspaces/ai/workspace-citations.ts b/src/features/workspaces/ai/workspace-references.ts similarity index 52% rename from src/features/workspaces/ai/workspace-citations.ts rename to src/features/workspaces/ai/workspace-references.ts index 2fabf627f..7981016cb 100644 --- a/src/features/workspaces/ai/workspace-citations.ts +++ b/src/features/workspaces/ai/workspace-references.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { getWorkspaceToolResultAdapter } from "#/features/workspaces/ai/workspace-tool-result-adapters"; import { getWorkspaceLocationKey, + indexWorkspaceReferenceRecords, parseWorkspaceReference, type WorkspaceReference, type WorkspaceReferenceRecord, @@ -11,43 +12,49 @@ import { workspaceReferenceRecordSchema, } from "#/features/workspaces/locations/workspace-location"; -export const WORKSPACE_CITATIONS_DATA_PART_TYPE = "data-workspace-citations"; +export const WORKSPACE_REFERENCES_DATA_PART_TYPE = "data-workspace-references"; const MAX_WORKSPACE_CITATIONS_PER_MESSAGE = 50; const workspaceCitationTagPattern = /|>\s*<\/citation\s*>)/g; const anyCompleteWorkspaceCitationTagPattern = /<\/?citation\b[^>]*>/gi; -const workspaceCitationsDataSchema = z.strictObject({ - citations: z.array(workspaceReferenceRecordSchema).max(MAX_WORKSPACE_CITATIONS_PER_MESSAGE), +const workspaceReferencesDataSchema = z.strictObject({ + references: z.array(workspaceReferenceRecordSchema), version: z.literal(1), }); /** - * Reconciles one normalized citation data part onto an assistant message. + * Reconciles one hidden reference data part onto an assistant message. * - * Only exact refs present in the assistant text and mapped unambiguously by an - * app-issued candidate survive. Unknown or colliding refs remain inert. + * It retains valid cited refs plus refs produced inside Code Mode that do not + * already live in a direct tool result. Unknown or colliding citations stay inert. * * @param message - Finalized assistant message. * @param candidates - App-issued refs available to this response. * @returns The original message when already normalized, otherwise an updated copy. */ -export function reconcileWorkspaceMessageCitations( +export function reconcileWorkspaceMessageReferences( message: UIMessage, candidates: readonly WorkspaceReferenceRecord[], + retain: readonly WorkspaceReferenceRecord[] = [], ): UIMessage { - const citations = resolveUsedWorkspaceCitations(message, candidates); - - if (hasCanonicalWorkspaceCitationPart(message, citations)) { + const directRecords = collectWorkspaceToolReferenceRecords(message); + const directKeys = new Set(directRecords.map(getWorkspaceReferenceRecordKey)); + const references = dedupeWorkspaceReferenceRecords([ + ...resolveUsedWorkspaceCitations(message, candidates), + ...retain.filter((record) => !directKeys.has(getWorkspaceReferenceRecordKey(record))), + ]); + + if (hasCanonicalWorkspaceReferencePart(message, references)) { return message; } - const parts = message.parts.filter((part) => part.type !== WORKSPACE_CITATIONS_DATA_PART_TYPE); - if (citations.length > 0) { + const parts = message.parts.filter((part) => part.type !== WORKSPACE_REFERENCES_DATA_PART_TYPE); + if (references.length > 0) { parts.push({ - type: WORKSPACE_CITATIONS_DATA_PART_TYPE, + type: WORKSPACE_REFERENCES_DATA_PART_TYPE, data: { - citations, + references, version: 1, }, }); @@ -57,20 +64,22 @@ export function reconcileWorkspaceMessageCitations( } /** - * Returns validated durable citations persisted on a UI message. + * Returns validated durable reference records persisted on a UI message. * * @param message - Any persisted or streaming UI message. - * @returns The first valid normalized citation list, or an empty list. + * @returns The first valid normalized reference list, or an empty list. */ -function getWorkspaceCitationRecords(message: UIMessage): readonly WorkspaceReferenceRecord[] { +function getWorkspaceMessageReferenceRecords( + message: UIMessage, +): readonly WorkspaceReferenceRecord[] { for (const part of message.parts) { - if (part.type !== WORKSPACE_CITATIONS_DATA_PART_TYPE || !("data" in part)) { + if (part.type !== WORKSPACE_REFERENCES_DATA_PART_TYPE || !("data" in part)) { continue; } - const parsed = workspaceCitationsDataSchema.safeParse(part.data); + const parsed = workspaceReferencesDataSchema.safeParse(part.data); if (parsed.success) { - return parsed.data.citations; + return parsed.data.references; } } @@ -79,7 +88,7 @@ function getWorkspaceCitationRecords(message: UIMessage): readonly WorkspaceRefe /** * Collects app-issued reference records from direct workspace tools and - * normalized citation data in a persisted transcript. + * normalized hidden reference data in a persisted transcript. * * Code Mode's final result is model-authored, so genuine nested reads are * captured during execution instead of trusted from the orchestration output. @@ -97,20 +106,8 @@ export function collectWorkspaceReferenceRecords( continue; } - records.push(...getWorkspaceCitationRecords(message)); - - for (const part of message.parts) { - if (!isToolUIPart(part) || part.state !== "output-available") { - continue; - } - - const toolName = - part.type === "dynamic-tool" ? part.toolName : part.type.split("-").slice(1).join("-"); - - records.push( - ...(getWorkspaceToolResultAdapter(toolName)?.collectReferences(part.output) ?? []), - ); - } + records.push(...getWorkspaceMessageReferenceRecords(message)); + records.push(...collectWorkspaceToolReferenceRecords(message)); } return records; @@ -119,7 +116,7 @@ export function collectWorkspaceReferenceRecords( /** * Builds the unambiguous ref-to-location map available while rendering a message. * - * Persisted citations and completed direct workspace-tool outputs both + * Persisted references and completed direct workspace-tool outputs both * contribute, so direct tool citations can appear before post-response * reconciliation arrives. * @@ -129,13 +126,11 @@ export function collectWorkspaceReferenceRecords( export function getWorkspaceCitationLocations( message: UIMessage, ): ReadonlyMap { - const index = indexWorkspaceReferenceCandidates(collectWorkspaceReferenceRecords([message])); + const index = indexWorkspaceReferenceRecords(collectWorkspaceReferenceRecords([message])); const locations = new Map(); - for (const [ref, candidate] of index) { - if (candidate.status === "resolved") { - locations.set(ref, candidate.record.location); - } + for (const [ref, record] of index) { + if (record) locations.set(ref, record.location); } return locations; @@ -155,7 +150,7 @@ function resolveUsedWorkspaceCitations( message: UIMessage, candidates: readonly WorkspaceReferenceRecord[], ) { - const candidateIndex = indexWorkspaceReferenceCandidates(candidates); + const candidateIndex = indexWorkspaceReferenceRecords(candidates); const citations: WorkspaceReferenceRecord[] = []; const usedRefs = new Set(); @@ -171,12 +166,12 @@ function resolveUsedWorkspaceCitations( } const candidate = candidateIndex.get(ref); - if (!candidate || candidate.status === "ambiguous") { + if (!candidate) { continue; } usedRefs.add(ref); - citations.push(candidate.record); + citations.push(candidate); if (citations.length === MAX_WORKSPACE_CITATIONS_PER_MESSAGE) { return citations; } @@ -186,31 +181,6 @@ function resolveUsedWorkspaceCitations( return citations; } -function indexWorkspaceReferenceCandidates(candidates: readonly WorkspaceReferenceRecord[]) { - const index = new Map< - WorkspaceReference, - | { readonly record: WorkspaceReferenceRecord; readonly status: "resolved" } - | { readonly status: "ambiguous" } - >(); - - for (const record of candidates) { - const existing = index.get(record.ref); - if (!existing) { - index.set(record.ref, { record, status: "resolved" }); - continue; - } - - if ( - existing.status === "resolved" && - getWorkspaceLocationKey(existing.record.location) !== getWorkspaceLocationKey(record.location) - ) { - index.set(record.ref, { status: "ambiguous" }); - } - } - - return index; -} - function haveSameWorkspaceReferences( left: readonly WorkspaceReferenceRecord[], right: readonly WorkspaceReferenceRecord[], @@ -220,32 +190,61 @@ function haveSameWorkspaceReferences( left.every( (record, index) => record.ref === right[index]?.ref && - getWorkspaceLocationKey(record.location) === getWorkspaceLocationKey(right[index].location), + getWorkspaceLocationKey(record.location) === + getWorkspaceLocationKey(right[index].location) && + record.revision === right[index].revision, ) ); } -function hasCanonicalWorkspaceCitationPart( +function hasCanonicalWorkspaceReferencePart( message: UIMessage, - citations: readonly WorkspaceReferenceRecord[], + references: readonly WorkspaceReferenceRecord[], ) { - const citationParts = message.parts.filter( - (part) => part.type === WORKSPACE_CITATIONS_DATA_PART_TYPE, + const referenceParts = message.parts.filter( + (part) => part.type === WORKSPACE_REFERENCES_DATA_PART_TYPE, ); - if (citations.length === 0) { - return citationParts.length === 0; + if (references.length === 0) { + return referenceParts.length === 0; } - if (citationParts.length !== 1) { + if (referenceParts.length !== 1) { return false; } - const [part] = citationParts; + const [part] = referenceParts; if (!part || !("data" in part)) { return false; } - const parsed = workspaceCitationsDataSchema.safeParse(part.data); - return parsed.success && haveSameWorkspaceReferences(parsed.data.citations, citations); + const parsed = workspaceReferencesDataSchema.safeParse(part.data); + return parsed.success && haveSameWorkspaceReferences(parsed.data.references, references); +} + +function collectWorkspaceToolReferenceRecords(message: UIMessage) { + const records: WorkspaceReferenceRecord[] = []; + for (const part of message.parts) { + if (!isToolUIPart(part) || part.state !== "output-available") continue; + const toolName = + part.type === "dynamic-tool" ? part.toolName : part.type.split("-").slice(1).join("-"); + records.push( + ...(getWorkspaceToolResultAdapter(toolName)?.collectReferences(part.output) ?? []), + ); + } + return records; +} + +function dedupeWorkspaceReferenceRecords(records: readonly WorkspaceReferenceRecord[]) { + const seen = new Set(); + return records.filter((record) => { + const key = getWorkspaceReferenceRecordKey(record); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function getWorkspaceReferenceRecordKey(record: WorkspaceReferenceRecord) { + return `${record.ref}:${getWorkspaceLocationKey(record.location)}:${record.revision ?? ""}`; } diff --git a/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts b/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts index fca2bfbeb..254ccb821 100644 --- a/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts +++ b/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts @@ -3,15 +3,6 @@ import { describe, expect, it } from "vitest"; import { getWorkspaceToolResultAdapter } from "#/features/workspaces/ai/workspace-tool-result-adapters"; describe("workspace tool result adapters", () => { - it("passes through results that predate the current output schema", () => { - // Persisted before create results carried references, as replayed history does. - const legacy = { failed: [], items: [{ path: "/Notes/A", type: "document" }] }; - - expect(getWorkspaceToolResultAdapter("workspace_create_items")?.projectOutput(legacy)).toEqual( - legacy, - ); - }); - it("projects current results onto the model-facing shape", () => { const output = { failed: [], @@ -21,7 +12,7 @@ describe("workspace tool result adapters", () => { expect(getWorkspaceToolResultAdapter("workspace_create_items")?.projectOutput(output)).toEqual({ failed: [], - items: [{ path: "/Notes/A", reference: "wr_7Kp2Qa9x", type: "document" }], + items: [{ path: "/Notes/A", ref: "wr_7Kp2Qa9x", type: "document" }], }); }); @@ -45,9 +36,9 @@ describe("workspace tool result adapters", () => { }); }); - it("projects historical create failures without exposing internal references", () => { + it("projects create failures without exposing internal references", () => { const output = { - failed: [{ code: "path_not_canonical", index: 0, path: "/Old" }], + failed: [{ code: "path_already_exists", index: 0, path: "/Old" }], items: [{ itemId: "itm_internal", path: "/Old", type: "document" }], references: [ { @@ -59,7 +50,7 @@ describe("workspace tool result adapters", () => { expect(getWorkspaceToolResultAdapter("workspace_create_items")?.projectOutput(output)).toEqual({ failed: output.failed, - items: [{ path: "/Old", reference: "wr_7Kp2Qa9x", type: "document" }], + items: [{ path: "/Old", ref: "wr_7Kp2Qa9x", type: "document" }], }); }); diff --git a/src/features/workspaces/ai/workspace-tool-result-adapters.ts b/src/features/workspaces/ai/workspace-tool-result-adapters.ts index 0e029feb5..d7ac9a4c5 100644 --- a/src/features/workspaces/ai/workspace-tool-result-adapters.ts +++ b/src/features/workspaces/ai/workspace-tool-result-adapters.ts @@ -3,10 +3,11 @@ import { z } from "zod"; import { workspaceReadItemsOutputSchema } from "#/features/workspaces/content/workspace-content-contract"; import { createWorkspaceReadItemsModelOutput } from "#/features/workspaces/content/workspace-read-references"; +import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; import { - workspaceReferenceRecordSchema, - type WorkspaceReferenceRecord, -} from "#/features/workspaces/locations/workspace-location"; + workspaceCreateItemsOutputSchema, + workspaceEditItemOutputSchema, +} from "#/features/workspaces/operations/workspace-tool-schemas"; function defineWorkspaceToolResultAdapter(input: { collectReferences?: (output: z.output) => readonly WorkspaceReferenceRecord[]; @@ -18,12 +19,8 @@ function defineWorkspaceToolResultAdapter(input: { const parsed = input.outputSchema.safeParse(output); return parsed.success ? (input.collectReferences?.(parsed.data) ?? []) : []; }, - // Also runs when history is replayed, on results shaped by older versions - // of this schema. Execution already validated them, so projection is - // presentation only: project what parses, pass through what does not. projectOutput: (output: unknown) => { - const parsed = input.outputSchema.safeParse(output); - return (parsed.success ? input.projectOutput(parsed.data) : output) as JSONValue; + return input.projectOutput(input.outputSchema.parse(output)) as JSONValue; }, }; } @@ -36,24 +33,7 @@ const workspaceReadItemsResultAdapter = defineWorkspaceToolResultAdapter({ const workspaceCreateItemsResultAdapter = defineWorkspaceToolResultAdapter({ collectReferences: (output) => output.references, - outputSchema: z.object({ - failed: z.array( - z.object({ - code: z.string(), - detail: z.string().optional(), - index: z.number(), - path: z.string(), - }), - ), - items: z.array( - z.object({ - itemId: z.string(), - path: z.string(), - type: z.enum(["document", "folder"]), - }), - ), - references: z.array(workspaceReferenceRecordSchema), - }), + outputSchema: workspaceCreateItemsOutputSchema, projectOutput: (output) => { const refsByItemId = new Map( output.references.flatMap((record) => @@ -64,9 +44,9 @@ const workspaceCreateItemsResultAdapter = defineWorkspaceToolResultAdapter({ return { failed: output.failed, items: output.items.map(({ itemId, path, type }) => { - const reference = refsByItemId.get(itemId); + const ref = refsByItemId.get(itemId); - return { path, ...(reference ? { reference } : {}), type }; + return { path, ...(ref ? { ref } : {}), type }; }), }; }, @@ -75,14 +55,8 @@ const workspaceCreateItemsResultAdapter = defineWorkspaceToolResultAdapter({ // The operation output also carries the durable item id used by the app's // review controls. Keep the model-facing receipt limited to actionable counts. const workspaceEditItemResultAdapter = defineWorkspaceToolResultAdapter({ - outputSchema: z.object({ - applied: z.number(), - failed: z.array( - z.object({ code: z.string(), detail: z.string().optional(), index: z.number() }), - ), - path: z.string(), - }), - projectOutput: (output) => output, + outputSchema: workspaceEditItemOutputSchema, + projectOutput: ({ applied, failed, path }) => ({ applied, failed, path }), }); const workspaceToolResultAdapters = { diff --git a/src/features/workspaces/components/ai-chat/WorkspaceCitation.tsx b/src/features/workspaces/components/WorkspaceCitation.tsx similarity index 100% rename from src/features/workspaces/components/ai-chat/WorkspaceCitation.tsx rename to src/features/workspaces/components/WorkspaceCitation.tsx diff --git a/src/features/workspaces/components/WorkspaceCitationNode.tsx b/src/features/workspaces/components/WorkspaceCitationNode.tsx new file mode 100644 index 000000000..e3683b596 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceCitationNode.tsx @@ -0,0 +1,24 @@ +import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; + +import { WorkspaceCitation } from "#/features/workspaces/components/WorkspaceCitation"; +import { + Citation, + getWorkspaceCitationLocation, +} from "#/features/workspaces/documents/tiptap-schema"; + +/** Draws a persisted Tiptap citation through the shared workspace citation UI. */ +export const WorkspaceCitationNode = Citation.extend({ + addNodeView() { + return ReactNodeViewRenderer(WorkspaceCitationNodeView, { as: "span" }); + }, +}); + +function WorkspaceCitationNodeView({ node }: { node: { attrs: Record } }) { + const location = getWorkspaceCitationLocation(node.attrs); + + return ( + + {location ? : null} + + ); +} diff --git a/src/features/workspaces/components/WorkspaceContent.tsx b/src/features/workspaces/components/WorkspaceContent.tsx index 96df09153..7fbfc8f79 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"; @@ -35,15 +36,11 @@ import { MoveWorkspaceItemsDialog } from "#/features/workspaces/components/Works import { useWorkspacePaneHotkey } from "#/features/workspaces/components/WorkspacePaneRuntime"; import { WorkspaceRootEmptyPreview } from "#/features/workspaces/components/WorkspaceRootEmptyPreview"; import WorkspaceSelectionActionBar from "#/features/workspaces/components/WorkspaceSelectionActionBar"; +import type { WorkspaceCreateItemRequest } from "#/features/workspaces/components/workspace-presentation-model"; import { workspaceItemGridClass } from "#/features/workspaces/components/workspace-item-card-chrome"; import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access"; import { useWorkspaceViewCapabilities } from "#/features/workspaces/components/workspace-view-policy"; -import { - type WorkspaceItem, - type WorkspaceItemType, - type WorkspaceSummary, - getWorkspaceItemContentKind, -} from "#/features/workspaces/contracts"; +import { type WorkspaceItem, type WorkspaceSummary } from "#/features/workspaces/contracts"; import { getWorkspaceItemDisplay } from "#/features/workspaces/model/item-display"; import { getWorkspaceChildren, @@ -61,7 +58,7 @@ interface WorkspaceContentProps { items: WorkspaceItem[]; activeItem?: WorkspaceItem; workspace: WorkspaceSummary; - onCreateItem: (input: { type: WorkspaceItemType; parentId: string | null }) => void; + onCreateItem: (input: WorkspaceCreateItemRequest) => void; onOpenItem: (item: WorkspaceItem, options?: { background?: boolean }) => void; } @@ -83,7 +80,7 @@ export default function WorkspaceContent({ return ( <> ; + } + const { Icon: ItemIcon, iconClassName, surfaceClassName } = getWorkspaceItemDisplay(item); const itemViewContent = (
diff --git a/src/features/workspaces/components/WorkspaceContextActions.tsx b/src/features/workspaces/components/WorkspaceContextActions.tsx index ccc468ed8..362267a73 100644 --- a/src/features/workspaces/components/WorkspaceContextActions.tsx +++ b/src/features/workspaces/components/WorkspaceContextActions.tsx @@ -7,17 +7,14 @@ import { WorkspaceToolbarIconButton, WorkspaceToolbarTextButton, } from "#/features/workspaces/components/WorkspaceToolbar"; -import { - type WorkspaceItem, - type WorkspaceItemType, - isWorkspaceItemContainer, -} from "#/features/workspaces/contracts"; +import type { WorkspaceCreateItemRequest } from "#/features/workspaces/components/workspace-presentation-model"; +import { type WorkspaceItem, isWorkspaceItemContainer } from "#/features/workspaces/contracts"; interface WorkspaceContextActionsProps { activeItem?: WorkspaceItem; createParentId: string | null; searchHotkey: string; - onCreateItem: (input: { type: WorkspaceItemType; parentId: string | null }) => void; + onCreateItem: (input: WorkspaceCreateItemRequest) => void; onSearch: () => void; onCloseItemView?: () => void; } diff --git a/src/features/workspaces/components/WorkspaceContextBar.tsx b/src/features/workspaces/components/WorkspaceContextBar.tsx index d22a612f9..5c39d5af3 100644 --- a/src/features/workspaces/components/WorkspaceContextBar.tsx +++ b/src/features/workspaces/components/WorkspaceContextBar.tsx @@ -22,11 +22,8 @@ import WorkspaceMobileBreadcrumbOverflow from "#/features/workspaces/components/ import WorkspaceRootActionsMenu from "#/features/workspaces/components/WorkspaceRootActionsMenu"; import { WorkspaceSearchDialog } from "#/features/workspaces/components/WorkspaceSearchDialog"; import { WorkspaceToolbarGroup } from "#/features/workspaces/components/WorkspaceToolbar"; -import type { - WorkspaceItem, - WorkspaceItemType, - WorkspaceSummary, -} from "#/features/workspaces/contracts"; +import type { WorkspaceCreateItemRequest } from "#/features/workspaces/components/workspace-presentation-model"; +import type { WorkspaceItem, WorkspaceSummary } from "#/features/workspaces/contracts"; import { getWorkspaceDisplay } from "#/features/workspaces/model/display"; import { getWorkspaceItemDisplay } from "#/features/workspaces/model/item-display"; import { getWorkspaceBreadcrumbItems } from "#/features/workspaces/model/tree"; @@ -44,7 +41,7 @@ interface WorkspaceContextBarProps { activeItem?: WorkspaceItem; itemsById: Map; toolbarSlotId?: string; - onCreateItem: (input: { type: WorkspaceItemType; parentId: string | null }) => void; + onCreateItem: (input: WorkspaceCreateItemRequest) => void; onCloseItemView?: () => void; onNavigateToRoot: () => void; onNavigateToItem: (item: WorkspaceItem) => void; diff --git a/src/features/workspaces/components/WorkspaceCreateMenu.tsx b/src/features/workspaces/components/WorkspaceCreateMenu.tsx index 0e979d149..6203bf69a 100644 --- a/src/features/workspaces/components/WorkspaceCreateMenu.tsx +++ b/src/features/workspaces/components/WorkspaceCreateMenu.tsx @@ -11,6 +11,7 @@ import { workspaceDropdownMenuRenderer, } from "#/features/workspaces/components/WorkspaceMenuRenderers"; import { WorkspaceToolbarTextButton } from "#/features/workspaces/components/WorkspaceToolbar"; +import type { WorkspaceCreateItemRequest } from "#/features/workspaces/components/workspace-presentation-model"; import { applyWorkspaceMenuReadOnly, renderWorkspaceMenuActions, @@ -21,15 +22,11 @@ import { WorkspaceViewerMenuNotice, 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; - onCreateItem: (input: { type: WorkspaceItemType; parentId: string | null }) => void; + onCreateItem: (input: WorkspaceCreateItemRequest) => void; } export default function WorkspaceCreateMenu({ parentId, onCreateItem }: WorkspaceCreateMenuProps) { @@ -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..5952c69b0 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"; @@ -18,29 +19,30 @@ import WorkspaceShellSkeleton from "#/features/workspaces/components/WorkspaceSh import WorkspaceSplitPresentation from "#/features/workspaces/components/WorkspaceSplitPresentation"; import WorkspaceStandardTabPanes from "#/features/workspaces/components/WorkspaceStandardTabPanes"; import WorkspaceTopBar from "#/features/workspaces/components/WorkspaceTopBar"; +import type { WorkspaceCreateItemRequest } from "#/features/workspaces/components/workspace-presentation-model"; import { WorkspaceMutationAccessProvider } from "#/features/workspaces/components/workspace-mutation-access"; import { useWorkspaceViewPolicy, WorkspaceViewCapabilitiesProvider, } from "#/features/workspaces/components/workspace-view-policy"; -import type { - WorkspaceItem, - WorkspaceItemType, - WorkspaceSummary, -} from "#/features/workspaces/contracts"; +import type { WorkspaceItem, WorkspaceSummary } from "#/features/workspaces/contracts"; import type { WorkspaceLocation } from "#/features/workspaces/locations/workspace-location"; 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 +70,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); @@ -127,11 +130,15 @@ export function WorkspaceShell({ return viewInstanceId; }; - const createWorkspaceItem = (input: { type: WorkspaceItemType; parentId: string | null }) => { + const createWorkspaceItem = (input: WorkspaceCreateItemRequest) => { if (!getWorkspaceMemberCapabilities(workspace.membershipRole).canMutateContent) { return; } + if (input.type === "flashcard") { + setFlashcardParentId(input.parentId); + return; + } createWorkspaceItemMutation.mutate({ id: crypto.randomUUID(), workspaceId: workspace.id, @@ -178,7 +185,7 @@ export function WorkspaceShell({ const aiContextScope = { activeItem: isWorkspaceItemView(activeItem) ? activeItem : undefined, activeTabId: activeTab.id, - itemViewStatesByItemId, + itemViewStatesByViewInstanceId, itemsById, presentation, selectedItemIds, @@ -193,7 +200,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 +297,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 +310,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..020448a90 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, @@ -59,7 +60,7 @@ import { WorkspacePdfPageControl } from "#/features/workspaces/components/Worksp import { useWorkspaceViewCapabilities } from "#/features/workspaces/components/workspace-view-policy"; import { createCaptureAttachmentFile } from "#/features/workspaces/components/workspace-region-capture"; import { stageCaptureAttachmentToComposerWithFeedback } from "#/features/workspaces/composer/workspace-composer-actions"; -import { useWorkspacePdfPageRevealRequest } from "#/features/workspaces/locations/workspace-location-context"; +import { useWorkspaceRevealRequest } from "#/features/workspaces/locations/workspace-location-context"; import type { WorkspaceItem } from "#/features/workspaces/contracts"; import { getWorkspaceFileContentUrl } from "#/features/workspaces/model/workspace-file"; import { @@ -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 (
@@ -200,7 +203,7 @@ function WorkspacePdfDocumentLoader({ const { provides: documentManager } = useDocumentManagerCapability(); const { provides: scrollCapability } = useScrollCapability(); const paneRuntime = useWorkspacePaneRuntime(); - const { consume, request } = useWorkspacePdfPageRevealRequest(viewInstanceId); + const { complete, request } = useWorkspaceRevealRequest(viewInstanceId, "pdf-page"); const [openError, setOpenError] = useState<{ documentId: string; message: string; @@ -258,7 +261,7 @@ function WorkspacePdfDocumentLoader({ } if (currentOpenError || request.location.itemId !== documentId) { - consume(request); + complete(request, false); return; } @@ -274,17 +277,18 @@ function WorkspacePdfDocumentLoader({ } handled = true; - if (request.location.pageNumber <= event.totalPages) { + const pageExists = request.location.pageNumber <= event.totalPages; + if (pageExists) { scrollCapability.forDocument(documentId).scrollToPage({ behavior: "instant", pageNumber: request.location.pageNumber, }); } - consume(request); + complete(request, pageExists); }); }, [ activeDocumentId, - consume, + complete, currentOpenError, documentId, isActive, @@ -315,6 +319,7 @@ function WorkspacePdfDocumentLoader({ itemId={itemId} onCaptureModeExit={onCaptureModeExit} onCaptureModeToggle={onCaptureModeToggle} + viewInstanceId={viewInstanceId} workspaceId={workspaceId} {...props} /> @@ -335,6 +340,7 @@ function WorkspacePdfDocumentContent({ itemId, onCaptureModeExit, onCaptureModeToggle, + viewInstanceId, workspaceId, }: { documentId: string; @@ -348,6 +354,7 @@ function WorkspacePdfDocumentContent({ itemId: string; onCaptureModeExit: () => void; onCaptureModeToggle: () => void; + viewInstanceId: string; workspaceId: string; }) { const [selectionPoint, setSelectionPoint] = useState(null); @@ -391,6 +398,7 @@ function WorkspacePdfDocumentContent({ @@ -540,10 +548,12 @@ function WorkspacePdfPage({ function WorkspacePdfItemViewStateReporter({ documentId, itemId, + viewInstanceId, workspaceId, }: { documentId: string; itemId: string; + viewInstanceId: string; workspaceId: string; }) { const { @@ -551,20 +561,20 @@ function WorkspacePdfItemViewStateReporter({ } = useScroll(documentId); const clearItemViewState = useWorkspaceUiStore((state) => state.clearItemViewState); const setItemViewState = useWorkspaceUiStore((state) => state.setItemViewState); + const pageNumber = Math.max(1, currentPage); useEffect(() => { - setItemViewState(workspaceId, { - kind: "pdf-page", + setItemViewState(workspaceId, viewInstanceId, { itemId, - pageNumber: currentPage, + label: `p. ${pageNumber}`, }); - }, [currentPage, itemId, setItemViewState, workspaceId]); + }, [itemId, pageNumber, 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/WorkspaceStandardTabPanes.tsx b/src/features/workspaces/components/WorkspaceStandardTabPanes.tsx index 7013a620e..2bfc98e59 100644 --- a/src/features/workspaces/components/WorkspaceStandardTabPanes.tsx +++ b/src/features/workspaces/components/WorkspaceStandardTabPanes.tsx @@ -1,10 +1,7 @@ import WorkspaceContent from "#/features/workspaces/components/WorkspaceContent"; import { WorkspacePaneRuntimeProvider } from "#/features/workspaces/components/WorkspacePaneRuntime"; -import type { - WorkspaceItem, - WorkspaceItemType, - WorkspaceSummary, -} from "#/features/workspaces/contracts"; +import type { WorkspaceCreateItemRequest } from "#/features/workspaces/components/workspace-presentation-model"; +import type { WorkspaceItem, WorkspaceSummary } from "#/features/workspaces/contracts"; import type { WorkspaceTab } from "#/features/workspaces/model/tab-types"; import { cn } from "#/lib/utils"; @@ -24,7 +21,7 @@ export default function WorkspaceStandardTabPanes({ tabs: WorkspaceTab[]; workspace: WorkspaceSummary; onCloseItemView?: () => void; - onCreateItem: (input: { type: WorkspaceItemType; parentId: string | null }) => void; + onCreateItem: (input: WorkspaceCreateItemRequest) => void; onOpenItem: (item: WorkspaceItem, options?: { background?: boolean }) => void; }) { return ( diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx index ad2c170b1..a463da156 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx @@ -12,7 +12,7 @@ import { } from "#/features/workspaces/locations/workspace-location"; import { MarkdownCodeBlock } from "#/features/workspaces/components/ai-chat/ai-chat-code-block"; import { normalizeLlmMarkdown } from "#/features/workspaces/components/ai-chat/normalize-llm-markdown"; -import { WorkspaceCitation } from "#/features/workspaces/components/ai-chat/WorkspaceCitation"; +import { WorkspaceCitation } from "#/features/workspaces/components/WorkspaceCitation"; import { cn } from "#/lib/utils"; type AiChatMessageResponseProps = Omit< diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx index c5014e0d9..d6d9eee63 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx @@ -14,7 +14,7 @@ import { getAiChatDocumentEditGroups } from "#/features/workspaces/components/ai import { getWorkspaceCitationLocations, stripWorkspaceCitationTags, -} from "#/features/workspaces/ai/workspace-citations"; +} from "#/features/workspaces/ai/workspace-references"; import { type AiChatRenderablePart, type AssistantRowDisplay, 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..08d4db5bc 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,36 @@ 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 || presentation.isBusy) { + 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, + presentation.isBusy, + setDraftText, + takeDirectPrompt, + threadId, + ]); return (
@@ -109,7 +145,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/agents-message-reconciliation.test.ts b/src/features/workspaces/components/ai-chat/agents-message-reconciliation.test.ts index 8243a4375..905bb56f9 100644 --- a/src/features/workspaces/components/ai-chat/agents-message-reconciliation.test.ts +++ b/src/features/workspaces/components/ai-chat/agents-message-reconciliation.test.ts @@ -37,11 +37,10 @@ describe("agents message reconciliation", () => { const first = toolMessage("assistant-first", "call-reused", "output-available"); const second = toolMessage("assistant-second", "call-reused", "input-available"); - const reconciled = reconcileMessages([first, second], [structuredClone(first)]); - const secondPart = reconciled[1].parts[0]; + const [reconciled] = reconcileMessages([second], [structuredClone(first)]); + const secondPart = reconciled.parts[0]; - expect(reconciled[0].id).toBe("assistant-first"); - expect(reconciled[1].id).toBe("assistant-second"); + expect(reconciled.id).toBe("assistant-second"); expect(secondPart).toMatchObject({ state: "input-available", toolCallId: "call-reused", diff --git a/src/features/workspaces/components/ai-chat/agents-use-agent-chat.test.tsx b/src/features/workspaces/components/ai-chat/agents-use-agent-chat.test.tsx index 1fa071a68..5d001654f 100644 --- a/src/features/workspaces/components/ai-chat/agents-use-agent-chat.test.tsx +++ b/src/features/workspaces/components/ai-chat/agents-use-agent-chat.test.tsx @@ -120,7 +120,9 @@ function continuationBaseline() { }, { id: "assistant-1", + metadata: { phase: "before-continuation" }, parts: [ + { text: "already ", type: "text" }, { input: { query: "topic" }, output: { result: "found" }, @@ -313,8 +315,11 @@ describe("agents useAgentChat observer", () => { dispatchContinuation(target, "continuation-1", "text-1"); }); await vi.waitFor(() => { - expect(assistantText(chat()?.messages)).toBe("already streamed"); + expect(assistantText(chat()?.messages)).toBe("already already streamed"); expect(assistantHasTool(chat()?.messages)).toBe(true); + expect(lastAssistant(chat()?.messages)?.metadata).toEqual({ + phase: "before-continuation", + }); }); await act(async () => { @@ -339,7 +344,42 @@ describe("agents useAgentChat observer", () => { }); await vi.waitFor(() => { - expect(assistantText(chat()?.messages)).toBe("already streamed"); + expect(assistantText(chat()?.messages)).toBe("already already streamed"); + }); + }); + + it("keeps a text-only baseline when a resumed text part starts", async () => { + const { agent, sentFrames, target } = createFakeAgent(); + const baseline = continuationBaseline(); + baseline[1].parts.splice(1); + const chat = await mountChat(agent, baseline); + + await vi.waitFor(() => { + expect(countFrames(sentFrames, "cf_agent_stream_resume_request")).toBe(1); + }); + await act(async () => { + dispatch(target, { + id: "continuation-1", + type: "cf_agent_stream_resuming", + }); + }); + await vi.waitFor(() => { + expect(countFrames(sentFrames, "cf_agent_stream_resume_ack")).toBe(1); + }); + + await act(async () => { + dispatchChunk(target, "continuation-1", { + messageId: "assistant-1", + type: "start", + }); + dispatchChunk(target, "continuation-1", { + id: "text-1", + type: "text-start", + }); + }); + + await vi.waitFor(() => { + expect(assistantText(chat()?.messages)).toBe("already "); }); }); @@ -365,7 +405,7 @@ describe("agents useAgentChat observer", () => { dispatchContinuation(target, "continuation-2", "text-2"); }); await vi.waitFor(() => { - expect(assistantText(chat()?.messages)).toBe("already streamed"); + expect(assistantText(chat()?.messages)).toBe("already already streamed"); expect(assistantHasTool(chat()?.messages)).toBe(true); }); @@ -388,7 +428,7 @@ describe("agents useAgentChat observer", () => { }); await vi.waitFor(() => { - expect(assistantText(chat()?.messages)).toBe("already streamed"); + expect(assistantText(chat()?.messages)).toBe("already already streamed"); }); }); }); diff --git a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts index 8eba3a1a4..9bfd771d2 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts @@ -1,6 +1,6 @@ import { isToolUIPart } from "ai"; -import { WORKSPACE_CITATIONS_DATA_PART_TYPE } from "#/features/workspaces/ai/workspace-citations"; +import { WORKSPACE_REFERENCES_DATA_PART_TYPE } from "#/features/workspaces/ai/workspace-references"; import type { AiChatMessage, AiChatMessagePart, @@ -199,7 +199,7 @@ export function getDisplayableParts(message: AiChatMessage): AiChatRenderablePar } export function isDisplayableMessagePart(part: AiChatMessagePart): boolean { - if (part.type === WORKSPACE_CITATIONS_DATA_PART_TYPE) { + if (part.type === WORKSPACE_REFERENCES_DATA_PART_TYPE) { return false; } 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..e01507877 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 { useEffect, 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"; @@ -25,6 +26,7 @@ import { useDocumentEditReviewOverlay } from "#/features/workspaces/documents/us import type { WorkspaceItem } from "#/features/workspaces/contracts"; import { DEFAULT_COLLABORATION_COLOR } from "#/lib/design-system-colors"; import { getAuthSessionQueryOptions } from "#/lib/session-query"; +import { useWorkspaceRevealRequest } from "#/features/workspaces/locations/workspace-location-context"; export function DocumentEditorSurface({ documentPath, @@ -106,15 +108,32 @@ function DocumentEditorInstance({ }, }, }); + const { complete: completeRevealRequest, request: revealRequest } = useWorkspaceRevealRequest( + viewInstanceId, + "document-block", + ); + useEffect(() => { + if (!editor || !revealRequest) return; + const target = editor.view.dom.querySelector( + `[data-ref="${revealRequest.location.blockId}"]`, + ); + target?.scrollIntoView({ block: "center" }); + completeRevealRequest(revealRequest, Boolean(target)); + }, [completeRevealRequest, editor, revealRequest]); - 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 +154,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..37d7db68e --- /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 || ![5, 10, 15, 20].includes(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? +