mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 01:57:33 +02:00
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:
1 parent
0b12ca9532
commit
f38b5693f6
46 files changed
+1019
-590
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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";
|
||||
+910
-10
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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({});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -318,7 +318,7 @@ describe("Layout", () => {
|
||||
version: "1.2.3",
|
||||
});
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
censorUsernameInLogs: false,
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableApps: true,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -229,7 +229,6 @@ function makeInstanceSettings({
|
||||
defaultEnvironmentId,
|
||||
general: {
|
||||
censorUsernameInLogs: true,
|
||||
keyboardShortcuts: true,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
backupRetention: {
|
||||
dailyDays: 7,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,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,
|
||||
]);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user