fix: always enable keyboard shortcuts (#14643)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The web UI has keyboard shortcuts for the inbox, task lists, cases,
and task detail, plus global shortcuts such as `c`, `/`, `?`, `[`, and
`]`
> - Shortcut enablement was an instance-wide General setting until
#14141 moved it to a per-user preference that defaults to off
> - The move did not carry the old instance value over, so every
existing user lost shortcuts on upgrade and had to find a new toggle
under Profile settings
> - A toggle that only turns off a standard, input-safe feature costs a
setting, a database column, two API routes, and a React context for
little benefit
> - This pull request removes both the instance setting and the personal
preference and enables keyboard shortcuts for every signed-in user
> - The benefit is one less thing to configure, no silent loss of
shortcuts on upgrade, and less code to maintain

## Linked Issues or Issue Description

Refs #14141 (the change that introduced the personal preference).

**What existing behavior does this improve?**

Keyboard shortcuts in the web UI stay off unless each user turns them on
in Profile settings.

**Subsystem affected**

Web UI shortcuts, Profile settings, instance general settings, the
`/api/auth/preferences` routes, and the `user` table.

**Current behavior**

Shortcuts default to off per user. #14141 moved the toggle from Instance
settings → General to Profile settings and did not carry the old
instance value over. Users who had shortcuts on lost them after the
upgrade and had to find the new toggle.

**Proposed behavior**

Keyboard shortcuts are always enabled for every signed-in user. There is
no instance setting and no personal preference. Shortcuts already ignore
key presses inside text inputs and modal dialogs, so an opt-out is not
needed.

**Reason and benefit**

Fewer settings, no silent loss of shortcuts on upgrade, and removal of a
database column, two API routes, a query hook, and a React context that
existed only to gate this feature.

**Breaking changes**

`GET` and `PATCH /api/auth/preferences` are removed. `PATCH
/api/instance/settings/general` no longer accepts `keyboardShortcuts`;
that schema is strict, so the key now returns 400.
`instance.general.keyboardShortcuts` is no longer a valid
`PAPERCLIP_HIDDEN_SETTINGS` key; the parser ignores unknown keys with a
warning.

## What Changed

- Removed the Keyboard shortcuts section from Profile settings, the
`useUserPreferences` hook, `queryKeys.auth.preferences`, and
`authApi.getPreferences` / `authApi.updatePreferences`.
- Removed `GeneralSettingsContext`. The inbox, legacy inbox, task list,
legacy task list, cases, and task detail pages no longer gate their key
handlers.
- Removed the `enabled` option from `useKeyboardShortcuts`. The app
shell always registers the global shortcuts.
- Removed `GET` and `PATCH /api/auth/preferences`, their OpenAPI
entries, and the `currentUserPreferencesSchema` /
`updateCurrentUserPreferencesSchema` validators.
- Removed `keyboardShortcuts` from `InstanceGeneralSettings`, the
general settings zod schema, the settings service defaults, and
`HIDEABLE_GENERAL_SECTIONS`.
- Added migration `0289_drop_user_keyboard_shortcuts`, which drops
`user.keyboard_shortcuts`.
- Updated `AGENTS.md`, `doc/SPEC.md`, `doc/SPEC-implementation.md`, and
`docs/deploy/environment-variables.md`.
- Parsed the stored general settings row with
`instanceGeneralSettingsSchema.strip()` in the feedback vote path, so a
retired key left in the row cannot reset the sharing preference to
`prompt` and overwrite the stored choice.
- Kept every bare global shortcut (`c`, `?`, `[`, `]`, `/`) out of open
modal dialogs in `useKeyboardShortcuts`; only `/` had that guard before.
- Updated the affected tests and added a Profile settings test that
asserts the toggle is gone, a hook test for the modal dialog guard, and
a feedback service regression test for the retired-key case.

## Verification

- Typecheck passes for `@paperclipai/shared`, `@paperclipai/db`
(including the migration numbering and safety checks),
`@paperclipai/server`, and `ui`.
- `pnpm exec vitest run
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/openapi-routes.test.ts
server/src/__tests__/auth-routes.test.ts
server/src/__tests__/sentry.test.ts` → 119 passed.
- `pnpm exec vitest run ui/src/components/Layout.test.tsx
ui/src/pages/ProfileSettings.test.tsx ui/src/pages/IssueDetail.test.tsx
ui/src/pages/Inbox.test.tsx ui/src/pages/Cases.test.tsx
ui/src/hooks/useKeyboardShortcuts.test.tsx ui/src/pages/Agents.test.tsx
ui/src/pages/InstanceGeneralSettings.test.tsx` → 286 passed.
- `pnpm exec vitest run packages/shared/src/settings-visibility.test.ts`
→ 16 passed.
- `pnpm exec vitest run ui/src/hooks/useKeyboardShortcuts.test.tsx` → 7
passed.
- `pnpm exec vitest run server/src/__tests__/feedback-service.test.ts`
(embedded Postgres) → the new retired-key test passes with the fix and
fails without it.
- Manual: sign in with no settings changed, open the inbox, press `j`
and `k` to move the selection, press `?` to open the cheatsheet. Open
Settings → Profile and confirm there is no Keyboard shortcuts section.

## Risks

- The migration drops a column. It uses `DROP COLUMN IF EXISTS`, and the
column has no readers after this change. If you roll back to a build
from before this PR after the migration has run, re-add the column
first: `ALTER TABLE "user" ADD COLUMN "keyboard_shortcuts" boolean
DEFAULT false NOT NULL;`. The older build's ORM selects that column when
it loads users.
- Any external client that still sends `keyboardShortcuts` to `PATCH
/api/instance/settings/general` receives a 400. No in-repo client does.
- Stored `instance_settings.general.keyboardShortcuts` values are
stripped on read and ignored.
- Users who never turned the toggle on now get shortcuts. The handlers
skip text inputs, contenteditable regions, and modal dialogs, so typing
is unaffected.

## Model Used

Claude Fable 5.1 (`claude-fable-5-1`) in Claude Code, with extended
thinking and tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley authored and GitHub committed 2026-09-29 21:28:06 -07:00
1 parent 0b12ca9532
commit f38b5693f6
46 files changed
+1019 -590

No files matched your search

+1 -4
View File
@@ -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.
+3 -14
View File
@@ -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)
+3 -14
View File
@@ -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
+1 -1
View File
@@ -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`.
@@ -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";
@@ -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": "",
+7
View File
@@ -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
}
]
}
-1
View File
@@ -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(),
});
-4
View File
@@ -2330,10 +2330,6 @@ export {
resolveCliAuthChallengeSchema,
createBoardApiKeySchema,
currentUserProfileSchema,
currentUserPreferencesSchema,
updateCurrentUserPreferencesSchema,
type CurrentUserPreferences,
type UpdateCurrentUserPreferences,
authSessionSchema,
updateCurrentUserProfileSchema,
updateCompanyMemberSchema,
@@ -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",
-2
View File
@@ -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;
/**
-12
View File
@@ -220,15 +220,3 @@ export const updateCurrentUserProfileSchema = z.object({
});
export type UpdateCurrentUserProfile = z.infer<typeof updateCurrentUserProfileSchema>;
// 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<typeof currentUserPreferencesSchema>;
export type UpdateCurrentUserPreferences = z.infer<typeof updateCurrentUserPreferencesSchema>;
-4
View File
@@ -757,10 +757,6 @@ export {
resolveCliAuthChallengeSchema,
createBoardApiKeySchema,
currentUserProfileSchema,
currentUserPreferencesSchema,
updateCurrentUserPreferencesSchema,
type CurrentUserPreferences,
type UpdateCurrentUserPreferences,
authSessionSchema,
updateCurrentUserProfileSchema,
updateCompanyMemberSchema,
@@ -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,
),
@@ -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();
@@ -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 },
});
@@ -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 }),
]));
});
@@ -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<typeof dialect.sqlToQuery>[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<typeof dialect.sqlToQuery>[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<typeof dialect.sqlToQuery>[0]) => read(where) }) }),
update,
transaction: async (fn: (tx: unknown) => Promise<unknown>): Promise<unknown> => {
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();
});
});
+1 -56
View File
@@ -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;
}
-18
View File
@@ -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({
+4 -1
View File
@@ -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({});
}
-2
View File
@@ -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,
};
-20
View File
@@ -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<CurrentUserPreferences> => {
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<CurrentUserPreferences> =>
authPatch("/preferences", input, (payload) => currentUserPreferencesSchema.parse(payload)),
getProfile: async (): Promise<CurrentUserProfile> => {
const res = await fetch("/api/auth/profile", {
credentials: "include",
+1 -4
View File
@@ -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<HTMLDivElement | null>(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
-6
View File
@@ -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 (
<ChatSetupSidebarProvider>
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
@@ -788,7 +783,6 @@ export function Layout() {
<ToastViewport />
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</GeneralSettingsProvider>
</ChatSetupSidebarProvider>
);
}
+1 -1
View File
@@ -318,7 +318,7 @@ describe("Layout", () => {
version: "1.2.3",
});
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: false,
censorUsernameInLogs: false,
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
-6
View File
@@ -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 (
<ChatSetupSidebarProvider>
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
@@ -790,7 +785,6 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<AnnouncementWell health={health} />
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</GeneralSettingsProvider>
</ChatSetupSidebarProvider>
);
}
+1 -4
View File
@@ -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<HTMLDivElement | null>(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
-28
View File
@@ -1,28 +0,0 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
export interface GeneralSettingsContextValue {
keyboardShortcutsEnabled: boolean;
}
const GeneralSettingsContext = createContext<GeneralSettingsContextValue>({
keyboardShortcutsEnabled: false,
});
export function GeneralSettingsProvider({
value,
children,
}: {
value: GeneralSettingsContextValue;
children: ReactNode;
}) {
return (
<GeneralSettingsContext.Provider value={value}>
{children}
</GeneralSettingsContext.Provider>
);
}
export function useGeneralSettings() {
return useContext(GeneralSettingsContext);
}
+28 -1
View File
@@ -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(<TestHarness onNewIssue={onNewIssue} onSearch={onSearch} />);
});
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);
+7 -9
View File
@@ -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]);
}
-56
View File
@@ -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 <span>{useUserPreferences().data?.keyboardShortcuts === true ? "enabled" : "disabled"}</span>;
}
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
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 <span>{useUserPreferences().data?.keyboardShortcuts === true ? "enabled" : "disabled"}</span>;
}
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
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();
});
-19
View File
@@ -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 };
}
-1
View File
@@ -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: {
-1
View File
@@ -229,7 +229,6 @@ function makeInstanceSettings({
defaultEnvironmentId,
general: {
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "prompt",
backupRetention: {
dailyDays: 7,
-5
View File
@@ -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<typeof import("@/api/cases")>()),
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({
+1 -5
View File
@@ -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 <PageSkeleton variant="list" />;
+1 -11
View File
@@ -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());
}
});
+1 -5
View File
@@ -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<string | null>(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(() => {
@@ -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 },
});
-7
View File
@@ -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",
});
+1 -14
View File
@@ -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<number | null>(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,
]);
+1 -5
View File
@@ -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<string | null>(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(() => {
+4 -30
View File
@@ -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(<QueryClientProvider client={queryClient}><ProfileSettings /></QueryClientProvider>);
});
await flushReact();
await flushReact();
const toggle = container.querySelector<HTMLButtonElement>('[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(<QueryClientProvider client={queryClient}><ProfileSettings /></QueryClientProvider>);
});
await flushReact();
await flushReact();
await act(async () => container.querySelector<HTMLButtonElement>('[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());
});
-35
View File
@@ -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<HTMLInputElement | null>(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() {
</div>
</form>
<section>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
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.
</p>
</div>
<ToggleSwitch
checked={preferencesQuery.data?.keyboardShortcuts === true}
onCheckedChange={(keyboardShortcuts) => {
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"
/>
</div>
{preferencesQuery.error || updatePreferencesMutation.error ? (
<p role="alert" className="text-sm text-destructive">
{(updatePreferencesMutation.error ?? preferencesQuery.error)?.message}
</p>
) : null}
</section>
<InboxAgentPolicyControl companyId={selectedCompanyId} />
</section>
</div>
@@ -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]);