mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
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 <[email protected]> Co-authored-by: Paperclip <[email protected]>
This commit is contained in:
75 files changed
+1203
-547
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+13
-4
@@ -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.
|
||||
+4
-4
@@ -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)
|
||||
|
||||
|
||||
+26
-18
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
+340
-2
@@ -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": {
|
||||
+7
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>> = [];
|
||||
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);
|
||||
|
||||
});
|
||||
@@ -198,12 +198,11 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableAgentChat: {
|
||||
title: "Agent Chat",
|
||||
description:
|
||||
"Persistent task-backed conversations that clarify goals and hand work off to tasks. Chat leads the Work group with an agent rail, and each chat's side panel shows the agent's tasks and artifacts as cards.",
|
||||
title: "Agent Chat (compatibility)",
|
||||
description: "Deprecated compatibility key. Agent Chat is always enabled; stored and managed values are ignored.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
cloudDefault: true,
|
||||
selfHostedDefault: true,
|
||||
},
|
||||
enableCombinedInboxTasks: {
|
||||
title: "Combined Inbox + Task List",
|
||||
|
||||
@@ -64,7 +64,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
enablePaperclipNotebook: z.boolean().default(false),
|
||||
enableGoogleWorkspaceConnector: z.boolean().default(false),
|
||||
enableCases: z.boolean().default(false),
|
||||
enableAgentChat: z.boolean().default(false),
|
||||
enableAgentChat: z.boolean().default(true),
|
||||
enableCombinedInboxTasks: z.boolean().default(false),
|
||||
enableArtifactsV1: z.boolean().default(true),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
|
||||
@@ -369,11 +369,11 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
|
||||
expect((await f.snapshot()).issues.filter(i => 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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<WorkspacePreference> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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("[email protected]", { exact: true })).toBeVisible();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -6,13 +6,17 @@ async function json<T = Record<string, any>>(response: APIResponse): Promise<T>
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()}` } }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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(<ClassicWorkspaceSetting />));
|
||||
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<HTMLButtonElement>('[role="switch"]')!.click());
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith("company", !enabled);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Card className="block bg-transparent p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h3 id="classic-workspace-title" className="text-sm font-medium">Classic workspace</h3>
|
||||
<p id="classic-workspace-description" className="text-sm text-muted-foreground">
|
||||
Use the previous workspace instead of the standard Coordinator workspace. This changes your interface across companies on this instance.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Off by default. Turn it off to return to Coordinator. Your work and your teammates’ settings stay the same.
|
||||
</p>
|
||||
{workspace.userId === "local-board" && (
|
||||
<p className="text-xs text-muted-foreground">This local instance uses the shared Local Board identity.</p>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={workspace.loaded && !workspace.enabled}
|
||||
disabled={!workspace.loaded || workspace.isSaving || !workspace.userId || !selectedCompany || !!workspace.error}
|
||||
aria-label="Enable classic workspace"
|
||||
aria-describedby="classic-workspace-description"
|
||||
onCheckedChange={(classicEnabled) => {
|
||||
if (!selectedCompany) return;
|
||||
void workspace.setEnabled(selectedCompany.id, !classicEnabled).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{workspace.isSaving && <p role="status" className="mt-3 text-sm text-muted-foreground">Saving your workspace…</p>}
|
||||
{!workspace.loaded && <p role="status" className="mt-3 text-sm text-muted-foreground">Loading your preference…</p>}
|
||||
{error && (
|
||||
<div role="alert" className="mt-3 flex items-center gap-2 text-sm text-destructive">
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => { void workspace.retry(); }}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, projectTasks: vi.fn() }));
|
||||
recentTasks: [] as Array<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, 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<HTMLButtonElement>('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();
|
||||
|
||||
@@ -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<HTMLDivElement>(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 }) {
|
||||
<Flyout open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<FlyoutTrigger asChild><button type="button" aria-label="More workspace pages" className="design-sidebar-item flex w-auto items-center gap-2.5 rounded-lg py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring"><MoreHorizontal className="size-4 shrink-0" /><span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>More</span></button></FlyoutTrigger>
|
||||
<FlyoutContent ref={moreContentRef} tabIndex={-1} onOpenAutoFocus={(event) => { 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 <div key={to}>
|
||||
|
||||
@@ -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<string>(),
|
||||
}));
|
||||
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(<QueryClientProvider client={client}><CoordinatorWorkspaceSetting /></QueryClientProvider>));
|
||||
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[role="switch"]')!.disabled).toBe(false);
|
||||
expect(mocks.access).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Card className="block bg-transparent p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h3 id="coordinator-workspace-title" className="text-sm font-medium">Coordinator workspace</h3>
|
||||
<p id="coordinator-workspace-description" className="text-sm text-muted-foreground">
|
||||
Try the new Chat, Tasks, and Needs You workspace. This changes your interface across companies on this instance.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Off by default. Turn it off to return to the classic workspace. Your work and your teammates’ settings stay the same.
|
||||
</p>
|
||||
{workspace.userId === "local-board" && (
|
||||
<p className="text-xs text-muted-foreground">This local instance uses the shared Local Board identity.</p>
|
||||
)}
|
||||
{chatDisabled && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agent Chat is off for this instance. Your workspace preference does not enable it.{" "}
|
||||
{canEnableChat ? (
|
||||
<a href="#experimental-enableAgentChat" className="underline underline-offset-4">Enable Agent Chat below</a>
|
||||
) : chatManaged || chatHidden ? (
|
||||
"Chat availability is managed by your hosting provider."
|
||||
) : (
|
||||
"Ask an administrator to enable Agent Chat in Experimental settings."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={workspace.enabled}
|
||||
disabled={!workspace.loaded || workspace.isSaving || !workspace.userId || !selectedCompany || !!workspace.error}
|
||||
aria-label="Enable coordinator workspace"
|
||||
aria-describedby="coordinator-workspace-description"
|
||||
onCheckedChange={(enabled) => {
|
||||
if (!selectedCompany) return;
|
||||
// The hook exposes the error for the account that attempted the save.
|
||||
void workspace.setEnabled(selectedCompany.id, enabled).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{workspace.isSaving && <p role="status" className="mt-3 text-sm text-muted-foreground">Saving your workspace…</p>}
|
||||
{!workspace.loaded && <p role="status" className="mt-3 text-sm text-muted-foreground">Loading your preference…</p>}
|
||||
{error && (
|
||||
<div role="alert" className="mt-3 flex items-center gap-2 text-sm text-destructive">
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => { void workspace.retry(); }}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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: () => <div>Proposed sidebar</div> }));
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<IssueRecentComments> => {
|
||||
const previous = queryClient.getQueryData<IssueRecentComments>(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<IssueComment[]>({
|
||||
queryKey: queryKeys.issues.acknowledgedComments(issueRef ?? ""),
|
||||
|
||||
@@ -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<IssueRecentComments> => {
|
||||
const previous = queryClient.getQueryData<IssueRecentComments>(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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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<InstanceExperimentalSettings>(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,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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<Issue[]>(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);
|
||||
|
||||
@@ -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<typeof createRoot> | null, queryClient: QueryClient) {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider><ToastProvider>
|
||||
<Agents />
|
||||
</ToastProvider>
|
||||
</ToastProvider></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -399,7 +400,7 @@ describe("Agents", () => {
|
||||
makeAgent({ id: "beta", name: "Beta", role: "designer" }),
|
||||
]);
|
||||
root = createRoot(container);
|
||||
await act(async () => root!.render(<QueryClientProvider client={queryClient}><ToastProvider><Agents /></ToastProvider></QueryClientProvider>));
|
||||
await act(async () => root!.render(<QueryClientProvider client={queryClient}><TooltipProvider><ToastProvider><Agents /></ToastProvider></TooltipProvider></QueryClientProvider>));
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
const input = container.querySelector<HTMLInputElement>('input[aria-label="Search agents"]')!;
|
||||
@@ -426,9 +427,9 @@ describe("Agents", () => {
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider><ToastProvider>
|
||||
<AgentList />
|
||||
</ToastProvider>
|
||||
</ToastProvider></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -842,7 +843,7 @@ describe("Agents", () => {
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(<QueryClientProvider client={queryClient}><ToastProvider><AgentList /></ToastProvider></QueryClientProvider>);
|
||||
root!.render(<QueryClientProvider client={queryClient}><TooltipProvider><ToastProvider><AgentList /></ToastProvider></TooltipProvider></QueryClientProvider>);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider><ToastProvider>
|
||||
<AgentList />
|
||||
</ToastProvider>
|
||||
</ToastProvider></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -984,7 +985,7 @@ describe("Agents", () => {
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(<QueryClientProvider client={queryClient}><ToastProvider><AgentList /></ToastProvider></QueryClientProvider>);
|
||||
root!.render(<QueryClientProvider client={queryClient}><TooltipProvider><ToastProvider><AgentList /></ToastProvider></TooltipProvider></QueryClientProvider>);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
@@ -1024,9 +1025,9 @@ describe("Agents", () => {
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider><ToastProvider>
|
||||
<AgentList />
|
||||
</ToastProvider>
|
||||
</ToastProvider></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -1058,9 +1059,9 @@ describe("Agents", () => {
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider><ToastProvider>
|
||||
<AgentList />
|
||||
</ToastProvider>
|
||||
</ToastProvider></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -1088,9 +1089,9 @@ describe("Agents", () => {
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider><ToastProvider>
|
||||
<AgentList />
|
||||
</ToastProvider>
|
||||
</ToastProvider></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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" } }),
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<CoordinatorWorkspaceSetting key="personal-workspace" />
|
||||
<ClassicWorkspaceSetting key="personal-workspace" />
|
||||
<div className="text-sm text-muted-foreground">Loading experimental settings...</div>
|
||||
</div>
|
||||
);
|
||||
@@ -192,7 +192,7 @@ export function InstanceExperimentalSettings() {
|
||||
|
||||
if (experimentalView.kind === "error") {
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6"><CoordinatorWorkspaceSetting key="personal-workspace" /> <QueryErrorState
|
||||
<div className="max-w-6xl space-y-6"><ClassicWorkspaceSetting key="personal-workspace" /> <QueryErrorState
|
||||
size="page"
|
||||
error={experimentalQuery.error}
|
||||
action="load experimental settings"
|
||||
@@ -274,7 +274,7 @@ export function InstanceExperimentalSettings() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CoordinatorWorkspaceSetting key="personal-workspace" />
|
||||
<ClassicWorkspaceSetting key="personal-workspace" />
|
||||
|
||||
<div
|
||||
role="alert"
|
||||
@@ -308,18 +308,6 @@ export function InstanceExperimentalSettings() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Agent Chat"
|
||||
description="Talk to each agent in one ongoing conversation. Clarify goals and create tasks for execution. Chat leads the Work group and opens an agent rail beside the nav, and each chat's side panel shows the agent's tasks and artifacts as cards."
|
||||
footnote="Turning this off preserves conversations and lets active runs finish, but prevents new messages."
|
||||
checked={experimentalQuery.data?.enableAgentChat ?? false}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableAgentChat: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableAgentChat"
|
||||
managed={managedKeys.enableAgentChat}
|
||||
ariaLabel="Toggle agent chat experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Assistant connections (MCP)"
|
||||
description="Connect Codex, Claude, and other assistants to your Paperclip organization. People sign in, select an organization once, and approve access to review work, delegate tasks, and add feedback."
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
shouldScrollIssueDetailToTopOnNavigation,
|
||||
} from "./IssueDetail";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { prefetchIssueDetailForNavigation } from "../lib/issueDetailCache";
|
||||
import { markCachedSession } from "../lib/cached-session";
|
||||
import { startPersistedQueryCache } from "../lib/persisted-query-cache";
|
||||
import { clearLocalInboxArchive, filterLocalInboxArchivedIssues, getLocalInboxArchiveIssueIds } from "../lib/inboxArchiveCache";
|
||||
@@ -2501,6 +2502,32 @@ describe("IssueDetail", () => {
|
||||
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<Issue>().promise);
|
||||
mockIssuesApi.recentComments.mockClear().mockReturnValue(createDeferred<IssueRecentComments>().promise);
|
||||
mockIssuesApi.get.mockReturnValue(createDeferred<Issue>().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<Issue>();
|
||||
|
||||
@@ -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(
|
||||
|
||||
+26
-13
@@ -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<typeof CoordinatorWorkspaceSetting>;
|
||||
} satisfies Meta<typeof ClassicWorkspaceSetting>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user