diff --git a/AGENTS.md b/AGENTS.md index 3c1c4188486..c957ca41ead 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,10 +74,7 @@ pnpm dev 1. Keep changes company-scoped. Every domain entity should be scoped to a company and company boundaries must be enforced in routes/services. -Explicit exceptions: personal keyboard shortcut enablement is stored on the -authenticated user and applies across companies, like the user profile. Only -that user can read or change it; writes validate company membership for their -audit context. Announcement dismissals are instance-wide user preferences, +Explicit exception: announcement dismissals are instance-wide user preferences, keyed by user and announcement so they persist across companies. Their audit context must still validate company membership. The announcement publication-ID registry is instance-level feed metadata; it contains no company or user data. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 5d61946fae4..7e0f656ab2f 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1734,21 +1734,10 @@ dismissal retries after withdrawal while rejecting caller-invented IDs. It stores no announcement content, account data or interaction events. See [Announcements](ANNOUNCEMENTS.md) for API and publishing details. -### Personal keyboard shortcut preference +### Keyboard shortcuts -Keyboard shortcuts are off by default and are enabled in Settings → Profile. -The preference is stored on the signed-in user, applies across companies and -devices, and does not require instance administrator access. The local trusted -board user has the same preference. `GET /api/auth/preferences` returns only the -current board user's preference. `PATCH /api/auth/preferences` updates only that -user and requires an accessible `companyId` for the activity log, including viewer -memberships. The preference and audit record commit in one transaction. Both -requests require `expectedUserId` (GET query parameter or PATCH body) matching -the authenticated actor, so a cookie change cannot mix accounts in the cache. -Agents cannot -read or change these preferences. The legacy instance general setting is retained -for API compatibility but no longer controls shortcut behavior in the app; -users opt in individually after the upgrade. +Keyboard shortcuts are always enabled for every signed-in user. There is no +instance setting and no personal preference that turns them off. ### Persistent managed agent files (2026-09-28) diff --git a/doc/SPEC.md b/doc/SPEC.md index 4fdc093cfad..e1c6020b881 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -617,21 +617,10 @@ title matches lead; current conversation and document content supplies supportin evidence. See [Task search relevance](SEARCH.md) for the evaluation rubric, matching contract and reproducible quality tests. -### Personal keyboard shortcut preference +### Keyboard shortcuts -Keyboard shortcuts are off by default and are enabled in Settings → Profile. -The preference is stored on the signed-in user, applies across companies and -devices, and does not require instance administrator access. The local trusted -board user has the same preference. `GET /api/auth/preferences` returns only the -current board user's preference. `PATCH /api/auth/preferences` updates only that -user and requires an accessible `companyId` for the activity log, including viewer -memberships. The preference and audit record commit in one transaction. Both -requests require `expectedUserId` (GET query parameter or PATCH body) matching -the authenticated actor, so a cookie change cannot mix accounts in the cache. -Agents cannot -read or change these preferences. The legacy instance general setting is retained -for API compatibility but no longer controls shortcut behavior in the app; -users opt in individually after the upgrade. +Keyboard shortcuts are always enabled for every signed-in user. There is no +instance setting and no personal preference that turns them off. Managed agents own a persistent file directory across tasks and sessions. The Instructions Editor and stopped agent execution synchronize the same current diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index c21d4f34b35..78ca705b7e6 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -93,7 +93,7 @@ Daytona snapshot for future leases. their management endpoints with `403 settings_operator_managed`; hiding `instance.experimental` floors every experimental toggle write. - Any Instance → General section: `instance.general.censorUsernameInLogs`, - `instance.general.keyboardShortcuts`, `instance.general.backupRetention`, + `instance.general.backupRetention`, `instance.general.feedbackDataSharingPreference` (each also rejects value-changing writes via `PATCH /api/instance/settings/general`), plus the UI-only `instance.general.deploymentStatus` and `instance.general.signOut`. diff --git a/packages/db/src/migrations/0289_drop_user_keyboard_shortcuts.sql b/packages/db/src/migrations/0289_drop_user_keyboard_shortcuts.sql new file mode 100644 index 00000000000..4d2b6e2ab16 --- /dev/null +++ b/packages/db/src/migrations/0289_drop_user_keyboard_shortcuts.sql @@ -0,0 +1,5 @@ +-- Drop the per-user keyboard shortcut preference. Keyboard shortcuts are now +-- always enabled for every signed-in user, so the column lost its last reader +-- when the Profile settings toggle and the /api/auth/preferences routes were +-- removed. +ALTER TABLE "user" DROP COLUMN IF EXISTS "keyboard_shortcuts"; diff --git a/packages/db/src/migrations/meta/0284_snapshot.json b/packages/db/src/migrations/meta/0289_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0284_snapshot.json rename to packages/db/src/migrations/meta/0289_snapshot.json index e2d4cb653a2..1b3fb2249ad 100644 --- a/packages/db/src/migrations/meta/0284_snapshot.json +++ b/packages/db/src/migrations/meta/0289_snapshot.json @@ -1,6 +1,6 @@ { - "id": "7db2ceec-ed62-4968-90c9-4c79206cfe34", - "prevId": "786cd4a9-366b-4934-be90-014e1ee15b51", + "id": "3960ae6b-5bf1-4fd7-91db-6a18fc7eaa19", + "prevId": "770b6d59-6346-4c92-8e49-0b71cf4819ab", "version": "7", "dialect": "postgresql", "tables": { @@ -829,6 +829,479 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_instruction_heads": { + "name": "agent_instruction_heads", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entry_file": { + "name": "entry_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision_id": { + "name": "revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk": { + "name": "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk", + "tableFrom": "agent_instruction_heads", + "tableTo": "agent_instruction_revisions", + "columnsFrom": [ + "company_id", + "agent_id", + "entry_file", + "revision_id" + ], + "columnsTo": [ + "company_id", + "agent_id", + "entry_file", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_instruction_heads_identity_uq": { + "name": "agent_instruction_heads_identity_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "agent_id", + "entry_file" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_instruction_revisions": { + "name": "agent_instruction_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entry_file": { + "name": "entry_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_length": { + "name": "byte_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_revision_id": { + "name": "base_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "restored_from_revision_id": { + "name": "restored_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_run_id": { + "name": "source_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_instruction_revisions_history_idx": { + "name": "agent_instruction_revisions_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entry_file", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk": { + "name": "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk", + "tableFrom": "agent_instruction_revisions", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_instruction_revisions_identity_uq": { + "name": "agent_instruction_revisions_identity_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "agent_id", + "entry_file", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_instruction_working_copies": { + "name": "agent_instruction_working_copies", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_file": { + "name": "entry_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_revision_id": { + "name": "base_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_hash": { + "name": "base_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_root": { + "name": "local_root", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_root": { + "name": "execution_root", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prepared'" + }, + "candidate_base64": { + "name": "candidate_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "candidate_hash": { + "name": "candidate_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt": { + "name": "receipt", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "process_stopped_at": { + "name": "process_stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_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": { + "agent_instruction_copies_pending_idx": { + "name": "agent_instruction_copies_pending_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_instruction_copies_agent_idx": { + "name": "agent_instruction_copies_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk": { + "name": "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_instruction_working_copies", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk": { + "name": "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk", + "tableFrom": "agent_instruction_working_copies", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.agent_memberships": { "name": "agent_memberships", "schema": "", @@ -1942,6 +2415,28 @@ "method": "btree", "with": {} }, + "agent_wakeup_requests_chat_completion_uq": { + "name": "agent_wakeup_requests_chat_completion_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'chat-completion:%'", + "concurrently": false, + "method": "btree", + "with": {} + }, "agent_wakeup_requests_company_payload_issue_idx": { "name": "agent_wakeup_requests_company_payload_issue_idx", "columns": [ @@ -2917,7 +3412,7 @@ }, "byte_size": { "name": "byte_size", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, @@ -3302,13 +3797,6 @@ "primaryKey": false, "notNull": false }, - "keyboard_shortcuts": { - "name": "keyboard_shortcuts", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -8067,6 +8555,311 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.chat_completion_deliveries": { + "name": "chat_completion_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status_version": { + "name": "status_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "target_run_id": { + "name": "target_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "response_comment_id": { + "name": "response_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_completion_deliveries_transition_uq": { + "name": "chat_completion_deliveries_transition_uq", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_completion_deliveries_pending_idx": { + "name": "chat_completion_deliveries_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_completion_deliveries_company_id_companies_id_fk": { + "name": "chat_completion_deliveries_company_id_companies_id_fk", + "tableFrom": "chat_completion_deliveries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_completion_deliveries_task_id_chat_task_handoffs_task_id_fk": { + "name": "chat_completion_deliveries_task_id_chat_task_handoffs_task_id_fk", + "tableFrom": "chat_completion_deliveries", + "tableTo": "chat_task_handoffs", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "task_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_completion_deliveries_target_run_id_heartbeat_runs_id_fk": { + "name": "chat_completion_deliveries_target_run_id_heartbeat_runs_id_fk", + "tableFrom": "chat_completion_deliveries", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "target_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_completion_deliveries_response_comment_id_issue_comments_id_fk": { + "name": "chat_completion_deliveries_response_comment_id_issue_comments_id_fk", + "tableFrom": "chat_completion_deliveries", + "tableTo": "issue_comments", + "columnsFrom": [ + "response_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_task_handoffs": { + "name": "chat_task_handoffs", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_task_handoffs_source_idx": { + "name": "chat_task_handoffs_source_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_task_handoffs_task_id_issues_id_fk": { + "name": "chat_task_handoffs_task_id_issues_id_fk", + "tableFrom": "chat_task_handoffs", + "tableTo": "issues", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_task_handoffs_company_id_companies_id_fk": { + "name": "chat_task_handoffs_company_id_companies_id_fk", + "tableFrom": "chat_task_handoffs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_task_handoffs_conversation_id_issues_id_fk": { + "name": "chat_task_handoffs_conversation_id_issues_id_fk", + "tableFrom": "chat_task_handoffs", + "tableTo": "issues", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_task_handoffs_agent_id_agents_id_fk": { + "name": "chat_task_handoffs_agent_id_agents_id_fk", + "tableFrom": "chat_task_handoffs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.chat_discord_command_owners": { "name": "chat_discord_command_owners", "schema": "", @@ -38939,6 +39732,113 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.runner_api_response_reservations": { + "name": "runner_api_response_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "runner_api_response_reservations_company_idx": { + "name": "runner_api_response_reservations_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "runner_api_response_reservations_company_id_companies_id_fk": { + "name": "runner_api_response_reservations_company_id_companies_id_fk", + "tableFrom": "runner_api_response_reservations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "runner_api_response_reservations_run_id_heartbeat_runs_id_fk": { + "name": "runner_api_response_reservations_run_id_heartbeat_runs_id_fk", + "tableFrom": "runner_api_response_reservations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "runner_api_response_reservations_asset_id_assets_id_fk": { + "name": "runner_api_response_reservations_asset_id_assets_id_fk", + "tableFrom": "runner_api_response_reservations", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.secret_access_events": { "name": "secret_access_events", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 825dba758d1..c4a33f53500 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2010,6 +2010,13 @@ "when": 1790690265758, "tag": "0288_glorious_jamie_braddock", "breakpoints": true + }, + { + "idx": 289, + "version": "7", + "when": 1790727655506, + "tag": "0289_drop_user_keyboard_shortcuts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index faae5df7076..1e82b8a1256 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -6,7 +6,6 @@ export const authUsers = pgTable("user", { email: text("email").notNull(), emailVerified: boolean("email_verified").notNull().default(false), image: text("image"), - keyboardShortcuts: boolean("keyboard_shortcuts").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 54079768311..434c8c8c184 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2330,10 +2330,6 @@ export { resolveCliAuthChallengeSchema, createBoardApiKeySchema, currentUserProfileSchema, - currentUserPreferencesSchema, - updateCurrentUserPreferencesSchema, - type CurrentUserPreferences, - type UpdateCurrentUserPreferences, authSessionSchema, updateCurrentUserProfileSchema, updateCompanyMemberSchema, diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 31c99ae80e4..4651724fe80 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -79,7 +79,6 @@ export type HideableCompanySection = (typeof HIDEABLE_COMPANY_SECTIONS)[number]; export const HIDEABLE_GENERAL_SECTIONS = [ "instance.general.deploymentStatus", "instance.general.censorUsernameInLogs", - "instance.general.keyboardShortcuts", "instance.general.backupRetention", "instance.general.feedbackDataSharingPreference", "instance.general.signOut", diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 50f653d325f..6ae25e8af1f 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -30,8 +30,6 @@ export type InstanceExecutionMode = "kubernetes" | "any"; export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; - /** @deprecated Legacy instance value. Use /auth/preferences for personal shortcuts. */ - keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; backupRetention: BackupRetentionPolicy; /** diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 9623aa74eca..c046b84d56d 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -220,15 +220,3 @@ export const updateCurrentUserProfileSchema = z.object({ }); export type UpdateCurrentUserProfile = z.infer; - -// Personal preferences are shared across companies, but only the signed-in -// user can read or change them. companyId supplies the mutation audit context. -export const currentUserPreferencesSchema = z.object({ - keyboardShortcuts: z.boolean(), -}); -export const updateCurrentUserPreferencesSchema = currentUserPreferencesSchema.extend({ - expectedUserId: z.string().min(1), - companyId: z.string().uuid(), -}).strict(); -export type CurrentUserPreferences = z.infer; -export type UpdateCurrentUserPreferences = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 8489488d1c7..66b082f2cf7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -757,10 +757,6 @@ export { resolveCliAuthChallengeSchema, createBoardApiKeySchema, currentUserProfileSchema, - currentUserPreferencesSchema, - updateCurrentUserPreferencesSchema, - type CurrentUserPreferences, - type UpdateCurrentUserPreferences, authSessionSchema, updateCurrentUserProfileSchema, updateCompanyMemberSchema, diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 85cf6d076a9..016368b097f 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -24,7 +24,6 @@ export const backupRetentionPolicySchema = z.object({ export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), - keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), diff --git a/server/src/__tests__/feedback-service.test.ts b/server/src/__tests__/feedback-service.test.ts index 9770c94170c..ed3c7f21898 100644 --- a/server/src/__tests__/feedback-service.test.ts +++ b/server/src/__tests__/feedback-service.test.ts @@ -457,6 +457,36 @@ describeEmbeddedPostgres("feedbackService.saveIssueVote", () => { expect(traces[0]?.exportId).toBeNull(); }); + it("keeps a stored sharing preference when the settings row still carries retired keys", async () => { + const { issueId, commentId } = await seedIssueWithAgentComment(); + await db.insert(instanceSettings).values({ + singletonKey: "default", + general: { feedbackDataSharingPreference: "not_allowed", keyboardShortcuts: true }, + experimental: {}, + }); + + const result = await svc.saveIssueVote({ + issueId, + targetType: "issue_comment", + targetId: commentId, + vote: "up", + authorUserId: "user-1", + allowSharing: true, + }); + + expect(result.persistedSharingPreference).toBeNull(); + + const settings = await db + .select() + .from(instanceSettings) + .where(eq(instanceSettings.singletonKey, "default")) + .then((rows) => rows[0] ?? null); + + expect(settings?.general).toMatchObject({ + feedbackDataSharingPreference: "not_allowed", + }); + }); + it("enables sharing metadata on the first consented vote and upserts subsequent votes", async () => { const { companyId, issueId, commentId } = await seedIssueWithAgentComment(); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index ca3747c5848..68b5fe7d40c 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -124,7 +124,6 @@ describe("instance settings routes", () => { defaultEnvironmentId: null, general: { censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }, experimental: { @@ -149,7 +148,6 @@ describe("instance settings routes", () => { }); mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); mockInstanceSettingsService.getExperimental.mockResolvedValue({ @@ -174,7 +172,6 @@ describe("instance settings routes", () => { defaultEnvironmentId: "env-1", general: { censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }, experimental: { @@ -201,7 +198,6 @@ describe("instance settings routes", () => { id: "instance-settings-1", general: { censorUsernameInLogs: true, - keyboardShortcuts: true, feedbackDataSharingPreference: "allowed", }, }); @@ -593,7 +589,6 @@ describe("instance settings routes", () => { expect(getRes.status).toBe(200); expect(getRes.body).toEqual({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); @@ -601,14 +596,12 @@ describe("instance settings routes", () => { .patch("/api/instance/settings/general") .send({ censorUsernameInLogs: true, - keyboardShortcuts: true, feedbackDataSharingPreference: "allowed", }); expect(patchRes.status).toBe(200); expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ censorUsernameInLogs: true, - keyboardShortcuts: true, feedbackDataSharingPreference: "allowed", }); expect(mockLogActivity).toHaveBeenCalledTimes(2); @@ -628,7 +621,6 @@ describe("instance settings routes", () => { expect(res.status).toBe(200); expect(res.body).toEqual({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); }); @@ -660,7 +652,7 @@ describe("instance settings routes", () => { const res = await request(app) .patch("/api/instance/settings/general") - .send({ censorUsernameInLogs: true, keyboardShortcuts: true }); + .send({ censorUsernameInLogs: true }); expect(res.status).toBe(403); expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled(); @@ -701,7 +693,6 @@ describe("instance settings routes", () => { it("rejects a write that changes executionMode", async () => { mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", executionMode: "kubernetes", }); @@ -730,7 +721,6 @@ describe("instance settings routes", () => { it("allows a same-value executionMode echo so full-object settings forms keep working", async () => { mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", executionMode: "kubernetes", }); @@ -738,12 +728,12 @@ describe("instance settings routes", () => { const res = await request(app) .patch("/api/instance/settings/general") - .send({ executionMode: "kubernetes", keyboardShortcuts: true }); + .send({ executionMode: "kubernetes", censorUsernameInLogs: true }); expect(res.status).toBe(200); expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ executionMode: "kubernetes", - keyboardShortcuts: true, + censorUsernameInLogs: true, }); }); @@ -752,11 +742,11 @@ describe("instance settings routes", () => { const res = await request(app) .patch("/api/instance/settings/general") - .send({ keyboardShortcuts: true }); + .send({ censorUsernameInLogs: true }); expect(res.status).toBe(200); expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); - expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ keyboardShortcuts: true }); + expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ censorUsernameInLogs: true }); }); it("keeps executionMode writable on self-hosted instances", async () => { @@ -809,12 +799,12 @@ describe("instance settings routes", () => { const res = await request(app) .patch("/api/instance/settings/general") - .send({ censorUsernameInLogs: false, keyboardShortcuts: true }); + .send({ censorUsernameInLogs: false, feedbackDataSharingPreference: "allowed" }); expect(res.status).toBe(200); expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ censorUsernameInLogs: false, - keyboardShortcuts: true, + feedbackDataSharingPreference: "allowed", }); }); @@ -822,7 +812,6 @@ describe("instance settings routes", () => { process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.backupRetention"; mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, }); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 479b1418a5d..22f40613943 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -956,10 +956,3 @@ describe("heartbeat run ID OpenAPI contract", () => { expect(checked).toBe(12); }); }); - -it("documents the account binding required for preference reads", () => { - const operation = buildOpenApiSpec().paths["/api/auth/preferences"]?.get; - expect(operation?.parameters).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: "expectedUserId", in: "query", required: true }), - ])); -}); diff --git a/server/src/__tests__/user-preferences-routes.test.ts b/server/src/__tests__/user-preferences-routes.test.ts deleted file mode 100644 index 42a4ef249a9..00000000000 --- a/server/src/__tests__/user-preferences-routes.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { PgDialect } from "drizzle-orm/pg-core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { authRoutes } from "../routes/auth.js"; -import { errorHandler } from "../middleware/index.js"; - -const { logActivity, publishActivity } = vi.hoisted(() => ({ logActivity: vi.fn(), publishActivity: vi.fn() })); -vi.mock("../services/activity-log.js", () => ({ logActivity, publishActivity })); - -const companyId = "11111111-1111-4111-8111-111111111111"; -const actorExpectedId = "user-1"; -const otherCompanyId = "22222222-2222-4222-8222-222222222222"; -const dialect = new PgDialect(); - -function setup(actor: Express.Request["actor"]) { - const users = new Map([ - ["user-1", { keyboardShortcuts: false }], - ["user-2", { keyboardShortcuts: false }], - ["local-board", { keyboardShortcuts: false }], - ]); - const read = (where: Parameters[0]) => { - const query = dialect.sqlToQuery(where); - expect(query.sql).toContain('"user"."id" ='); - const user = users.get(query.params[0] as string); - return user ? [user] : []; - }; - const update = vi.fn(() => ({ - set: (patch: { keyboardShortcuts: boolean }) => ({ - where: (where: Parameters[0]) => ({ - returning: async () => { - const rows = read(where); - for (const row of rows) row.keyboardShortcuts = patch.keyboardShortcuts; - return rows; - }, - }), - }), - })); - const db = { - select: () => ({ from: () => ({ where: async (where: Parameters[0]) => read(where) }) }), - update, - transaction: async (fn: (tx: unknown) => Promise): Promise => { - const snapshot = structuredClone(users); - try { return await fn(db); } - catch (error) { users.clear(); for (const [key, value] of snapshot) users.set(key, value); throw error; } - }, - }; - const app = express(); - app.use(express.json()); - app.use((req, _res, next) => { req.actor = actor; next(); }); - app.use("/api/auth", authRoutes(db as never)); - app.use(errorHandler); - return { app, users, update }; -} - -const board: Express.Request["actor"] = { - type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: [companyId], -}; - -describe("personal keyboard shortcut preferences", () => { - beforeEach(() => vi.clearAllMocks()); - - it("lets a non-admin persist their preference without changing another user", async () => { - const { app, users } = setup(board); - expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).body).toEqual({ keyboardShortcuts: false }); - const saved = await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: actorExpectedId, keyboardShortcuts: true }); - expect(saved.status).toBe(200); - expect(saved.body).toEqual({ keyboardShortcuts: true }); - expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).body).toEqual({ keyboardShortcuts: true }); - expect(users.get("user-2")).toEqual({ keyboardShortcuts: false }); - expect(logActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ - companyId, actorId: "user-1", entityId: "user-1", action: "user.preferences_updated", - }), expect.any(Array)); - expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: actorExpectedId, keyboardShortcuts: false })).body) - .toEqual({ keyboardShortcuts: false }); - }); - - it("allows viewer members to save their own preferences", async () => { - const { app } = setup({ ...board, memberships: [{ companyId, membershipRole: "viewer", status: "active" }] } as Express.Request["actor"]); - expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true })).status).toBe(200); - }); - - it("rejects reads and writes after the cookie changes accounts", async () => { - const { app, update } = setup({ ...board, userId: "user-2" }); - expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).status).toBe(401); - expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true })).status).toBe(401); - expect(update).not.toHaveBeenCalled(); - }); - - it("rolls back the preference when its audit record fails", async () => { - const { app, users } = setup(board); - logActivity.mockRejectedValueOnce(new Error("audit unavailable")); - expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true })).status).toBe(500); - expect(users.get("user-1")?.keyboardShortcuts).toBe(false); - }); - - it("reports a committed save as successful when activity publication fails", async () => { - const { app, users } = setup(board); - logActivity.mockImplementationOnce(async (_db, _input, publications) => { - publications.push({ companyId, payload: {} }); - }); - publishActivity.mockImplementationOnce(() => { throw new Error("subscriber unavailable"); }); - const saved = await request(app).patch("/api/auth/preferences") - .send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true }); - expect(saved.status).toBe(200); - expect(saved.body).toEqual({ keyboardShortcuts: true }); - expect(users.get("user-1")?.keyboardShortcuts).toBe(true); - expect(publishActivity).toHaveBeenCalledOnce(); - }); - - it("supports the local trusted board identity", async () => { - const { app } = setup({ type: "board", userId: "local-board", source: "local_implicit" }); - expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "local-board", keyboardShortcuts: true })).status).toBe(200); - }); - - it.each([ - { type: "none" }, - { type: "agent", agentId: "agent-1", companyId }, - { type: "board", source: "session" }, - ] as Express.Request["actor"][])("rejects requests without a board user: %j", async (actor) => { - const { app, update } = setup(actor); - expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).status).toBe(401); - expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: actorExpectedId, keyboardShortcuts: true })).status).toBe(401); - expect(update).not.toHaveBeenCalled(); - }); - - it("rejects an inaccessible company audit context", async () => { - const { app, update } = setup(board); - expect((await request(app).patch("/api/auth/preferences").send({ companyId: otherCompanyId, expectedUserId: actorExpectedId, keyboardShortcuts: true })).status).toBe(403); - expect(update).not.toHaveBeenCalled(); - expect(logActivity).not.toHaveBeenCalled(); - }); - - it.each([ - { keyboardShortcuts: true }, - { companyId, keyboardShortcuts: "true" }, - { companyId, keyboardShortcuts: true, userId: "user-2" }, - ])("rejects invalid or caller-selected identities: %j", async (body) => { - const { app, update } = setup(board); - expect((await request(app).patch("/api/auth/preferences").send(body)).status).toBe(400); - expect(update).not.toHaveBeenCalled(); - }); -}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index faf0a522c7a..cfa65e033cb 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -4,15 +4,10 @@ import type { Db } from "@paperclipai/db"; import { authUsers } from "@paperclipai/db"; import { authSessionSchema, - currentUserPreferencesSchema, - updateCurrentUserPreferencesSchema, currentUserProfileSchema, updateCurrentUserProfileSchema, } from "@paperclipai/shared"; -import { hasCompanyAccess } from "./authz.js"; -import { logActivity, publishActivity, type ActivityPublication } from "../services/activity-log.js"; -import { forbidden, unauthorized } from "../errors.js"; -import { logger } from "../middleware/logger.js"; +import { unauthorized } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { resolveSentryDsns } from "../sentry-dsn.js"; @@ -109,55 +104,5 @@ export function authRoutes(db: Db) { })); }); - router.get("/preferences", async (req, res) => { - if (req.actor.type !== "board" || !req.actor.userId) { - throw unauthorized("Board authentication required"); - } - if (req.query.expectedUserId !== req.actor.userId) throw unauthorized("Account changed. Refresh and try again."); - const [user] = await db.select({ keyboardShortcuts: authUsers.keyboardShortcuts }) - .from(authUsers).where(eq(authUsers.id, req.actor.userId)); - if (!user) throw unauthorized("Signed-in user not found"); - res.json(currentUserPreferencesSchema.parse(user)); - }); - - router.patch("/preferences", validate(updateCurrentUserPreferencesSchema), async (req, res) => { - if (req.actor.type !== "board" || !req.actor.userId) { - throw unauthorized("Board authentication required"); - } - const { companyId, keyboardShortcuts, expectedUserId } = updateCurrentUserPreferencesSchema.parse(req.body); - const userId = req.actor.userId; - if (expectedUserId !== userId) throw unauthorized("Account changed. Refresh and try again."); - // This is a personal write; company membership supplies audit context only. - if (!hasCompanyAccess(req, companyId)) throw forbidden("User does not have access to this company"); - const publications: ActivityPublication[] = []; - const user = await db.transaction(async (tx) => { - const [updated] = await tx.update(authUsers) - .set({ keyboardShortcuts, updatedAt: new Date() }) - .where(eq(authUsers.id, userId)) - .returning({ keyboardShortcuts: authUsers.keyboardShortcuts }); - if (!updated) throw unauthorized("Signed-in user not found"); - await logActivity(tx as unknown as Db, { - companyId, - actorType: "user", - actorId: userId, - action: "user.preferences_updated", - entityType: "user", - entityId: userId, - details: { keyboardShortcuts }, - }, publications); - return updated; - }); - for (const publication of publications) { - try { - publishActivity(publication); - } catch (err) { - // The preference and audit row are committed; notification failure must - // not tell the caller that its durable save failed. - logger.warn({ err, companyId, userId }, "Could not publish user preference activity"); - } - } - res.json(currentUserPreferencesSchema.parse(user)); - }); - return router; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 38b876197d6..b9970f24034 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -176,7 +176,6 @@ import { withdrawIssueThreadInteractionSchema, // Auth / profile updateCurrentUserProfileSchema, - updateCurrentUserPreferencesSchema, // Company portability (legacy routes) companyPortabilityExportSchema, companyPortabilityPreviewSchema, @@ -6591,23 +6590,6 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); -registry.registerPath({ - method: "get", - path: "/api/auth/preferences", - tags: ["auth"], - summary: "Get the signed-in user's personal preferences", - request: { query: z.object({ expectedUserId: z.string().min(1) }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, -}); -registry.registerPath({ - method: "patch", - path: "/api/auth/preferences", - tags: ["auth"], - summary: "Update personal preferences with a company audit context", - request: { body: jsonBody(updateCurrentUserPreferencesSchema) }, - responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, -}); - // ─── Auth / profile ────────────────────────────────────────────────────────── registry.registerPath({ diff --git a/server/src/services/feedback.ts b/server/src/services/feedback.ts index 018a82546f4..2bbc0361a3c 100644 --- a/server/src/services/feedback.ts +++ b/server/src/services/feedback.ts @@ -157,7 +157,10 @@ function contentTypeForPath(filePath: string) { } function normalizeInstanceGeneralSettings(raw: unknown) { - const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {}); + // Stored rows can carry retired keys (for example keyboardShortcuts) until + // the settings row is rewritten. Strip them instead of failing closed, or a + // stale key would reset the sharing preference to "prompt" on this path. + const parsed = instanceGeneralSettingsSchema.strip().safeParse(raw ?? {}); if (parsed.success) return parsed.data; return instanceGeneralSettingsSchema.parse({}); } diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index fa88c0e256a..2e7d555f0c2 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -203,7 +203,6 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { if (parsed.success) { return { censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false, - keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION, @@ -213,7 +212,6 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { } return { censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, backupRetention: DEFAULT_BACKUP_RETENTION, }; diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 2a66e77539e..202e59e8c1f 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,8 +1,5 @@ import { authSessionSchema, - currentUserPreferencesSchema, - type CurrentUserPreferences, - type UpdateCurrentUserPreferences, currentUserProfileSchema, type AuthSession, type CurrentUserProfile, @@ -182,23 +179,6 @@ export const authApi = { await authPost("/sign-up/email", input); }, - getPreferences: async (expectedUserId: string): Promise => { - const res = await fetch(`/api/auth/preferences?expectedUserId=${encodeURIComponent(expectedUserId)}`, { - credentials: "include", - headers: { Accept: "application/json" }, - }); - const payload = await res.json().catch(() => null); - if (!res.ok) { - const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); - if (recovery) return recovery; - throw extractAuthError(payload as AuthErrorBody, res.status); - } - return currentUserPreferencesSchema.parse(payload); - }, - - updatePreferences: (input: UpdateCurrentUserPreferences): Promise => - authPatch("/preferences", input, (payload) => currentUserPreferencesSchema.parse(payload)), - getProfile: async (): Promise => { const res = await fetch("/api/auth/profile", { credentials: "include", diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 25375b02a31..f5718914b45 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -85,7 +85,6 @@ import { import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { getInboxKeyboardSelectionIndex } from "../lib/inbox"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; -import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { statusBadge } from "../lib/status-colors"; import { workflowSort } from "../lib/workflow-sort"; @@ -733,7 +732,6 @@ function StreamlinedIssuesList({ const rootRef = useRef(null); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { keyboardShortcutsEnabled } = useGeneralSettings(); // Keyboard selection for the list view (mirrors the inbox). Hover moves the // selection only after real pointer movement, so keyboard-driven scrolling // doesn't hand the selection to whatever row lands under the cursor. @@ -1432,7 +1430,6 @@ function StreamlinedIssuesList({ }, []); useEffect(() => { - if (!keyboardShortcutsEnabled) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; const target = e.target; @@ -1530,7 +1527,7 @@ function StreamlinedIssuesList({ }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [keyboardShortcutsEnabled, navigate, queryClient]); + }, [navigate, queryClient]); // Keep the keyboard selection visible while navigating. Depends on the // render budget too: a selection past the mounted batch scrolls once its diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index c809a8cb286..58ce8ef65a6 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -1,4 +1,3 @@ -import { useUserPreferences } from "../hooks/useUserPreferences"; import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext"; import { PluginAppShellOverlays } from "./PluginAppShellOverlays"; import { @@ -41,7 +40,6 @@ import { SidebarShell } from "./SidebarShell.production"; import { SecondarySidebar } from "./SecondarySidebar.production"; import { SidebarAccountMenu } from "./SidebarAccountMenu.production"; import { useDialogActions } from "../context/DialogContext"; -import { GeneralSettingsProvider } from "../context/GeneralSettingsContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; @@ -254,7 +252,6 @@ export function Layout() { }, refetchIntervalInBackground: false, }); - const keyboardShortcutsEnabled = useUserPreferences().data?.keyboardShortcuts === true; // A secondary sidebar always collapses the app sidebar to its rail (still // peek-able) — a hard invariant that overrides the user pin while the route @@ -464,7 +461,6 @@ export function Layout() { useCompanyPageMemory(); useKeyboardShortcuts({ - enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), onSearch: openSearch, onToggleSidebar: toggleSidebar, @@ -635,7 +631,6 @@ export function Layout() { return ( -
-
); } diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 7522e6ff3bc..ff30b552460 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -318,7 +318,7 @@ describe("Layout", () => { version: "1.2.3", }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ - keyboardShortcuts: false, + censorUsernameInLogs: false, }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true, diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index ccf674d8532..a71770149cf 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,3 @@ -import { useUserPreferences } from "../hooks/useUserPreferences"; import { SetupWizardSidebarOutlet } from "./SetupWizard"; import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; @@ -33,7 +32,6 @@ import { SecondarySidebar } from "./SecondarySidebar"; import { ContextualSidebarFrame } from "./ContextualSidebarFrame"; import { SidebarAccountMenu } from "./SidebarAccountMenu"; import { useDialogActions } from "../context/DialogContext"; -import { GeneralSettingsProvider } from "../context/GeneralSettingsContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; @@ -241,7 +239,6 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { }, refetchIntervalInBackground: false, }); - const keyboardShortcutsEnabled = useUserPreferences().data?.keyboardShortcuts === true; useLayoutEffect(() => { setForceCollapsed(!streamlinedUiEnabled && hasSecondarySidebar); @@ -450,7 +447,6 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { useCompanyPageMemory(); useKeyboardShortcuts({ - enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), onSearch: openSearch, onToggleSidebar: toggleSidebar, @@ -615,7 +611,6 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { return ( -
-
); } diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx index d35ead188b5..55cfbb48f6d 100644 --- a/ui/src/components/LegacyIssuesList.tsx +++ b/ui/src/components/LegacyIssuesList.tsx @@ -81,7 +81,6 @@ import { import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { getInboxKeyboardSelectionIndex } from "../lib/inbox"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; -import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { statusBadge } from "../lib/status-colors"; import { workflowSort } from "../lib/workflow-sort"; @@ -712,7 +711,6 @@ export function IssuesList({ const rootRef = useRef(null); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { keyboardShortcutsEnabled } = useGeneralSettings(); // Keyboard selection for the list view (mirrors the inbox). Hover moves the // selection only after real pointer movement, so keyboard-driven scrolling // doesn't hand the selection to whatever row lands under the cursor. @@ -1382,7 +1380,6 @@ export function IssuesList({ }, []); useEffect(() => { - if (!keyboardShortcutsEnabled) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; const target = e.target; @@ -1480,7 +1477,7 @@ export function IssuesList({ }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [keyboardShortcutsEnabled, navigate, queryClient]); + }, [navigate, queryClient]); // Keep the keyboard selection visible while navigating. Depends on the // render budget too: a selection past the mounted batch scrolls once its diff --git a/ui/src/context/GeneralSettingsContext.tsx b/ui/src/context/GeneralSettingsContext.tsx deleted file mode 100644 index a3917ed647f..00000000000 --- a/ui/src/context/GeneralSettingsContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { ReactNode } from "react"; -import { createContext, useContext } from "react"; - -export interface GeneralSettingsContextValue { - keyboardShortcutsEnabled: boolean; -} - -const GeneralSettingsContext = createContext({ - keyboardShortcutsEnabled: false, -}); - -export function GeneralSettingsProvider({ - value, - children, -}: { - value: GeneralSettingsContextValue; - children: ReactNode; -}) { - return ( - - {children} - - ); -} - -export function useGeneralSettings() { - return useContext(GeneralSettingsContext); -} diff --git a/ui/src/hooks/useKeyboardShortcuts.test.tsx b/ui/src/hooks/useKeyboardShortcuts.test.tsx index 3e87fc57f48..f484728a2bf 100644 --- a/ui/src/hooks/useKeyboardShortcuts.test.tsx +++ b/ui/src/hooks/useKeyboardShortcuts.test.tsx @@ -18,7 +18,6 @@ function TestHarness({ onGoToInbox?: () => void; }) { useKeyboardShortcuts({ - enabled: true, onNewIssue, onSearch, onGoToInbox, @@ -110,6 +109,34 @@ describe("useKeyboardShortcuts", () => { }); }); + it("ignores bare shortcuts while a modal dialog is open", () => { + const root = createRoot(container); + const onNewIssue = vi.fn(); + const onSearch = vi.fn(); + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + document.body.appendChild(dialog); + + act(() => { + root.render(); + }); + + for (const key of ["c", "/"]) { + document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); + } + expect(onNewIssue).not.toHaveBeenCalled(); + expect(onSearch).not.toHaveBeenCalled(); + + dialog.remove(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true })); + expect(onNewIssue).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + it("does not intercept the retired Cmd/Ctrl+B collapse shortcut", () => { const root = createRoot(container); diff --git a/ui/src/hooks/useKeyboardShortcuts.ts b/ui/src/hooks/useKeyboardShortcuts.ts index c246ba8cbf4..c6d0b9bcafd 100644 --- a/ui/src/hooks/useKeyboardShortcuts.ts +++ b/ui/src/hooks/useKeyboardShortcuts.ts @@ -7,7 +7,6 @@ import { } from "../lib/keyboardShortcuts"; interface ShortcutHandlers { - enabled?: boolean; onNewIssue?: () => void; onSearch?: () => void; onToggleSidebar?: () => void; @@ -17,7 +16,6 @@ interface ShortcutHandlers { } export function useKeyboardShortcuts({ - enabled = true, onNewIssue, onSearch, onToggleSidebar, @@ -26,8 +24,6 @@ export function useKeyboardShortcuts({ onGoToInbox, }: ShortcutHandlers) { useEffect(() => { - if (!enabled) return; - // g → i chord state. IssueDetail runs its own capture-phase handler with // extra chords (g c, g f) and stops propagation when it handles one, so // this bubble-phase chord only fires outside the issue detail page. @@ -94,12 +90,14 @@ export function useKeyboardShortcuts({ return; } + // Don't fire shortcuts over a modal dialog. The dialog owns the + // keyboard until it closes (Escape or its own controls). + if (hasBlockingShortcutDialog()) { + return; + } + // / → Page search when available, otherwise quick search if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) { - if (hasBlockingShortcutDialog()) { - return; - } - e.preventDefault(); if (!focusPageSearchShortcutTarget()) { onSearch?.(); @@ -147,5 +145,5 @@ export function useKeyboardShortcuts({ document.removeEventListener("focusin", handleFocusIn, true); document.removeEventListener("keydown", handleKeyDown); }; - }, [enabled, onNewIssue, onSearch, onToggleSidebar, onTogglePanel, onShowShortcuts, onGoToInbox]); + }, [onNewIssue, onSearch, onToggleSidebar, onTogglePanel, onShowShortcuts, onGoToInbox]); } diff --git a/ui/src/hooks/useUserPreferences.test.tsx b/ui/src/hooks/useUserPreferences.test.tsx deleted file mode 100644 index dd1cd0def44..00000000000 --- a/ui/src/hooks/useUserPreferences.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment jsdom -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { expect, it, vi } from "vitest"; -import { useUserPreferences } from "./useUserPreferences"; -import { queryKeys } from "../lib/queryKeys"; - -const getPreferences = vi.hoisted(() => vi.fn()); -vi.mock("../api/auth", () => ({ authApi: { getSession: vi.fn(), getPreferences } })); - -it("does not reuse another account's enabled shortcuts while preferences load", async () => { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); - client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); - client.setQueryData(queryKeys.auth.preferences("user-1"), { keyboardShortcuts: true }); - getPreferences.mockReturnValue(new Promise(() => {})); - const container = document.createElement("div"); - const root = createRoot(container); - function Probe() { - return {useUserPreferences().data?.keyboardShortcuts === true ? "enabled" : "disabled"}; - } - await act(async () => root.render()); - expect(container.textContent).toBe("enabled"); - await act(async () => { - client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.textContent).toBe("disabled"); - expect(getPreferences).toHaveBeenCalledWith("user-2"); - await act(async () => root.unmount()); - client.clear(); -}); - - -it("disables cached shortcuts when an account-bound refetch is rejected", async () => { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); - client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); - client.setQueryData(queryKeys.auth.preferences("user-1"), { keyboardShortcuts: true }); - getPreferences.mockRejectedValue(new Error("Account changed. Refresh and try again.")); - const container = document.createElement("div"); - const root = createRoot(container); - function Probe() { - return {useUserPreferences().data?.keyboardShortcuts === true ? "enabled" : "disabled"}; - } - await act(async () => root.render()); - expect(container.textContent).toBe("enabled"); - await act(async () => { - await client.invalidateQueries({ queryKey: queryKeys.auth.preferences("user-1") }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.textContent).toBe("disabled"); - expect(getPreferences).toHaveBeenCalledWith("user-1"); - expect(client.getQueryData(queryKeys.auth.preferences("user-1"))).toEqual({ keyboardShortcuts: true }); - await act(async () => root.unmount()); - client.clear(); -}); diff --git a/ui/src/hooks/useUserPreferences.ts b/ui/src/hooks/useUserPreferences.ts deleted file mode 100644 index 2c41234264a..00000000000 --- a/ui/src/hooks/useUserPreferences.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { authApi } from "../api/auth"; -import { queryKeys } from "../lib/queryKeys"; - -export function useUserPreferences() { - const session = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - retry: false, - }); - const userId = session.data?.user?.id ?? null; - const preferences = useQuery({ - queryKey: queryKeys.auth.preferences(userId), - queryFn: () => authApi.getPreferences(userId!), - enabled: !!userId, - retry: false, - }); - return { ...preferences, data: preferences.isError ? undefined : preferences.data }; -} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 37bbd92812a..5e3ab064b6d 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -594,7 +594,6 @@ export const queryKeys = { currentBoardAccess: ["access", "current-board-access"] as const, }, auth: { - preferences: (userId: string | null) => ["auth", "preferences", userId] as const, session: ["auth", "session"] as const, }, inboxAgentPolicy: { diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index fbf15f16728..332b321ad2d 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -229,7 +229,6 @@ function makeInstanceSettings({ defaultEnvironmentId, general: { censorUsernameInLogs: true, - keyboardShortcuts: true, feedbackDataSharingPreference: "prompt", backupRetention: { dailyDays: 7, diff --git a/ui/src/pages/Cases.test.tsx b/ui/src/pages/Cases.test.tsx index 3caec1c8cf7..643992f2a98 100644 --- a/ui/src/pages/Cases.test.tsx +++ b/ui/src/pages/Cases.test.tsx @@ -18,11 +18,9 @@ const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() })); const mockIssuesApi = vi.hoisted(() => ({ listLabels: vi.fn() })); const mockCopyTextToClipboard = vi.hoisted(() => vi.fn(() => Promise.resolve())); const mockNavigate = vi.hoisted(() => vi.fn()); -const generalSettingsState = vi.hoisted(() => ({ keyboardShortcutsEnabled: false })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => companyState })); vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); -vi.mock("@/context/GeneralSettingsContext", () => ({ useGeneralSettings: () => generalSettingsState })); vi.mock("@/api/cases", async (importOriginal) => ({ ...(await importOriginal()), casesApi: mockCasesApi, @@ -114,7 +112,6 @@ describe("Cases list", () => { mockIssuesApi.listLabels.mockReset().mockResolvedValue([]); mockCopyTextToClipboard.mockClear(); mockNavigate.mockClear(); - generalSettingsState.keyboardShortcutsEnabled = false; HTMLElement.prototype.scrollIntoView = vi.fn(); }); afterEach(() => { @@ -413,7 +410,6 @@ describe("Cases list", () => { }); it("supports inbox-style keyboard navigation, group folding, and opening on grouped case rows", async () => { - generalSettingsState.keyboardShortcutsEnabled = true; mockCasesApi.list.mockResolvedValue([ createCase({ id: "blog", @@ -462,7 +458,6 @@ describe("Cases list", () => { }); it("supports keyboard tree folding and opening parent case rows", async () => { - generalSettingsState.keyboardShortcutsEnabled = true; window.localStorage.setItem( "paperclip:cases:company-1:view", JSON.stringify({ diff --git a/ui/src/pages/Cases.tsx b/ui/src/pages/Cases.tsx index 284ef37d250..a5b60465dcc 100644 --- a/ui/src/pages/Cases.tsx +++ b/ui/src/pages/Cases.tsx @@ -4,7 +4,6 @@ import { ArrowUpDown, Check, ChevronDown, Columns3, Filter, Layers, ListTree, Se import { Link, useCaseHref, useNavigate } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; -import { useGeneralSettings } from "@/context/GeneralSettingsContext"; import { queryKeys } from "@/lib/queryKeys"; import { casesApi, CASE_STATUSES, TERMINAL_CASE_STATUSES, type CaseStatus, type CaseSummary } from "@/api/cases"; import { projectsApi } from "@/api/projects"; @@ -742,7 +741,6 @@ function CasesEmptyHero() { export function Cases() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const { keyboardShortcutsEnabled } = useGeneralSettings(); const queryClient = useQueryClient(); const navigate = useNavigate(); const caseHref = useCaseHref(); @@ -1122,8 +1120,6 @@ export function Cases() { } useEffect(() => { - if (!keyboardShortcutsEnabled) return; - function handleKeyDown(event: KeyboardEvent) { if (event.defaultPrevented) return; const target = event.target; @@ -1183,7 +1179,7 @@ export function Cases() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [caseHref, keyboardNavItems, keyboardShortcutsEnabled, navigate, selectedIndex, viewState.treeView]); + }, [caseHref, keyboardNavItems, navigate, selectedIndex, viewState.treeView]); if (casesQuery.isLoading) return ; diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 829b93bd1e3..caf85d5ba1a 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -120,11 +120,6 @@ vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }), })); -const generalSettingsMock = { keyboardShortcutsEnabled: false }; -vi.mock("../context/GeneralSettingsContext", () => ({ - useGeneralSettings: () => generalSettingsMock, -})); - vi.mock("../hooks/useInboxBadge", () => ({ useDismissedInboxAlerts: () => ({ dismissed: new Set(), dismiss: vi.fn() }), useInboxDismissals: () => ({ dismissedAtByKey: new Map(), dismiss: vi.fn() }), @@ -973,8 +968,7 @@ describe("Inbox toolbar", () => { // state-selected band (which would swap to hover:bg-transparent). Coupling // hover to React state was the per-hover re-render storm behind the lag; // scrubbing the list must not touch selection state. (Keyboard nav that - // continues from the hovered row is exercised in live/e2e verification — - // this unit mocks keyboardShortcutsEnabled off.) + // continues from the hovered row is exercised in live/e2e verification.) await act(async () => { rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false })); @@ -1060,7 +1054,6 @@ describe("Inbox toolbar", () => { it("keeps hover→j/k selection in sync after the list reshapes (PAP-9679)", async () => { routerMock.location.pathname = "/inbox/mine"; - generalSettingsMock.keyboardShortcutsEnabled = true; const issueA = createIssue({ id: "issue-a", identifier: "PAP-2001", title: "Sync row A" }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-2002", title: "Sync row B" }); const issueC = createIssue({ id: "issue-c", identifier: "PAP-2003", title: "Sync row C" }); @@ -1118,7 +1111,6 @@ describe("Inbox toolbar", () => { }); expect(selectedRowIndex()).toBe(2); } finally { - generalSettingsMock.keyboardShortcutsEnabled = false; act(() => { root.unmount(); }); @@ -1342,7 +1334,6 @@ describe("Inbox toolbar", () => { }); it("restores a locally hidden archive when undo is pressed", async () => { - generalSettingsMock.keyboardShortcutsEnabled = true; routerMock.location.pathname = "/inbox/mine"; const archivedIssue = createIssue({ id: "issue-a", @@ -1386,7 +1377,6 @@ describe("Inbox toolbar", () => { expect(container.textContent).toContain("Undoable inbox row"); }); } finally { - generalSettingsMock.keyboardShortcutsEnabled = false; act(() => root.unmount()); } }); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 1dcbe23af8b..f4808ebab83 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -23,7 +23,6 @@ import { import { useCompany } from "../context/CompanyContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { useSidebar } from "../context/SidebarContext"; import { queryKeys } from "../lib/queryKeys"; import { useDialogActions } from "../context/DialogContext"; @@ -806,7 +805,6 @@ function StreamlinedInbox() { const location = useLocation(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); - const { keyboardShortcutsEnabled } = useGeneralSettings(); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), @@ -2131,8 +2129,6 @@ function StreamlinedInbox() { // Keyboard shortcuts (mail-client style) — single stable listener using refs useEffect(() => { - if (!keyboardShortcutsEnabled) return; - const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; @@ -2321,7 +2317,7 @@ function StreamlinedInbox() { }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [issueLinkState, keyboardShortcutsEnabled, noteInboxSortInteraction]); + }, [issueLinkState, noteInboxSortInteraction]); // Scroll selected item into view useEffect(() => { diff --git a/ui/src/pages/InstanceGeneralSettings.test.tsx b/ui/src/pages/InstanceGeneralSettings.test.tsx index 0cf38db7625..0efa275d8bc 100644 --- a/ui/src/pages/InstanceGeneralSettings.test.tsx +++ b/ui/src/pages/InstanceGeneralSettings.test.tsx @@ -57,7 +57,6 @@ describe("InstanceGeneralSettings sign-out", () => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "not_allowed", backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, }); @@ -218,7 +217,6 @@ describe("InstanceGeneralSettings operator-hidden sections", () => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, - keyboardShortcuts: false, feedbackDataSharingPreference: "not_allowed", backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, }); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 43705c15932..70b4d86ad55 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -103,7 +103,6 @@ const mockAccessApi = vi.hoisted(() => ({ const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), - getPreferences: vi.fn(), })); const mockProjectsApi = vi.hoisted(() => ({ @@ -1373,11 +1372,9 @@ describe("IssueDetail", () => { }); mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] }); mockAuthApi.getSession.mockResolvedValue({ session: null, user: null }); - mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: false }); mockProjectsApi.list.mockResolvedValue([]); mockDecisionsApi.list.mockResolvedValue([]); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ @@ -2839,9 +2836,7 @@ describe("IssueDetail", () => { ); mockIssuesApi.get.mockResolvedValue(createIssue()); mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" } }); - mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: true }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); @@ -2881,9 +2876,7 @@ describe("IssueDetail", () => { ); mockIssuesApi.get.mockResolvedValue(createIssue()); mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" } }); - mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: true }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ - keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a358ec337c8..4ba7730f48a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,6 +1,5 @@ import { TextAttachmentContext } from "../context/TextAttachmentContext"; import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery"; -import { useUserPreferences } from "../hooks/useUserPreferences"; import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; import { mergeComposerRunSettings, type ComposerRunSettings } from "@/components/task-chat/composer-run-settings"; @@ -3391,7 +3390,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS enabled: !!issueId, retry: false, }); - const keyboardShortcutsEnabled = useUserPreferences().data?.keyboardShortcuts === true; // Experimental Cases: linkify `PAP-C7` chips in this issue's comment bodies. const casesChipsEnabled = instanceExperimentalSettings?.enableCases === true; const feedbackDataSharingPreference = @@ -5749,8 +5747,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const goToInboxShortcutArmedRef = useRef(false); const goToInboxShortcutTimeoutRef = useRef(null); - const canQuickArchiveFromInbox = - keyboardShortcutsEnabled && !issue?.hiddenAt; + const canQuickArchiveFromInbox = !issue?.hiddenAt; useEffect(() => { if (!issue?.id || !canQuickArchiveFromInbox) return; @@ -5781,15 +5778,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS }, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]); useEffect(() => { - if (!keyboardShortcutsEnabled) { - goToInboxShortcutArmedRef.current = false; - if (goToInboxShortcutTimeoutRef.current !== null) { - window.clearTimeout(goToInboxShortcutTimeoutRef.current); - goToInboxShortcutTimeoutRef.current = null; - } - return; - } - const clearArmTimeout = () => { if (goToInboxShortcutTimeoutRef.current !== null) { window.clearTimeout(goToInboxShortcutTimeoutRef.current); @@ -5878,7 +5866,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS }; }, [ fileViewerEnabled, - keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href, ]); diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx index a7a34e00966..35196635373 100644 --- a/ui/src/pages/LegacyInbox.tsx +++ b/ui/src/pages/LegacyInbox.tsx @@ -23,7 +23,6 @@ import { import { useCompany } from "../context/CompanyContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { useSidebar } from "../context/SidebarContext"; import { queryKeys } from "../lib/queryKeys"; import { useDialogActions } from "../context/DialogContext"; @@ -700,7 +699,6 @@ export function Inbox() { const location = useLocation(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); - const { keyboardShortcutsEnabled } = useGeneralSettings(); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), @@ -2010,8 +2008,6 @@ export function Inbox() { // Keyboard shortcuts (mail-client style) — single stable listener using refs useEffect(() => { - if (!keyboardShortcutsEnabled) return; - const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; @@ -2200,7 +2196,7 @@ export function Inbox() { }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [issueLinkState, keyboardShortcutsEnabled, noteInboxSortInteraction]); + }, [issueLinkState, noteInboxSortInteraction]); // Scroll selected item into view useEffect(() => { diff --git a/ui/src/pages/ProfileSettings.test.tsx b/ui/src/pages/ProfileSettings.test.tsx index c53ca77d71f..3b81f9adc56 100644 --- a/ui/src/pages/ProfileSettings.test.tsx +++ b/ui/src/pages/ProfileSettings.test.tsx @@ -9,8 +9,6 @@ import { ProfileSettings } from "./ProfileSettings"; const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), - getPreferences: vi.fn(), - updatePreferences: vi.fn(), signInEmail: vi.fn(), signUpEmail: vi.fn(), getProfile: vi.fn(), @@ -72,8 +70,6 @@ describe("ProfileSettings", () => { image: "https://example.com/jane.png", }, }); - mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: false }); - mockAuthApi.updatePreferences.mockResolvedValue({ keyboardShortcuts: true }); mockAssetsApi.uploadImage.mockResolvedValue({ assetId: "asset-1", contentPath: "/api/assets/asset-1/content", @@ -92,38 +88,16 @@ describe("ProfileSettings", () => { vi.clearAllMocks(); }); - it("saves personal shortcuts and immediately updates the user-scoped cache", async () => { + it("does not render a keyboard shortcuts toggle because shortcuts are always enabled", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { root.render(); }); await flushReact(); - await flushReact(); - const toggle = container.querySelector('[aria-label="Toggle keyboard shortcuts"]'); - expect(toggle).not.toBeNull(); - expect(toggle?.disabled).toBe(false); - await act(async () => toggle?.click()); - await flushReact(); - expect(mockAuthApi.updatePreferences).toHaveBeenCalledWith({ companyId: "company-1", keyboardShortcuts: true, expectedUserId: "user-1" }, expect.anything()); - expect(queryClient.getQueryData(["auth", "preferences", "user-1"])).toEqual({ keyboardShortcuts: true }); - expect(mockAuthApi.updateProfile).not.toHaveBeenCalled(); - await act(async () => root.unmount()); - }); - - it("shows a preference save failure and leaves shortcuts disabled", async () => { - mockAuthApi.updatePreferences.mockRejectedValueOnce(new Error("Could not save shortcuts")); - const root = createRoot(container); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - await act(async () => { - root.render(); - }); - await flushReact(); - await flushReact(); - await act(async () => container.querySelector('[aria-label="Toggle keyboard shortcuts"]')?.click()); - await flushReact(); - expect(container.textContent).toContain("Could not save shortcuts"); - expect(queryClient.getQueryData(["auth", "preferences", "user-1"])).toEqual({ keyboardShortcuts: false }); + expect(container.textContent).toContain("Jane Example"); + expect(container.textContent).not.toContain("Keyboard shortcuts"); + expect(container.querySelector('[aria-label="Toggle keyboard shortcuts"]')).toBeNull(); await act(async () => root.unmount()); }); diff --git a/ui/src/pages/ProfileSettings.tsx b/ui/src/pages/ProfileSettings.tsx index d79af6468cd..1b7717c5ac3 100644 --- a/ui/src/pages/ProfileSettings.tsx +++ b/ui/src/pages/ProfileSettings.tsx @@ -13,8 +13,6 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { ToggleSwitch } from "@/components/ui/toggle-switch"; -import { useUserPreferences } from "../hooks/useUserPreferences"; function deriveInitials(name: string) { const parts = name.trim().split(/\s+/).filter(Boolean); @@ -26,7 +24,6 @@ export function ProfileSettings() { const { setBreadcrumbs } = useBreadcrumbs(); const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); - const preferencesQuery = useUserPreferences(); const avatarInputId = useId(); const avatarInputRef = useRef(null); const [name, setName] = useState(""); @@ -37,13 +34,6 @@ export function ProfileSettings() { queryFn: () => authApi.getSession(), retry: false, }); - const updatePreferencesMutation = useMutation({ - mutationFn: authApi.updatePreferences, - onMutate: () => sessionQuery.data?.user.id ?? null, - onSuccess: (preferences, _input, userId) => { - queryClient.setQueryData(queryKeys.auth.preferences(userId), preferences); - }, - }); useEffect(() => { setBreadcrumbs([ @@ -280,31 +270,6 @@ export function ProfileSettings() { -
-
-
-

Keyboard shortcuts

-

- Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating tasks or - toggling panels. This applies only to your account, across all organizations and devices. Off by default. -

-
- { - if (selectedCompanyId && sessionQuery.data?.user.id) updatePreferencesMutation.mutate({ companyId: selectedCompanyId, keyboardShortcuts, expectedUserId: sessionQuery.data.user.id }); - }} - disabled={!selectedCompanyId || !preferencesQuery.data || preferencesQuery.isError || updatePreferencesMutation.isPending} - aria-label="Toggle keyboard shortcuts" - /> -
- {preferencesQuery.error || updatePreferencesMutation.error ? ( -

- {(updatePreferencesMutation.error ?? preferencesQuery.error)?.message} -

- ) : null} -
- diff --git a/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx b/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx index f43165b7993..f2e4fb2f458 100644 --- a/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx +++ b/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx @@ -160,7 +160,6 @@ function TaskPageData({ children, scenario, withAncestors }: { children: React.R } } client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" }); - client.setQueryData(queryKeys.auth.preferences(storybookAuthSession.user.id), { keyboardShortcuts: true }); client.setQueryData(queryKeys.access.currentBoardAccess, { companyIds: [] }); client.setQueryData(queryKeys.issues.listCreatedFromIssue(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.originRunId && runSources.get(row.originRunId) === sourceTask.id)); client.setQueryData(queryKeys.issues.listByDescendantRoot(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.parentId === sourceTask.id)); @@ -178,7 +177,6 @@ function TaskPageData({ children, scenario, withAncestors }: { children: React.R const url = new URL(raw, window.location.origin); const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); if (url.pathname === "/api/health") return Response.json({ status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" }); - if (url.pathname === "/api/auth/preferences") return Response.json({ keyboardShortcuts: true }); const projectMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/); if (projectMatch && method === "GET") { const project = storybookProjects.find((item) => item.id === projectMatch[1] || projectRouteRef(item) === projectMatch[1]);