From a3313f69447e522a82558798ccbb19fdf6694fee Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 4 Nov 2026 14:07:18 -0800 Subject: [PATCH 001/533] fix(ui): complete read recovery and enforce query error rendering (#18822) ## Thinking Path > - Paperclip lets people manage agent work through the board. > - The board reads task, agent, settings, plugin, and run data from the server. > - A deploy can interrupt those reads after content is already visible. > - Local error branches can replace that content with an error or show raw codes. > - This pull request moves the remaining reads to the shared recovery behavior. > - The query-error guard prevents new local error patterns from returning. ## Linked Issues or Issue Description **What happened?** Loaded lists, settings, task panels, and manual reads could clear their content or show errors during a server restart. Some reads also disabled the shared retry policy. **Expected behavior** Keep loaded content during a temporary failure. Show the shared connection banner. Retry reads after recovery. Replace content after a real access denial or missing resource, with readable copy. **Steps to reproduce** Open a task, settings page, or run log. Return 503 with `tenant_app_unavailable` from the API. Restore the API and check that content refreshes. Repeat with 403 and 404 to check the failure state. Related merged work: Refs #15754, Refs #15772, Refs #17960. This change covers remaining reads and enforcement. Browser persistence and the outage browser suite follow separately. ## What Changed - Repair an existing PostgreSQL deadline fixture at UTC boundaries. A daily cap defers a heartbeat until both its original interval and the next UTC day allow it. Keep the counted run in its seeded day and cover noon, just before midnight and the first second after midnight with explicit clocks and real PostgreSQL. Production scheduling is unchanged. - Integrate the saved list, settings, shell, task, and manual-read recovery changes against current master. Preserve the current task, Inbox, and run-log structure. - Add `useResilientFetch` for component-local reads. Preserve loaded data, cancel stale requests, and retry through the shared connectivity policy. Keep browser and pairing capabilities in memory. - Retain run log chunks across stream changes and catch up after recovery without duplicate chunks or stalled requests. - Keep genuine authorization and missing-resource failures visible. Translate known single-word codes such as `forbidden`. Show known skills and disconnected-app failures before sibling loading states. Keep Retry bound to its failed request. - Make the query-error guard enforce by default and run it in the CI policy job. Document the narrow exceptions and the read recovery contract. - Invalidate pending OAuth discovery when the operator leaves setup or its context changes. Keep reserved-popup cleanup bound to the current attempt. ## Verification - Current read head `09d1db29dea` is merge-ready: all 59 check runs are completed (54 successful, three skipped, two neutral), the legacy Snyk status is successful, and both required `ci / verify` and `ci / e2e` gates pass. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads; the branch has no merge conflicts. The current CI run is `41748339602`. - Current read head `09d1db29dea` includes master `683fa4725e9`. The latest change touches only the existing server deadline test fixture. Application source remains the previously verified `86376fa751e` revision: all 60 affected skills/disconnected-app tests and both application UI typechecks passed, along with token gates and the enforcing query-error guard. Those UI suites were not repeated for this test-only repair. - The fixture repair passes all seven deadline integration cases and the complete server typecheck, including the server test layer. The old assertion deterministically fails at 23:59:06.888 UTC; its one-second-old run also fails the first-second-of-day probe. The corrected fixture passes noon and both midnight probes. The permanent three-case coverage uses seeded 2099 clocks. - Twelve new regressions fail against the previous implementation. Both skills variants and disconnected-app pages show either request's 403 or 429 while the sibling pauses or remains fetching. Permission failures stay visible. Retry stays usable for its failed request and recovers independently. An initial test-fixture query-key typo was corrected before the passing run. The finding is replied to and resolved. Greptile reviewed prior application revision `86376fa751e` at 5/5 with no new actionable findings or unresolved threads. The current test-only head has now received its own Apex 5/5 review with no unresolved threads, and its CI is successful as recorded above. - The preceding OAuth fix passed all 237 setup tests on both application branches. Its ten regressions prove that Back, Cancel, unmount, or context changes retire initial discovery without creating a connection, caching old identities, navigating an abandoned popup, or closing a newer attempt. - Earlier master integration `e992f770339` passed all 309 tests across eight affected Slack, recovery, retry, submission and task files, plus UI typecheck, token gates, enforcing query-error scan and seven guard tests. The integration preserves Slack workspace reconnect and shared recovery, readable execution errors, source-inspection company scoping, and current submission behavior. Newly inherited retained-file recovery controls keep cached data through transient failures and hide it after definitive denial. - The preceding cache head `97c0bc3966f` passed 1,038 focused cache/write/editor/account/task/API tests. All 22 built-UI outage/write/loading cases passed in 4.7 minutes on exact browser head `b7deaaee5da`, with strict checking of all three specs. The latest skills/app rendering fix is covered by its affected component suites; those broader cases were not repeated for this separate read path. - Historical broader UI, CLI and build results and unresolved aggregate server/DB/adapter failures remain in the PR discussion. Interrupted CI runs were audited and recorded separately; they are not counted as passes. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Real hosted acceptance remains required after merge and deployment. Commands for the latest fix: ```sh pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx pnpm --filter @paperclipai/ui typecheck pnpm check:token-gates node scripts/check-query-error-rendering.mjs --enforce --summary ``` ## Risks This migration touches many read surfaces. The main risks are initial loading, request cancellation, retained authorization state, and run-log paging. The tests cover those states. There are no server, schema, dependency, or API contract changes. The follow-up browser suite and hosted deploy acceptance are required before release acceptance. Recovered commits retain their original authors. If squash-merging, preserve `Co-Authored-By: Bender (Fable) ` and the Paperclip trailer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. Saved source commits keep their original authorship. ## 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 described the issue in-PR following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] The affected local tests pass; broader limitations are recorded above - [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 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: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip Co-authored-by: Bender (Fable) Co-authored-by: Claude Fable 5.1 --- .github/workflows/pr-trusted.yml | 5 +- doc/DEVELOPING.md | 7 + scripts/check-query-error-rendering.mjs | 34 +- scripts/check-query-error-rendering.test.mjs | 8 + .../idle-domain-deadlines-postgres.test.ts | 29 +- tests/e2e/chat-adapters-ui-messaging.spec.ts | 3 + tests/e2e/disconnect-write-recovery.spec.ts | 9 +- ui/src/api/client.test.ts | 19 +- ui/src/api/client.ts | 17 +- ui/src/api/companies-query.ts | 3 - ui/src/api/errors.test.ts | 2 +- ui/src/api/errors.ts | 3 +- ui/src/api/tools.ts | 10 +- ui/src/components/ActiveAgentsPanel.tsx | 1 - ui/src/components/AgentActionButtons.tsx | 6 +- .../AgentConfigForm.render.test.tsx | 81 +++++ ui/src/components/AgentConfigForm.tsx | 120 +++---- ui/src/components/AppErrorBoundary.tsx | 3 +- ui/src/components/BlockedInboxView.test.tsx | 60 +++- ui/src/components/BlockedInboxView.tsx | 31 +- .../ChatConnectorsExperimentalGate.test.tsx | 40 +++ .../ChatConnectorsExperimentalGate.tsx | 23 +- ui/src/components/CommandPalette.tsx | 1 - .../CompanySettingsSidebar.production.tsx | 1 - ui/src/components/CompanySettingsSidebar.tsx | 1 - .../components/ConfigureBuiltInAgentModal.tsx | 3 +- .../CoordinatorWorkspaceSetting.tsx | 1 - .../components/DotRunnerConnection.test.tsx | 2 +- ui/src/components/DotRunnerConnection.tsx | 19 +- .../components/EmailConnectionAccess.test.tsx | 37 ++ ui/src/components/EmailConnectionAccess.tsx | 22 +- ui/src/components/EmailTaskActivity.tsx | 90 +++-- ui/src/components/ExecutionBlockerNotice.tsx | 3 +- .../ExecutionWorkspaceCloseDialog.tsx | 21 +- ui/src/components/FileTree.test.tsx | 13 + ui/src/components/FileTree.tsx | 3 +- ui/src/components/GrokBotConnection.tsx | 5 +- ui/src/components/GrokBotWorkSummary.tsx | 5 +- ui/src/components/InboxAgentPolicyControl.tsx | 22 +- ui/src/components/InstructionHistory.tsx | 36 +- ui/src/components/IssuePrivacyActions.tsx | 17 +- ui/src/components/IssueRunLedger.tsx | 2 - ui/src/components/IssueScheduledRetryCard.tsx | 6 +- ui/src/components/IssuesList.test.tsx | 103 +++++- ui/src/components/IssuesList.tsx | 17 +- ui/src/components/Layout.production.tsx | 1 - ui/src/components/Layout.tsx | 1 - ui/src/components/LegacyIssuesList.tsx | 17 +- ui/src/components/LegacyProjectRepository.tsx | 3 +- ui/src/components/MuseRunnerConnection.tsx | 5 +- ui/src/components/NewProjectDialog.tsx | 3 +- ui/src/components/OnboardingWizard.test.tsx | 4 +- ui/src/components/OnboardingWizard.tsx | 12 +- .../components/PluginOrganizationSwitcher.tsx | 2 +- ui/src/components/ProjectAccessMembers.tsx | 21 +- ui/src/components/ProjectAccessNotice.tsx | 3 +- ui/src/components/ProjectProperties.tsx | 11 +- ui/src/components/ProjectRepositories.tsx | 3 +- ui/src/components/ProjectRepositoryInput.tsx | 17 +- ui/src/components/RouteErrorBoundary.tsx | 3 +- ui/src/components/RoutineHistoryTab.tsx | 30 +- .../components/RoutineRunVariablesDialog.tsx | 1 - .../RunWorkspaceRecoverySurface.tsx | 1 - ui/src/components/RunnerInspector.test.tsx | 181 ++++++++++ ui/src/components/RunnerInspector.tsx | 124 ++++--- ui/src/components/SentryGate.tsx | 1 - .../SidebarAccountMenu.production.tsx | 1 - ui/src/components/SidebarAccountMenu.tsx | 1 - .../SidebarCompanyMenu.production.tsx | 1 - ui/src/components/SidebarCompanyMenu.tsx | 1 - ui/src/components/SummarySlotCard.tsx | 33 +- ui/src/components/TaskChatThread.tsx | 4 +- ui/src/components/TaskDecisionRecord.tsx | 7 +- .../components/WorkspaceBaseRefRecovery.tsx | 3 +- .../WorkspaceExportRecovery.control.test.tsx | 28 ++ ui/src/components/WorkspaceExportRecovery.tsx | 33 +- .../WorkspaceReconstructionRecovery.tsx | 7 +- .../components/access/InvitesSection.test.tsx | 75 ++++ ui/src/components/access/InvitesSection.tsx | 31 +- ui/src/components/agents/AgentBulkRoster.tsx | 2 +- .../agents/AgentBulkSettingsSheet.tsx | 4 +- .../AiConnectionAccountControls.tsx | 3 +- .../AiConnectionCredentialStep.tsx | 21 +- .../ai-connections/AiConnectionField.test.tsx | 28 ++ .../ai-connections/AiConnectionField.tsx | 11 +- .../AiConnectionPoolConnector.tsx | 24 +- .../ai-connections/AiConnectionUsagePanel.tsx | 3 +- .../ai-connections/AiProviderSetup.tsx | 14 +- .../ManagedAiConnectionDetails.tsx | 24 +- .../ai-connections/useConnectionModels.ts | 7 +- .../ai-connections/useLocalAiLogin.ts | 3 +- .../artifacts/IssueArtifactCard.tsx | 24 +- .../artifacts/v1/ArtifactFileRow.tsx | 9 +- .../components/cases/CaseDocumentPreview.tsx | 4 +- .../components/cases/CaseHandoffHistory.tsx | 9 +- .../chat/ExternallyConnectedTaskBanner.tsx | 14 +- .../coordinator/CoordinatorAttentionTasks.tsx | 9 +- .../CoordinatorChatDecisionReceipts.tsx | 3 +- .../CoordinatorChatPendingQuestions.tsx | 3 +- .../CoordinatorChatTaskHandoffs.tsx | 3 +- .../CoordinatorConversationTasksPanel.tsx | 9 +- .../coordinator/CoordinatorNeedsYou.tsx | 20 +- .../coordinator/CoordinatorTaskCommitment.tsx | 3 +- .../DecisionModelSettings.test.tsx | 53 +++ .../decision-models/DecisionModelSettings.tsx | 12 +- .../new-agent/AgentBasicsDialog.tsx | 28 +- .../new-agent/AgentProviderConnection.tsx | 14 +- .../new-agent/ExternalAgentInviteDialog.tsx | 24 +- ui/src/components/new-agent/GrokBotSetup.tsx | 7 +- ui/src/components/new-agent/NewAgentSetup.tsx | 33 +- .../AgentObservabilitySettings.tsx | 6 +- .../onboarding/SavedProviderKeySelect.tsx | 4 - .../query-recovery-placeholders.test.tsx | 37 ++ .../operate-sections.test.tsx | 15 +- .../routine-sections/operate-sections.tsx | 37 +- ui/src/components/run-log/LogViewer.tsx | 134 ++++--- ui/src/components/run-log/RunDetails.tsx | 14 +- .../skill-studio/SkillSourceBranchRepair.tsx | 5 +- .../SkillSourceConnectionRepair.tsx | 5 +- ui/src/components/task-chat/QuestionForm.tsx | 5 +- .../task-side-panel/TaskBrowserPanel.test.tsx | 107 +++++- .../task-side-panel/TaskBrowserPanel.tsx | 122 ++++--- .../task-side-panel/TaskSidePanel.tsx | 8 +- .../TaskWorkspaceFilePanel.tsx | 2 - ui/src/components/useSummaryDraftStream.ts | 3 +- .../voice/NativeVoiceConversation.tsx | 3 +- ui/src/components/voice/SpekoCallHistory.tsx | 4 +- .../components/voice/SpekoIncomingCalls.tsx | 14 +- .../components/voice/SpekoPhoneCallback.tsx | 2 +- ui/src/components/voice/SpekoPhoneLine.tsx | 2 +- ui/src/components/voice/TaskVoiceLauncher.tsx | 8 +- ui/src/context/LiveUpdatesProvider.tsx | 1 - ui/src/context/PrimaryAgentProvider.tsx | 5 +- .../connections/AgentMailCredentialField.tsx | 8 +- .../connections/AgentMailIntentSetup.tsx | 5 +- .../connections/ConnectionInstructions.tsx | 9 +- .../ConnectionIntentInteractionBody.test.tsx | 40 ++- .../ConnectionIntentInteractionBody.tsx | 72 ++-- .../connections/ConnectionSetupFlow.tsx | 157 +++++--- .../connections/GoogleWorkspaceServices.tsx | 6 +- .../connections/HonchoWorkspaceSettings.tsx | 3 +- .../RemoteMcpProductionSetup.test.tsx | 90 +++++ .../remote-mcp/RemoteMcpProductionSetup.tsx | 12 +- .../connections/useEmailAddressCheck.ts | 3 +- ui/src/hooks/useBrowserViewport.ts | 4 +- ui/src/hooks/useChatConnectorsEnabled.ts | 11 +- ui/src/hooks/useCloudInviteUrl.ts | 6 +- ui/src/hooks/useCompanyInviteAccess.ts | 1 - ui/src/hooks/useConnectorAvailability.ts | 6 +- ui/src/hooks/useConversationHistoryPages.ts | 1 - ui/src/hooks/useFastResponsesEnabled.ts | 4 +- ui/src/hooks/useIssueExternalObjects.ts | 27 +- ui/src/hooks/useIssueHierarchyContext.ts | 10 +- ui/src/hooks/useMemoryConnectorsEnabled.ts | 4 +- ui/src/hooks/useMuseConnection.ts | 2 +- ui/src/hooks/usePrimaryAgent.ts | 2 +- ui/src/hooks/useProjectCreatedItems.ts | 1 - ui/src/hooks/useRecentTasks.ts | 1 - ui/src/hooks/useResilientFetch.test.tsx | 335 ++++++++++++++++++ ui/src/hooks/useResilientFetch.ts | 331 +++++++++++++++++ ui/src/hooks/useStagingCommit.ts | 13 +- ui/src/hooks/useTaskBrowsers.ts | 6 +- ui/src/hooks/useTaskPluginPanels.ts | 3 +- ui/src/lib/issue-chat-messages.ts | 4 +- ui/src/lib/transcriptPresentation.ts | 2 +- ui/src/pages/AgentAdvancedTab.tsx | 4 +- .../pages/AgentDetail.log-resilience.test.tsx | 158 +++++++++ .../pages/AgentDetail.log-visibility.test.tsx | 99 ++++++ ui/src/pages/AgentDetail.production.tsx | 1 + ui/src/pages/AgentDetail.tsx | 1 + ui/src/pages/AgentToolsTab.tsx | 2 +- ui/src/pages/Agents.production.tsx | 22 +- ui/src/pages/Agents.test.tsx | 93 ++++- ui/src/pages/Agents.tsx | 21 +- ui/src/pages/Approvals.tsx | 23 +- ui/src/pages/Artifacts.tsx | 32 +- ui/src/pages/Auth.test.tsx | 10 +- ui/src/pages/Auth.tsx | 35 +- ui/src/pages/BoardClaim.tsx | 24 +- ui/src/pages/Cases.tsx | 23 +- ui/src/pages/CliAuth.tsx | 25 +- ui/src/pages/Companies.tsx | 8 +- ui/src/pages/CompanyAccess.test.tsx | 125 +++++++ ui/src/pages/CompanyAccess.tsx | 39 +- ui/src/pages/CompanyEnvironments.test.tsx | 53 +++ ui/src/pages/CompanyEnvironments.tsx | 115 +++--- ui/src/pages/CompanyExport.test.tsx | 5 +- ui/src/pages/CompanyExport.tsx | 9 +- ui/src/pages/CompanyImport.tsx | 22 +- ui/src/pages/CompanySettings.tsx | 32 +- ui/src/pages/CompanySkills.production.tsx | 77 ++-- ui/src/pages/CompanySkills.test.tsx | 209 ++++++++++- ui/src/pages/CompanySkills.tsx | 77 ++-- ui/src/pages/Dashboard.tsx | 22 +- ui/src/pages/DecisionQueuePage.tsx | 31 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 66 ++-- ui/src/pages/Goals.test.tsx | 155 ++++++++ ui/src/pages/Goals.tsx | 22 +- ui/src/pages/Inbox.test.tsx | 51 ++- ui/src/pages/Inbox.tsx | 59 +-- ui/src/pages/InstanceAccess.test.tsx | 40 ++- ui/src/pages/InstanceAccess.tsx | 59 +-- .../InstanceExperimentalSettings.test.tsx | 75 ++++ ui/src/pages/InstanceExperimentalSettings.tsx | 25 +- ui/src/pages/InstanceGeneralSettings.test.tsx | 75 ++++ ui/src/pages/InstanceGeneralSettings.tsx | 30 +- ui/src/pages/InviteLanding.tsx | 6 +- ui/src/pages/Issues.routine-filter.test.tsx | 38 ++ ui/src/pages/Issues.tsx | 17 +- ui/src/pages/JoinRequestQueue.test.tsx | 153 ++++++++ ui/src/pages/JoinRequestQueue.tsx | 30 +- ui/src/pages/LegacyInbox.tsx | 78 ++-- ui/src/pages/McpConnect.test.tsx | 61 +++- ui/src/pages/McpConnect.tsx | 58 +-- ui/src/pages/MyIssues.tsx | 22 +- ui/src/pages/Org.tsx | 22 +- ui/src/pages/OrgChart.production.tsx | 21 +- ui/src/pages/OrgChart.tsx | 21 +- ui/src/pages/PluginSettings.test.tsx | 85 +++++ ui/src/pages/PluginSettings.tsx | 57 ++- ui/src/pages/ProfileSettings.test.tsx | 65 ++++ ui/src/pages/ProfileSettings.tsx | 28 +- ui/src/pages/ProjectDetail.test.tsx | 19 + ui/src/pages/ProjectDetail.tsx | 18 +- ui/src/pages/Projects.tsx | 22 +- ui/src/pages/RoutineDetail.production.tsx | 42 ++- ui/src/pages/RoutineDetail.tsx | 55 ++- ui/src/pages/Routines.production.tsx | 55 +-- ui/src/pages/Routines.tsx | 55 +-- ui/src/pages/Search.tsx | 26 +- ui/src/pages/Secrets.render.test.tsx | 81 +++++ ui/src/pages/Secrets.tsx | 103 +++--- ui/src/pages/SkillSources.test.tsx | 44 ++- ui/src/pages/SkillSources.tsx | 28 +- ui/src/pages/SkillStudio.tsx | 5 +- .../StatusCards/CreateStatusCardDialog.tsx | 3 +- .../StatusCards/StatusCardDetailDrawer.tsx | 25 +- ui/src/pages/StatusCards/index.tsx | 24 +- ui/src/pages/TeamCatalog.tsx | 27 +- ui/src/pages/UserProfile.tsx | 1 - ui/src/pages/WhatNeedsMe.tsx | 26 +- ui/src/pages/agent-skills/AgentSkillsTab.tsx | 7 +- ui/src/pages/apps/AggregatorAppManager.tsx | 7 +- ui/src/pages/apps/AppDetail.test.tsx | 53 +++ ui/src/pages/apps/AppDetail.tsx | 91 ++--- ui/src/pages/apps/AppNotConnected.test.tsx | 41 +++ ui/src/pages/apps/AppNotConnected.tsx | 25 +- ui/src/pages/apps/AppsConnect.test.tsx | 166 ++++++++- ui/src/pages/apps/AppsConnect.tsx | 31 +- ui/src/pages/apps/ArcadeDiscoverySetup.tsx | 3 +- .../pages/apps/AssistantConnection.test.tsx | 56 ++- ui/src/pages/apps/AssistantConnection.tsx | 50 +-- ui/src/pages/apps/Browse.test.tsx | 44 ++- ui/src/pages/apps/Browse.tsx | 106 +++--- ui/src/pages/apps/ComposioAppManager.tsx | 10 +- ui/src/pages/apps/ComposioAppSetup.tsx | 3 +- ui/src/pages/apps/Connections.tsx | 17 +- ui/src/pages/apps/ExecutorManagementSetup.tsx | 3 +- .../pages/apps/PaperclipCloudOAuthHandoff.tsx | 3 +- ui/src/pages/apps/ReviewQueueCard.tsx | 9 +- .../apps/app-detail/ActionTestDialog.tsx | 63 ++-- .../pages/apps/app-detail/AdvancedPanel.tsx | 9 +- .../apps/app-detail/AgentConnectionAccess.tsx | 3 +- ui/src/pages/apps/app-detail/AppReadError.tsx | 23 ++ .../app-detail/BrowserUseSettingsPanel.tsx | 39 +- .../app-detail/ConnectedAggregatorApps.tsx | 16 +- .../apps/app-detail/RailwayAccessPanel.tsx | 3 +- .../chat/ChatCommunicationInstructions.tsx | 3 +- .../apps/chat/ChatEndpoint.clipboard.test.tsx | 50 ++- .../ChatEndpointDetail.agentmail.test.tsx | 6 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 105 +++--- ui/src/pages/apps/chat/ChatEndpointSetup.tsx | 44 +-- .../apps/chat/ChatIdentityConfirm.test.tsx | 11 +- .../pages/apps/chat/ChatIdentityConfirm.tsx | 39 +- .../apps/chat/EmailEndpointSetup.test.tsx | 28 +- ui/src/pages/apps/chat/EmailEndpointSetup.tsx | 88 +++-- .../apps/chat/GitHubBotConfiguration.tsx | 39 +- .../apps/chat/GitHubBotManagement.test.tsx | 53 ++- .../pages/apps/chat/GitHubBotManagement.tsx | 87 ++--- ui/src/pages/apps/chat/GitHubChatSetup.tsx | 42 ++- .../apps/chat/GitHubConnectionComplete.tsx | 4 +- ui/src/pages/apps/chat/PhotonConnectStep.tsx | 4 +- ui/src/pages/apps/chat/SlackIdentityStep.tsx | 12 +- .../apps/chat/SlackTestMessageButton.tsx | 11 +- ui/src/pages/apps/chat/SlackToolSettings.tsx | 19 +- ui/src/pages/apps/chat/chat-setup-error.ts | 1 + .../apps/chat/slack/ManagedSlackSetup.tsx | 10 +- ui/src/pages/apps/chat/slack/SlackSetup.tsx | 2 +- .../apps/gateways/ConnectClientDialog.tsx | 5 +- .../pages/apps/gateways/EditGatewayDialog.tsx | 3 +- ui/src/pages/apps/gateways/GatewayDetail.tsx | 20 +- .../pages/apps/gateways/GatewaysList.test.tsx | 170 +++++++++ ui/src/pages/apps/gateways/GatewaysList.tsx | 18 +- .../pages/apps/gateways/NewGatewayDialog.tsx | 3 +- .../gateways/panels/GatewayActivityPanel.tsx | 18 +- .../gateways/panels/GatewayAdvancedPanel.tsx | 3 +- .../apps/gateways/panels/TokensPanel.tsx | 7 +- ui/src/pages/audit/AgentCommentary.tsx | 5 +- ui/src/pages/audit/AuditFeed.production.tsx | 29 +- ui/src/pages/audit/AuditFeed.test.tsx | 5 +- ui/src/pages/audit/AuditFeed.tsx | 29 +- ui/src/pages/audit/AuditRuns.tsx | 49 ++- ui/src/pages/audit/RoutineAuditActivity.tsx | 25 +- .../pages/secrets/ImportFromVaultDialog.tsx | 11 +- .../secrets/MissingUserSecretsBanner.tsx | 1 - .../pages/secrets/MyUserSecretsTab.test.tsx | 154 ++++++++ ui/src/pages/secrets/MyUserSecretsTab.tsx | 24 +- ui/src/pages/secrets/ProposalsTab.tsx | 16 +- .../pages/secrets/SetMyUserSecretDialog.tsx | 8 +- .../secrets/UserSecretDefinitionsTab.tsx | 27 +- ui/src/pages/secrets/proposal-review.tsx | 6 +- .../skills/ImportSkillsFromProjectDialog.tsx | 47 ++- ui/src/pages/skills/SkillPackagePreview.tsx | 9 +- ui/src/pages/tools/AdvancedToolsRoute.tsx | 20 +- ui/src/pages/tools/AuditTab.test.tsx | 36 ++ ui/src/pages/tools/AuditTab.tsx | 16 +- ui/src/pages/tools/GatewaysTab.test.tsx | 50 +++ ui/src/pages/tools/GatewaysTab.tsx | 26 +- ui/src/pages/tools/PasteConfigTab.tsx | 15 +- ui/src/pages/tools/ProfilesTab.tsx | 42 ++- ui/src/pages/tools/SmokeLabTab.tsx | 11 +- ui/src/pages/tools/connection-dialogs.tsx | 30 +- .../tools/profiles/ProfileDetail.test.tsx | 42 +++ ui/src/pages/tools/profiles/ProfileDetail.tsx | 34 +- .../tools/profiles/ProfilesIndex.test.tsx | 43 +++ ui/src/pages/tools/profiles/ProfilesIndex.tsx | 15 +- .../tools/profiles/ToolsAdminGate.test.tsx | 132 +++++++ .../pages/tools/profiles/ToolsAdminGate.tsx | 20 +- ui/src/pages/tools/shared.tsx | 57 +-- .../costs/subscriptions.stories.tsx | 6 +- 330 files changed, 7809 insertions(+), 2252 deletions(-) create mode 100644 ui/src/components/EmailConnectionAccess.test.tsx create mode 100644 ui/src/components/query-recovery-placeholders.test.tsx create mode 100644 ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.test.tsx create mode 100644 ui/src/hooks/useResilientFetch.test.tsx create mode 100644 ui/src/hooks/useResilientFetch.ts create mode 100644 ui/src/pages/AgentDetail.log-resilience.test.tsx create mode 100644 ui/src/pages/Goals.test.tsx create mode 100644 ui/src/pages/JoinRequestQueue.test.tsx create mode 100644 ui/src/pages/apps/app-detail/AppReadError.tsx create mode 100644 ui/src/pages/apps/gateways/GatewaysList.test.tsx create mode 100644 ui/src/pages/secrets/MyUserSecretsTab.test.tsx create mode 100644 ui/src/pages/tools/profiles/ToolsAdminGate.test.tsx diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 420f1d9825..9d83f5ac94 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -186,9 +186,8 @@ jobs: - name: Test feature module boundary check run: node "$CI_NODE_TEST" ./scripts/check-module-boundaries.test.mjs - # Report-only for now (always exits 0); --enforce turns findings into failures. - - name: Report raw query-error rendering - run: node scripts/check-query-error-rendering.mjs --summary + - name: Reject raw query-error rendering + run: node scripts/check-query-error-rendering.mjs --enforce --summary - name: Test query-error rendering check run: node --test ./scripts/check-query-error-rendering.test.mjs diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 071b75ae89..2e8540363a 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4548,3 +4548,10 @@ one-time config-file seed preserves existing choices, then later starts use the database without loading home `.env` files or repairing worktree config. See [Cloud runtime configuration](cloud-runtime-configuration.md) for field ownership, secret handling, initialization order and migration interfaces. + + +### Read resilience during deploys + +Read surfaces use `useQueryView` and `QueryErrorState`. Transient refetch failures keep loaded data visible. An initial outage keeps a quiet placeholder until recovery. Authorization, missing resources and validation failures still show readable errors. Exhausted first reads on a reachable server offer Retry, including task filters and hierarchy reads. Health probes and the socket own app-wide recovery; a successful component load may be an older in-flight response or a local no-op. Sensitive manual reads use `useResilientFetch`; browser viewer URLs and pairing previews remain in component memory and never enter a shared cache. + +Run `node scripts/check-query-error-rendering.mjs`. The guard now enforces across `ui/src`, including multiline expressions, and CI runs it as a policy check. Its small allowlist covers standalone preview/performance fixtures, transcript protocol result flags, and the existing account-resolution and Copilot discovery retry owners. These are not ordinary read-error renders. Additions require a specific reason; do not allowlist a page to hide an outage regression. diff --git a/scripts/check-query-error-rendering.mjs b/scripts/check-query-error-rendering.mjs index b8e9675c31..dbdde137d2 100644 --- a/scripts/check-query-error-rendering.mjs +++ b/scripts/check-query-error-rendering.mjs @@ -16,7 +16,7 @@ * before errors. Intentional exceptions go in ALLOWLIST below as * ":" with a reason. * - * Report-only by default: prints findings and exits 0, except for findings in + * Enforcing by default: prints findings and exits 0, except for findings in * ENFORCED_FILES (surfaces already migrated), which always exit 1. `--enforce` * exits 1 on any finding outside the allowlist (CI turns this on once the * long tail migrates). @@ -59,7 +59,16 @@ export const RULES = [ * a reason. The list shrinks as surfaces migrate to the shared error handling. */ export const ALLOWLIST = new Set([ - // Empty for now: report-only mode lists every finding as the baseline. + "ui/src/artifact-directions/main.tsx:retry-false", + "ui/src/wizard-preview-main.tsx:retry-false", + "ui/src/fixtures/TaskChatPerfHarness.tsx:retry-false", + "ui/src/pages/NewAgent.test-support.tsx:retry-false", + "ui/src/components/TaskChatThread.tsx:is-error-render", + "ui/src/components/transcript/RunTranscriptView.tsx:is-error-render", + "ui/src/components/task-chat/transcript-adapter.ts:is-error-render", + "ui/src/lib/issue-chat-messages.ts:is-error-render", + "ui/src/lib/account-scope.ts:retry-false", + "ui/src/lib/copilot-task-model-catalog.ts:retry-false", ]); /** @@ -133,15 +142,20 @@ function walk(dir, out) { export function scanSource(sourceText, path) { const findings = []; const lines = sourceText.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - if (/\/\/\s*query-error-ok\b/.test(line)) continue; + const checkable = lines.map((line) => { const trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; - for (const rule of RULES) { - if (rule.pattern.test(line)) findings.push({ path, line: index + 1, rule: rule.id, text: trimmed }); + return /\/\/\s*query-error-ok\b/.test(line) + || trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*") + ? " ".repeat(line.length) : line; + }).join("\n"); + for (const rule of RULES) { + const pattern = new RegExp(rule.pattern.source, "g"); + for (const match of checkable.matchAll(pattern)) { + const line = checkable.slice(0, match.index).split("\n").length; + findings.push({ path, line, rule: rule.id, text: lines[line - 1].trim() }); } } + findings.sort((a, b) => a.line - b.line || RULES.findIndex((rule) => rule.id === a.rule) - RULES.findIndex((rule) => rule.id === b.rule)); return findings; } @@ -163,7 +177,7 @@ export function scanQueryErrorRendering({ function main() { const args = new Set(process.argv.slice(2)); - const enforce = args.has("--enforce"); + const enforce = !args.has("--report-only"); const summaryOnly = args.has("--summary"); const findings = scanQueryErrorRendering(); const enforcedFindings = findings.filter((finding) => finding.enforced); @@ -191,7 +205,7 @@ function main() { process.exitCode = 1; } if (!enforce) { - console.log("Report-only mode: findings outside migrated files do not fail. Pass --enforce to fail on all findings."); + console.log("Explicit report-only mode: findings outside migrated files do not fail."); return; } if (findings.length > 0) process.exitCode = 1; diff --git a/scripts/check-query-error-rendering.test.mjs b/scripts/check-query-error-rendering.test.mjs index 55573ef093..f9a7c20c5c 100644 --- a/scripts/check-query-error-rendering.test.mjs +++ b/scripts/check-query-error-rendering.test.mjs @@ -71,3 +71,11 @@ test("scans a tree, skips tests, and honors the allowlist", () => { rmSync(root, { recursive: true, force: true }); } }); + + +test("flags multiline renders and retry overrides", () => { + assert.deepEqual(rulesFor("

{\n error.message\n}

"), ["raw-error-message"]); + assert.deepEqual(rulesFor("

{\n (error as Error).message\n}

"), ["cast-error-message"]); + assert.deepEqual(rulesFor("query.isError\n ? : "), ["is-error-render"]); + assert.deepEqual(rulesFor("useQuery({ retry:\n false })"), ["retry-false"]); +}); diff --git a/server/src/__tests__/idle-domain-deadlines-postgres.test.ts b/server/src/__tests__/idle-domain-deadlines-postgres.test.ts index 35141db2ea..c8242b4cc8 100644 --- a/server/src/__tests__/idle-domain-deadlines-postgres.test.ts +++ b/server/src/__tests__/idle-domain-deadlines-postgres.test.ts @@ -29,6 +29,7 @@ import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database import { readIdleDeadlineProof } from "../services/idle-deadline-proof.js"; import type { VerifiedIdleCapabilities } from "../services/idle-incoming-wake-capability.js"; import { claimDueTimerHeartbeatForAgent } from "../services/heartbeat/queue.js"; +import { createHeartbeatTimerPolicy } from "../services/heartbeat/timer-policy.js"; import { pluginJobStore } from "../services/plugin-job-store.js"; const external = resolveExternalTestDatabaseUrl("PAPERCLIP_IDLE_DEADLINE_TEST_DATABASE_URL"); @@ -119,6 +120,29 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu } }, 30_000); + it.each([ + ["2099-01-01T12:00:00.000Z", "2099-01-02T00:00:00.000Z"], + ["2099-01-01T23:59:06.888Z", "2099-01-02T00:06:36.888Z"], + ["2099-01-02T00:00:00.500Z", "2099-01-03T00:00:00.000Z"], + ])("preserves both the timer interval and UTC daily cap at %s", async (instant, deadline) => { + const now = new Date(instant), companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "UTC timer fixture", issuePrefix: `T${companyId.slice(0, 6)}` }); + try { + const [agent] = await db.insert(agents).values({ id: agentId, companyId, name: "Capped timer", role: "engineer", + status: "idle", adapterType: "process", createdAt: now, lastHeartbeatAt: now, + runtimeConfig: { heartbeat: { enabled: true, intervalSec: 450, maxDailyRuns: 1 } } }).returning(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", invocationSource: "timer", + startedAt: now, finishedAt: now, costAccountingPending: false }); + const schedule = await createHeartbeatTimerPolicy(db).readTimerHeartbeatSchedule(agent, now); + expect(schedule.deadline).toBe(Date.parse(deadline)); + expect(schedule.cap).toMatchObject({ reason: "heartbeat.daily_run_limit", observed: 1, limit: 1 }); + } finally { + await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + await db.delete(agents).where(eq(agents.id, agentId)); + await db.delete(companies).where(eq(companies.id, companyId)); + } + }); + it("projects durable timers across client reconnection and executes the original plugin and trace owners", async () => { const now = Date.now(), companyId = randomUUID(), agentId = randomUUID(), pluginId = randomUUID(), runId = randomUUID(); let hold: { ownerId: string; expiresAt: number } = { ownerId: randomUUID(), expiresAt: now + 300_000 }; @@ -162,10 +186,11 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu expect(claimed.filter(Boolean)).toHaveLength(1); expect((await read()).scheduledWake?.nextWakeAt).toBe(new Date(now + 450_000).toISOString()); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", invocationSource: "timer", - startedAt: new Date(now - 1000), finishedAt: new Date(now), costAccountingPending: false }); + startedAt: new Date(now), finishedAt: new Date(now), costAccountingPending: false }); await db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: true, intervalSec: 450, maxDailyRuns: 1 } } }).where(eq(agents.id, agentId)); const date = new Date(now); - expect((await read()).scheduledWake?.nextWakeAt).toBe(new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1)).toISOString()); + const nextDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1); + expect((await read()).scheduledWake?.nextWakeAt).toBe(new Date(Math.max(now + 450_000, nextDay)).toISOString()); await db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: true, intervalSec: 450, skipTimerWhenNoActionableWork: true } } }).where(eq(agents.id, agentId)); expect((await read()).scheduledWake).toBeUndefined(); await db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: "true", intervalSec: 450 } } }).where(eq(agents.id, agentId)); diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 9f6fc62b34..746eef745b 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -257,6 +257,9 @@ test.describe("Board send delivery refresh", () => { ? `/issues/${issue.id.toUpperCase()}` : `/${selected.prefix}/issues/${issue.identifier}?external=chat#files`, ); + if (!classic) { + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + } await expect( page.getByRole("heading", { name: "Organization-bound task link", diff --git a/tests/e2e/disconnect-write-recovery.spec.ts b/tests/e2e/disconnect-write-recovery.spec.ts index 1450aec855..d398d8fb46 100644 --- a/tests/e2e/disconnect-write-recovery.spec.ts +++ b/tests/e2e/disconnect-write-recovery.spec.ts @@ -44,6 +44,7 @@ test("a task title survives an outage and saves after server recovery", async ({ { timeout: 30_000 }).toBe("A title retained through the outage"); expect((await json(await request.get(`/api/issues/${other.id}`))).title).toBe(other.title); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: "A title retained through the outage", exact: true })).toBeVisible(); expect((await json(await request.get(`/api/issues/${issue.id}`))).status).toBe("backlog"); } finally { @@ -88,6 +89,7 @@ test("two cached tasks keep separate title saves while the browser is offline", await expect.poll(async () => (await json(await request.get(`/api/issues/${first.id}`))).title, { timeout: 30_000 }).toBe("First title retained offline"); await expect.poll(async () => (await json(await request.get(`/api/issues/${second.id}`))).title, { timeout: 30_000 }).toBe("Second title retained offline"); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: "Second title retained offline", exact: true })).toBeVisible(); } finally { await page.context().setOffline(false).catch(() => {}); @@ -144,6 +146,7 @@ test("a cold task waits for the first session and retains a title saved during a await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title, { timeout: 15_000 }).toBe(savedTitle); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: savedTitle, exact: true })).toBeVisible(); } finally { releaseSession(); @@ -218,7 +221,10 @@ for (const delayWorkspacePreference of [false, true]) test(delayWorkspacePrefere await header.locator("textarea").press("Enter"); await expect.poll(() => failedAfterSave).toBeGreaterThan(0); if (delayWorkspacePreference) { - await expect(page.getByText("Couldn't update the task", { exact: true })).toBeVisible(); + // Identity is still unknown: retain the unowned edit in memory until the + // first verified session, then prove it survives the presentation remount. + await expect(header.locator("textarea")).toHaveValue("Saved after session recovery"); + expect((await json(await request.get(`/api/issues/${issue.id}`))).title).toBe(issue.title); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve(null)))); } healthy = true; @@ -232,6 +238,7 @@ for (const delayWorkspacePreference of [false, true]) test(delayWorkspacePrefere await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title, { timeout: 15_000 }).toBe("Saved after session recovery"); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: "Saved after session recovery", exact: true })).toBeVisible(); } finally { releasePreference(); diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 00156e922a..cdd371a011 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -93,7 +93,8 @@ describe("tenant-session recovery", () => { await expect(api.get("/ordinary-401")).rejects.toMatchObject({ name: "ApiError", status: 401, - message: "unauthorized", + code: "unauthorized", + message: "Your session has ended. Sign in again to continue.", }); fetchMock.mockResolvedValue(jsonResponse({ recovered: true })); @@ -103,6 +104,22 @@ describe("tenant-session recovery", () => { }); describe("in-tab GET coalescing", () => { + it("keeps fresh validation reads separate from older and concurrent requests", async () => { + const old = deferred(); + fetchMock.mockReturnValueOnce(old.promise).mockResolvedValue(jsonResponse({ title: "Current C" })); + const stale = api.get("/fresh-validation"); + const first = api.get("/fresh-validation", { cache: "no-store", fresh: true }); + const second = api.get("/fresh-validation", { cache: "no-store", fresh: true }); + const joined = api.get("/fresh-validation"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ cache: "no-store" }); + await expect(first).resolves.toEqual({ title: "Current C" }); + await expect(second).resolves.toEqual({ title: "Current C" }); + old.resolve(jsonResponse({ title: "Old A" })); + await expect(stale).resolves.toEqual({ title: "Old A" }); + await expect(joined).resolves.toEqual({ title: "Old A" }); + }); + it("times out a caller without cancelling another and starts a fresh fetch on retry", async () => { vi.useFakeTimers(); const first = deferred(); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 78ec560949..b0128490f4 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -99,6 +99,8 @@ export async function apiFetch(input: string, init?: RequestInit): Promise; } -const inflightGets = new Map(); +const inflightGets = new Map(); function coalescedGet(path: string, options?: RequestOptions): Promise { const signal = options?.signal; if (signal?.aborted) return Promise.reject(abortError()); - let entry = inflightGets.get(path); + const key = options?.fresh ? Symbol(path) : path; + let entry = inflightGets.get(key); if (!entry) { const controller = new AbortController(); const promise = request(path, { @@ -186,13 +189,13 @@ function coalescedGet(path: string, options?: RequestOptions): Promise { // Clear the shared entry once settled so later calls issue a fresh request. promise.then( () => { - if (inflightGets.get(path) === created) inflightGets.delete(path); + if (inflightGets.get(key) === created) inflightGets.delete(key); }, () => { - if (inflightGets.get(path) === created) inflightGets.delete(path); + if (inflightGets.get(key) === created) inflightGets.delete(key); }, ); - inflightGets.set(path, created); + inflightGets.set(key, created); entry = created; } @@ -204,7 +207,7 @@ function coalescedGet(path: string, options?: RequestOptions): Promise { if (!activeEntry.refs.delete(ref)) return; // Last caller gone before the fetch settled → abort the shared request. if (activeEntry.refs.size === 0) { - if (inflightGets.get(path) === activeEntry) inflightGets.delete(path); + if (inflightGets.get(key) === activeEntry) inflightGets.delete(key); activeEntry.controller.abort(); } }; @@ -225,7 +228,7 @@ function coalescedGet(path: string, options?: RequestOptions): Promise { timer = setTimeout(() => { cleanup(); // Other callers keep their request, but retries must not rejoin it. - if (inflightGets.get(path) === activeEntry) inflightGets.delete(path); + if (inflightGets.get(key) === activeEntry) inflightGets.delete(key); releaseRef(); reject(new ApiError(errorCopy("TIMEOUT").body, 408, { error: "TIMEOUT" })); }, options.timeoutMs); diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts index 4475703e76..5eff1cb0c2 100644 --- a/ui/src/api/companies-query.ts +++ b/ui/src/api/companies-query.ts @@ -37,7 +37,6 @@ export function companyListQueryOptions(userId: string | null) { throw err; } }, - retry: false, } as const; } @@ -49,14 +48,12 @@ export function companyDirectoryQueryOptions(userId: string | null) { companiesApi.detachInflightDirectory(); return companiesApi.directory(); }, - retry: false, } as const; } const sessionQueryOptions = { queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - retry: false, } as const; /** diff --git a/ui/src/api/errors.test.ts b/ui/src/api/errors.test.ts index e2b706e4a5..c3d6a0c196 100644 --- a/ui/src/api/errors.test.ts +++ b/ui/src/api/errors.test.ts @@ -162,7 +162,7 @@ describe("ApiError", () => { }); describe("isRawErrorCode", () => { - it.each(["tenant_app_unavailable", "WORKER_UNAVAILABLE", "TIMEOUT", "issue.not_found"])("%s is a code", (value) => { + it.each(["tenant_app_unavailable", "WORKER_UNAVAILABLE", "TIMEOUT", "issue.not_found", "forbidden", "unauthorized", "conflict"])("%s is a code", (value) => { expect(isRawErrorCode(value)).toBe(true); }); diff --git a/ui/src/api/errors.ts b/ui/src/api/errors.ts index 1c94961d28..e4ce2f2029 100644 --- a/ui/src/api/errors.ts +++ b/ui/src/api/errors.ts @@ -159,7 +159,8 @@ export function errorCodes(err: unknown): string[] { export function isRawErrorCode(value: string): boolean { const text = value.trim(); if (!text || /\s/.test(text)) return false; - return /^[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+$/.test(text) || /^[A-Z][A-Z0-9_]{2,}$/.test(text); + return Object.hasOwn(KIND_COPY, text) || text === "unauthorized" + || /^[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+$/.test(text) || /^[A-Z][A-Z0-9_]{2,}$/.test(text); } /** A server message that is safe to show: not a raw code, not a generic status line. */ diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 8ab5b4ed9e..6e583e6200 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -339,8 +339,9 @@ export const toolsApi = { api.get(`/tool-connections/${connectionId}/review-counts`), listAppsAttention: (companyId: string) => api.get(`/companies/${companyId}/tools/apps/attention`), - listApplications: (companyId: string) => - api.get(`/companies/${companyId}/tools/applications`), + listApplications: (companyId: string, options?: RequestOptions) => + options ? api.get(`/companies/${companyId}/tools/applications`, options) + : api.get(`/companies/${companyId}/tools/applications`), createApplication: (companyId: string, input: CreateToolApplicationInput) => api.post(`/companies/${companyId}/tools/applications`, input), updateApplication: (applicationId: string, input: UpdateToolApplicationInput) => @@ -349,8 +350,9 @@ export const toolsApi = { api.delete(`/tool-applications/${applicationId}`), // --- Connections --- - listConnections: (companyId: string) => - api.get(`/companies/${companyId}/tools/connections`), + listConnections: (companyId: string, options?: RequestOptions) => + options ? api.get(`/companies/${companyId}/tools/connections`, options) + : api.get(`/companies/${companyId}/tools/connections`), getConnection: (connectionId: string) => api.get(`/tool-connections/${connectionId}`), // --- Installs (Phase 3b, PAP-13618): which agents carry this connection's diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 8f09811cf6..091637d8c4 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -107,7 +107,6 @@ export function ActiveAgentsPanel({ queryKey: queryKeys.issues.detail(issueId), queryFn: () => issuesApi.get(issueId), staleTime: 30_000, - retry: false, })), }); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index a474f827ca..d7aca4b895 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -246,7 +246,7 @@ export function AgentActionButtons({ const resolvedCompanyId = companyId ?? agent.companyId; const canonicalAgentRef = agentRouteRef(agent); const isPaused = agent.lifecycleState ? agent.lifecycleState === "paused" : agent.status === "paused"; - const isError = agent.status === "error"; + const isAgentError = agent.status === "error"; const needsSetup = agent.pauseReason === "setup" && agent.status !== "terminated" && !["terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState ?? ""); @@ -450,7 +450,7 @@ export function AgentActionButtons({ {runLabel} ) : null} - {needsSetup ? null : isError ? ( + {needsSetup ? null : isAgentError ? ( diff --git a/ui/src/components/ChatConnectorsExperimentalGate.test.tsx b/ui/src/components/ChatConnectorsExperimentalGate.test.tsx index 440758e3a6..c76d326837 100644 --- a/ui/src/components/ChatConnectorsExperimentalGate.test.tsx +++ b/ui/src/components/ChatConnectorsExperimentalGate.test.tsx @@ -4,6 +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, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/api/client"; import { ChatConnectorsExperimentalGate } from "./ChatConnectorsExperimentalGate"; import { AgentChannelsPanel } from "./chat/AgentChannelsPanel"; import { ExternallyConnectedTaskBanner } from "./chat/ExternallyConnectedTaskBanner"; @@ -169,6 +170,45 @@ describe("Chat connectors visibility gate", () => { expect(container.querySelector("[data-chat-setup]")).toBeNull(); expect(container.querySelector("[data-redirect]")).not.toBeNull(); }); + it("keeps a loaded endpoint gating the page when its refetch fails transiently", async () => { + api.endpointId = "endpoint-1"; + api.get.mockResolvedValue({ provider: "slack" }); + api.settings.mockResolvedValue({ enableChatConnectors: true }); + await render(); + await vi.waitFor(() => expect(container.querySelector("[data-chat-setup]")).not.toBeNull()); + + api.get.mockRejectedValue( + new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }), + ); + await client.invalidateQueries({ queryKey: queryKeys.chatEndpoints.detail("endpoint-1") }); + await flushReact(); + + expect(api.get).toHaveBeenCalledTimes(2); + expect(container.querySelector("[data-chat-setup]")).not.toBeNull(); + expect(container.querySelector('[data-query-view="error"]')).toBeNull(); + expect(container.textContent).not.toContain("Paperclip is restarting."); + expect(container.textContent).not.toContain("tenant_app_unavailable"); + }); + it("shows readable copy with Retry when the endpoint fails to load", async () => { + api.endpointId = "endpoint-1"; + api.get + .mockRejectedValueOnce(new ApiError("Boom", 500, { error: "Boom" })) + .mockResolvedValue({ provider: "slack" }); + api.settings.mockResolvedValue({ enableChatConnectors: true }); + await render(); + await vi.waitFor(() => expect(container.querySelector('[data-query-view="error"]')).not.toBeNull()); + + expect(container.textContent).toContain("Couldn't load this endpoint"); + expect(container.querySelector("[data-chat-setup]")).toBeNull(); + expect(container.querySelector("[data-redirect]")).toBeNull(); + const retry = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Retry"); + expect(retry, "Retry button").toBeTruthy(); + retry!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushReact(); + + await vi.waitFor(() => expect(container.querySelector("[data-chat-setup]")).not.toBeNull()); + expect(container.querySelector('[data-query-view="error"]')).toBeNull(); + }); it("defaults off without a query provider and performs no settings request", async () => { await render(false); expect(container.querySelector("[data-chat-setup]")).toBeNull(); diff --git a/ui/src/components/ChatConnectorsExperimentalGate.tsx b/ui/src/components/ChatConnectorsExperimentalGate.tsx index ee9e5d6143..50ff9d5d5c 100644 --- a/ui/src/components/ChatConnectorsExperimentalGate.tsx +++ b/ui/src/components/ChatConnectorsExperimentalGate.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { chatEndpointsApi } from "@/api/chatEndpoints"; import { queryKeys } from "@/lib/queryKeys"; -import { Button } from "@/components/ui/button"; +import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { useChatConnectorsEnabled, chatProviderVisible, @@ -20,17 +20,20 @@ function ProviderEndpointGate({ queryKey: queryKeys.chatEndpoints.detail(endpointId), queryFn: () => chatEndpointsApi.get(endpointId), }); + const endpointView = useQueryView(endpoint); const { enabled, githubEnabled } = useChatConnectorsEnabled(); - if (endpoint.isError) + // A cached endpoint keeps gating the page through an outage; only a real + // failure with nothing loaded shows the error. + if (endpointView.kind === "error") return ( -
-

- {endpoint.error.message} -

- -
+ ); if (!endpoint.data) return null; return chatProviderVisible(endpoint.data.provider, enabled, githubEnabled) ? ( diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 85dc925a18..7f29445f67 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -102,7 +102,6 @@ export function CommandPalette() { const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), - retry: false, }); const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); const fileViewerEnabled = experimentalSettings?.enableExperimentalFileViewer === true; diff --git a/ui/src/components/CompanySettingsSidebar.production.tsx b/ui/src/components/CompanySettingsSidebar.production.tsx index f651922c15..2dd61e2837 100644 --- a/ui/src/components/CompanySettingsSidebar.production.tsx +++ b/ui/src/components/CompanySettingsSidebar.production.tsx @@ -72,7 +72,6 @@ export function CompanySettingsSidebar() { } }, enabled: !!selectedCompanyId, - retry: false, refetchInterval: 15_000, }); const { data: plugins } = useQuery({ diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index d7a999dd13..0545cdf317 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -70,7 +70,6 @@ export function CompanySettingsSidebar() { } }, enabled: !!selectedCompanyId, - retry: false, refetchInterval: 15_000, }); const { data: plugins } = useQuery({ diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index 7121207404..4c197de998 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -1,3 +1,4 @@ +import { describeError } from "@/api/errors"; import { useMemo, useState } from "react"; import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared"; import { adaptersApi } from "@/api/adapters"; @@ -192,7 +193,7 @@ export function ConfigureBuiltInAgentModal({ {["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} + pending={adapters.isFetching} error={adapters.error ? describeError(adapters.error).body : undefined} onRetry={() => { void adapters.refetch(); }} defaultRunner={runnerAvailability?.defaultRunner} supportedRunners={runnerAvailability?.supportedRunners} onChange={setRunner} />
} diff --git a/ui/src/components/CoordinatorWorkspaceSetting.tsx b/ui/src/components/CoordinatorWorkspaceSetting.tsx index 0cc82f049e..ad334c0bcf 100644 --- a/ui/src/components/CoordinatorWorkspaceSetting.tsx +++ b/ui/src/components/CoordinatorWorkspaceSetting.tsx @@ -23,7 +23,6 @@ export function CoordinatorWorkspaceSetting() { queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), enabled: chatDisabled && !!workspace.userId, - retry: false, staleTime: 30_000, }); const chatManaged = experimental.data?.managedKeys?.enableAgentChat?.managed === true; diff --git a/ui/src/components/DotRunnerConnection.test.tsx b/ui/src/components/DotRunnerConnection.test.tsx index 80df6a6424..981081c099 100644 --- a/ui/src/components/DotRunnerConnection.test.tsx +++ b/ui/src/components/DotRunnerConnection.test.tsx @@ -73,7 +73,7 @@ it("keeps a revoked binding cleared when the follow-up connection read fails", a await flush(); expect(container.querySelector("output")?.textContent).toBe(""); expect(onBinding).toHaveBeenLastCalledWith(""); - expect(container.querySelector("[role=alert]")?.textContent).toBe("Connection refresh failed"); + expect(container.querySelector("[role=alert]")?.textContent).toContain("Connection refresh failed"); expect(client.getQueryData(["dot-binding", "company", "agent"])).toMatchObject({ binding: null }); expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent === "Revoke connection")).toBe(false); }); diff --git a/ui/src/components/DotRunnerConnection.tsx b/ui/src/components/DotRunnerConnection.tsx index 124ee1bfde..133f304db4 100644 --- a/ui/src/components/DotRunnerConnection.tsx +++ b/ui/src/components/DotRunnerConnection.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../api/client"; +import { describeError } from "../api/errors"; import { Button } from "./ui/button"; import { buildDotSetupPrompt } from "../lib/dot-setup-prompt"; import { AgentSetupPrompt } from "./AgentSetupPrompt"; +import { QueryErrorState, useQueryView } from "./QueryView"; interface Connection { enabled: boolean; @@ -25,6 +27,10 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding } refetchInterval: query => query.state.data?.binding?.status === "pairing" || (query.state.data?.binding?.connected && !query.state.data.binding.subscriptionVerified) || query.state.data?.binding?.hasPendingChallenge || query.state.data?.binding?.assignment ? 5000 : false }); + // Read the connection state through the shared view: cached data stays + // visible while a transient refetch fails,and a no-data failure shows + // readable copy with Retry. + const stateView = useQueryView(state); const pair = useMutation({ mutationFn: () => api.post<{ bindingId: string; pairingCode: string; expiresAt: string }>(path, {}), onSuccess: result => { setPairing(result); onBinding(result.bindingId); void client.invalidateQueries({ queryKey: key }); } }); const test = useMutation({ mutationFn: () => api.post(path + "/event-test", {}), @@ -46,7 +52,7 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding } if (existingBindingId && existingBindingId !== bindingId && !revoke.isPending) onBinding(existingBindingId); }, [existingBindingId, bindingId, onBinding, revoke.isPending]); if (!agentId) return

