From f2593b7905226117066eb0de035118ab1712effc Mon Sep 17 00:00:00 2001 From: scotttong Date: Thu, 5 Nov 2026 16:28:25 -0800 Subject: [PATCH] feat: make Coordinator and Agent Chat standard and speed up navigation (#19360) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People enter that work through the workspace, agent conversations, and tasks. > - Coordinator and Agent Chat still require experimental enrollment. > - Task link prefetches do not warm the compact queries used by the default task view. Chat also waits for a request when its navigation list already holds the conversation. > - This pull request makes Coordinator and Agent Chat standard and reuses the reads needed for navigation. > - People can choose Classic workspace in Experimental settings. Cached work can appear while fresh data loads. ## Linked Issues or Issue Description **What existing behavior does this improve?** Workspace selection, Agent Chat availability, and navigation between Chat, Tasks, and task details. **Subsystem affected** Cross-cutting: UI, workspace preferences, instance settings, and the database migration. **Current behavior** People must enable Coordinator and Agent Chat in Experimental settings. Opening a task can repeat requests because prefetch warms the full detail and comment queries, while the default view reads compact task data and a recent comment window. Opening Chat waits for a detail request even when the current person's conversation list contains it. **Proposed behavior** Coordinator is the standard workspace for everyone. Experimental settings offer Classic workspace, off by default. Old Coordinator experiment values do not select Classic. Agent Chat is available to everyone, including instances with an old saved or managed off value. Task link intent warms the queries used by the mounted view. Chat can use the current person's company-scoped conversation list during refresh. **Reason and benefit** People can use the standard workspace without setup. Repeat navigation can show cached content while the server refreshes it. **Breaking changes** Existing Coordinator experiment and Agent Chat off values no longer disable these surfaces. Classic requires an explicit new opt-in. The workspace API keeps its existing response shape and audit checks. The migration retains legacy values and adds a separate Classic preference. Execution permissions, company access, and chat privacy checks still apply. **Additional context** Roadmap check: this graduates existing surfaces and improves their navigation. It does not add a new agent execution capability. Duplicate searches found no open Coordinator workspace PR. Related server cache work, Refs: #17828, addresses compact issue-list invalidation; this PR addresses client navigation queries. ## What Changed - Add a default-off Classic workspace preference. Derive the existing workspace API response from that choice. Keep personal persistence, account isolation, and audited writes in the workspace preference service. - Replace the Coordinator experiment control with Classic workspace. Use Coordinator while preferences load. Update the existing settings examples and navigation recipe. - Retire the Agent Chat experiment control. Ignore old stored and managed off values. Retain the compatibility key and the existing execution and access checks. - Reuse account- and company-scoped chat list data with its original freshness timestamp. - Share compact task and recent-comment query options between prefetch and the task view. Keep full-history prefetch for Classic tasks and expanded history. - Add upgrade, replay, cache, account isolation, and browser switching regressions. Update product, database, development, and roadmap documentation. ## Verification - Targeted preference, settings, migration, cache, and UI regression tests passed during implementation. The cache tests hold refresh requests pending and check that content still renders. They also check account isolation and stale refresh behavior. - A real browser test verified fresh Coordinator defaults, Classic opt-in persistence, and return to Coordinator. It used an isolated source checkout with fresh data on macOS. - Token, migration numbering and safety, map, and Storybook catalog checks pass after the rebase. - Repository typecheck and build pass. Typecheck and token gates also pass after review repairs. The broad local run passed all six chat integration shards (1,931 tests). It then found the host-CLI-dependent recovery fixture described below. Its remaining duplicate work was stopped after the full current-head CI matrix passed; this is not a claim that the broad local command completed green. - Review repairs passed 419 UI/migration fixture tests, 19 Coordinator navigation tests, and all 214 tests in the five affected server suites. The 24 Chat/workspace browser tests pass, including standard Coordinator switching and explicit Classic persistence. - The broader browser repair run passed 85 tests. It retained two failed assertions and skipped five later serial cases. The affected files then passed all 11 tests after the fixes: reopen the desktop panel after reload and open More for Audit and Connectors. This includes the five serial cases that had not run. - The broad local suite exposed a recovery fixture that executed the host Codex CLI during a simulated remote probe. On a host with Codex 0.154.0, model fallback broke its fixed-model assertion. The fake sandbox now reports Codex 0.160.0. All 20 selected recovery/provider compatibility cases pass after the repair. - A CLI company import/export suite timed out during server startup in the second CI run. The port fallback is intentional in that fixture. No root cause was established. That suite passes on the final commit in CI. This records recovery, not a claimed product fix for the earlier timeout. - The first CI run exposed remaining assumptions about the retired flags and Classic UI. The repair commit updates those fixtures and navigation steps. It also restores the missing enabled Cases destination in Coordinator. Earlier local recovery attempts used the wrong fixture notification and advanced fake time during live execution; the corrected tests use the subscribed recovery queue and wall time during execution. - Greptile reviewed commit `98972b030d4505d838427a2fca6b196b8cad6d47` at **5/5** with no new comments. The original finding is resolved. - Current-head CI is green: **56 successful checks**, with three checks skipped by workflow filters. This includes typecheck, build, the complete Vitest matrix, all eight browser shards, Runner verification, release checks, and security checks. [Final CI run](https://github.com/paperclipai/paperclip/actions/runs/42132651414). - Typecheck caught missing `pid` and `startedAt` fields in the simulated command result. The fixture now returns the complete process contract, and the server test-layer typecheck passes. - Storybook catalog alignment was checked. Storybook play tests were not run. Hosted latency and cold bundle loading were not measured. ## Risks - The new default changes the workspace for people who previously left the Coordinator experiment off. They can opt into Classic in Experimental settings. - The additive migration must run before the new server code. It is safe to replay and preserves explicit Classic choices. - Cached content can be stale during background refresh. Existing query invalidation and source timestamps still control freshness. - These changes reduce avoidable loading waits. They do not establish a measured improvement in hosted request latency or initial bundle download time. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools, code execution, and browser automation. The exact serving revision and context-window size are not exposed in this session. ## 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 --------- Co-authored-by: Scott Tong Co-authored-by: Paperclip --- ROADMAP.md | 4 +- doc/DATABASE.md | 11 + doc/DEVELOPING.md | 17 +- doc/PRODUCT.md | 8 +- doc/SPEC-implementation.md | 44 ++- maps/agent-access-map/agent-chat.md | 8 +- maps/feature-map/navigation-preferences.md | 20 +- .../migrations/0433_workspace_defaults.sql | 2 + ...{0428_snapshot.json => 0433_snapshot.json} | 342 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 9 +- .../src/schema/user_workspace_preferences.ts | 3 +- ...er-workspace-preferences-migration.test.ts | 7 +- .../src/workspace-defaults-migration.test.ts | 77 ++++ packages/shared/src/feature-catalog.ts | 9 +- packages/shared/src/validators/instance.ts | 2 +- .../src/__tests__/agent-chat-messages.test.ts | 4 +- .../src/__tests__/agent-conversations.test.ts | 36 +- .../deferred-execution-receipt-work.test.ts | 88 ++--- .../instance-settings-managed-overlay.test.ts | 2 +- .../instance-settings-service.test.ts | 18 +- .../issue-recovery-conversations.test.ts | 17 +- .../__tests__/issue-recovery.test-fixture.ts | 8 +- .../__tests__/workspace-preferences.test.ts | 23 +- .../wake-queue/adapters/postgres.test.ts | 20 +- server/src/services/instance-settings.ts | 8 +- server/src/services/task-retry.test.ts | 11 +- server/src/services/workspace-preferences.ts | 6 +- .../src/services/workspace-recovery.test.ts | 12 +- tests/e2e/agent-chat-messages.spec.ts | 4 +- tests/e2e/agent-chat-projects.spec.ts | 19 +- tests/e2e/agent-chat-sessions.spec.ts | 53 ++- tests/e2e/agentmail.spec.ts | 1 + tests/e2e/apps-dark-mode-shots.spec.ts | 4 +- tests/e2e/artifact-tab-arrival.spec.ts | 11 +- tests/e2e/artifact-uploads.spec.ts | 7 +- tests/e2e/board-attachment-receipts.spec.ts | 13 +- tests/e2e/cases-read-only.spec.ts | 1 + tests/e2e/chat-adapters-ui-messaging.spec.ts | 7 + tests/e2e/classic-workspace.spec.ts | 40 ++ .../completed-task-paused-assignee.spec.ts | 17 +- tests/e2e/disconnect-resilience.spec.ts | 4 +- tests/e2e/disconnect-write-recovery.spec.ts | 2 +- tests/e2e/document-image-gallery.spec.ts | 3 + tests/e2e/new-task-picker-touch.spec.ts | 6 + tests/e2e/paused-composer.spec.ts | 6 + tests/e2e/sidebar-takeover.spec.ts | 3 + tests/e2e/signoff-policy.spec.ts | 3 + .../ClassicWorkspaceSetting.test.tsx | 33 ++ ui/src/components/ClassicWorkspaceSetting.tsx | 47 +++ ui/src/components/CoordinatorSidebar.test.tsx | 11 +- ui/src/components/CoordinatorSidebar.tsx | 12 +- .../CoordinatorWorkspaceSetting.test.tsx | 67 ---- .../CoordinatorWorkspaceSetting.tsx | 83 ----- ui/src/components/Layout.test.tsx | 21 ++ ui/src/components/PropertiesPanel.test.tsx | 4 + ui/src/components/Sidebar.test.tsx | 20 +- ui/src/components/Sidebar.tsx | 7 +- ui/src/components/TaskChatThread.test.tsx | 4 + .../TaskChatInteractionCard.test.tsx | 4 + ui/src/hooks/useAgentChatEnabled.ts | 12 +- .../useCoordinatorWorkspaceEnabled.test.tsx | 12 +- .../hooks/useCoordinatorWorkspaceEnabled.ts | 7 +- ui/src/hooks/useIssueHistoryWindow.ts | 55 +-- ui/src/lib/issue-comment-window-query.ts | 59 +++ ui/src/lib/issueDetailCache.test.ts | 39 ++ ui/src/lib/issueDetailCache.ts | 45 ++- ui/src/lib/prefetchIssueComments.test.ts | 24 +- ui/src/pages/AgentChat.test.tsx | 28 +- ui/src/pages/AgentChat.tsx | 8 + ui/src/pages/Agents.test.tsx | 33 +- .../InstanceExperimentalSettings.test.tsx | 2 +- ui/src/pages/InstanceExperimentalSettings.tsx | 20 +- ui/src/pages/IssueDetail.test.tsx | 27 ++ ui/src/pages/IssueDetail.tsx | 9 +- .../coordinator-workspace-setting.stories.tsx | 39 +- 75 files changed, 1204 insertions(+), 548 deletions(-) create mode 100644 packages/db/src/migrations/0433_workspace_defaults.sql rename packages/db/src/migrations/meta/{0428_snapshot.json => 0433_snapshot.json} (99%) create mode 100644 packages/db/src/workspace-defaults-migration.test.ts create mode 100644 tests/e2e/classic-workspace.spec.ts create mode 100644 ui/src/components/ClassicWorkspaceSetting.test.tsx create mode 100644 ui/src/components/ClassicWorkspaceSetting.tsx delete mode 100644 ui/src/components/CoordinatorWorkspaceSetting.test.tsx delete mode 100644 ui/src/components/CoordinatorWorkspaceSetting.tsx create mode 100644 ui/src/lib/issue-comment-window-query.ts diff --git a/ROADMAP.md b/ROADMAP.md index 8a6003e275..8a3e4144ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,9 +134,9 @@ As companies grow, agents should be able to propose useful structural changes su Paperclip should get better at turning completed work into reusable organizational knowledge. That includes capturing playbooks, recurring fixes, and decision patterns so future work starts from what the company has already learned. -### 🟡 Agent Chat (including CEO Chat) +### ✅ Agent Chat (including CEO Chat) -Experimental Agent Chat provides persistent conversations with any agent, including leadership. Conversations keep their history and plans, then hand execution off to linked tasks. Agent Chat is off by default. Experimental chat and email connectors provide additional entry points through configured external services; they have separate setup and access controls. +Agent Chat provides persistent conversations with any agent, including leadership. Conversations keep their history and plans, then hand execution off to linked tasks. Agent Chat is available to everyone. Experimental chat and email connectors provide additional entry points through configured external services; they have separate setup and access controls. ### 🟡 Cloud deployments diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 6c7ef6ea6d..99000257b3 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -1904,3 +1904,14 @@ pre-upgrade database transactions before enabling independent writers. Do not ru pre-upgrade synchronous counter/evaluation writers beside unlocked writers. Rollback must use a compatible version that reads the ledger and retains the durable evaluation feed; a pre-contract binary is not a safe rollback target. + +## Standard workspace and Classic opt-in + +Migration `0433_workspace_defaults.sql` adds `classic_workspace_enabled`, default +false, to personal workspace preferences and sets the legacy Coordinator column's +default to true. Existing rows retain their old Coordinator experiment values, +but those values no longer select the active workspace. API reads derive +`coordinatorWorkspaceEnabled` from the inverse of the Classic choice. Saves +retain the existing account and company-audit checks. Replaying the migration +preserves explicit Classic choices. Agent Chat ignores its retired stored flag +without rewriting instance settings. No task, agent, or execution state changes. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index bbb5e191cb..8ad73ffa98 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -554,8 +554,7 @@ views. The plus button opens the existing agent picker; adding an agent creates one sidebar entry, and choosing them again reopens the same conversation. The **Add and reopen** component story exercises that constraint. Agent selection and sending use local fixtures. The live `/chats` and `/chats/:agentRef` routes -use the same sidebar, picker, landing page, and existing chat surface when the -Agent Chat experimental setting is enabled. +use the same sidebar, picker, landing page, and existing chat surface for everyone. Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance** to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the @@ -2895,8 +2894,7 @@ and `deny-all` options. Codex conversion keeps a non-empty model and otherwise stores the shared `gpt-5.6-sol` default. The native execution boundary applies the same default to older runner rows whose model is missing or blank. -For an Agent Chat test drive, enable **Agent Chat** in Experimental settings and -configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect +For an Agent Chat test drive, configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect the Claude account through the agent's **AI connection** section (or supply an explicit supported provider credential); an ambient Claude CLI login alone is not a credential source for its isolated runner home. The default @@ -4590,3 +4588,14 @@ A restored task shows its header and properties while uncached history is still Query-cache cleanup snapshots storage keys before deletion because native storage can reorder them as records are removed. Flush cleanup affects only its owning account and yields if another account becomes active. Run `pnpm exec playwright test -c tests/e2e/playwright.config.ts tests/e2e/disconnect-resilience.spec.ts` for the built-UI outage regressions. The suite uses fresh isolated local data, 20-second API interceptions, and a controlled sign-out session response. It covers retained tasks, comment and document recovery, queued-comment reloads, cached settings/sidebar/task reads, legacy Inbox reloads, real error status handling and sign-out clearing across several company records. The existing CI e2e shards discover it automatically. A real hosted restart or deploy remains a separate acceptance check. + +### Chat and task navigation caching + +Task-link intent warms the compact core and recent-comments queries used by the +thread, rather than fetching full enrichment and an unused older-history page. +Classic and already-expanded tasks keep their full-history prefetch. +The mounted reads reuse the normal 30-second freshness window; live invalidation +and background revalidation still apply. Recent-window catch-up retains messages +observed before navigation. Agent Chat can render from the current person's +company-scoped navigation list, carrying its original fetch timestamp so stale +conversations refresh in the background. No conversation is created by a read. diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 38a1de0258..8b5f0a8940 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -377,17 +377,17 @@ allocation retains one selected number and individually enabled groups. See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation contract, setup, recovery, boundaries, and qualification status. -### Experimental persistent agent conversations +### Persistent agent conversations Agents can deliver proactive task and routine results into their own chat with the task's responsible human. A morning PR digest arrives as an agent message with a source-task link and unread indicators, even before the first human message. The person can reply using the digest as context. Publishing a message and completing work are separate actions; sending never starts or interrupts a -chat turn. Delivery obeys membership, run authority and task privacy, and stays -behind the Agent Chat setting. This version delivers in-app only. +chat turn. Delivery obeys membership, run authority and task privacy. This version delivers +in-app only. -Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract. +Coordinator is the standard workspace for everyone. Settings → Experimental offers a personal **Classic workspace** opt-in, off by default. Historical Coordinator experiment settings do not enable Classic. Agent Chat is available to everyone on new and existing instances. The retired `enableAgentChat` key remains accepted for compatibility; stored and managed off values are ignored. Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract. ### Agent chat project handoff (2026-09-11) diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 49f11d402e..a504d07870 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2156,7 +2156,7 @@ Agent appearances are stable, versioned ClipLab end-cap personas, separate from behavioral instructions. Compact surfaces use on-demand cached PNG URLs; larger placements may use a lazy live character. See [agent-personas.md](agent-personas.md) for persistence, migration, rendering, and integration contracts. -### Experimental task-backed agent chat (2026-09-10) +### Task-backed agent chat (2026-09-10) Ordinary task and routine runs in standard mode may publish with `send_chat_message({body,idempotencyKey})` or `POST /api/agents/me/chat/messages`. @@ -2189,10 +2189,10 @@ Chat list/detail responses use per-user read state before human participation; the foreground chat marks rendered messages read through an optional comment cutoff, retaining unseen concurrent arrivals. Existing navigation ordering is preserved with accessible unread dots and an aggregate Chat indicator. -`enableAgentChat` gates delivery; external channels, push/email and private-chat +Agent Chat is available to every person; external channels, push/email and private-chat redesign are outside this contract. -`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected. +`enableAgentChat` is a retired compatibility key. Agent Chat is always available for Cloud and self-hosted instances, including existing people and instances with stored or managed off values. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected. `GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue when adding a chat or on first send/upload. `GET /api/companies/:companyId/chats` lists only the current board user’s conversations in that company, subject to ordinary issue read access. The Chat navigation opens a searchable secondary sidebar with agent avatars and a picker for starting or reopening the same per-agent conversation. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window. @@ -2208,7 +2208,7 @@ in decision history. Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider. -Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. Feature disablement prevents new turns and resets while retaining data and lifecycle protection; already-running turns may settle normally. +Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. The retired feature flag cannot prevent turns, resets, or discovery. Ordinary authentication, company and conversation ownership, privacy, run authority, and execution admission checks still apply. ### Agent chat project handoff (2026-09-11) @@ -2844,22 +2844,30 @@ connection. Settings → Connections is permanent; valid model verdicts take precedence and dependency failures use the original built-in detector. -### Personal experimental workspace preference +### Personal workspace preference -Settings → Experimental can opt the current human into the Coordinator workspace. -The choice defaults off and is saved per user across companies on an instance in -`user_workspace_preferences`; it is not an instance feature or a company capability. -Turning it off restores the classic shell without modifying canonical tasks, agents, -artifacts, permissions, or runs. Local-trusted development uses the existing shared -`local-board` identity, explicitly identified in Settings. Anonymous and agent actors -cannot read or update this personal choice. +Coordinator is the standard workspace for every person. It is not an experiment. +Settings → Experimental offers **Classic workspace**, a personal opt-in that +defaults off. Existing Coordinator experiment values, including historical off +values, do not opt anyone into Classic. The choice is saved per user across +companies on an instance in `user_workspace_preferences.classic_workspace_enabled`; +it is not an instance feature or a company capability. Turning Classic off returns +to Coordinator without modifying canonical tasks, agents, artifacts, permissions, +or runs. Local-trusted development uses the existing shared `local-board` identity, +explicitly identified in Settings. Anonymous and agent actors cannot read or update +this personal choice. + +`GET /api/workspace-preferences/me` reads only the caller's choice. For client +compatibility, `coordinatorWorkspaceEnabled` reports the inverse of the Classic +opt-in. The old stored Coordinator column is retained but no longer selects the +workspace. `PUT /api/companies/{companyId}/workspace-preferences/me` saves the +Classic choice through that same inverse property with authorized company audit +context; active viewers may update their own presentation. The preference and +sparse audit event commit atomically. Client caches are keyed by human identity, +and switching waits for successful persistence; unavailable preferences use the +standard Coordinator workspace. This preference does not replace +`enableClassicTaskInterface`. -`GET /api/workspace-preferences/me` reads only the caller's choice. -`PUT /api/companies/{companyId}/workspace-preferences/me` updates it with authorized -company audit context; active viewers may update their own presentation. The preference -and sparse audit event commit atomically. Client caches are keyed by human identity, -and switching waits for successful persistence; unavailable preferences fall back to -the classic shell. This preference does not replace `enableClassicTaskInterface`. ## Agent connection administration (2026-10-13) Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`. diff --git a/maps/agent-access-map/agent-chat.md b/maps/agent-access-map/agent-chat.md index 851d0fec48..1fbb9e138a 100644 --- a/maps/agent-access-map/agent-chat.md +++ b/maps/agent-access-map/agent-chat.md @@ -25,7 +25,7 @@ API: `present` — representative operation IDs: Agent access: `restricted` — Board-user REST; the responding agent gets its assigned conversation context. -Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +Policy: Conversation discovery/creation requires a board user; task access follows task visibility. Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at @@ -80,7 +80,7 @@ API: `present` — representative operation IDs: Agent access: `partial` — Assigned conversation task tools / REST for eligible task history and replies. -Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +Policy: Conversation discovery/creation requires a board user; task access follows task visibility. Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at @@ -122,7 +122,7 @@ API: `present` — representative operation IDs: Agent access: `available` — Native list_projects/create_project/create_task; project MCP tools; REST. -Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +Policy: Conversation discovery/creation requires a board user; task access follows task visibility. Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at @@ -164,7 +164,7 @@ API: `present` — representative operation IDs: Agent access: `partial` — Existing SSE client in local_trusted mode with the experimental gate. -Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +Policy: Conversation discovery/creation requires a board user; task access follows task visibility. Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at diff --git a/maps/feature-map/navigation-preferences.md b/maps/feature-map/navigation-preferences.md index 7aa726e3bc..92c03bc2cb 100644 --- a/maps/feature-map/navigation-preferences.md +++ b/maps/feature-map/navigation-preferences.md @@ -100,20 +100,18 @@ Successful omissions remove unavailable entries; failed reads preserve history. Verify rename, inbox archive, pause/restart, reload, cross-tab updates and account switches. Inbox archive does not remove the task from recent history. -### Personal Coordinator workspace +### Personal workspace choice -Settings → Experimental → Coordinator workspace opts the current person into the -experimental shell; off restores classic. The server saves the choice by user across -companies, separate from instance feature flags. The local-trusted identity is shared -Local Board, not evidence of multi-user isolation. -Agent Chat remains an independent instance prerequisite. When it is off, the -personal card explains the dependency and links eligible instance administrators -to the existing Agent Chat control. Hidden or Cloud-managed controls are not -presented as editable. Disabled Chat routes link back to Experimental settings. -Enabling the personal workspace never changes the instance capability. +Coordinator is the standard workspace. Settings → Experimental → Classic workspace +opts the current person into the previous workspace; it defaults off. Historical +Coordinator experiment values do not enable Classic. The server saves the choice +by user across companies, separate from instance feature flags. The local-trusted +identity is shared Local Board, not evidence of multi-user isolation. +Agent Chat is available to everyone independently of this personal workspace +choice. Its retired instance flag and historical managed off values are ignored. Automated: [preference hook](../../ui/src/hooks/useCoordinatorWorkspaceEnabled.test.tsx), -[prerequisite settings card](../../ui/src/components/CoordinatorWorkspaceSetting.test.tsx), +[Classic settings card](../../ui/src/components/ClassicWorkspaceSetting.test.tsx), [authorization](../../server/src/__tests__/workspace-preferences-routes.test.ts), and [database persistence](../../server/src/__tests__/workspace-preferences.test.ts). Manual: toggle on/off, reload, switch companies/accounts, and simulate failed reads diff --git a/packages/db/src/migrations/0433_workspace_defaults.sql b/packages/db/src/migrations/0433_workspace_defaults.sql new file mode 100644 index 0000000000..aaaeb906a6 --- /dev/null +++ b/packages/db/src/migrations/0433_workspace_defaults.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_workspace_preferences" ALTER COLUMN "coordinator_workspace_enabled" SET DEFAULT true;--> statement-breakpoint +ALTER TABLE "user_workspace_preferences" ADD COLUMN IF NOT EXISTS "classic_workspace_enabled" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0428_snapshot.json b/packages/db/src/migrations/meta/0433_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0428_snapshot.json rename to packages/db/src/migrations/meta/0433_snapshot.json index 1dad6da7c8..6b3488fc69 100644 --- a/packages/db/src/migrations/meta/0428_snapshot.json +++ b/packages/db/src/migrations/meta/0433_snapshot.json @@ -1,6 +1,6 @@ { - "id": "fb4383f6-cbe6-4aff-9fd2-6eeb69a6927a", - "prevId": "dd860bff-55de-4849-8787-58b9de729258", + "id": "10b23b32-3586-4a30-a993-618e7d3ca893", + "prevId": "a87278a8-15b3-4187-94a9-f4bb79d8640d", "version": "7", "dialect": "postgresql", "tables": { @@ -713,6 +713,12 @@ "notNull": true, "default": "'{}'::jsonb" }, + "previous_usage_json": { + "name": "previous_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "reason": { "name": "reason", "type": "text", @@ -7171,6 +7177,147 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.budget_evaluation_work": { + "name": "budget_evaluation_work", + "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": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_evaluation_work_company_pending_idx": { + "name": "budget_evaluation_work_company_pending_idx", + "columns": [ + { + "expression": "company_id", + "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": {} + }, + "budget_evaluation_work_agent_pending_idx": { + "name": "budget_evaluation_work_agent_pending_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": {} + }, + "budget_evaluation_work_project_pending_idx": { + "name": "budget_evaluation_work_project_pending_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_evaluation_work_company_id_companies_id_fk": { + "name": "budget_evaluation_work_company_id_companies_id_fk", + "tableFrom": "budget_evaluation_work", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.budget_incidents": { "name": "budget_incidents", "schema": "", @@ -7518,6 +7665,13 @@ "notNull": true, "default": 0 }, + "evaluation_version": { + "name": "evaluation_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "created_by_user_id": { "name": "created_by_user_id", "type": "text", @@ -21885,6 +22039,12 @@ "primaryKey": false, "notNull": false }, + "reported_usage_json": { + "name": "reported_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "pricing_provenance": { "name": "pricing_provenance", "type": "jsonb", @@ -34026,6 +34186,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "heartbeat_run_events_usage_item_run_idx": { + "name": "heartbeat_run_events_usage_item_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_run_events\".\"event_type\" = 'item.completed' and (\"heartbeat_run_events\".\"payload\" -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_compaction_session_idx": { + "name": "heartbeat_run_events_compaction_session_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\" -> 'prpEvent' ->> 'normalizedSessionId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_run_events\".\"event_type\" = 'context.compacted'", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -44026,6 +44230,133 @@ }, "isRLSEnabled": false }, + "public.native_harness_backups": { + "name": "native_harness_backups", + "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 + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_digest": { + "name": "scope_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reference": { + "name": "reference", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "native_harness_backups_current_idx": { + "name": "native_harness_backups_current_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"native_harness_backups\".\"status\" = 'current'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_harness_backups_scope_idx": { + "name": "native_harness_backups_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "manifest_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.native_run_finalizations": { "name": "native_run_finalizations", "schema": "", @@ -61553,6 +61884,13 @@ "type": "boolean", "primaryKey": false, "notNull": true, + "default": true + }, + "classic_workspace_enabled": { + "name": "classic_workspace_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, "default": false }, "created_at": { diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 47ff289665..224416c4f3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -3018,6 +3018,13 @@ "when": 1793916406980, "tag": "0432_strong_mole_man", "breakpoints": true + }, + { + "idx": 433, + "version": "7", + "when": 1793920110905, + "tag": "0433_workspace_defaults", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/user_workspace_preferences.ts b/packages/db/src/schema/user_workspace_preferences.ts index 67c1a7034a..95b633cd43 100644 --- a/packages/db/src/schema/user_workspace_preferences.ts +++ b/packages/db/src/schema/user_workspace_preferences.ts @@ -5,7 +5,8 @@ import { boolean, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-or export const userWorkspacePreferences = pgTable("user_workspace_preferences", { id: uuid("id").primaryKey().defaultRandom(), userId: text("user_id").notNull(), - coordinatorWorkspaceEnabled: boolean("coordinator_workspace_enabled").notNull().default(false), + coordinatorWorkspaceEnabled: boolean("coordinator_workspace_enabled").notNull().default(true), + classicWorkspaceEnabled: boolean("classic_workspace_enabled").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, table => ({ diff --git a/packages/db/src/user-workspace-preferences-migration.test.ts b/packages/db/src/user-workspace-preferences-migration.test.ts index e47d39cb15..908bd2a1f4 100644 --- a/packages/db/src/user-workspace-preferences-migration.test.ts +++ b/packages/db/src/user-workspace-preferences-migration.test.ts @@ -12,7 +12,7 @@ const support = await getEmbeddedPostgresTestSupport(); const describePostgres = support.supported ? describe : describe.skip; describePostgres("workspace preferences migration", () => { - it("defaults off and preserves existing preferences when the renumbered migration replays", async () => { + it("defaults Classic off and preserves existing preferences when the table migration replays", async () => { const database = await startEmbeddedPostgresTestDatabase("paperclip-workspace-preferences-replay-"); const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); try { @@ -20,8 +20,9 @@ describePostgres("workspace preferences migration", () => { const [original] = await sql` INSERT INTO user_workspace_preferences (user_id) VALUES (${userId}) RETURNING * `; - expect(original.coordinator_workspace_enabled).toBe(false); - await sql`UPDATE user_workspace_preferences SET coordinator_workspace_enabled = true WHERE user_id = ${userId}`; + expect(original.coordinator_workspace_enabled).toBe(true); + expect(original.classic_workspace_enabled).toBe(false); + await sql`UPDATE user_workspace_preferences SET coordinator_workspace_enabled = false, classic_workspace_enabled = true WHERE user_id = ${userId}`; const [before] = await sql`SELECT * FROM user_workspace_preferences WHERE user_id = ${userId}`; // An isolated build may already have applied this table under an older diff --git a/packages/db/src/workspace-defaults-migration.test.ts b/packages/db/src/workspace-defaults-migration.test.ts new file mode 100644 index 0000000000..a9d585ceac --- /dev/null +++ b/packages/db/src/workspace-defaults-migration.test.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import postgres from "postgres"; +import { afterEach, describe, expect, it } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js"; + +const migration = await readFile(new URL("./migrations/0433_workspace_defaults.sql", import.meta.url), "utf8"); +const support = await getEmbeddedPostgresTestSupport(); +const cleanups: Array<() => Promise> = []; +afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); }); + +(support.supported ? describe : describe.skip)("standard workspace defaults", () => { + it("defaults new people to Coordinator", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-workspace-defaults-fresh-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); + try { + expect(await sql`SELECT user_id FROM user_workspace_preferences`).toHaveLength(0); + const [row] = await sql`INSERT INTO user_workspace_preferences (user_id) VALUES ('new-person') RETURNING coordinator_workspace_enabled`; + expect(row.coordinator_workspace_enabled).toBe(true); + } finally { await sql.end(); } + }, 30_000); + + it.each([null, true, false])("preserves legacy preferences and unrelated instance storage (%s) on upgrade and replay", async chat => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-workspace-defaults-upgrade-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); + try { + const companyId = randomUUID(); + await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Existing company', 'OLD')`; + for (const id of ['unchanged-classic', 'saved-coordinator', 'saved-classic']) { + await sql`INSERT INTO "user" (id, name, email, created_at, updated_at) VALUES (${id}, ${id}, ${id + '@example.test'}, now(), now())`; + } + await sql`INSERT INTO company_memberships (company_id, principal_type, principal_id) VALUES (${companyId}, 'user', 'existing-member')`; + await sql`INSERT INTO user_workspace_preferences (user_id, coordinator_workspace_enabled) VALUES ('saved-coordinator', true), ('saved-classic', false)`; + const settings = { enableStatusCards: true, ...(chat === null ? {} : { enableAgentChat: chat }) }; + await sql`INSERT INTO instance_settings (singleton_key, experimental) VALUES ('default', ${sql.json(settings)}) ON CONFLICT (singleton_key) DO UPDATE SET experimental = EXCLUDED.experimental`; + for (let replay = 0; replay < 2; replay++) { + for (const statement of migration.split('--> statement-breakpoint')) { + if (statement.trim()) await sql.unsafe(statement); + } + } + const rows = await sql`SELECT user_id, coordinator_workspace_enabled FROM user_workspace_preferences`; + expect(Object.fromEntries(rows.map(row => [row.user_id, row.coordinator_workspace_enabled]))).toEqual({ + 'saved-coordinator': true, 'saved-classic': false, + }); + const [instance] = await sql`SELECT experimental FROM instance_settings WHERE singleton_key = 'default'`; + expect(instance.experimental).toEqual(settings); + const [newPerson] = await sql`INSERT INTO user_workspace_preferences (user_id) VALUES ('new-person') RETURNING coordinator_workspace_enabled`; + expect(newPerson.coordinator_workspace_enabled).toBe(true); + } finally { await sql.end(); } + }, 30_000); + + it("starts Classic off for legacy preferences and preserves a later explicit opt-in on replay", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-classic-workspace-upgrade-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); + try { + await sql`ALTER TABLE user_workspace_preferences DROP COLUMN classic_workspace_enabled`; + await sql`INSERT INTO user_workspace_preferences (user_id, coordinator_workspace_enabled) VALUES ('old-off', false), ('old-on', true)`; + for (const statement of migration.split('--> statement-breakpoint')) { + if (statement.trim()) await sql.unsafe(statement); + } + const upgraded = await sql`SELECT user_id, classic_workspace_enabled FROM user_workspace_preferences ORDER BY user_id`; + expect(upgraded.map(row => row.classic_workspace_enabled)).toEqual([false, false]); + await sql`UPDATE user_workspace_preferences SET classic_workspace_enabled = true WHERE user_id = 'old-off'`; + for (const statement of migration.split('--> statement-breakpoint')) { + if (statement.trim()) await sql.unsafe(statement); + } + const [saved] = await sql`SELECT classic_workspace_enabled FROM user_workspace_preferences WHERE user_id = 'old-off'`; + expect(saved.classic_workspace_enabled).toBe(true); + const [fresh] = await sql`INSERT INTO user_workspace_preferences (user_id) VALUES ('new-classic-choice') RETURNING classic_workspace_enabled`; + expect(fresh.classic_workspace_enabled).toBe(false); + } finally { await sql.end(); } + }, 30_000); + +}); diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index a6e3701701..cf667a5831 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -198,12 +198,11 @@ export const INSTANCE_FEATURE_CATALOG: Record i.conversationAgentId)).toHaveLength(0); }); - it("rejects forged destinations and server-owned provenance, and respects the feature gate", async () => { + it("rejects forged destinations and server-owned provenance while ignoring the retired flag", async () => { const f = await fixture(); expect((await httpSend(f, { ...input, agentId: f.agentId })).status).toBe(400); await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: false }); - try { expect((await httpSend(f)).status).toBe(404); } + try { expect((await httpSend(f)).status).toBe(201); } finally { await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: true }); } const receipt = await send(f); const [comment] = await server.db.select().from(issueComments).where(eq(issueComments.id, receipt!.commentId)); diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index 1302266781..8d72f9b7d9 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -226,7 +226,7 @@ import { expect((await request(appFor(colleague)).get(listPath)).body).toEqual([]); expect((await request(appFor(randomUUID(), false)).get(listPath)).status).toBe(403); await instanceSettingsService(db).updateExperimental({ enableAgentChat: false }); - expect((await request(app).get(listPath)).status).toBe(404); + expect((await request(app).get(listPath)).status).toBe(200); await instanceSettingsService(db).updateExperimental({ enableAgentChat: true }); expect( ( @@ -290,9 +290,7 @@ import { await instanceSettingsService(db).updateExperimental({ enableAgentChat: false, }); - expect((await request(app).get(path)).status).toBe(404); - expect((await request(app).post(`/api/issues/${chatId}/queued-comments/interrupt`) - .send(queuedInterrupt)).status).toBe(404); + expect((await request(app).get(path)).status).toBe(200); expect( (await request(app).get(`/api/issues/${resolved[0].body.id}`)).status, ).toBe(200); @@ -597,20 +595,6 @@ import { .where(eq(issues.id, issue.id)); expect(current.conversationSessionGeneration).toBe(2); expect(current.conversationBoundaryCommentId).toBe(commands[2].id); - await instanceSettingsService(db).updateExperimental({ - enableAgentChat: false, - }); - expect( - await heartbeat.wakeup(agentId, { - contextSnapshot: { - issueId: issue.id, - wakeCommentId: commands[3].id, - }, - }), - ).toBeNull(); - await instanceSettingsService(db).updateExperimental({ - enableAgentChat: true, - }); } finally { runningProcesses.delete(active.id); } @@ -975,22 +959,6 @@ import { agents: [], }), ).toEqual([]); - await instanceSettingsService(db).updateExperimental({ - enableAgentChat: false, - }); - await expect( - issueService(db).addComment(issue.id, "/new", { - userId: "local-board", - }), - ).rejects.toThrow(/disabled/); - expect( - isWaitingConversation( - (await db.select().from(issues).where(eq(issues.id, issue.id)))[0], - ), - ).toBe(true); - await instanceSettingsService(db).updateExperimental({ - enableAgentChat: true, - }); const child = await issueService(db).create(companyId, { title: "Execute work", status: "done" }); await db.update(issues).set({ parentId: issue.id }).where(eq(issues.id, child.id)); expect( diff --git a/server/src/__tests__/deferred-execution-receipt-work.test.ts b/server/src/__tests__/deferred-execution-receipt-work.test.ts index e9f51ba398..be0135fc64 100644 --- a/server/src/__tests__/deferred-execution-receipt-work.test.ts +++ b/server/src/__tests__/deferred-execution-receipt-work.test.ts @@ -258,8 +258,10 @@ describe("deferred receipt durable dispatch", () => { expect(await db.select().from(heartbeatRuns).where(sql`${heartbeatRuns.contextSnapshot}->>'taskRetryRequestId' = ${receipt.requestId}`)).toHaveLength(1); await quiet(); }); - async function blockedRetry(gate: "budget" | "project budget" | "paused agent") { + async function blockedRetry(gate: "budget" | "project budget" | "paused agent", conversation = false) { const f = await fixture(), lifecycle = createAgentLifecycle(db), budgets = budgetService(db); + if (conversation) await db.update(issues).set({ status: "in_review", conversationAgentId: f.agent.id, + conversationUserId: f.actorId, conversationState: "waiting" }).where(eq(issues.id, f.issue.id)); // Host lifecycle completion is controlled; all state, admission and the // eventual process adapter execution use their production implementations. const lifecycleWorker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], @@ -648,54 +650,26 @@ describe("deferred receipt durable dispatch", () => { expect(resume).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ companyId: f.companyId, requestId: wake.id })); await quiet(); }); - it("parks an accepted conversation Retry while chat is disabled and resumes it on enablement", async () => { - const settings = instanceSettingsService(db), original = await settings.getExperimental(); - await settings.updateExperimental({ enableAgentChat: true }); - try { - const f = await fixture(); - await db.update(issues).set({ status: "in_review", conversationAgentId: f.agent.id, - conversationUserId: f.actorId, conversationState: "waiting" }).where(eq(issues.id, f.issue.id)); - const request = await requestTaskRetry({ db, companyId: f.companyId, issueId: f.issue.id, - actorId: f.actorId, observedRunId: f.source.id, idempotencyKey: randomUUID() }); - expect(request.accepted).toBe(true); - await settings.updateExperimental({ enableAgentChat: false }); - await expect(requestTaskRetry({ db, companyId: f.companyId, issueId: f.issue.id, - actorId: f.actorId, observedRunId: f.source.id, idempotencyKey: randomUUID() })) - .rejects.toMatchObject({ status: 404, message: "Agent Chat is disabled" }); - const priorCursor = new Date(Date.now() - 31_000); - await db.update(agentWakeupRequests).set({ updatedAt: priorCursor }).where(eq(agentWakeupRequests.id, request.requestId!)); - await heartbeat.resumeExecutionWaitComments({ companyId: f.companyId }); - const [parked] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, request.requestId!)); - expect(parked.updatedAt.getTime()).toBeGreaterThan(priorCursor.getTime()); - expect(parked).toMatchObject({ status: "deferred_issue_execution", runId: null }); - await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready; - await heartbeat.registerRunDispatchWork(coordinator, () => !drain).ready; - await Promise.allSettled([...passes]); - expect(errors).toEqual([]); - const read = async () => (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, request.requestId!)))[0]; - expect(await read()).toMatchObject({ status: "deferred_issue_execution", runId: null }); - await waitForReal(async () => { - await Promise.allSettled([...passes]); - expect(coordinator.nextWakeAt()).toBeNull(); expect(idleWorkSnapshot().active).toBe(0); - }, { timeout: 40_000 }); - const idleStatements = statements.length; - await new Promise(resolve => setTimeout(resolve, 31_000)); - expect(statements).toHaveLength(idleStatements); - await settings.updateExperimental({ enableAgentChat: true }); - let runId: string | null = null; - await waitForReal(async () => { - const saved = await read(); expect(saved.runId).toBeTruthy(); runId = saved.runId; - }, { timeout: 5_000 }); - await waitForReal(async () => { - await heartbeat.drainActiveRunExecutions(); - const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId!)); - expect(run.status).toBe("succeeded"); expect(run.stdoutExcerpt).toContain("receipt-proof"); - }, { timeout: 15_000 }); - expect(await db.select().from(heartbeatRuns).where(sql`${heartbeatRuns.contextSnapshot}->>'taskRetryRequestId' = ${request.requestId}`)).toHaveLength(1); + it("keeps a conversation Retry behind agent pause while ignoring retired Chat off values", async () => { + await instanceSettingsService(db).updateExperimental({ enableAgentChat: false }); + const { f, lifecycle, lifecycleWorker, request, read } = await blockedRetry("paused agent", true); + expect((await instanceSettingsService(db).getExperimental()).enableAgentChat).toBe(true); + expect(await read()).toMatchObject({ status: "deferred_issue_execution", runId: null }); + await lifecycle.resumeAgent(f.agent.id); + await lifecycleWorker.process(f.agent.id); + let runId: string | null = null; + await waitForReal(async () => { + const saved = await read(); expect(saved.runId).toBeTruthy(); runId = saved.runId; + }, { timeout: 40_000 }); + await waitForReal(async () => { await heartbeat.drainActiveRunExecutions(); - } finally { await settings.updateExperimental({ enableAgentChat: original.enableAgentChat }); } + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId!)); + expect(run.status, JSON.stringify({ error: run.error, code: run.errorCode, stderr: run.stderrExcerpt })).toBe("succeeded"); expect(run.stdoutExcerpt).toContain("receipt-proof"); + }, { timeout: 15_000 }); + expect(await db.select().from(heartbeatRuns).where(sql`${heartbeatRuns.contextSnapshot}->>'taskRetryRequestId' = ${request.requestId}`)).toHaveLength(1); + await quiet(); }); - it("resumes saved conversation input only after chat is re-enabled and its transaction commits", async () => { + it("resumes saved conversation input only after its agent resume commits despite retired Chat off values", async () => { const settings = instanceSettingsService(db), original = await settings.getExperimental(); await settings.updateExperimental({ enableAgentChat: true }); try { @@ -712,25 +686,31 @@ describe("deferred receipt durable dispatch", () => { resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, queuedCommentInterruptQueueId: wake.id } }).where(eq(heartbeatRuns.id, f.source.id)); await settings.updateExperimental({ enableAgentChat: false }); + await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agent.id)); await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready; await heartbeat.registerRunDispatchWork(coordinator, () => !drain).ready; await waitForReal(async () => { await Promise.allSettled([...passes]); expect(coordinator.nextWakeAt()).toBeNull(); expect(idleWorkSnapshot().active).toBe(0); - }, { timeout: 5_000 }); + }, { timeout: 40_000 }); const idleStatements = statements.length; await new Promise(resolve => setTimeout(resolve, 31_000)); expect(statements).toHaveLength(idleStatements); const read = async () => (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id)))[0]; expect(await read()).toMatchObject({ status: "deferred_issue_execution", runId: null }); await expect(db.transaction(async tx => { - await instanceSettingsService(tx as unknown as typeof db).updateExperimental({ enableAgentChat: true }); - throw new Error("rolled back chat enablement"); - })).rejects.toThrow("rolled back chat enablement"); + await notifyDeferredExecutionEligibility(tx, { companyId: f.companyId }); + await tx.update(agents).set({ status: "idle" }).where(eq(agents.id, f.agent.id)); + throw new Error("rolled back agent resume"); + })).rejects.toThrow("rolled back agent resume"); await Promise.allSettled([...passes]); expect(await read()).toMatchObject({ status: "deferred_issue_execution", runId: null }); - expect((await settings.getExperimental()).enableAgentChat).toBe(false); - await settings.updateExperimental({ enableAgentChat: true }); + expect((await settings.getExperimental()).enableAgentChat).toBe(true); + expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0].status).toBe("paused"); + await db.transaction(async tx => { + await notifyDeferredExecutionEligibility(tx, { companyId: f.companyId }); + await tx.update(agents).set({ status: "idle" }).where(eq(agents.id, f.agent.id)); + }); let runId: string | null = null; await waitForReal(async () => { const saved = await read(); expect(saved.runId).toBeTruthy(); runId = saved.runId; @@ -738,7 +718,7 @@ describe("deferred receipt durable dispatch", () => { await waitForReal(async () => { await heartbeat.drainActiveRunExecutions(); const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId!)); - expect(run.status).toBe("succeeded"); expect(run.stdoutExcerpt).toContain("receipt-proof"); + expect(run.status, JSON.stringify({ error: run.error, code: run.errorCode, stderr: run.stderrExcerpt })).toBe("succeeded"); expect(run.stdoutExcerpt).toContain("receipt-proof"); }, { timeout: 15_000 }); const runs = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), sql`${heartbeatRuns.id} <> ${f.source.id}`)); diff --git a/server/src/__tests__/instance-settings-managed-overlay.test.ts b/server/src/__tests__/instance-settings-managed-overlay.test.ts index 60f9964fb6..c3deecad9e 100644 --- a/server/src/__tests__/instance-settings-managed-overlay.test.ts +++ b/server/src/__tests__/instance-settings-managed-overlay.test.ts @@ -126,7 +126,7 @@ describe("instanceSettingsService managed overlay", () => { "enforces every Cloud-enabled flag over old tenant values on %s", async (primaryHost) => { const features = Object.fromEntries(Object.entries(INSTANCE_FEATURE_CATALOG) - .filter(([, entry]) => entry.tier === "managed") + .filter(([key, entry]) => entry.tier === "managed" && key !== "enableAgentChat") .map(([key]) => [key, true])); const stored = Object.fromEntries(Object.keys(features).map((key) => [key, false])); const { db, persistedSets } = stubDb(settingsRow(stored)); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 2597523f15..6c2847b6eb 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -18,6 +18,22 @@ describe("instance settings service", () => { expect(applyExperimentalSettingsPatch(saved, { enableCases: true }).enableLegacyComposerSubmit).toBe(true); expect(applyExperimentalSettingsPatch(saved, { enableLegacyComposerSubmit: false }).enableLegacyComposerSubmit).toBe(false); }); + it("keeps Agent Chat on for new and existing people, ignoring stored and managed off values", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enableAgentChat).toBe(true); + expect(INSTANCE_FEATURE_CATALOG.enableAgentChat).toMatchObject({ cloudDefault: true, selfHostedDefault: true }); + for (const stored of [undefined, null, {}, { enableAgentChat: false }, { enableAgentChat: false, enableCases: "invalid" }]) { + expect(normalizeExperimentalSettings(stored).enableAgentChat).toBe(true); + } + expect(applyExperimentalSettingsPatch({}, { enableAgentChat: false }).enableAgentChat).toBe(true); + const config: ManagedInstanceConfig = { + v: 1, mode: "cloud", catalogVersion: "test", plugins: { autoInstall: [] }, environments: [], + features: { enableAgentChat: false }, + }; + const result = applyManagedExperimentalOverlay(normalizeExperimentalSettings({ enableAgentChat: false }), config); + expect(result.experimental.enableAgentChat).toBe(true); + expect(result.managedKeys.enableAgentChat).toBeUndefined(); + }); + it("defaults remote workspace enrollment on while preserving explicit operator choices", () => { expect(instanceExperimentalSettingsSchema.parse({}).remoteWorkspaceStorageEnabled).toBe(true); expect(INSTANCE_FEATURE_CATALOG.remoteWorkspaceStorageEnabled).toMatchObject({ cloudDefault: true, selfHostedDefault: true }); @@ -112,7 +128,7 @@ describe("instance settings service", () => { enableStreamlinedUi: true, enableApps: true, enableMcpAggregators: true, - enableAgentChat: false, + enableAgentChat: true, enablePublicMcp: false, enableOpenAiDot: false, enableMuse: false, diff --git a/server/src/__tests__/issue-recovery-conversations.test.ts b/server/src/__tests__/issue-recovery-conversations.test.ts index 36aaa28d5c..4f246b0420 100644 --- a/server/src/__tests__/issue-recovery-conversations.test.ts +++ b/server/src/__tests__/issue-recovery-conversations.test.ts @@ -1,8 +1,7 @@ import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { expect, it, vi } from "vitest"; -import { heartbeatRuns, issues, issueComments } from "@paperclipai/db"; -import { instanceSettingsService } from "../services/instance-settings.js"; +import { agents, heartbeatRuns, issues, issueComments } from "@paperclipai/db"; import { undeliveredConversationComments } from "../services/agent-conversations.js"; import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../services/delivery-work-notifications.js"; import { issueService } from "../services/issues.js"; @@ -11,11 +10,10 @@ import { describeIssueRecovery, issueRecoveryFixture } from "./issue-recovery.te describeIssueRecovery("issue recovery production producers and conversations", () => { const state = issueRecoveryFixture(); const { quiet, completed, savedConversation } = state; - it("dispatches a saved conversation only from the committed Agent Chat enable transition", async () => { + it("dispatches a saved conversation after agent resume despite retired Chat off values", async () => { const f = await savedConversation(); expect(await undeliveredConversationComments(state.db, f.companyId, f.chat.id)).toHaveLength(1); - await f.settings.updateExperimental({ enableAgentChat: true }); - expect(state.coordinator.nextWakeAt()).not.toBeNull(); + await f.resume(); await vi.waitFor(async () => expect(await state.db.select().from(heartbeatRuns) .where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1), { timeout: 40_000 }); const run = await completed(f.chat.id); @@ -32,14 +30,15 @@ describeIssueRecovery("issue recovery production producers and conversations", ( finally { stop(); } expect(f.replies).toEqual([run.id]); }); - it("keeps disabled saved conversations parked after rollback, unrelated flags and unchanged values", async () => { + it("keeps paused conversations parked after resume rollback and unrelated settings changes", async () => { const f = await savedConversation(); await expect(state.db.transaction(async tx => { - await instanceSettingsService(tx as unknown as typeof state.db).updateExperimental({ enableAgentChat: true }); + await f.resume(tx as unknown as typeof state.db); throw new Error("chat flag rollback"); })).rejects.toThrow("chat flag rollback"); await quiet(); - expect((await f.settings.getExperimental()).enableAgentChat).toBe(false); + expect((await f.settings.getExperimental()).enableAgentChat).toBe(true); + expect((await state.db.select().from(agents).where(eq(agents.id, f.agentId)))[0].status).toBe("paused"); let hints = 0; const stop = subscribeDeliveryWork(state.db, DELIVERY_QUEUES.strandedAssignments, () => { hints++; }); try { @@ -51,7 +50,7 @@ describeIssueRecovery("issue recovery production producers and conversations", ( expect(await state.db.select().from(heartbeatRuns)).toEqual([]); expect(await undeliveredConversationComments(state.db, f.companyId, f.chat.id)).toHaveLength(1); }); - it("does not dispatch an accepted saved message after Agent Chat is disabled", async () => { + it("keeps accepted saved input parked while its agent is paused despite retired Chat off values", async () => { const f = await savedConversation(false); await f.settings.updateExperimental({ enableAgentChat: true }); await quiet(); state.draining = true; diff --git a/server/src/__tests__/issue-recovery.test-fixture.ts b/server/src/__tests__/issue-recovery.test-fixture.ts index 41a6732e08..31a7191726 100644 --- a/server/src/__tests__/issue-recovery.test-fixture.ts +++ b/server/src/__tests__/issue-recovery.test-fixture.ts @@ -7,6 +7,7 @@ import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRu import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { heartbeatService } from "../services/heartbeat.js"; +import { notifyIssueRecovery } from "../services/issue-recovery-notifications.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { issueService } from "../services/issues.js"; import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; @@ -218,9 +219,14 @@ export function issueRecoveryFixture() { conversationState: "waiting" }); const message = withMessage ? await issueService(db).addComment(chat.id, "Keep this message", { userId: "recovery-user" }, { clientRequestId: randomUUID() }) : null; await settings.updateExperimental({ enableAgentChat: false }); + await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId)); await start(); await quiet(); expect(replies).toEqual([]); - return { ...f, settings, chat, message, replies, failures }; + const resume = async (owner = db) => owner.transaction(async tx => { + await notifyIssueRecovery(tx, { companyId: f.companyId }); + await tx.update(agents).set({ status: "idle" }).where(eq(agents.id, f.agentId)); + }); + return { ...f, settings, chat, message, replies, failures, resume }; } return { start, compete, fixture, completingProvider, settled, quiet, finish, completed, savedConversation, diff --git a/server/src/__tests__/workspace-preferences.test.ts b/server/src/__tests__/workspace-preferences.test.ts index 05eb7d403e..89f5a93142 100644 --- a/server/src/__tests__/workspace-preferences.test.ts +++ b/server/src/__tests__/workspace-preferences.test.ts @@ -38,19 +38,30 @@ if (!support.supported) console.warn(`Workspace preference database tests unavai } const url = (companyId: string) => `/api/companies/${companyId}/workspace-preferences/me`; - it("defaults to classic without creating a row", async () => { + it("defaults to Coordinator without creating a row", async () => { const userId = randomUUID(); - expect(await workspacePreferenceService(db).get(userId)).toEqual({ userId, coordinatorWorkspaceEnabled: false, updatedAt: null }); + expect(await workspacePreferenceService(db).get(userId)).toEqual({ userId, coordinatorWorkspaceEnabled: true, updatedAt: null }); expect(await db.select().from(userWorkspacePreferences).where(eq(userWorkspacePreferences.userId, userId))).toHaveLength(0); }); + it.each([false, true])("uses Coordinator for an old experimental preference (%s) until Classic is explicitly selected", async legacyEnabled => { + const userId = randomUUID(); + const companyId = await company(); + await db.insert(userWorkspacePreferences).values({ userId, coordinatorWorkspaceEnabled: legacyEnabled }); + expect((await workspacePreferenceService(db).get(userId)).coordinatorWorkspaceEnabled).toBe(true); + await request(app(actor(companyId, userId))).put(url(companyId)).send({ coordinatorWorkspaceEnabled: false }).expect(200); + expect((await workspacePreferenceService(db).get(userId)).coordinatorWorkspaceEnabled).toBe(false); + await request(app(actor(companyId, userId))).put(url(companyId)).send({ coordinatorWorkspaceEnabled: true }).expect(200); + expect((await workspacePreferenceService(db).get(userId)).coordinatorWorkspaceEnabled).toBe(true); + }); + it("lets a viewer opt in and out across companies without changing another person's choice", async () => { const first = await company(), second = await company(), userId = randomUUID(), otherUser = randomUUID(); await request(app(actor(first, userId))).put(url(first)).send({ coordinatorWorkspaceEnabled: true }).expect(200); const response = await request(app(actor(second, userId))).get("/api/workspace-preferences/me").expect(200); expect(response.body).toMatchObject({ userId, coordinatorWorkspaceEnabled: true }); expect(response.headers["cache-control"]).toBe("private, no-store"); - expect((await request(app(actor(first, otherUser))).get("/api/workspace-preferences/me").expect(200)).body.coordinatorWorkspaceEnabled).toBe(false); + expect((await request(app(actor(first, otherUser))).get("/api/workspace-preferences/me").expect(200)).body.coordinatorWorkspaceEnabled).toBe(true); await request(app(actor(second, userId))).put(url(second)).send({ coordinatorWorkspaceEnabled: false }).expect(200); expect((await workspacePreferenceService(db).get(userId)).coordinatorWorkspaceEnabled).toBe(false); const events = await db.select().from(activityLog).where(and(eq(activityLog.actorId, userId), eq(activityLog.action, "workspace_preferences.updated"))); @@ -66,7 +77,7 @@ if (!support.supported) console.warn(`Workspace preference database tests unavai await request(app(actor(companyId, userId))).put(url(companyId)).send({ coordinatorWorkspaceEnabled: true, userId: "someone-else" }).expect(400); await request(app({ type: "none", source: "none" })).get("/api/workspace-preferences/me").expect(403); await request(app({ type: "agent", source: "agent_key", companyId, agentId: randomUUID() })).get("/api/workspace-preferences/me").expect(403); - expect((await workspacePreferenceService(db).get(userId)).coordinatorWorkspaceEnabled).toBe(false); + expect((await workspacePreferenceService(db).get(userId)).coordinatorWorkspaceEnabled).toBe(true); }); it("uses the explicit local board principal without creating a signed-in user's setting", async () => { @@ -74,12 +85,12 @@ if (!support.supported) console.warn(`Workspace preference database tests unavai const local: Express.Request["actor"] = { type: "board", source: "local_implicit", userId: "local-board", isInstanceAdmin: true }; await request(app(local)).put(url(companyId)).send({ coordinatorWorkspaceEnabled: true }).expect(200); expect((await request(app(local)).get("/api/workspace-preferences/me").expect(200)).body).toMatchObject({ userId: "local-board", coordinatorWorkspaceEnabled: true }); - expect((await workspacePreferenceService(db).get(randomUUID())).coordinatorWorkspaceEnabled).toBe(false); + expect((await workspacePreferenceService(db).get(randomUUID())).coordinatorWorkspaceEnabled).toBe(true); }); it("rolls the preference back when the audit cannot be persisted", async () => { const userId = randomUUID(); await expect(workspacePreferenceService(db).set(randomUUID(), userId, true)).rejects.toThrow(); - expect(await workspacePreferenceService(db).get(userId)).toEqual({ userId, coordinatorWorkspaceEnabled: false, updatedAt: null }); + expect(await workspacePreferenceService(db).get(userId)).toEqual({ userId, coordinatorWorkspaceEnabled: true, updatedAt: null }); }); }); diff --git a/server/src/modules/wake-queue/adapters/postgres.test.ts b/server/src/modules/wake-queue/adapters/postgres.test.ts index b4a4574d49..48649b9f7c 100644 --- a/server/src/modules/wake-queue/adapters/postgres.test.ts +++ b/server/src/modules/wake-queue/adapters/postgres.test.ts @@ -590,7 +590,7 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => { expect(result!.containedSelfAuthoredComment).toBe(false); }); - it.each([false, true])("rechecks disabled chat mode before interrupted queue promotion (conversation=%s)", async (conversation) => { + it.each([false, true])("ignores retired Chat off values before interrupted queue promotion (conversation=%s)", async (conversation) => { const settings = instanceSettingsService(db); const original = (await settings.getExperimental()).enableAgentChat; const companyId = await seedCompany(); @@ -617,19 +617,11 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => { try { await settings.updateExperimental({ enableAgentChat: false }); const result = await release({ companyId, runId, now: new Date() }); - if (conversation) { - expect(result.outcome.kind).toBe("released"); - expect(result.postCommitEffects).toEqual([]); - const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); - expect(issue).toMatchObject({ executionRunId: null, checkoutRunId: null }); - const [pending] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)); - expect(pending).toMatchObject({ status: "deferred_issue_execution", runId: null }); - expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(1); - await settings.updateExperimental({ enableAgentChat: true }); - expect((await release({ companyId, runId, now: new Date() })).outcome.kind).toBe("promoted"); - } else { - expect(result.outcome.kind).toBe("promoted"); - } + expect(result.outcome.kind).toBe("promoted"); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(2); + expect((await db.select().from(issueComments).where(eq(issueComments.id, comment.id)))[0].body).toBe(comment.body); + await release({ companyId, runId, now: new Date() }); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(2); } finally { await settings.updateExperimental({ enableAgentChat: original }); } diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 9161536900..73d9d9645b 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -267,7 +267,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePaperclipNotebook: parsed.data.enablePaperclipNotebook ?? false, enableGoogleWorkspaceConnector: parsed.data.enableGoogleWorkspaceConnector ?? false, enableCases: parsed.data.enableCases ?? false, - enableAgentChat: parsed.data.enableAgentChat ?? false, + enableAgentChat: true, enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false, enableArtifactsV1: parsed.data.enableArtifactsV1 ?? true, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, @@ -326,7 +326,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePaperclipNotebook: false, enableGoogleWorkspaceConnector: false, enableCases: false, - enableAgentChat: false, + enableAgentChat: true, enableCombinedInboxTasks: false, enableArtifactsV1: true, enableConferenceRoomChat: false, @@ -384,8 +384,8 @@ export function applyManagedExperimentalOverlay( [ManagedExperimentalFeatureKey, boolean] >) { // Existing Cloud stack configs may still carry retired flags. Accept the - // document during rollout, but never let retired flags disable Apps or MCP aggregators. - if (key === "enableApps" || key === "enableMcpAggregators") continue; + // document during rollout, but never let retired flags disable Apps, Agent Chat or MCP aggregators. + if (key === "enableApps" || key === "enableAgentChat" || key === "enableMcpAggregators") continue; next[key] = value; managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY }; } diff --git a/server/src/services/task-retry.test.ts b/server/src/services/task-retry.test.ts index 65efc37e21..d7e02d1d33 100644 --- a/server/src/services/task-retry.test.ts +++ b/server/src/services/task-retry.test.ts @@ -268,7 +268,7 @@ describe("durable task Retry admission", () => { expect((await db.select().from(issues).where(eq(issues.id, input.issueId)))[0]) .toMatchObject({ status: "blocked", assigneeAgentId: issue.assigneeAgentId, executionRunId: null, checkoutRunId: null }); }); - it("requires current Agent Chat ownership and enablement even when replaying a retry receipt", async () => { + it("requires current Chat ownership and ignores retired off values when replaying a retry receipt", async () => { const input = await seed(); const settings = instanceSettingsService(db); await settings.updateExperimental({ enableAgentChat: true }); @@ -280,7 +280,7 @@ describe("durable task Retry admission", () => { const accepted = await requestTaskRetry(input); expect(accepted.accepted).toBe(true); await settings.updateExperimental({ enableAgentChat: false }); - await expect(requestTaskRetry(input)).rejects.toMatchObject({ status: 404 }); + expect((await requestTaskRetry(input)).requestId).toBe(accepted.requestId); await settings.updateExperimental({ enableAgentChat: true }); await db.update(issues).set({ conversationUserId: "new-owner" }).where(eq(issues.id, input.issueId)); await expect(requestTaskRetry(input)).rejects.toMatchObject({ status: 403 }); @@ -290,7 +290,7 @@ describe("durable task Retry admission", () => { expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, accepted.requestId!)))[0].status).toBe("failed"); } finally { await settings.updateExperimental({ enableAgentChat: false }); } }); - it.each(["owner", "feature"] as const)("rechecks Agent Chat $0 at dispatch after waiting for the issue lock", async gate => { + it.each(["owner", "feature"] as const)("rechecks Chat ownership and ignores retired $0 values after waiting for the issue lock", async gate => { const input = { ...await seed(), actorId: "local-board", actorSource: "local_implicit" }; const settings = instanceSettingsService(db); await settings.updateExperimental({ enableAgentChat: true, enableNativeRunner: true }); @@ -320,9 +320,10 @@ describe("durable task Retry admission", () => { select 1 from pg_stat_activity where datname = current_database() and ${holderPid} = any(pg_blocking_pids(pid))) as waiting`))[0].waiting, { timeout: 5_000 }).toBe(true); release(); await holder; - expect(await dispatch).toMatchObject({ status: gate === "owner" ? 403 : 404 }); + if (gate === "owner") expect(await dispatch).toMatchObject({ status: 403 }); + else await dispatch; expect(await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), - eq(heartbeatRuns.status, "queued")))).toHaveLength(0); + eq(heartbeatRuns.status, "queued")))).toHaveLength(gate === "owner" ? 0 : 1); // Restore current authority; the same saved receipt can then admit one run. await settings.updateExperimental({ enableAgentChat: true }); await db.update(issues).set({ conversationUserId: input.actorId }).where(eq(issues.id, issue.id)); diff --git a/server/src/services/workspace-preferences.ts b/server/src/services/workspace-preferences.ts index 5df4fc63ee..4d0b9b4aac 100644 --- a/server/src/services/workspace-preferences.ts +++ b/server/src/services/workspace-preferences.ts @@ -11,7 +11,7 @@ export function workspacePreferenceService(db: Db) { }); return { userId, - coordinatorWorkspaceEnabled: row?.coordinatorWorkspaceEnabled ?? false, + coordinatorWorkspaceEnabled: !(row?.classicWorkspaceEnabled ?? false), updatedAt: row?.updatedAt.toISOString() ?? null, }; } @@ -20,10 +20,10 @@ export function workspacePreferenceService(db: Db) { async set(companyId: string, userId: string, enabled: boolean): Promise { const result = await db.transaction(async tx => { const txDb = tx as unknown as Db; - await tx.insert(userWorkspacePreferences).values({ userId, coordinatorWorkspaceEnabled: enabled }) + await tx.insert(userWorkspacePreferences).values({ userId, coordinatorWorkspaceEnabled: enabled, classicWorkspaceEnabled: !enabled }) .onConflictDoUpdate({ target: userWorkspacePreferences.userId, - set: { coordinatorWorkspaceEnabled: enabled, updatedAt: new Date() }, + set: { coordinatorWorkspaceEnabled: enabled, classicWorkspaceEnabled: !enabled, updatedAt: new Date() }, }); const { publication } = await persistActivity(txDb, { companyId, actorType: "user", actorId: userId, diff --git a/server/src/services/workspace-recovery.test.ts b/server/src/services/workspace-recovery.test.ts index 9562987665..dd6b8359b8 100644 --- a/server/src/services/workspace-recovery.test.ts +++ b/server/src/services/workspace-recovery.test.ts @@ -1804,7 +1804,17 @@ describe("automatic retained workspace recovery", () => { } return { filesTransferred: operations.reduce((count, operation) => count + operation.files.length, 0), bytesTransferred: 0, operations: [] }; }; - const executeCommand = oldTarget.runner!.execute; + const filesystemExecute = oldTarget.runner!.execute; + const fixtureCodex = "/opt/paperclip-fixture/bin/codex"; + const executeCommand: typeof filesystemExecute = async input => { + if (input.command === "sh" && input.args?.some(arg => arg.includes("command -v codex"))) { + return { exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(), stdout: `${fixtureCodex}\n`, stderr: "" }; + } + if (input.command === fixtureCodex && input.args?.[0] === "--version") { + return { exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(), stdout: "codex-cli 0.160.0\n", stderr: "" }; + } + return filesystemExecute(input); + }; const runtime = environmentRuntimeService(db, { drivers: [{ driver: "sandbox", acquireRunLease: async input => { const lease = await environment.acquireLease({ companyId: input.companyId, environmentId: input.environment.id, diff --git a/tests/e2e/agent-chat-messages.spec.ts b/tests/e2e/agent-chat-messages.spec.ts index ff6c8d288c..20968a9e6b 100644 --- a/tests/e2e/agent-chat-messages.spec.ts +++ b/tests/e2e/agent-chat-messages.spec.ts @@ -13,7 +13,7 @@ test("a routine delivers a first digest, unread state, source link and replyable title: "Morning PR review", description: "proactive-digest-fixture", assigneeAgentId: f.agent.id, } })); await json(await request.post(`/api/routines/${routine.id}/run`, { data: { idempotencyKey: "browser-morning" } })); - await expect(page.getByRole("img", { name: "Unread messages from Alpha", exact: true })).toBeVisible({ timeout: 45_000 }); + await expect(page.getByLabel(/unread chats/)).toBeVisible({ timeout: 45_000 }); await expect(page.getByLabel(/unread chats/)).toBeVisible(); let chat: any; await expect.poll(async () => { @@ -28,7 +28,7 @@ test("a routine delivers a first digest, unread state, source link and replyable expect(source.originKind).toBe("routine_execution"); expect(await json(await request.get(`/api/issues/${chat.id}/runs`))).toEqual([]); await page.reload(); - await expect(page.getByRole("img", { name: "Unread messages from Alpha", exact: true })).toBeVisible(); + await expect(page.getByLabel(/unread chats/)).toBeVisible(); await page.goto(f.route); await expect(page.getByText("Morning PR digest", { exact: true })).toBeVisible(); const sourceLink = page.getByRole("link", { name: `From ${source.identifier}`, exact: true }); diff --git a/tests/e2e/agent-chat-projects.spec.ts b/tests/e2e/agent-chat-projects.spec.ts index a0effe0666..6d1c03a191 100644 --- a/tests/e2e/agent-chat-projects.spec.ts +++ b/tests/e2e/agent-chat-projects.spec.ts @@ -335,9 +335,8 @@ for (const mode of ["Ask", "Plan"]) ); expect(revised.latestRevisionId).not.toBe(first.latestRevisionId); expect(revised.body).toContain("Revised plan"); - await expect( - page.getByRole("tab", { name: "Plan", exact: true }), - ).toBeVisible(); + await page.getByRole("link", { name: /Open Revised plan revision 2 in task sidebar/ }).click(); + await expect(page.getByRole("tab", { name: "Plan", exact: true })).toBeVisible(); } expect( await json(await request.get(`/api/companies/${f.company.id}/issues`)), @@ -616,17 +615,18 @@ test("shared history loads older messages without replacing the latest turn", as } }); -test("disabling the experiment lets an active turn settle and keeps idle history", async ({ +test("retired Chat opt-outs do not interrupt active turns or hide existing history", async ({ page, request, }) => { const original = await json( await request.get("/api/instance/settings/experimental"), ); - expect(original.enableAgentChat).toBe(false); + expect(original.enableAgentChat).toBe(true); const f = await setup(request); try { await page.goto(f.route); + await expect(page.getByRole("navigation", { name: "Workspace navigation" })).toBeVisible(); await send(page, { action: "delayed" }); await expect( page.getByText("Turn started before feature disable.", { exact: true }), @@ -651,20 +651,13 @@ test("disabling the experiment lets an active turn settle and keeps idle history .conversationState, ) .toBe("waiting"); - expect( - ( - await request.post(`/api/issues/${chat.id}/comments`, { - data: { body: "/new" }, - }) - ).status(), - ).toBe(404); const before = ( await json( await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), ) ).length; await page.reload(); - await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible(); + await expect(page.getByText("Active turn settled after feature disable.", { exact: true })).toBeVisible(); expect( ( await json( diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index d014c59be2..d4d9b9303a 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -126,7 +126,7 @@ test("chat first open is read-only; concurrent first sends and retries share one } }); -test("feature flag blocks new sends and resets while preserving existing history", async ({ +test("retired Chat opt-outs allow sends and resets while preserving existing history", async ({ page, request, }) => { @@ -140,22 +140,22 @@ test("feature flag blocks new sends and resets while preserving existing history data: { enableAgentChat: false }, }), ); - for (const body of ["blocked message", "/new"]) + for (const body of ["Available to existing people", "/new"]) expect( ( await request.post(`/api/issues/${issue.id}/comments`, { - data: { body }, + data: { body, clientRequestId: crypto.randomUUID() }, }) ).status(), - ).toBe(404); + ).toBe(201); await page.reload(); - await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible(); + await expect(page.getByText("Visible history", { exact: true }).first()).toBeVisible(); expect( (await json(await request.get(`/api/issues/${issue.id}/comments`))).some( (c: any) => c.body.includes("Visible history"), ), ).toBe(true); - expect((await request.post(f.chatPath, { data: {} })).status()).toBe(404); + expect((await request.post(f.chatPath, { data: {} })).status()).toBe(200); } finally { await f.restore(); } @@ -253,13 +253,53 @@ test("Stop then queued /new resets unpause the conversation without losing histo } }); +test("Coordinator agent switching preserves drafts and opens one conversation only on send", async ({ page, request }) => { + const f = await setup(request); + try { + await page.goto(f.route); + await expect(page.getByRole("navigation", { name: "Workspace navigation" })).toBeVisible(); + const editor = page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]'); + const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true }); + const switchTo = async (name: string) => { + await page.getByRole("button", { name: /^Switch agent. Currently / }).click(); + await picker.getByRole("combobox").fill(name); + await picker.getByRole("combobox").press("Enter"); + await expect(picker).not.toBeVisible(); + await expect(page.getByRole("button", { name: `Switch agent. Currently ${name}`, exact: true })).toBeVisible(); + }; + await editor.fill("Unsent Coordinator draft for Alpha"); + await switchTo("Beta"); + await expect(editor).toHaveText(""); + expect(await json(await request.get(`/api/companies/${f.company.id}/chats/${f.agents[1].id}`))).toBeNull(); + await switchTo("Alpha"); + await expect(editor).toContainText("Unsent Coordinator draft for Alpha"); + await page.reload(); + await expect(editor).toContainText("Unsent Coordinator draft for Alpha"); + expect(await json(await request.get(f.chatPath))).toBeNull(); + await editor.fill(""); + await send(page, "Coordinator conversation acceptance"); + await idle(request, f.chatPath); + const chat = await json(await request.get(f.chatPath)); + await switchTo("Beta"); + await switchTo("Alpha"); + await expect(page.getByText("Coordinator conversation acceptance", { exact: true })).toBeVisible(); + expect((await json(await request.get(f.chatPath))).id).toBe(chat.id); + expect(await json(await request.get(`/api/companies/${f.company.id}/chats`))).toHaveLength(1); + } finally { + await f.restore(); + } +}); + test("secondary chat navigation preserves layout, unique conversations, history, and drafts", async ({ page, request, }) => { const originalExperimental = await json(await request.get("/api/instance/settings/experimental")); const f = await setup(request); + const originalWorkspace = await json(await request.get("/api/workspace-preferences/me")); + const workspacePath = `/api/companies/${f.company.id}/workspace-preferences/me`; try { + await json(await request.put(workspacePath, { data: { coordinatorWorkspaceEnabled: false } })); await json(await request.patch("/api/instance/settings/experimental", { data: { enableStreamlinedUi: true }, })); @@ -365,6 +405,7 @@ test("secondary chat navigation preserves layout, unique conversations, history, await page.reload(); await expect(page.getByText("Background activity", { exact: true })).toBeVisible(); } finally { + await json(await request.put(workspacePath, { data: { coordinatorWorkspaceEnabled: originalWorkspace.coordinatorWorkspaceEnabled } })); await f.restore(); await json(await request.patch("/api/instance/settings/experimental", { data: { enableStreamlinedUi: originalExperimental.enableStreamlinedUi }, diff --git a/tests/e2e/agentmail.spec.ts b/tests/e2e/agentmail.spec.ts index d8fb4d58f1..43373e9984 100644 --- a/tests/e2e/agentmail.spec.ts +++ b/tests/e2e/agentmail.spec.ts @@ -264,6 +264,7 @@ test("AgentMail setup and email work through the normal task conversation", asyn await expect(page.getByText("Internal server error", { exact: true })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Your agent’s email is ready" })).toHaveCount(0); await page.screenshot({ path: test.info().outputPath("agentmail-permission-denied.png"), fullPage: true }); + await page.getByRole("button", { name: "More workspace pages", exact: true }).click(); await page.getByRole("link", { name: "Connectors", exact: true }).click(); await page.getByRole("button", { name: "Finish setup", exact: true }).click(); await expect(page.getByText("mail-agent-free@verified.example.test", { exact: true })).toBeVisible(); diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index 99544e4a62..c9ed810e11 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -117,7 +117,8 @@ test.describe.serial("dark-mode Apps surfaces", () => { test("sidebar says Connectors and links to /apps", async ({ page }) => { await forceDark(page); - await page.goto(`/${seed.prefix}/dashboard`); + await page.goto(`/${seed.prefix}/issues`); + await page.getByRole("button", { name: "More workspace pages", exact: true }).click(); const connectorsLink = page.getByRole("link", { name: "Connectors", exact: true }); await expect(connectorsLink).toBeVisible({ timeout: 30_000 }); await expect(connectorsLink).toHaveAttribute("href", new RegExp(`/${seed.prefix}/apps$`)); @@ -161,6 +162,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toHaveCount(0); await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toHaveCount(0); await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toHaveCount(0); + await page.getByRole("button", { name: "More workspace pages", exact: true }).click(); await expect(page.locator('a[href$="/activity"]', { hasText: "Audit" })).toBeVisible(); await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0); // Apps section lives in the same sidebar now. diff --git a/tests/e2e/artifact-tab-arrival.spec.ts b/tests/e2e/artifact-tab-arrival.spec.ts index 2f93a32e28..2286d49abf 100644 --- a/tests/e2e/artifact-tab-arrival.spec.ts +++ b/tests/e2e/artifact-tab-arrival.spec.ts @@ -46,15 +46,8 @@ for (const mobile of [false, true]) { } else { await page.getByRole("button", { name: "Show properties", exact: true }).click(); } - const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("tab", { name: "Artifacts", exact: true }) }); - if (mobile) await panel.getByRole("button", { name: /^Switch tabs, \d+ open$/ }).click(); - const artifacts = mobile - ? page.getByRole("dialog", { name: "Open tabs", exact: true }).getByRole("button", { name: "Artifacts", exact: true }) - : panel.getByRole("tab", { name: "Artifacts", exact: true }); - await expect(artifacts).toBeVisible(); - if (mobile) await expect(artifacts).not.toHaveAttribute("aria-current", "true"); - else await expect(artifacts).toHaveAttribute("aria-selected", "false"); - await artifacts.click(); + const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("button", { name: "Open a new tab", exact: true }) }); + await panel.getByRole("option", { name: "Artifacts", exact: true }).click(); await expectTaskPanelTab(panel, mobile, "Artifacts"); const document = panel.locator("#document-report"); await expect(document.getByRole("heading", { name: "Arriving report", level: 1, exact: true })).toBeVisible(); diff --git a/tests/e2e/artifact-uploads.spec.ts b/tests/e2e/artifact-uploads.spec.ts index 9a7dcb478a..c42062ea45 100644 --- a/tests/e2e/artifact-uploads.spec.ts +++ b/tests/e2e/artifact-uploads.spec.ts @@ -24,10 +24,11 @@ async function setup(page: Page, request: APIRequestContext, mobile = false) { if (mobile) { await page.getByRole("button", { name: "More actions", exact: true }).click(); await page.getByRole("button", { name: "Properties", exact: true }).click(); + } else { + await page.getByRole("button", { name: "Show properties", exact: true }).click(); } const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("button", { name: "Open a new tab", exact: true }) }); - await panel.getByRole("button", { name: "Open a new tab", exact: true }).click(); - await page.getByRole("option", { name: "Artifacts", exact: true }).click(); + await panel.getByRole("option", { name: "Artifacts", exact: true }).click(); await expectTaskPanelTab(panel, mobile, "Artifacts"); await expect(panel.getByRole("button", { name: "Upload files", exact: true })).toBeVisible(); return { company, issue, panel }; @@ -86,6 +87,8 @@ for (const mobile of [false, true]) { if (mobile) { await page.getByRole("button", { name: "More actions", exact: true }).click(); await page.getByRole("button", { name: "Properties", exact: true }).click(); + } else { + await page.getByRole("button", { name: "Show properties", exact: true }).click(); } await expect(document.getByRole("heading", { name: "Engineering handoff", exact: true })).toBeVisible(); await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible(); diff --git a/tests/e2e/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts index c23a299bf3..4b89c54f1a 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -40,6 +40,9 @@ async function dismissAnnouncement(page: Page, dismiss: Locator) { } async function setup(page: Page, request: APIRequestContext, classic: boolean) { + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: !classic, updatedAt: null, + } })); // Announcements can appear after navigation or reload and cover the classic // composer's attachment button. Dismiss through the same UI as a Board user. await page.addLocatorHandler( @@ -135,7 +138,7 @@ async function openAttachmentChooser(page: Page, composer: Locator) { test("new-task images survive reload, retain bytes, and clear after creation", async ({ page, request }) => { const fixture = await setup(page, request, false); - await page.getByRole("button", { name: "New Task", exact: true }).first().click(); + await page.getByRole("button", { name: /^New [Tt]ask$/, exact: true }).first().click(); const dialog = page.getByRole("dialog", { name: "New task", exact: true }); const editor = dialog.getByRole("textbox", { name: "editable markdown", exact: true }); await editor.fill("Keep the caption and image after reload."); @@ -160,7 +163,7 @@ test("new-task images survive reload, retain bytes, and clear after creation", a await expect.poll(() => page.evaluate(() => Object.entries(localStorage) .some(([key, value]) => key.startsWith("paperclip:issue-draft:") && value.includes("Keep the caption")))).toBe(true); await page.reload(); - await page.getByRole("button", { name: "New Task", exact: true }).first().click(); + await page.getByRole("button", { name: /^New [Tt]ask$/, exact: true }).first().click(); await expect(editor).toContainText("Keep the caption and image after reload."); await expect(dialog.getByRole("img", { name: "board-fresh.png", exact: true })).toBeVisible(); const upload = page.waitForResponse((response) => response.request().method() === "POST" @@ -172,7 +175,7 @@ test("new-task images survive reload, retain bytes, and clear after creation", a expect(await content.body()).toEqual(files[1]!.buffer); await expect(dialog).toBeHidden(); await page.reload(); - await page.getByRole("button", { name: "New Task", exact: true }).first().click(); + await page.getByRole("button", { name: /^New [Tt]ask$/, exact: true }).first().click(); await expect(dialog.getByRole("img")).toHaveCount(0); }); @@ -197,7 +200,7 @@ test("two open tabs retain their staged images when either tab saves or creates await second.goto(page.url()); const dialogs = [page, second].map((tab) => tab.getByRole("dialog", { name: "New task", exact: true })); for (const [index, tab] of [page, second].entries()) { - await tab.getByRole("button", { name: "New Task", exact: true }).first().click(); + await tab.getByRole("button", { name: /^New [Tt]ask$/, exact: true }).first().click(); await dialogs[index]!.getByRole("textbox", { name: "editable markdown", exact: true }).fill(`Keep tab ${index + 1}'s image.`); await expect(dialogs[index]!.getByRole("button", { name: "Create task", exact: true })).toBeEnabled(); } @@ -226,7 +229,7 @@ test("two open tabs retain their staged images when either tab saves or creates await expect(dialogs[0]!).toBeHidden(); await expect.poll(savedNames).toEqual(["tab-2.png"]); await second.reload(); - await second.getByRole("button", { name: "New Task", exact: true }).first().click(); + await second.getByRole("button", { name: /^New [Tt]ask$/, exact: true }).first().click(); await expect(dialogs[1]!.getByRole("img", { name: "tab-2.png", exact: true })).toBeVisible(); await dialogs[1]!.getByRole("textbox", { name: "editable markdown", exact: true }).fill("Create the remaining tab's image."); await dialogs[1]!.getByRole("button", { name: "Create task", exact: true }).click(); diff --git a/tests/e2e/cases-read-only.spec.ts b/tests/e2e/cases-read-only.spec.ts index fbd98223df..22183302f1 100644 --- a/tests/e2e/cases-read-only.spec.ts +++ b/tests/e2e/cases-read-only.spec.ts @@ -77,6 +77,7 @@ test("browses passive cases and rolled-up task documents without starting work o // The agentless dashboard offers onboarding. Start on the ordinary task // list so this zero-agent fixture can use Cases navigation without setup. await page.goto(`/${company.issuePrefix}/issues`); + await page.getByRole("button", { name: "More workspace pages", exact: true }).click(); await page.getByRole("link", { name: /^Cases\b/ }).first().click(); const casePage = page.getByTestId("case-pages"); const list = page.getByRole("region", { name: "Case list", exact: true }); diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 746eef745b..daac13d15d 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -236,6 +236,9 @@ test.describe("Board send delivery refresh", () => { }), "create linked task in another organization", ); + await page.route("**/api/workspace-preferences/me", route => fulfill(route, { + userId: "local-board", coordinatorWorkspaceEnabled: !classic, updatedAt: null, + })); await page.route("**/api/instance/settings/experimental", (route) => fulfill(route, { enableChatConnectors: true, @@ -1409,6 +1412,10 @@ test.describe("Failed chat task and exact run retry", () => { await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()); const pathname = url.pathname; + if (pathname === "/api/workspace-preferences/me") { + await fulfill(route, { userId: "local-board", coordinatorWorkspaceEnabled: surface !== "Legacy Inbox", updatedAt: null }); + return; + } if (pathname === "/api/instance/settings/experimental") { await fulfill(route, { enableChatConnectors: true, diff --git a/tests/e2e/classic-workspace.spec.ts b/tests/e2e/classic-workspace.spec.ts new file mode 100644 index 0000000000..f672f69af7 --- /dev/null +++ b/tests/e2e/classic-workspace.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { json, setup } from "./agent-chat.shared"; + +test("Coordinator is standard and Classic is an explicit personal experiment", async ({ page, request }) => { + const original = await json(await request.get("/api/workspace-preferences/me")); + const f = await setup(request); + const settingsUrl = `/${f.company.issuePrefix}/company/settings/instance/experimental`; + const preferencePath = `/api/companies/${f.company.id}/workspace-preferences/me`; + try { + expect(original.coordinatorWorkspaceEnabled).toBe(true); + await page.goto(f.route); + await expect(page.getByRole("navigation", { name: "Workspace navigation" })).toBeVisible(); + await page.goto(settingsUrl); + const classic = page.getByRole("switch", { name: "Enable classic workspace", exact: true }); + await expect(classic).toBeEnabled(); + await expect(classic).not.toBeChecked(); + await expect(page.getByRole("switch", { name: "Enable coordinator workspace", exact: true })).toHaveCount(0); + + await classic.click(); + await expect(classic).toBeChecked(); + await expect.poll(async () => (await json(await request.get("/api/workspace-preferences/me"))).coordinatorWorkspaceEnabled).toBe(false); + await page.goto(f.route); + await expect(page.getByRole("navigation", { name: "Workspace navigation" })).toHaveCount(0); + await page.goto(settingsUrl); + await page.reload(); + await expect(classic).toBeChecked(); + + await classic.click(); + await expect(classic).not.toBeChecked(); + await page.goto(f.route); + await expect(page.getByRole("navigation", { name: "Workspace navigation" })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("navigation", { name: "Workspace navigation" })).toBeVisible(); + await page.goto(settingsUrl); + await expect(classic).not.toBeChecked(); + } finally { + await request.put(preferencePath, { data: { coordinatorWorkspaceEnabled: original.coordinatorWorkspaceEnabled } }); + await f.restore(); + } +}); diff --git a/tests/e2e/completed-task-paused-assignee.spec.ts b/tests/e2e/completed-task-paused-assignee.spec.ts index 703c576b00..2400ccb43c 100644 --- a/tests/e2e/completed-task-paused-assignee.spec.ts +++ b/tests/e2e/completed-task-paused-assignee.spec.ts @@ -6,13 +6,17 @@ async function json>(response: APIResponse): Promise return response.json(); } -let cleanup: { companyId: string; enableClassicTaskInterface: boolean } | undefined; +let cleanup: { companyId: string; enableClassicTaskInterface: boolean; coordinatorWorkspaceEnabled: boolean } | undefined; test.afterEach(async ({ request }, testInfo) => { const state = cleanup; cleanup = undefined; if (!state) return; testInfo.setTimeout(15_000); + const workspaceResult = await request.put(`/api/companies/${state.companyId}/workspace-preferences/me`, { + data: { coordinatorWorkspaceEnabled: state.coordinatorWorkspaceEnabled }, timeout: 5_000, + }); + expect(workspaceResult.ok()).toBe(true); const results = await Promise.allSettled([ request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: state.enableClassicTaskInterface }, timeout: 5_000, @@ -36,7 +40,12 @@ for (const classic of [false, true]) { const company = await json(await request.post("/api/companies", { data: { name: `Completed task status ${randomUUID()}` }, })); - cleanup = { companyId: company.id, enableClassicTaskInterface: settings.enableClassicTaskInterface }; + const workspace = await json(await request.get("/api/workspace-preferences/me")); + cleanup = { companyId: company.id, enableClassicTaskInterface: settings.enableClassicTaskInterface, + coordinatorWorkspaceEnabled: workspace.coordinatorWorkspaceEnabled }; + await json(await request.put(`/api/companies/${company.id}/workspace-preferences/me`, { + data: { coordinatorWorkspaceEnabled: !classic }, + })); await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: classic }, })); @@ -73,6 +82,8 @@ for (const classic of [false, true]) { }; const observe = async (status: string, warningVisible: boolean) => { await expect(page.getByText(task.title, { exact: true }).first()).toBeVisible(); + const showProperties = page.getByRole("button", { name: "Show properties", exact: true }); + if (await showProperties.isVisible()) await showProperties.click(); const statusLabel = status[0].toUpperCase() + status.slice(1); await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: status === "blocked" @@ -82,7 +93,7 @@ for (const classic of [false, true]) { }).first()).toBeVisible(); // Wait for the actual agent-backed controls and thread to render before // asserting absence; a title alone can appear before the agent map loads. - await expect(page.getByRole("button", { name: /Receipt implementation/ }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: classic ? /Receipt implementation/ : "Select assignee" }).first()).toBeVisible(); await expect(page.getByRole("textbox", { name: "editable markdown" })).toBeVisible(); if (warningVisible) { await expect(notice).toBeVisible(); diff --git a/tests/e2e/disconnect-resilience.spec.ts b/tests/e2e/disconnect-resilience.spec.ts index e74ea8da08..99324869f0 100644 --- a/tests/e2e/disconnect-resilience.spec.ts +++ b/tests/e2e/disconnect-resilience.spec.ts @@ -208,9 +208,9 @@ test("a document edit stays visible and saves after a 20-second outage", async ( await page.addInitScript(() => localStorage.setItem("paperclip:panel-visible", "true")); await page.goto(data.route); await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: "Show properties", exact: true }).click(); const panel = page.locator("aside").filter({ has: page.getByRole("button", { name: "Open a new tab", exact: true }) }); - await panel.getByRole("tab", { name: "Artifacts", exact: true }) - .or(panel.getByRole("button", { name: "Artifacts", exact: true })).click(); + await panel.getByRole("option", { name: "Artifacts", exact: true }).click(); const document = panel.locator("#document-handoff"); await expect(document.getByText(original, { exact: true })).toBeVisible(); await document.getByRole("button", { name: "Document actions", exact: true }).click(); diff --git a/tests/e2e/disconnect-write-recovery.spec.ts b/tests/e2e/disconnect-write-recovery.spec.ts index d398d8fb46..0f43d64fe2 100644 --- a/tests/e2e/disconnect-write-recovery.spec.ts +++ b/tests/e2e/disconnect-write-recovery.spec.ts @@ -76,7 +76,7 @@ test("two cached tasks keep separate title saves while the browser is offline", await header.locator("textarea").press("Enter"); await secondLink.focus(); await secondLink.press("Enter", { timeout: 5_000 }); - await expect(page).toHaveURL(new RegExp(`${second.id}$`)); + await expect(page).toHaveURL(new RegExp(`/issues/(?:${second.id}|${second.identifier})$`)); await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: second.title, exact: true })).toBeVisible(); await header.getByRole("heading", { name: second.title, exact: true }).click(); diff --git a/tests/e2e/document-image-gallery.spec.ts b/tests/e2e/document-image-gallery.spec.ts index f999a83c2c..178efa0229 100644 --- a/tests/e2e/document-image-gallery.spec.ts +++ b/tests/e2e/document-image-gallery.spec.ts @@ -11,6 +11,9 @@ for (const mode of ["desktop", "mobile", "classic"] as const) { const mobile = mode === "mobile"; await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }); const settings = await json(await request.get("/api/instance/settings/experimental")); + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: mode !== "classic", updatedAt: null, + } })); try { await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: mode === "classic" } })); const company = await json(await request.post("/api/companies", { data: { name: `Document gallery ${randomUUID()}` } })); diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts index 50c591d3a9..adc604a106 100644 --- a/tests/e2e/new-task-picker-touch.spec.ts +++ b/tests/e2e/new-task-picker-touch.spec.ts @@ -1,6 +1,12 @@ import { randomUUID } from "node:crypto"; import { expect, test, type APIResponse, type Locator, type Page } from "@playwright/test"; +test.beforeEach(async ({ page }) => { + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: false, updatedAt: null, + } })); +}); + async function json(response: APIResponse) { expect(response.ok(), await response.text()).toBe(true); return response.json(); diff --git a/tests/e2e/paused-composer.spec.ts b/tests/e2e/paused-composer.spec.ts index 372535434c..6bab5f45f4 100644 --- a/tests/e2e/paused-composer.spec.ts +++ b/tests/e2e/paused-composer.spec.ts @@ -51,6 +51,9 @@ for (const classic of [false, true]) { test.setTimeout(120_000); const company = await json(await request.post("/api/companies", { data: { name: `Resume preview ${Date.now()}` } })); const settings = await json(await request.get("/api/instance/settings/experimental")); + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: !classic, updatedAt: null, + } })); let primaryFailed = false; try { await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: classic } })); @@ -98,6 +101,9 @@ for (const classic of [false, true]) { test.setTimeout(120_000); const company = await json(await request.post("/api/companies", { data: { name: `Paused composer ${Date.now()}` } })); const settings = await json(await request.get("/api/instance/settings/experimental")); + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: !classic, updatedAt: null, + } })); let primaryFailed = false; try { await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: classic } })); diff --git a/tests/e2e/sidebar-takeover.spec.ts b/tests/e2e/sidebar-takeover.spec.ts index 8c5831bb75..7e75317e67 100644 --- a/tests/e2e/sidebar-takeover.spec.ts +++ b/tests/e2e/sidebar-takeover.spec.ts @@ -54,6 +54,9 @@ test.describe("Contextual sidebar companion", () => { }); test.beforeEach(async ({ page }) => { + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: false, updatedAt: null, + } })); await page.addInitScript((key) => { window.localStorage.removeItem(key); window.sessionStorage.clear(); diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index a9483e31bb..86fc38ee15 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -646,6 +646,9 @@ test.describe("Signoff execution policy", () => { }); // Step 2: Navigate to issue in UI and verify execution label + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: false, updatedAt: null, + } })); await page.goto(`/${ctx.companyPrefix}/issues/${issue.identifier}`); await expect(page.locator("text=Review pending")).toBeVisible({ timeout: 10_000 }); diff --git a/ui/src/components/ClassicWorkspaceSetting.test.tsx b/ui/src/components/ClassicWorkspaceSetting.test.tsx new file mode 100644 index 0000000000..a9100160f1 --- /dev/null +++ b/ui/src/components/ClassicWorkspaceSetting.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ClassicWorkspaceSetting } from "./ClassicWorkspaceSetting"; + +const mocks = vi.hoisted(() => ({ + setEnabled: vi.fn(), enabled: false, +})); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: { id: "company" } }) })); +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ + enabled: mocks.enabled, loaded: true, userId: "person", error: null, isSaving: false, setEnabled: mocks.setEnabled, +}) })); + +describe("Classic workspace preference", () => { + let container: HTMLDivElement, root: Root; + beforeEach(() => { + vi.clearAllMocks(); mocks.enabled = false; + mocks.setEnabled.mockResolvedValue(undefined); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + }); + afterEach(() => { flushSync(() => root.unmount()); container.remove(); }); + it.each([false, true])("changes only the personal workspace (enabled: %s)", enabled => { + mocks.enabled = enabled; + flushSync(() => root.render()); + expect(container.querySelector("h3")?.textContent).toBe("Classic workspace"); + expect(container.textContent).toContain("Off by default"); + expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe(String(!enabled)); + expect(container.querySelector("a")).toBeNull(); + flushSync(() => container.querySelector('[role="switch"]')!.click()); + expect(mocks.setEnabled).toHaveBeenCalledWith("company", !enabled); + }); +}); diff --git a/ui/src/components/ClassicWorkspaceSetting.tsx b/ui/src/components/ClassicWorkspaceSetting.tsx new file mode 100644 index 0000000000..ae56fdba94 --- /dev/null +++ b/ui/src/components/ClassicWorkspaceSetting.tsx @@ -0,0 +1,47 @@ +import { useCompany } from "@/context/CompanyContext"; +import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; + +export function ClassicWorkspaceSetting() { + const { selectedCompany } = useCompany(); + const workspace = useCoordinatorWorkspaceEnabled(); + const error = workspace.error instanceof Error ? workspace.error.message : null; + return ( + +
+
+

Classic workspace

+

+ Use the previous workspace instead of the standard Coordinator workspace. This changes your interface across companies on this instance. +

+

+ Off by default. Turn it off to return to Coordinator. Your work and your teammates’ settings stay the same. +

+ {workspace.userId === "local-board" && ( +

This local instance uses the shared Local Board identity.

+ )} +
+ { + if (!selectedCompany) return; + void workspace.setEnabled(selectedCompany.id, !classicEnabled).catch(() => {}); + }} + /> +
+ {workspace.isSaving &&

Saving your workspace…

} + {!workspace.loaded &&

Loading your preference…

} + {error && ( +
+ {error} + +
+ )} +
+ ); +} diff --git a/ui/src/components/CoordinatorSidebar.test.tsx b/ui/src/components/CoordinatorSidebar.test.tsx index 53b854a8fc..9c8757d0c7 100644 --- a/ui/src/components/CoordinatorSidebar.test.tsx +++ b/ui/src/components/CoordinatorSidebar.test.tsx @@ -11,7 +11,7 @@ import { queryKeys } from "@/lib/queryKeys"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }>, - recentTasks: [] as Array>, projects: [] as Array>, projectTasks: vi.fn() })); + recentTasks: [] as Array>, projects: [] as Array>, projectTasks: vi.fn(), enableCases: false })); vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({ companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: fixture.chats }, session: { isSuccess: true }, }) })); @@ -20,6 +20,7 @@ vi.mock("@/api/issues", () => ({ issuesApi: { listCompact: fixture.projectTasks vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: fixture.openNewIssue }) })); vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) })); vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention, summary: fixture.summary } })); +vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: async () => ({ enableCases: fixture.enableCases }) } })); vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => fixture.projects } })); vi.mock("./SidebarCompanyMenu", () => ({ SidebarCompanyMenu: () => null })); vi.mock("@/lib/router", () => ({ @@ -45,7 +46,7 @@ function summary(items: AttentionItem[]) { let root: Root, container: HTMLDivElement, client: QueryClient; beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues"; - fixture.chats = []; fixture.recentTasks = []; fixture.projects = []; + fixture.chats = []; fixture.recentTasks = []; fixture.projects = []; fixture.enableCases = false; fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")])); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -79,6 +80,12 @@ describe("Coordinator task creation", () => { }); describe("Coordinator pinned pages", () => { + it.each([false, true])("keeps Cases reachable in More only when enabled (%s)", async enabled => { + fixture.enableCases = enabled; + await renderSidebar(); + await act(async () => container.querySelector('button[aria-label="More workspace pages"]')!.click()); + expect(document.querySelector('a[href="/cases"]') !== null).toBe(enabled); + }); it("announces unread chat totals in expanded and collapsed navigation", async () => { fixture.chats = [{ isUnreadForMe: true }, { isUnreadForMe: false }, { isUnreadForMe: true }]; await renderSidebar(); diff --git a/ui/src/components/CoordinatorSidebar.tsx b/ui/src/components/CoordinatorSidebar.tsx index 2b11e8f8a0..fc9b930491 100644 --- a/ui/src/components/CoordinatorSidebar.tsx +++ b/ui/src/components/CoordinatorSidebar.tsx @@ -4,6 +4,7 @@ import { Inbox, Bell, Boxes, ChevronRight, CircleCheck, FolderOpen, History, Lay import type { Project } from "@paperclipai/shared"; import { useCoordinatorAttentionCount } from "@/hooks/useCoordinatorAttentionCount"; import { issuesApi } from "@/api/issues"; +import { instanceSettingsApi } from "@/api/instanceSettings"; import { projectsApi } from "@/api/projects"; import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation"; import { useRecentTasks } from "@/hooks/useRecentTasks"; @@ -67,9 +68,16 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) { const { companyId, userId, agents, chats, session } = useAgentChatNavigation(); const unreadChatCount = chats.data?.filter(chat => chat.isUnreadForMe).length ?? 0; const pinnedNavigation = usePinnedNavigation(userId); + const { data: experimental } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const destinations = experimental?.enableCases + ? [...moreDestinations, { label: "Cases", to: "/cases", icon: Boxes }] + : moreDestinations; const [moreOpen, setMoreOpen] = useState(false); const moreContentRef = useRef(null); - const pinnedDestinations = pinnedNavigation.pins.flatMap(to => moreDestinations.filter(item => item.to === to)); + const pinnedDestinations = pinnedNavigation.pins.flatMap(to => destinations.filter(item => item.to === to)); const { entries: recentTasks } = useRecentTasks({ companyId, userId }); const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar(); const rail = collapsed && !peeking; @@ -127,7 +135,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) { { event.preventDefault(); moreContentRef.current?.focus(); }} side={isMobile ? "bottom" : "right"} align="start" className="w-56 p-1" aria-label="More workspace pages"> - {moreDestinations.map(({ label, to, icon: Icon }, index) => { + {destinations.map(({ label, to, icon: Icon }, index) => { const pinned = pinnedNavigation.pins.includes(to); const PinIcon = pinned ? PinOff : Pin; return
diff --git a/ui/src/components/CoordinatorWorkspaceSetting.test.tsx b/ui/src/components/CoordinatorWorkspaceSetting.test.tsx deleted file mode 100644 index 82d9b20fbf..0000000000 --- a/ui/src/components/CoordinatorWorkspaceSetting.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// @vitest-environment jsdom -import { flushSync } from "react-dom"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CoordinatorWorkspaceSetting } from "./CoordinatorWorkspaceSetting"; - -const mocks = vi.hoisted(() => ({ - getExperimental: vi.fn(), updateExperimental: vi.fn(), access: vi.fn(), setEnabled: vi.fn(), - enabled: false, hidden: new Set(), -})); -vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: mocks.getExperimental, updateExperimental: mocks.updateExperimental } })); -vi.mock("@/api/access", () => ({ accessApi: { getCurrentBoardAccess: mocks.access } })); -vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: { id: "company" } }) })); -vi.mock("@/hooks/useHiddenSettings", () => ({ useHiddenSettings: () => ({ hidden: mocks.hidden, loaded: true }) })); -vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ - enabled: mocks.enabled, loaded: true, userId: "person", error: null, isSaving: false, setEnabled: mocks.setEnabled, -}) })); - -describe("Coordinator workspace prerequisite", () => { - let container: HTMLDivElement, root: Root, client: QueryClient; - beforeEach(() => { - vi.clearAllMocks(); mocks.enabled = false; mocks.hidden.clear(); - mocks.getExperimental.mockResolvedValue({ enableAgentChat: false }); - mocks.access.mockResolvedValue({ userId: "person", source: "session", isInstanceAdmin: true }); - mocks.setEnabled.mockResolvedValue(undefined); - client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); - container = document.createElement("div"); document.body.append(container); root = createRoot(container); - }); - afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); }); - async function mount() { - flushSync(() => root.render()); - for (let i = 0; i < 10; i++) await new Promise(resolve => window.setTimeout(resolve, 0)); - flushSync(() => {}); - } - it("links an administrator to the separate capability without enabling it with the preference", async () => { - await mount(); - expect(container.textContent).toContain("Agent Chat is off"); - expect(container.querySelector("a")?.getAttribute("href")).toBe("#experimental-enableAgentChat"); - flushSync(() => container.querySelector('[role="switch"]')!.click()); - expect(mocks.setEnabled).toHaveBeenCalledWith("company", true); - expect(mocks.updateExperimental).not.toHaveBeenCalled(); - }); - it("keeps turning the personal workspace off available to a non-admin", async () => { - mocks.enabled = true; - mocks.access.mockResolvedValue({ userId: "person", source: "session", isInstanceAdmin: false }); - await mount(); - expect(container.textContent).toContain("Ask an administrator"); - expect(container.querySelector("a")).toBeNull(); - flushSync(() => container.querySelector('[role="switch"]')!.click()); - expect(mocks.setEnabled).toHaveBeenCalledWith("company", false); - }); - it.each(["managed", "hidden"])("does not offer an editable link for %s controls", async kind => { - if (kind === "managed") mocks.getExperimental.mockResolvedValue({ enableAgentChat: false, managedKeys: { enableAgentChat: { managed: true } } }); - else mocks.hidden.add("instance.experimental.enableAgentChat"); - await mount(); - expect(container.textContent).toContain("managed by your hosting provider"); - expect(container.querySelector("a")).toBeNull(); - }); - it("does not invent disabled capability state or block personal settings after a capability read error", async () => { - mocks.getExperimental.mockRejectedValue(new Error("Unavailable")); - await mount(); - expect(container.textContent).not.toContain("Agent Chat is off"); - expect(container.querySelector('[role="switch"]')!.disabled).toBe(false); - expect(mocks.access).not.toHaveBeenCalled(); - }); -}); diff --git a/ui/src/components/CoordinatorWorkspaceSetting.tsx b/ui/src/components/CoordinatorWorkspaceSetting.tsx deleted file mode 100644 index ad334c0bcf..0000000000 --- a/ui/src/components/CoordinatorWorkspaceSetting.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { experimentalSettingKey } from "@paperclipai/shared"; -import { accessApi } from "@/api/access"; -import { instanceSettingsApi } from "@/api/instanceSettings"; -import { useCompany } from "@/context/CompanyContext"; -import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; -import { useHiddenSettings } from "@/hooks/useHiddenSettings"; -import { queryKeys } from "@/lib/queryKeys"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { ToggleSwitch } from "@/components/ui/toggle-switch"; - -export function CoordinatorWorkspaceSetting() { - const { selectedCompany } = useCompany(); - const workspace = useCoordinatorWorkspaceEnabled(); - const { hidden, loaded: visibilityLoaded } = useHiddenSettings(); - const experimental = useQuery({ - queryKey: queryKeys.instance.experimentalSettings, - queryFn: () => instanceSettingsApi.getExperimental(), - }); - const chatDisabled = experimental.isSuccess && experimental.data.enableAgentChat !== true; - const boardAccess = useQuery({ - queryKey: queryKeys.access.currentBoardAccess, - queryFn: () => accessApi.getCurrentBoardAccess(), - enabled: chatDisabled && !!workspace.userId, - staleTime: 30_000, - }); - const chatManaged = experimental.data?.managedKeys?.enableAgentChat?.managed === true; - const chatHidden = hidden.has(experimentalSettingKey("enableAgentChat")); - const canEnableChat = visibilityLoaded && !chatHidden && !chatManaged && boardAccess.data?.userId === workspace.userId && ( - boardAccess.data?.source === "local_implicit" || boardAccess.data?.isInstanceAdmin === true - ); - const error = workspace.error instanceof Error ? workspace.error.message : null; - return ( - -
-
-

Coordinator workspace

-

- Try the new Chat, Tasks, and Needs You workspace. This changes your interface across companies on this instance. -

-

- Off by default. Turn it off to return to the classic workspace. Your work and your teammates’ settings stay the same. -

- {workspace.userId === "local-board" && ( -

This local instance uses the shared Local Board identity.

- )} - {chatDisabled && ( -

- Agent Chat is off for this instance. Your workspace preference does not enable it.{" "} - {canEnableChat ? ( - Enable Agent Chat below - ) : chatManaged || chatHidden ? ( - "Chat availability is managed by your hosting provider." - ) : ( - "Ask an administrator to enable Agent Chat in Experimental settings." - )} -

- )} -
- { - if (!selectedCompany) return; - // The hook exposes the error for the account that attempted the save. - void workspace.setEnabled(selectedCompany.id, enabled).catch(() => {}); - }} - /> -
- {workspace.isSaving &&

Saving your workspace…

} - {!workspace.loaded &&

Loading your preference…

} - {error && ( -
- {error} - -
- )} -
- ); -} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 343e5f1a25..1a7bac884e 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -6,6 +6,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Layout } from "./Layout"; +const classicWorkspace = vi.hoisted(() => ({ enabled: true })); +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ + useCoordinatorWorkspaceEnabled: () => ({ enabled: !classicWorkspace.enabled, loaded: true, userId: "viewer" }), +})); + const mockDesignSandbox = vi.hoisted(() => ({ enabled: false })); vi.mock("@/context/DesignSandboxContext", () => ({ useDesignSandbox: () => mockDesignSandbox.enabled })); vi.mock("./DesignSandboxSidebar", () => ({ DesignSandboxSidebar: () =>
Proposed sidebar
})); @@ -224,6 +229,7 @@ vi.mock("../context/DialogContext", () => ({ vi.mock("../context/PanelContext", () => ({ usePanel: () => ({ togglePanelVisible: vi.fn(), + setPanelVisible: vi.fn(), }), })); @@ -312,6 +318,7 @@ describe("Layout", () => { let container: HTMLDivElement; beforeEach(() => { + classicWorkspace.enabled = true; mockDesignSandbox.enabled = false; container = document.createElement("div"); document.body.appendChild(container); @@ -353,6 +360,20 @@ describe("Layout", () => { vi.clearAllMocks(); }); + it("uses the standard workspace presentation when Classic is off", async () => { + classicWorkspace.enabled = false; + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + try { + await act(async () => renderLayoutView(root, queryClient)); + await flushReact(); + expect(container.querySelector(".coordinator-workspace")).not.toBeNull(); + classicWorkspace.enabled = true; + await act(async () => renderLayoutView(root, queryClient)); + expect(container.querySelector(".coordinator-workspace")).toBeNull(); + } finally { await act(async () => root.unmount()); queryClient.clear(); } + }); + it("does not render the deployment explainer in the shared layout", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/components/PropertiesPanel.test.tsx b/ui/src/components/PropertiesPanel.test.tsx index f4562d26d4..65527ccc2e 100644 --- a/ui/src/components/PropertiesPanel.test.tsx +++ b/ui/src/components/PropertiesPanel.test.tsx @@ -5,6 +5,10 @@ import type { ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ + useCoordinatorWorkspaceEnabled: () => ({ enabled: false, loaded: true }), +})); import { TooltipProvider } from "@/components/ui/tooltip"; import { PropertiesPanel } from "./PropertiesPanel"; diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index b0516c7ab1..6070637958 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -8,6 +8,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Sidebar } from "./Sidebar"; import { TooltipProvider } from "@/components/ui/tooltip"; +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ + useCoordinatorWorkspaceEnabled: () => ({ enabled: false, loaded: true, userId: "viewer" }), +})); + const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn(), })); @@ -357,11 +361,11 @@ describe("Sidebar", () => { }); }); - it("keeps the Inbox row, Workspaces and no chat surfaces while both PAP-670 flags are off", async () => { + it("keeps Chat, Inbox and Workspaces without the combined Tasks experiment", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); const root = await renderSidebar(); - expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(false); + expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(true); expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/inbox")).toBe(true); expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/workspaces")).toBe(true); @@ -399,12 +403,12 @@ describe("Sidebar", () => { }); }); - it("does not render Workspaces anywhere in the nav, even with isolated workspaces on", async () => { + it("keeps Workspaces available beside Chat when isolated workspaces are on", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true, enableIsolatedWorkspaces: true }); const root = await renderSidebar(); - expect(container.textContent).not.toContain("Workspaces"); - expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/workspaces")).toBe(false); + expect(container.textContent).toContain("Workspaces"); + expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/workspaces")).toBe(true); flushSync(() => { root.unmount(); @@ -457,11 +461,11 @@ describe("Sidebar", () => { }); }); - it("adds no chat surfaces while Agent Chat is off", async () => { + it("keeps Chat available despite a retired off value", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCombinedInboxTasks: true, enableAgentChat: false }); const root = await renderSidebar(); - expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(false); + expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(true); flushSync(() => { root.unmount(); @@ -517,7 +521,7 @@ describe("Sidebar", () => { const labels = (section: Element | undefined) => [...(section?.querySelectorAll("a") ?? [])] .map((anchor) => anchor.textContent?.trim()); - expect(labels(workSection)).toEqual(["Tasks", "Projects", "Routines", "Artifacts"]); + expect(labels(workSection)).toEqual(["Chat", "Tasks", "Projects", "Routines", "Artifacts"]); expect(labels(orgSection)).toEqual(["Agents", "Skills", "Connectors", "Audit"]); expect(sections.indexOf(workSection!)).toBeLessThan(sections.indexOf(orgSection!)); expect( diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 83cee4db9e..0eba9d154f 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -99,16 +99,11 @@ function ClassicSidebar({ children }: { children?: ReactNode }) { const liveIssueIds = new Set( (liveRuns ?? []).flatMap((run) => run.issueId ? [run.issueId] : []), ); - // PAP-670 splits the nav reorganization across two experimental flags. - // Agent Chat: Chat leads Work as one row with its own agent rail (the - // streamlined shell only — the legacy shell keeps per-agent rows), and - // Workspaces leaves to make room. Combined Inbox + Task List: Inbox becomes - // views inside Tasks, so its row goes and its badge rides on Tasks. const chatRail = agentChatEnabled && streamlinedUiEnabled; // The merged Tasks page only exists in the streamlined shell, so the legacy // shell keeps its Inbox row even with the flag on. const combinedInboxTasks = streamlinedUiEnabled && experimentalSettings?.enableCombinedInboxTasks === true; - const showWorkspacesLink = !chatRail && experimentalSettings?.enableIsolatedWorkspaces === true; + const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; const showStatusCards = experimentalSettings?.enableStatusCards === true; const goalsLinkPending = experimentalSettings === undefined; const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 64d7d1259d..fc99f767c4 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -5,6 +5,10 @@ import { act, forwardRef, useImperativeHandle, type ForwardedRef } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ + useCoordinatorWorkspaceEnabled: () => ({ enabled: false, loaded: true }), +})); import { ThemeProvider } from "@/context/ThemeContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index 4db30eb51c..2260e4454a 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -4,6 +4,10 @@ import { act, forwardRef, useImperativeHandle, useState, type ForwardedRef } fro import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ + useCoordinatorWorkspaceEnabled: () => ({ enabled: false, loaded: true }), +})); import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { IssueDocument } from "@paperclipai/shared"; import type { diff --git a/ui/src/hooks/useAgentChatEnabled.ts b/ui/src/hooks/useAgentChatEnabled.ts index b99a00f06d..d5f6b55c8a 100644 --- a/ui/src/hooks/useAgentChatEnabled.ts +++ b/ui/src/hooks/useAgentChatEnabled.ts @@ -1,13 +1,3 @@ -import { useQuery } from "@tanstack/react-query"; -import { instanceSettingsApi } from "@/api/instanceSettings"; -import { queryKeys } from "@/lib/queryKeys"; export function useAgentChatEnabled() { - const query = useQuery({ - queryKey: queryKeys.instance.experimentalSettings, - queryFn: () => instanceSettingsApi.getExperimental(), - }); - return { - enabled: query.data?.enableAgentChat === true, - loaded: query.isFetched, - }; + return { enabled: true, loaded: true }; } diff --git a/ui/src/hooks/useCoordinatorWorkspaceEnabled.test.tsx b/ui/src/hooks/useCoordinatorWorkspaceEnabled.test.tsx index 161a3fa482..049667810b 100644 --- a/ui/src/hooks/useCoordinatorWorkspaceEnabled.test.tsx +++ b/ui/src/hooks/useCoordinatorWorkspaceEnabled.test.tsx @@ -77,6 +77,12 @@ describe("useCoordinatorWorkspaceEnabled", () => { expect(mocks.update).not.toHaveBeenCalled(); }); + it("uses Coordinator while a person's Classic choice is loading", async () => { + mocks.get.mockReturnValue(new Promise(() => {})); + await mount(); + expect(current).toMatchObject({ userId: "a", enabled: true, loaded: false }); + }); + it("defaults off in detached hosts without fetching", async () => { await mount(false); expect(current).toMatchObject({ enabled: false, loaded: true }); @@ -134,16 +140,16 @@ describe("useCoordinatorWorkspaceEnabled", () => { it("does not treat anonymous sessions as local board, even with an enabled cached local choice", async () => { mocks.session.mockResolvedValue(null); client.setQueryData(queryKeys.workspacePreferences.mine("local-board"), preference("local-board", true)); - await mount(); expect(current).toMatchObject({ userId: null, enabled: false, loaded: true }); + await mount(); expect(current).toMatchObject({ userId: null, enabled: true, loaded: true }); expect(mocks.get).not.toHaveBeenCalled(); }); - it("recognizes an explicitly local-trusted instance and fails closed on a read error", async () => { + it("recognizes a local-trusted instance and keeps the standard workspace on a read error", async () => { mocks.session.mockResolvedValue(null); mocks.health.mockResolvedValue({ status: "ok", deploymentMode: "local_trusted" }); mocks.get.mockResolvedValue(preference("local-board", true)); await mount(); expect(current).toMatchObject({ userId: "local-board", enabled: true }); mocks.get.mockRejectedValue(new Error("Unavailable")); await client.invalidateQueries({ queryKey: queryKeys.workspacePreferences.mine("local-board") }); await flush(); - expect(current).toMatchObject({ enabled: false, loaded: true }); + expect(current).toMatchObject({ enabled: true, loaded: true }); }); }); diff --git a/ui/src/hooks/useCoordinatorWorkspaceEnabled.ts b/ui/src/hooks/useCoordinatorWorkspaceEnabled.ts index f13263ef75..355c7a793b 100644 --- a/ui/src/hooks/useCoordinatorWorkspaceEnabled.ts +++ b/ui/src/hooks/useCoordinatorWorkspaceEnabled.ts @@ -9,8 +9,6 @@ import { queryKeys } from "@/lib/queryKeys"; let detachedClient: QueryClient | null = null; const getDetachedClient = () => detachedClient ??= new QueryClient(); -/** Per-person, initially-off presentation choice. The existing local-board - * principal is used only when the server explicitly reports local_trusted. */ export function useCoordinatorWorkspaceEnabled() { const contextClient = useContext(QueryClientContext); const client = contextClient ?? getDetachedClient(); @@ -44,10 +42,11 @@ export function useCoordinatorWorkspaceEnabled() { client.setQueryData(queryKeys.workspacePreferences.mine(result.userId), result); }, }, client); + const classicWorkspaceEnabled = !!userId && preference.isSuccess + && preference.data.userId === userId && preference.data.coordinatorWorkspaceEnabled === false; const identityLoaded = session.isFetched && (!!sessionUserId || session.isError || health.isFetched); return { - enabled: !!contextClient && !!userId && preference.isSuccess - && preference.data.userId === userId && preference.data.coordinatorWorkspaceEnabled === true, + enabled: !!contextClient && !classicWorkspaceEnabled, loaded: !contextClient || (identityLoaded && (!userId || preference.isFetched)), userId, error: session.error ?? (!sessionUserId ? health.error : null) ?? preference.error diff --git a/ui/src/hooks/useIssueHistoryWindow.ts b/ui/src/hooks/useIssueHistoryWindow.ts index 920124606c..4f332e4071 100644 --- a/ui/src/hooks/useIssueHistoryWindow.ts +++ b/ui/src/hooks/useIssueHistoryWindow.ts @@ -1,13 +1,10 @@ import { useCallback, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { Issue, IssueComment, IssueRecentComments } from "@paperclipai/shared"; -import { issuesApi } from "@/api/issues"; +import type { Issue, IssueComment } from "@paperclipai/shared"; +import { getIssueRecentCommentsQueryOptions, mergeWindowComments } from "@/lib/issue-comment-window-query"; import { queryKeys } from "@/lib/queryKeys"; -export function mergeWindowComments(...windows: readonly IssueComment[][]): IssueComment[] { - return [...new Map(windows.flat().map((comment) => [comment.id, comment])).values()] - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id.localeCompare(b.id)); -} +export { mergeWindowComments } from "@/lib/issue-comment-window-query"; /** Freeze the cold-open window, then retain every message observed while open. */ export function useIssueHistoryWindow( @@ -35,53 +32,9 @@ export function useIssueHistoryWindow( // Keep identifier and UUID navigation on the same expansion preference. if (!continuous && (showFullHistory || expanded)) expandHistory(); }, [continuous, showFullHistory, expanded, expandHistory]); - const recentKey = [...queryKeys.issues.comments(issueRef ?? ""), "recent"] as const; const recent = useQuery({ - queryKey: recentKey, + ...getIssueRecentCommentsQueryOptions(queryClient, issueRef ?? ""), enabled: Boolean(issueRef) && !historyExpanded, - queryFn: async ({ signal }): Promise => { - const previous = queryClient.getQueryData(recentKey); - const snapshot = await issuesApi.recentComments(issueRef!, { signal }); - if (!previous) return snapshot; - // A burst can contain more than the cold-open window. Catch up from the - // saved cursor instead of dropping messages between two invalidations. - let cursor = previous.latestCommentId; - const incoming: IssueComment[] = []; - // Canceling queued input deletes its comment, including a saved cursor. - // Retry from an earlier observed row, then from the start if that row - // was also deleted. Never advance to the preview past unread replies. - const recoveryAnchors = [ - previous.comments.findLast((comment) => comment.id !== cursor)?.id ?? null, - null, - ]; - while (cursor !== snapshot.latestCommentId) { - const page = await issuesApi.listComments(issueRef!, { - order: "asc", - ...(cursor ? { after: cursor } : {}), - limit: 50, - }, { signal }); - if (signal.aborted) throw new DOMException("Aborted", "AbortError"); - if (!page.length) { - if (cursor && recoveryAnchors.length) { - cursor = recoveryAnchors.shift() ?? null; - continue; - } - break; - } - incoming.push(...page); - const next = page.at(-1)!.id; - if (next === cursor) break; - cursor = next; - if (page.length < 50 || page.some((comment) => comment.id === snapshot.latestCommentId)) break; - } - return { - ...snapshot, - hasMore: previous.hasMore, - oldestCommentId: previous.oldestCommentId, - latestCommentId: incoming.at(-1)?.id ?? snapshot.latestCommentId, - comments: mergeWindowComments(previous.comments, incoming, snapshot.comments), - }; - }, }); const { data: acknowledgedComments = [] } = useQuery({ queryKey: queryKeys.issues.acknowledgedComments(issueRef ?? ""), diff --git a/ui/src/lib/issue-comment-window-query.ts b/ui/src/lib/issue-comment-window-query.ts new file mode 100644 index 0000000000..2597fdb77f --- /dev/null +++ b/ui/src/lib/issue-comment-window-query.ts @@ -0,0 +1,59 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { IssueComment, IssueRecentComments } from "@paperclipai/shared"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "./queryKeys"; + +export function mergeWindowComments(...windows: readonly IssueComment[][]): IssueComment[] { + return [...new Map(windows.flat().map((comment) => [comment.id, comment])).values()] + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id.localeCompare(b.id)); +} + +export function getIssueRecentCommentsQueryOptions(queryClient: QueryClient, issueRef: string) { + const recentKey = [...queryKeys.issues.comments(issueRef), "recent"] as const; + return { + queryKey: recentKey, + queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + const previous = queryClient.getQueryData(recentKey); + const snapshot = await issuesApi.recentComments(issueRef, { signal }); + if (!previous) return snapshot; + // A burst can contain more than the cold-open window. Catch up from the + // saved cursor instead of dropping messages between two invalidations. + let cursor = previous.latestCommentId; + const incoming: IssueComment[] = []; + // Canceling queued input deletes its comment, including a saved cursor. + // Retry from an earlier observed row, then from the start if that row + // was also deleted. Never advance to the preview past unread replies. + const recoveryAnchors = [ + previous.comments.findLast((comment) => comment.id !== cursor)?.id ?? null, + null, + ]; + while (cursor !== snapshot.latestCommentId) { + const page = await issuesApi.listComments(issueRef, { + order: "asc", + ...(cursor ? { after: cursor } : {}), + limit: 50, + }, { signal }); + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + if (!page.length) { + if (cursor && recoveryAnchors.length) { + cursor = recoveryAnchors.shift() ?? null; + continue; + } + break; + } + incoming.push(...page); + const next = page.at(-1)!.id; + if (next === cursor) break; + cursor = next; + if (page.length < 50 || page.some((comment) => comment.id === snapshot.latestCommentId)) break; + } + return { + ...snapshot, + hasMore: previous.hasMore, + oldestCommentId: previous.oldestCommentId, + latestCommentId: incoming.at(-1)?.id ?? snapshot.latestCommentId, + comments: mergeWindowComments(previous.comments, incoming, snapshot.comments), + }; + }, + }; +} diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts index 78c9d69561..a6713fbcf6 100644 --- a/ui/src/lib/issueDetailCache.test.ts +++ b/ui/src/lib/issueDetailCache.test.ts @@ -6,6 +6,7 @@ import { fetchIssueDetail, getCachedIssueDetail, prefetchIssueDetail, + prefetchIssueDetailForNavigation, seedIssueDetailCache, } from "./issueDetailCache"; import { queryKeys } from "./queryKeys"; @@ -13,6 +14,9 @@ import { queryKeys } from "./queryKeys"; vi.mock("@/api/issues", () => ({ issuesApi: { get: vi.fn(), + getCore: vi.fn(), + recentComments: vi.fn(), + listComments: vi.fn(), }, })); @@ -141,3 +145,38 @@ describe("issueDetailCache", () => { expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); }); }); + + +describe("task navigation prefetch", () => { + it("warms the compact task and recent exchange used by the default surface", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = createIssue(); + const recent = { comments: [], latestCommentId: null, oldestCommentId: null, hasMore: false }; + vi.mocked(issuesApi.getCore).mockResolvedValue(issue); + vi.mocked(issuesApi.recentComments).mockResolvedValue(recent); + vi.mocked(issuesApi.get).mockResolvedValue(issue); + vi.mocked(issuesApi.listComments).mockResolvedValue([]); + vi.clearAllMocks(); + try { + await prefetchIssueDetailForNavigation(client, issue.identifier!); + expect(client.getQueryData([...queryKeys.issues.detail(issue.identifier!), "core"])).toEqual(issue); + expect(client.getQueryData([...queryKeys.issues.comments(issue.identifier!), "recent"])).toEqual(recent); + expect(issuesApi.get).not.toHaveBeenCalled(); + expect(issuesApi.listComments).not.toHaveBeenCalled(); + } finally { client.clear(); } + }); + + it("retains full-history prefetch for an already expanded task", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = createIssue(); + client.setQueryData(["issues", "history-expanded", issue.identifier!], true); + vi.mocked(issuesApi.get).mockResolvedValue(issue); + vi.mocked(issuesApi.listComments).mockResolvedValue([]); + try { + await prefetchIssueDetailForNavigation(client, issue.identifier!); + expect(client.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue); + expect(client.getQueryData(queryKeys.issues.comments(issue.identifier!))).toMatchObject({ pages: [[]] }); + } finally { client.clear(); } + }); + +}); diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index 9cf08dbc21..cb05ae4e06 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -1,6 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { Issue, IssueComment } from "@paperclipai/shared"; +import type { InstanceExperimentalSettings, Issue, IssueComment } from "@paperclipai/shared"; import { issuesApi } from "@/api/issues"; +import { getIssueRecentCommentsQueryOptions } from "./issue-comment-window-query"; import { queryKeys } from "@/lib/queryKeys"; import { getNextIssueCommentPageParam, ISSUE_COMMENT_PAGE_SIZE } from "@/lib/optimistic-issue-comments"; @@ -147,6 +148,17 @@ export function seedIssueDetailProjections( } } +export function getIssueCoreQueryOptions(queryClient: QueryClient, issueRef: string) { + return { + queryKey: [...queryKeys.issues.detail(issueRef), "core"] as const, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const issue = await issuesApi.getCore(issueRef, { signal }); + seedIssueDetailProjections(queryClient, issue, issueRef); + return issue; + }, + }; +} + export async function fetchIssueDetail( queryClient: QueryClient, issueRef: string, @@ -212,21 +224,30 @@ export function prefetchIssueComments(queryClient: QueryClient, issueRef: string }); } -/** - * Prefetch everything the issue-detail first paint needs — the detail snapshot - * and the first comments page — for instant warm navigation from a list row. - * Seeds the full list-row snapshot when provided so the header + description - * paint immediately with no loading state. - */ export function prefetchIssueDetailForNavigation( queryClient: QueryClient, issueRef: string, - options?: { - issue?: Issue | null; - }, + options?: { issue?: Issue | null }, ) { + const fullHistory = queryClient.getQueryData(["issues", "history-expanded", issueRef]) === true + || queryClient.getQueryData(queryKeys.instance.experimentalSettings)?.enableClassicTaskInterface === true; + if (fullHistory) { + return Promise.all([ + prefetchIssueDetail(queryClient, issueRef, options), + prefetchIssueComments(queryClient, issueRef), + ]); + } + if (isCompleteIssueSnapshot(options?.issue)) { + seedIssueDetailCache(queryClient, options.issue, { issueRef }); + } return Promise.all([ - prefetchIssueDetail(queryClient, issueRef, options), - prefetchIssueComments(queryClient, issueRef), + queryClient.prefetchQuery({ + ...getIssueCoreQueryOptions(queryClient, issueRef), + staleTime: ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS, + }), + queryClient.prefetchQuery({ + ...getIssueRecentCommentsQueryOptions(queryClient, issueRef), + staleTime: ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS, + }), ]); } diff --git a/ui/src/lib/prefetchIssueComments.test.ts b/ui/src/lib/prefetchIssueComments.test.ts index eb948a0db4..ffd0a8bfd0 100644 --- a/ui/src/lib/prefetchIssueComments.test.ts +++ b/ui/src/lib/prefetchIssueComments.test.ts @@ -9,6 +9,8 @@ import { queryKeys } from "./queryKeys"; vi.mock("@/api/issues", () => ({ issuesApi: { get: vi.fn(), + getCore: vi.fn(), + recentComments: vi.fn(), listComments: vi.fn(), }, })); @@ -88,21 +90,23 @@ describe("prefetchIssueComments", () => { expect(issuesApi.listComments).toHaveBeenCalledTimes(1); }); - it.each(["index", "rows"] as const)("fetches the full task when navigating from a %s projection", async (listProjection) => { + it.each(["index", "rows"] as const)("fetches the compact task when navigating from a %s projection", async (listProjection) => { const issue = createIssue(); - vi.mocked(issuesApi.get).mockResolvedValue(issue); - vi.mocked(issuesApi.listComments).mockResolvedValue([]); + vi.mocked(issuesApi.getCore).mockResolvedValue(issue); + vi.mocked(issuesApi.recentComments).mockResolvedValue({ comments: [], latestCommentId: null, oldestCommentId: null, hasMore: false }); await prefetchIssueDetailForNavigation(queryClient, issue.identifier!, { issue: { ...issue, listProjection, description: null }, }); - expect(issuesApi.get).toHaveBeenCalledWith(issue.identifier!); - expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); + expect(issuesApi.getCore).toHaveBeenCalledWith(issue.identifier!, { signal: expect.any(AbortSignal) }); + expect(queryClient.getQueryData([...queryKeys.issues.detail(issue.id), "core"])).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toBeUndefined(); }); - it("seeds both the detail snapshot and the first comments page for navigation", async () => { + it("seeds a complete snapshot and warms recent messages for navigation", async () => { const issue = createIssue(); const firstPage = [createComment({ id: "c1" })]; - vi.mocked(issuesApi.listComments).mockResolvedValue(firstPage); + vi.mocked(issuesApi.getCore).mockResolvedValue(issue); + vi.mocked(issuesApi.recentComments).mockResolvedValue({ comments: firstPage, latestCommentId: "c1", oldestCommentId: "c1", hasMore: false }); await prefetchIssueDetailForNavigation(queryClient, issue.identifier!, { issue }); @@ -111,10 +115,10 @@ describe("prefetchIssueComments", () => { expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue); expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); - const cachedComments = queryClient.getQueryData<{ pages: IssueComment[][] }>( - queryKeys.issues.comments(issue.identifier!), + const cachedComments = queryClient.getQueryData<{ comments: IssueComment[] }>( + [...queryKeys.issues.comments(issue.identifier!), "recent"], ); - expect(cachedComments?.pages).toEqual([firstPage]); + expect(cachedComments?.comments).toEqual(firstPage); // Hover/focus uses this helper too. Full history starts only once the task // page mounts, so moving across links does not fan out expensive reads. expect(activityApi.forIssue).not.toHaveBeenCalled(); diff --git a/ui/src/pages/AgentChat.test.tsx b/ui/src/pages/AgentChat.test.tsx index dab56c458f..cbd03234a4 100644 --- a/ui/src/pages/AgentChat.test.tsx +++ b/ui/src/pages/AgentChat.test.tsx @@ -4,7 +4,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import type { Agent } from "@paperclipai/shared"; +import type { Agent, Issue } from "@paperclipai/shared"; import { ApiError } from "@/api/client"; import { queryKeys } from "@/lib/queryKeys"; import { AgentChat } from "./AgentChat"; @@ -103,3 +103,29 @@ it("does not display a historical agent from another company", async () => { expect(container.textContent).not.toContain("Private helper"); expect(api.getChat).not.toHaveBeenCalled(); }); + + +it("opens a stale navigation conversation immediately while revalidating in the background", async () => { + client.setDefaultOptions({ queries: { retry: false, staleTime: 30_000, gcTime: Infinity } }); + client.setQueryData(queryKeys.agents.list("company-a"), [{ id: agentId, companyId: "company-a", name: "Helper" }]); + client.setQueryData(queryKeys.agentChats.list("company-a", "viewer-a"), [{ + id: "existing-chat", companyId: "company-a", conversationAgentId: agentId, + } as Issue], { updatedAt: Date.now() - 60_000 }); + api.getChat.mockImplementation(() => new Promise(() => {})); + render(); + expect(container.textContent).toContain("History for Helper"); + expect(container.textContent).not.toContain("Loading conversation"); + await waitFor(() => expect(api.getChat).toHaveBeenCalledWith("company-a", agentId)); + expect(api.ensure).not.toHaveBeenCalled(); +}); + +it("does not reuse another account's cached conversation", async () => { + client.setQueryData(queryKeys.agents.list("company-a"), [{ id: agentId, companyId: "company-a", name: "Helper" }]); + client.setQueryData(queryKeys.agentChats.list("company-a", "viewer-b"), [{ + id: "private-chat", companyId: "company-a", conversationAgentId: agentId, + } as Issue]); + api.getChat.mockImplementation(() => new Promise(() => {})); + render(); + expect(container.textContent).toContain("Loading conversation"); + await waitFor(() => expect(api.getChat).toHaveBeenCalled()); +}); diff --git a/ui/src/pages/AgentChat.tsx b/ui/src/pages/AgentChat.tsx index e3b8b6db13..472261b3e6 100644 --- a/ui/src/pages/AgentChat.tsx +++ b/ui/src/pages/AgentChat.tsx @@ -44,6 +44,14 @@ export function AgentChat() { const chat = useQuery({ queryKey: chatKey, queryFn: () => agentChatsApi.get(selectedCompanyId!, agent!.id), + initialData: () => enabled && agent && session.isSuccess + ? client.getQueryData(queryKeys.agentChats.list(selectedCompanyId, userId)) + ?.find(issue => issue.companyId === selectedCompanyId && issue.conversationAgentId === agent?.id) + : undefined, + initialDataUpdatedAt: () => { + const state = client.getQueryState(queryKeys.agentChats.list(selectedCompanyId, userId)); + return state?.isInvalidated ? 0 : state?.dataUpdatedAt; + }, enabled: enabled && !!agent && session.isFetched, }); const sessionView = useQueryView(session); diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index abc015e6f4..d85ba92f5b 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react import type { Agent, Environment, EnvironmentCapabilities } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/api/client"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { ToastProvider } from "../context/ToastContext"; import type { BuiltInAgentState } from "../api/builtInAgents"; import { Agents, filterAgents, filterOrgTree } from "./Agents"; @@ -321,9 +322,9 @@ describe("agent search", () => { function renderAgentsView(root: ReturnType | null, queryClient: QueryClient) { root!.render( - + - + , ); } @@ -399,7 +400,7 @@ describe("Agents", () => { makeAgent({ id: "beta", name: "Beta", role: "designer" }), ]); root = createRoot(container); - await act(async () => root!.render()); + await act(async () => root!.render()); await flushReact(); await flushReact(); const input = container.querySelector('input[aria-label="Search agents"]')!; @@ -426,9 +427,9 @@ describe("Agents", () => { await act(async () => { root!.render( - + - + , ); }); @@ -842,7 +843,7 @@ describe("Agents", () => { root = createRoot(container); await act(async () => { - root!.render(); + root!.render(); }); await flushReact(); await flushReact(); @@ -913,7 +914,7 @@ describe("Agents", () => { expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 selected"); expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("Edit connection & model"); expect(mockRouterState.navigate).not.toHaveBeenCalled(); - expect(Array.from(container.querySelectorAll("th")).map(cell => cell.textContent)).toEqual(["", "Agent", "Status", "Harness", "Connection", "Default model"]); + expect(Array.from(container.querySelectorAll("th")).map(cell => cell.textContent)).toEqual(["", "Agent", "Chat", "Status", "Harness", "Connection", "Default model"]); }); it.each([ @@ -924,9 +925,9 @@ describe("Agents", () => { await act(async () => { root!.render( - + - + , ); }); @@ -984,7 +985,7 @@ describe("Agents", () => { root = createRoot(container); await act(async () => { - root!.render(); + root!.render(); }); await flushReact(); await flushReact(); @@ -1024,9 +1025,9 @@ describe("Agents", () => { await act(async () => { root!.render( - + - + , ); }); @@ -1058,9 +1059,9 @@ describe("Agents", () => { await act(async () => { root!.render( - + - + , ); }); @@ -1088,9 +1089,9 @@ describe("Agents", () => { await act(async () => { root!.render( - + - + , ); }); diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index fc00027320..08def9e8e6 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -23,7 +23,7 @@ vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); -vi.mock("@/components/CoordinatorWorkspaceSetting", () => ({ CoordinatorWorkspaceSetting: () => null })); +vi.mock("@/components/ClassicWorkspaceSetting", () => ({ ClassicWorkspaceSetting: () => null })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: { id: "butter", issuePrefix: "BUT" } }), diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index c33d413a3c..dc56e1a5f8 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -20,7 +20,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Link } from "@/lib/router"; -import { CoordinatorWorkspaceSetting } from "@/components/CoordinatorWorkspaceSetting"; +import { ClassicWorkspaceSetting } from "@/components/ClassicWorkspaceSetting"; import { describeError } from "@/api/errors"; type WorktreeRunExecutionDisplayState = @@ -184,7 +184,7 @@ export function InstanceExperimentalSettings() { if (experimentalQuery.isLoading || experimentalView.kind === "reconnecting") { return (
- +
Loading experimental settings...
); @@ -192,7 +192,7 @@ export function InstanceExperimentalSettings() { if (experimentalView.kind === "error") { return ( -
- +
- toggleMutation.mutate({ enableAgentChat: checked })} - disabled={toggleMutation.isPending} - settingKey="enableAgentChat" - managed={managedKeys.enableAgentChat} - ariaLabel="Toggle agent chat experimental setting" - /> - { expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled(); }); + it.each([false, true])("opens a prefetched task without waiting on navigation reads (Coordinator: %s)", async coordinator => { + coordinatorPreference.enabled = coordinator; + queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } }); + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + mockIssuesApi.getCore.mockResolvedValue(createIssue({ description: "Prefetched instruction" })); + mockIssuesApi.recentComments.mockResolvedValue({ + comments: [createIssueComment({ body: "Prefetched answer" })], + latestCommentId: "comment-1", oldestCommentId: "comment-1", hasMore: true, + }); + await prefetchIssueDetailForNavigation(queryClient, "PAP-1"); + mockIssuesApi.getCore.mockClear().mockReturnValue(createDeferred().promise); + mockIssuesApi.recentComments.mockClear().mockReturnValue(createDeferred().promise); + mockIssuesApi.get.mockReturnValue(createDeferred().promise); + await act(async () => renderIssueView()); + await waitForAssertion(() => { + expect(container.textContent).toContain("Issue detail smoke"); + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ + compactHistory: true, initialHistoryPending: false, + comments: [expect.objectContaining({ body: "Prefetched answer" })], + }); + }); + expect(mockIssuesApi.getCore).not.toHaveBeenCalled(); + expect(mockIssuesApi.recentComments).not.toHaveBeenCalled(); + expect(mockIssuesApi.listComments).not.toHaveBeenCalled(); + }); + it("reveals a cold task and recent exchange while full detail and older history are stalled", async () => { queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); const stalled = createDeferred(); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 87777a5358..d125987d01 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -143,7 +143,7 @@ import { shouldTrackIssueActiveRun, } from "../lib/issueActiveRun"; import { prefetchIssueThread } from "../lib/issue-thread-queries"; -import { getIssueDetailQueryOptions, seedIssueDetailProjections } from "../lib/issueDetailCache"; +import { getIssueCoreQueryOptions, getIssueDetailQueryOptions } from "../lib/issueDetailCache"; import { beginIssueDetailNavigation, recordIssueDetailReadiness, @@ -3336,12 +3336,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS ); const coreIssueQuery = useQuery({ - queryKey: [...queryKeys.issues.detail(issueId!), "core"], - queryFn: async ({ signal }) => { - const core = await issuesApi.getCore(issueId!, { signal }); - seedIssueDetailProjections(queryClient, core, issueId); - return core; - }, + ...getIssueCoreQueryOptions(queryClient, issueId!), enabled: !!issueId, }); const { historyExpanded, expandHistory, recent: recentComments, retainComment, acknowledgedComments } = useIssueHistoryWindow( diff --git a/ui/storybook/stories/navigation-preferences/coordinator-workspace-setting.stories.tsx b/ui/storybook/stories/navigation-preferences/coordinator-workspace-setting.stories.tsx index 1f641880fc..83f1bc7106 100644 --- a/ui/storybook/stories/navigation-preferences/coordinator-workspace-setting.stories.tsx +++ b/ui/storybook/stories/navigation-preferences/coordinator-workspace-setting.stories.tsx @@ -1,13 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { CoordinatorWorkspaceSetting } from "@/components/CoordinatorWorkspaceSetting"; +import { ClassicWorkspaceSetting } from "@/components/ClassicWorkspaceSetting"; import { storybookAuthSession } from "../../fixtures/paperclipData"; import { prepareFixtureApi, type FixtureState } from "../../support/capability-gaps/api"; const meta = { id: "feature-coordinator-workspace-setting", title: "Oversight and operation/Navigation, profile, and announcements/Coordinator workspace preference", - component: CoordinatorWorkspaceSetting, + component: ClassicWorkspaceSetting, parameters: { layout: "padded", paperclip: { @@ -15,35 +15,48 @@ const meta = { status: "current", implementation: "production", boundary: "fixtures", - notes: "Personal workspace Settings card with off/on/loading/failed-save states. API writes are simulated and retained only for this story. Real persistence, company/account isolation and shell switching need the feature recipe checks.", + notes: "Classic workspace Settings card with off/on/loading/failed-save states. Coordinator is the standard interface. API writes are simulated and retained only for this story. Real persistence, company/account isolation and shell switching need the feature recipe checks.", }, }, beforeEach: ({ parameters }) => { - let enabled = parameters.initiallyEnabled === true; - const preference = () => ({ userId: storybookAuthSession.user.id, coordinatorWorkspaceEnabled: enabled, updatedAt: null }); + let classicEnabled = parameters.initiallyEnabled === true; + const preference = () => ({ userId: storybookAuthSession.user.id, coordinatorWorkspaceEnabled: !classicEnabled, updatedAt: null }); return prepareFixtureApi([ { path: "/api/instance/settings/experimental", method: "GET", read: () => ({ enableAgentChat: parameters.chatDisabled !== true }) }, { path: "/api/cli-auth/me", method: "GET", read: () => ({ userId: storybookAuthSession.user.id, source: "session", isInstanceAdmin: parameters.instanceAdmin === true }) }, { path: "/api/workspace-preferences/me", method: "GET", read: preference }, { path: /\/api\/companies\/[^/]+\/workspace-preferences\/me$/, method: "PUT", read: ({ body }) => { - enabled = body.coordinatorWorkspaceEnabled === true; + classicEnabled = body.coordinatorWorkspaceEnabled === false; return preference(); } }, ], parameters.fixtureState as FixtureState).cleanup; }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; -export const OffByDefault: Story = {}; -export const Enabled: Story = { parameters: { initiallyEnabled: true } }; -export const AgentChatDisabledForAdmin: Story = { parameters: { chatDisabled: true, instanceAdmin: true } }; -export const AgentChatDisabledForMember: Story = { parameters: { chatDisabled: true } }; +export const OffByDefault: Story = { name: "Classic off by default" }; +export const Enabled: Story = { name: "Classic enabled", parameters: { initiallyEnabled: true } }; +export const AgentChatDisabledForAdmin: Story = { + name: "Historical: retired Chat prerequisite for administrators", + parameters: { chatDisabled: true, instanceAdmin: true, paperclip: { + status: "historical", capabilities: ["navigation-preferences/preferences"], implementation: "production", boundary: "fixtures", + notes: "Retained identity from the retired Chat prerequisite. The current Classic preference has no Chat dependency.", + } }, +}; +export const AgentChatDisabledForMember: Story = { + name: "Historical: retired Chat prerequisite for members", + parameters: { chatDisabled: true, paperclip: { + status: "historical", capabilities: ["navigation-preferences/preferences"], implementation: "production", boundary: "fixtures", + notes: "Retained identity from the retired Chat prerequisite. The current Classic preference has no Chat dependency.", + } }, +}; export const Loading: Story = { parameters: { fixtureState: "loading" } }; export const SaveAndReturnToClassic: Story = { + name: "Choose Classic and return to Coordinator", play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const toggle = await canvas.findByRole("switch", { name: "Enable coordinator workspace" }); + const toggle = await canvas.findByRole("switch", { name: "Enable classic workspace" }); await waitFor(() => expect(toggle).toBeEnabled()); await userEvent.click(toggle); await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "true")); @@ -55,7 +68,7 @@ export const SaveFailed: Story = { parameters: { fixtureState: "mutation-failure" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const toggle = await canvas.findByRole("switch", { name: "Enable coordinator workspace" }); + const toggle = await canvas.findByRole("switch", { name: "Enable classic workspace" }); await waitFor(() => expect(toggle).toBeEnabled()); await userEvent.click(toggle); await canvas.findByRole("alert");