Save the agent, then return here to pair your Dot.

; - const error = state.error ?? pair.error ?? test.error ?? revoke.error; + const mutationError = pair.error ?? test.error ?? revoke.error; const b = state.data?.binding; const setupPrompt = pairing && state.data?.resourceUrl && companyId && agentId ? buildDotSetupPrompt({ companyId, agentId, resourceUrl: state.data.resourceUrl, ...pairing }) @@ -77,6 +83,15 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding } {b && } {b?.hasPendingChallenge &&

Waiting for Dot to read and confirm the harmless mailbox challenge.

} - {error &&

{error instanceof Error ? error.message : "Connection failed"}

} + {stateView.kind === "error" && ( + + )} + {mutationError &&

{describeError(mutationError).body}

} ; } diff --git a/ui/src/components/EmailConnectionAccess.test.tsx b/ui/src/components/EmailConnectionAccess.test.tsx new file mode 100644 index 0000000000..9175630f5d --- /dev/null +++ b/ui/src/components/EmailConnectionAccess.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ApiError } from "@/api/client"; +import { EmailConnectionAccess } from "./EmailConnectionAccess"; + +const mocks = vi.hoisted(() => ({ grants: vi.fn(), installs: vi.fn(), save: vi.fn() })); +vi.mock("@/api/tools", () => ({ toolsApi: { listConnectionGrants: mocks.grants, getConnectionInstalls: mocks.installs, putConnectionInstalls: mocks.save } })); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root, host: HTMLDivElement, client: QueryClient; +let finishInstalls: () => void; +beforeEach(() => { + vi.clearAllMocks(); + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mocks.installs.mockReturnValue(new Promise(resolve => { finishInstalls = () => resolve({ installs: [] }); })); +}); +afterEach(async () => { + await act(async () => root.unmount()); finishInstalls(); client.clear(); host.remove(); +}); +it.each([403, 500])("shows a known %s before the other access read finishes", async status => { + mocks.grants.mockRejectedValue(new ApiError("Access lookup failed", status, {})); + await act(async () => root.render()); + await vi.waitFor(() => expect(host.querySelector('[data-query-view="error"]')).not.toBeNull()); + expect(host.textContent).not.toContain("Loading access"); + const retry = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Retry"); + if (status === 403) expect(retry).toBeUndefined(); + else { + expect(retry).toBeDefined(); + mocks.grants.mockResolvedValue({ grants: [], capabilities: { canConfigure: false } }); + await act(async () => retry!.click()); + await vi.waitFor(() => expect(mocks.grants).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(host.textContent).toContain("Loading access")); + } +}); diff --git a/ui/src/components/EmailConnectionAccess.tsx b/ui/src/components/EmailConnectionAccess.tsx index e3835fb6df..b16ac514da 100644 --- a/ui/src/components/EmailConnectionAccess.tsx +++ b/ui/src/components/EmailConnectionAccess.tsx @@ -1,6 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Agent } from "@paperclipai/shared"; +import { describeError } from "@/api/errors"; import { toolsApi } from "@/api/tools"; +import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { queryKeys } from "@/lib/queryKeys"; import { AgentMultiSelect } from "@/components/AgentMultiSelect"; import { RadioCardGroup } from "@/components/ui/radio-card"; @@ -33,14 +35,20 @@ export function EmailConnectionAccess({ }); }, }); - if (grants.isLoading || installs.isLoading) - return

Loading access…

; - if (grants.error || installs.error) + const grantsView = useQueryView(grants); + const installsView = useQueryView(installs); + const failedView = [grantsView, installsView].find((view) => view.kind === "error"); + if (failedView) return ( -

- Connection access could not be loaded. -

+ ); + if (grants.isLoading || installs.isLoading || grantsView.kind === "reconnecting" || installsView.kind === "reconnecting") + return

Loading access…

; const active = grants.data?.grants.filter((g) => g.status === "active") ?? []; const everyone = active.some((g) => g.kind === "organization"); const personal = active.find((g) => g.kind === "user"); @@ -111,7 +119,7 @@ export function EmailConnectionAccess({

{save.error && (

- {save.error.message} + {describeError(save.error).body}

)} diff --git a/ui/src/components/EmailTaskActivity.tsx b/ui/src/components/EmailTaskActivity.tsx index 6a384fb1d0..9a5781d82c 100644 --- a/ui/src/components/EmailTaskActivity.tsx +++ b/ui/src/components/EmailTaskActivity.tsx @@ -5,6 +5,8 @@ import { emailApi } from "@/api/email"; import { useEmailThread } from "@/hooks/useEmailThread"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { describeError } from "@/api/errors"; +import { QueryErrorState, useQueryView } from "./QueryView"; import type { EmailPublicationSummary } from "@paperclipai/shared"; // Email actions belong to the agent's task conversation. Only surface mail @@ -20,43 +22,55 @@ export function EmailTaskActivity({ const threadKey = ["email-thread", companyId, issueId]; const queryEnabled = Boolean(companyId && issueId) && !issueId.startsWith("chat:"); const thread = useEmailThread(companyId, issueId); + const threadView = useQueryView(thread); if (!queryEnabled) return null; - const data = thread.data; - const messages = data?.messages.filter((m) => !m.commentId) ?? []; - const publications = data?.publications.filter( - (p) => !p.providerMessageId || p.outcome === "uncertain", - ) ?? []; - if (!thread.error && !messages.length && !publications.length) return null; - return ( -
- {thread.error && ( -

- {thread.error.message} -

- )} - {messages.map((m) => ( - p.providerMessageId === m.providerMessageId, - )} - /> - ))} - {publications.map((p) => ( - { - void cache.invalidateQueries({ queryKey: threadKey }); - }} - /> - ))} -
- ); + if (threadView.kind === "stale" || threadView.kind === "ready") { + const data = threadView.data; + const messages = data?.messages.filter((m) => !m.commentId) ?? []; + const publications = data?.publications.filter( + (p) => !p.providerMessageId || p.outcome === "uncertain", + ) ?? []; + if (!messages.length && !publications.length) return null; + return ( +
+ {messages.map((m) => ( + p.providerMessageId === m.providerMessageId, + )} + /> + ))} + {publications.map((p) => ( + { + void cache.invalidateQueries({ queryKey: threadKey }); + }} + /> + ))} +
+ ); + } + // A quiet section while the initial read is still settling;a no-data read + // failure gets readable copy with Retry instead of a raw error line. + if (threadView.kind === "error") { + return ( + + ); + } + return null; } function EmailDelivery({ @@ -126,9 +140,9 @@ function EmailDelivery({ Confirm not sent - {resolve.error && ( + {resolve.isError && ( // query-error-ok: mutation result

- {resolve.error.message} + {describeError(resolve.error).body}

)} diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 2c13d9e697..31209cc4e9 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -1,3 +1,4 @@ +import { describeError } from "../api/errors"; import type { TaskInterruptionPresentation } from "@paperclipai/shared"; import { AlertCircle } from "lucide-react"; import { Button } from "./ui/button"; @@ -23,7 +24,7 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi if (presentation?.projectAccess && !optimistic && !takingLonger) return ; - if (error && !optimistic && !takingLonger) return
{error.message}{canRetry && }
; + if (error && !optimistic && !takingLonger) return
{describeError(error).body}{canRetry && }
; if (takingLonger) return
Taking longer than usual…{canRetry && }
; if (!optimistic && !presentation?.retryWait && presentation?.providerFailure && ["failed", "retry_scheduled", "waiting_for_user"].includes(presentation.state)) { return executionWorkspacesApi.getCloseReadiness(workspaceId), enabled: open, }); + // Read close readiness through the shared view: cached readiness stays + // usable during a transient refetch failure,and a no-data failure shows + // readable copy with Retry. + const readinessView = useQueryView(readinessQuery); const closeWorkspace = useMutation({ mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }), @@ -69,7 +75,7 @@ export function ExecutionWorkspaceCloseDialog({ onError: (error) => { pushToast({ title: "Failed to close workspace", - body: error instanceof Error ? error.message : "Unknown error", + body: describeError(error).body, tone: "error", }); }, @@ -98,15 +104,18 @@ export function ExecutionWorkspaceCloseDialog({ - {readinessQuery.isLoading ? ( + {(readinessView.kind === "loading" || readinessView.kind === "reconnecting") ? (
Checking whether this workspace is safe to close...
- ) : readinessQuery.error ? ( -
- {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."} -
+ ) : readinessView.kind === "error" ? ( + ) : readiness ? (
diff --git a/ui/src/components/FileTree.test.tsx b/ui/src/components/FileTree.test.tsx index 9b8b26ebbf..2bf8881020 100644 --- a/ui/src/components/FileTree.test.tsx +++ b/ui/src/components/FileTree.test.tsx @@ -29,6 +29,19 @@ describe("FileTree", () => { return container.querySelector(`[data-file-tree-path="${path}"]`) as HTMLDivElement | null; } + it("preserves readable plugin error details and retry without exposing raw codes", () => { + const retry = vi.fn(); + const renderError = (message: string) => act(() => root.render( + {}} onToggleDir={() => {}} error={{ message, retry }} />, + )); + renderError("The wiki source was removed. Choose another source."); + expect(container.textContent).toContain("The wiki source was removed. Choose another source."); + act(() => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry")!.click()); + expect(retry).toHaveBeenCalledOnce(); + renderError("source_unavailable"); + expect(container.textContent).not.toContain("source_unavailable"); + }); + it("selects file rows and expands directory rows", () => { const onSelectFile = vi.fn(); const onToggleDir = vi.fn(); diff --git a/ui/src/components/FileTree.tsx b/ui/src/components/FileTree.tsx index 8c585ada98..d58ecaee8c 100644 --- a/ui/src/components/FileTree.tsx +++ b/ui/src/components/FileTree.tsx @@ -1,6 +1,7 @@ import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; import { useMemo, useRef, useState } from "react"; import { cn } from "../lib/utils"; +import { describeError } from "../api/errors"; import { ChevronDown, ChevronRight, @@ -385,7 +386,7 @@ export function FileTree({ > error - {error.message} + {describeError(new Error(error.message)).body}
{error.retry && ( , publicBlockedReason, )} - {canManage && constraintsQuery.isError ? ( + {canManage && constraintsView.kind === "error" ? ( ) : null} diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 1bee4217d0..76c94da271 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -448,7 +448,6 @@ export function IssueRunLedger({ const { data: boardAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), - retry: false, }); const { data: runs } = useQuery({ queryKey: queryKeys.issues.runs(issueId), @@ -484,7 +483,6 @@ export function IssueRunLedger({ queryKey: queryKeys.providerTraceMetadata(companyId, traceRunIds), queryFn: () => heartbeatsApi.providerTraceMetadata(companyId, traceRunIds), enabled: canInspectProviderTrace && traceRunIds.length > 0, - retry: false, }); const providerTraceMetadata = useMemo( () => new Map((providerTraceRows ?? []).map((trace) => [trace.runId, trace])), diff --git a/ui/src/components/IssueScheduledRetryCard.tsx b/ui/src/components/IssueScheduledRetryCard.tsx index 83130ffaec..383fce3be8 100644 --- a/ui/src/components/IssueScheduledRetryCard.tsx +++ b/ui/src/components/IssueScheduledRetryCard.tsx @@ -8,6 +8,7 @@ import type { IssueScheduledRetry } from "@paperclipai/shared"; import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation"; import { Badge } from "@/components/ui/badge"; import { InlineBanner } from "@/components/InlineBanner"; +import { describeError } from "@/api/errors"; const MAX_TURN_CONTINUATION = "max_turns_continuation"; @@ -118,7 +119,8 @@ export function IssueScheduledRetryCard({ Last attempt failed: {scheduledRetry.error}{/[.!?]$/.test(scheduledRetry.error.trim()) ? "" : "."} Paperclip will retry automatically.
) : null} - {isError ? ( + {isError ? ( // query-error-ok: mutation result + { @@ -192,7 +194,7 @@ export function RetryErrorBand({ error, onRetry, className }: RetryErrorBandProp