diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8035a57ed83a..815a3bcdd46d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: quality: - name: Lint, Typecheck, Test, Browser Test, Build + name: Format, Lint, Typecheck, Test, Browser Test, Build runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout @@ -45,6 +45,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Format + run: bun run fmt:check + - name: Lint run: bun run lint diff --git a/.oxfmtrc.json b/.oxfmtrc.json index f0fbfde6d757..ef2236d0f2fa 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -7,7 +7,8 @@ "node_modules", "bun.lock", "*.tsbuildinfo", - "**/routeTree.gen.ts" + "**/routeTree.gen.ts", + "apps/web/public/mockServiceWorker.js" ], - "experimentalSortPackageJson": {} + "sortPackageJson": {} } diff --git a/.plans/01-shared-model-normalization.md b/.plans/01-shared-model-normalization.md index e1e5f77db484..d38c41643fa9 100644 --- a/.plans/01-shared-model-normalization.md +++ b/.plans/01-shared-model-normalization.md @@ -1,20 +1,24 @@ # 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 @@ -29,14 +33,17 @@ Move model alias/default normalization into `packages/contracts` so desktop and - 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 index 4e910043561a..fac5b1fc2e21 100644 --- a/.plans/02-typed-ipc-boundaries.md +++ b/.plans/02-typed-ipc-boundaries.md @@ -1,17 +1,21 @@ # 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 @@ -24,14 +28,17 @@ Replace loose payload casting in IPC handlers with strict schema parsing and typ 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 index e425a6ae7d2c..4f7fadb4314b 100644 --- a/.plans/03-split-codex-app-server-manager.md +++ b/.plans/03-split-codex-app-server-manager.md @@ -1,9 +1,11 @@ # 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 @@ -12,10 +14,12 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities. - 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` @@ -29,13 +33,16 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities. - 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 index 3d6f949e9c5c..abf30c04f898 100644 --- a/.plans/04-split-chatview-component.md +++ b/.plans/04-split-chatview-component.md @@ -1,9 +1,11 @@ # 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 @@ -13,10 +15,12 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. - 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` @@ -29,12 +33,15 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. 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 index 51fcf805d887..869da86796b3 100644 --- a/.plans/05-zod-persisted-state-validation.md +++ b/.plans/05-zod-persisted-state-validation.md @@ -1,17 +1,21 @@ # 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 @@ -23,12 +27,15 @@ Use explicit Zod schemas for localStorage state parsing and 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 index 71d1cbd95bd1..0a92de36f72d 100644 --- a/.plans/06-provider-logstream-lifecycle.md +++ b/.plans/06-provider-logstream-lifecycle.md @@ -1,17 +1,21 @@ # 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 @@ -20,12 +24,15 @@ Ensure `ProviderManager` logging stream is initialized, rotated/structured, and 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 index 5ebcb0448793..ff27a9dbd95e 100644 --- a/.plans/07-ci-quality-gates.md +++ b/.plans/07-ci-quality-gates.md @@ -1,17 +1,21 @@ # 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 @@ -22,13 +26,16 @@ Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-tes 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 index e892892781d5..a919ac07e471 100644 --- a/.plans/08-precommit-format-and-lint.md +++ b/.plans/08-precommit-format-and-lint.md @@ -1,17 +1,21 @@ # 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` @@ -20,13 +24,16 @@ Introduce pre-commit automation so formatting and basic lint checks happen befor 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 index 226ebf4cdabf..35db64bc0e46 100644 --- a/.plans/09-event-state-test-expansion.md +++ b/.plans/09-event-state-test-expansion.md @@ -1,17 +1,21 @@ # 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` @@ -24,12 +28,15 @@ Add focused tests for renderer event handling and session evolution logic. 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 index c3feec020f24..72f5d618b935 100644 --- a/.plans/10-unify-process-session-abstraction.md +++ b/.plans/10-unify-process-session-abstraction.md @@ -1,17 +1,21 @@ # 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()` @@ -24,12 +28,15 @@ Refactor `ProcessManager` to use a single runtime-session interface for child-pr 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 index 05f118dd7733..66521c20aa4a 100644 --- a/.plans/11-effect.md +++ b/.plans/11-effect.md @@ -1,5 +1,3 @@ - - 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). @@ -39,4 +37,4 @@ 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. \ No newline at end of file +Add architecture notes for service boundaries and error model. diff --git a/.plans/12-effect-new.md b/.plans/12-effect-new.md index 556b6c92b8ef..3d87049f8bae 100644 --- a/.plans/12-effect-new.md +++ b/.plans/12-effect-new.md @@ -1,12 +1,14 @@ # 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. @@ -14,12 +16,14 @@ Current status summary: - 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: @@ -29,12 +33,14 @@ Current status summary: - 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` @@ -43,16 +49,19 @@ Current status summary: - 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 index 0cc916429d63..f3fe4edf02ac 100644 --- a/.plans/13-provider-service-integration-tests.md +++ b/.plans/13-provider-service-integration-tests.md @@ -1,6 +1,7 @@ # ProviderService Integration Test Plan Goal: + - Validate end-to-end `ProviderService` behavior with real layers: - `ProviderServiceLive` - `CheckpointServiceLive` @@ -13,6 +14,7 @@ Goal: ## 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) @@ -29,13 +31,15 @@ Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Lay - `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 +1. `startSession` initializes checkpoint root exactly once + - Arrange: - start provider session in git repo. - Assert: @@ -43,7 +47,8 @@ Use real git-backed temporary workspaces in integration tests: - checkpoint ref exists in git. - second `startSession` for new session creates a new independent root. -2) Turn without filesystem change +2. Turn without filesystem change + - Arrange: - emit normal turn events, no file mutation. - Assert: @@ -54,7 +59,8 @@ Use real git-backed temporary workspaces in integration tests: - `listCheckpoints` returns root + turn 1. - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff. -3) Turn with filesystem change +3. Turn with filesystem change + - Arrange: - mutate `README.md` during turn. - Assert: @@ -62,7 +68,8 @@ Use real git-backed temporary workspaces in integration tests: - `getCheckpointDiff(0 -> 1)` contains file path and hunk. - persisted checkpoint metadata includes non-empty `checkpointRef`. -4) Multi-turn sequencing and checkpoint monotonicity +4. Multi-turn sequencing and checkpoint monotonicity + - Arrange: - turn 1: no file change - turn 2: file change @@ -72,7 +79,8 @@ Use real git-backed temporary workspaces in integration tests: - latest checkpoint is marked current. - diffs for adjacent turns map to expected filesystem deltas. -5) Revert to checkpoint +5. Revert to checkpoint + - Arrange: - execute 3 turns with at least one file-changing turn. - call `revertToCheckpoint(turnCount=1)`. @@ -82,7 +90,8 @@ Use real git-backed temporary workspaces in integration tests: - DB rows for turns >1 are removed. - later refs are deleted from git. -6) Capture failure surface +6. Capture failure surface + - Arrange: - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails. - Assert: @@ -92,6 +101,7 @@ Use real git-backed temporary workspaces in integration tests: ## 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: @@ -101,10 +111,13 @@ Add one ws server integration spec: ## 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 index c73cbc8830c3..e5c5023205a6 100644 --- a/.plans/14-server-authoritative-event-sourcing-cleanup.md +++ b/.plans/14-server-authoritative-event-sourcing-cleanup.md @@ -1,6 +1,7 @@ # Server-Authoritative Event-Sourcing Cleanup Plan Goal: + - Move to a cleaner service architecture with: - durable, server-authoritative event sourcing - strict command routing/validation @@ -85,6 +86,7 @@ CheckpointCatalog ------> SQLite ## Commit Series ### Commit 1: Split public vs system orchestration command contracts + - Create separate schemas/types: - `ClientOrchestrationCommandSchema` - `SystemOrchestrationCommandSchema` @@ -100,6 +102,7 @@ CheckpointCatalog ------> SQLite - 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.). @@ -113,6 +116,7 @@ CheckpointCatalog ------> SQLite - 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. @@ -125,6 +129,7 @@ CheckpointCatalog ------> SQLite - 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 @@ -139,6 +144,7 @@ CheckpointCatalog ------> SQLite - 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. @@ -152,6 +158,7 @@ CheckpointCatalog ------> SQLite - 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. @@ -165,6 +172,7 @@ CheckpointCatalog ------> SQLite - 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). @@ -177,6 +185,7 @@ CheckpointCatalog ------> SQLite - 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. @@ -188,6 +197,7 @@ CheckpointCatalog ------> SQLite - 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 @@ -196,6 +206,7 @@ CheckpointCatalog ------> SQLite - 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) @@ -210,6 +221,7 @@ CheckpointCatalog ------> SQLite - 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 index 50e869d49946..5e245bb8e9ef 100644 --- a/.plans/15-effect-server.md +++ b/.plans/15-effect-server.md @@ -1,10 +1,11 @@ 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) - - ... \ No newline at end of file + - ... diff --git a/.plans/16-pr89-review-remediation-phases.md b/.plans/16-pr89-review-remediation-phases.md index 52bedc2efa2d..81ed6bd9f2b7 100644 --- a/.plans/16-pr89-review-remediation-phases.md +++ b/.plans/16-pr89-review-remediation-phases.md @@ -24,21 +24,25 @@ - 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. @@ -46,17 +50,20 @@ Exit criteria: ## 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. @@ -64,12 +71,14 @@ Exit criteria: ## 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` @@ -77,6 +86,7 @@ Primary files: - `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. @@ -84,27 +94,32 @@ Exit criteria: ## 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` @@ -113,23 +128,27 @@ Primary files: - `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. @@ -140,6 +159,7 @@ Exit criteria: - 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 index c77b59ddaab1..6512e9246761 100644 --- a/.plans/16c-pr89-remediation-checklist.md +++ b/.plans/16c-pr89-remediation-checklist.md @@ -5,6 +5,7 @@ _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 @@ -150,7 +151,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` - 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. + - Audit note: Transport \_tag override risk exists but current callsites are constrained. ### Phase 2 @@ -181,7 +182,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` - 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: +- [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` @@ -198,7 +199,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` - 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)) + - 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: @@ -250,7 +251,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` ### 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: +- [ ] `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` diff --git a/.plans/spec-1-1-cutover-plan.md b/.plans/spec-1-1-cutover-plan.md index d48937b40514..7345995f1e8c 100644 --- a/.plans/spec-1-1-cutover-plan.md +++ b/.plans/spec-1-1-cutover-plan.md @@ -3,13 +3,16 @@ 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. @@ -17,17 +20,22 @@ Work units: - 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. @@ -47,17 +55,22 @@ Work units: - 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. @@ -65,17 +78,22 @@ Work units: - 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. @@ -84,16 +102,21 @@ Work units: - 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`. @@ -102,16 +125,21 @@ Work units: - 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. @@ -120,16 +148,21 @@ Work units: - 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`. @@ -138,16 +171,21 @@ Work units: - 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`. @@ -155,16 +193,21 @@ Work units: - 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. @@ -172,16 +215,21 @@ Work units: - 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. @@ -192,10 +240,13 @@ Work units: - 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 index 842f0d25d297..7cbb9509a6ad 100644 --- a/.plans/spec-contract-matrix.md +++ b/.plans/spec-contract-matrix.md @@ -1,6 +1,7 @@ # 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. @@ -9,6 +10,7 @@ Status legend: ## 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: @@ -32,6 +34,7 @@ Status legend: - 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: @@ -49,6 +52,7 @@ Status legend: - 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: @@ -65,6 +69,7 @@ Status legend: - 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: @@ -86,6 +91,7 @@ Status legend: ## 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. @@ -96,6 +102,7 @@ Status legend: - 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. @@ -106,6 +113,7 @@ Status legend: - 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. @@ -116,6 +124,7 @@ Status legend: - 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. @@ -128,6 +137,7 @@ Status legend: ## 7.3 Required Projected Tables (Read Models) ### P1 + - Spec ref: `7.3.1 projection_projects` - Requirement: persisted project projection table. - SQL contract: @@ -145,6 +155,7 @@ Status legend: - 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: @@ -165,6 +176,7 @@ Status legend: - Notes: missing table and projector writes. ### P3 + - Spec ref: `7.3.3 projection_thread_messages` - Requirement: persisted thread message projection table. - SQL contract: @@ -183,6 +195,7 @@ Status legend: - Notes: missing table and message projection writes. ### P4 + - Spec ref: `7.3.4 projection_thread_activities` - Requirement: persisted thread activity projection table. - SQL contract: @@ -201,6 +214,7 @@ Status legend: - Notes: no canonical activity projection persistence. ### P5 + - Spec ref: `7.3.5 projection_thread_sessions` - Requirement: persisted thread session projection table. - SQL contract: @@ -219,6 +233,7 @@ Status legend: - 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: @@ -237,6 +252,7 @@ Status legend: - Notes: missing table and projection logic. ### P7 + - Spec ref: `7.3.7 projection_checkpoints` - Requirement: persisted checkpoint summary projection table. - SQL contract: @@ -256,6 +272,7 @@ Status legend: - 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: @@ -273,6 +290,7 @@ Status legend: - Notes: missing table and projection logic. ### P9 + - Spec ref: `7.3.9 projection_state` - Requirement: projector progress tracking table. - SQL contract: @@ -286,6 +304,7 @@ Status legend: - 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. @@ -296,6 +315,7 @@ Status legend: - 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. @@ -308,6 +328,7 @@ Status legend: ## 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`. @@ -318,6 +339,7 @@ Status legend: - 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. @@ -328,6 +350,7 @@ Status legend: - 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`. @@ -338,6 +361,7 @@ Status legend: - 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. @@ -348,6 +372,7 @@ Status legend: - 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. @@ -358,6 +383,7 @@ Status legend: - 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. @@ -368,6 +394,7 @@ Status legend: - 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. @@ -380,22 +407,27 @@ Status legend: ## 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/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 6945835a62e5..eb215a1f2ff0 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -7,7 +7,11 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; -const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"]; +const requiredFiles = [ + "dist-electron/main.js", + "dist-electron/preload.js", + "../server/dist/index.mjs", +]; const watchedDirectories = [ { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, { directory: "../server/dist", files: new Set(["index.mjs"]) }, @@ -149,13 +153,17 @@ function scheduleRestart() { function startWatchers() { for (const { directory, files } of watchedDirectories) { - const watcher = watch(join(desktopDir, directory), { persistent: true }, (_eventType, filename) => { - if (typeof filename !== "string" || !files.has(filename)) { - return; - } + const watcher = watch( + join(desktopDir, directory), + { persistent: true }, + (_eventType, filename) => { + if (typeof filename !== "string" || !files.has(filename)) { + return; + } - scheduleRestart(); - }); + scheduleRestart(); + }, + ); watchers.push(watcher); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 03cf67cd6d3f..4fec75804fa7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,10 +15,7 @@ import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; -import { - getAutoUpdateDisabledReason, - shouldBroadcastDownloadProgress, -} from "./updateState"; +import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -702,7 +699,9 @@ async function checkForUpdates(reason: string): Promise { await autoUpdater.checkForUpdates(); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString())); + setUpdateState( + reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), + ); console.error(`[desktop-updater] Failed to check for updates: ${message}`); } finally { updateCheckInFlight = false; @@ -764,9 +763,7 @@ function configureAutoUpdater(): void { updaterConfigured = true; const githubToken = - process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || - process.env.GH_TOKEN?.trim() || - ""; + process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; if (githubToken) { // When a token is provided, re-configure the feed with `private: true` so // electron-updater uses the GitHub API (api.github.com) instead of the @@ -801,7 +798,13 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { - setUpdateState(reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, new Date().toISOString())); + setUpdateState( + reduceDesktopUpdateStateOnUpdateAvailable( + updateState, + info.version, + new Date().toISOString(), + ), + ); lastLoggedDownloadMilestone = -1; console.info(`[desktop-updater] Update available: ${info.version}`); }); diff --git a/apps/desktop/src/runtimeArch.ts b/apps/desktop/src/runtimeArch.ts index 7e4265afb03e..127abf51ab8b 100644 --- a/apps/desktop/src/runtimeArch.ts +++ b/apps/desktop/src/runtimeArch.ts @@ -25,8 +25,7 @@ export function resolveDesktopRuntimeInfo( }; } - const hostArch = - appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; + const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; return { hostArch, diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updateMachine.test.ts index 620c683b8251..7fbc982eff8a 100644 --- a/apps/desktop/src/updateMachine.test.ts +++ b/apps/desktop/src/updateMachine.test.ts @@ -83,7 +83,10 @@ describe("updateMachine", () => { }, "1.1.0", ); - const failedInstall = reduceDesktopUpdateStateOnInstallFailure(downloaded, "backend shutdown timed out"); + const failedInstall = reduceDesktopUpdateStateOnInstallFailure( + downloaded, + "backend shutdown timed out", + ); expect(downloaded.status).toBe("downloaded"); expect(downloaded.downloadedVersion).toBe("1.1.0"); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 25ce8773bd77..d970bf784921 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -72,10 +72,9 @@ function normalizeTurnState(value: unknown): "completed" | "failed" | "interrupt return "completed"; } -function mapRequestType(requestKind: unknown): - | "command_execution_approval" - | "file_change_approval" - | "unknown" { +function mapRequestType( + requestKind: unknown, +): "command_execution_approval" | "file_change_approval" | "unknown" { if (requestKind === "command") { return "command_execution_approval"; } @@ -239,323 +238,323 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter }> >(); - const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); + const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); - const startSession: ProviderAdapterShape["startSession"] = (input) => - Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== provider) { - return yield* new ProviderAdapterValidationError({ - provider, - operation: "startSession", - issue: `Expected provider '${provider}' but received '${input.provider}'.`, - }); - } + const startSession: ProviderAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== provider) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "startSession", + issue: `Expected provider '${provider}' but received '${input.provider}'.`, + }); + } - sessionCount += 1; - const threadId = input.threadId; - const createdAt = nowIso(); - - const session: ProviderSession = { - provider, - status: "ready", - runtimeMode: input.runtimeMode, - threadId, - cwd: input.cwd, - resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount }, - createdAt, - updatedAt: createdAt, - }; - - sessions.set(threadId, { - session, - snapshot: { + sessionCount += 1; + const threadId = input.threadId; + const createdAt = nowIso(); + + const session: ProviderSession = { + provider, + status: "ready", + runtimeMode: input.runtimeMode, threadId, - turns: [], - }, - turnCount: 0, - queuedResponses: queuedResponsesForNextSession.splice(0), - rollbackCalls: [], - }); + cwd: input.cwd, + resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount }, + createdAt, + updatedAt: createdAt, + }; - return session; - }); + sessions.set(threadId, { + session, + snapshot: { + threadId, + turns: [], + }, + turnCount: 0, + queuedResponses: queuedResponsesForNextSession.splice(0), + rollbackCalls: [], + }); - const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const state = sessions.get(input.threadId); - if (!state) { - return yield* missingSessionEffect(provider, input.threadId); - } + return session; + }); - state.turnCount += 1; - const turnCount = state.turnCount; - const turnId = TurnId.makeUnsafe(`turn-${turnCount}`); + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const state = sessions.get(input.threadId); + if (!state) { + return yield* missingSessionEffect(provider, input.threadId); + } - const response = state.queuedResponses.shift(); - if (!response) { - return yield* new ProviderAdapterValidationError({ - provider, - operation: "sendTurn", - issue: `No queued turn response for thread ${input.threadId}.`, - }); - } + state.turnCount += 1; + const turnCount = state.turnCount; + const turnId = TurnId.makeUnsafe(`turn-${turnCount}`); - const assistantDeltas: string[] = []; - const deferredTurnCompletedEvents: ProviderRuntimeEvent[] = []; - for (const fixtureEvent of response.events) { - const rawEvent: Record = { - ...(fixtureEvent as Record), - eventId: randomUUID(), - provider, - sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)), - createdAt: nowIso(), - }; - rawEvent.threadId = state.snapshot.threadId; - if (Object.hasOwn(rawEvent, "turnId")) { - rawEvent.turnId = turnId; + const response = state.queuedResponses.shift(); + if (!response) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "sendTurn", + issue: `No queued turn response for thread ${input.threadId}.`, + }); } - const runtimeEvent = normalizeFixtureEvent(rawEvent); - const runtimeType = (runtimeEvent as { type: string }).type; - if (runtimeType === "content.delta") { - const payload = runtimeEvent.payload as { delta?: unknown } | undefined; - if (typeof payload?.delta === "string") { - assistantDeltas.push(payload.delta); + const assistantDeltas: string[] = []; + const deferredTurnCompletedEvents: ProviderRuntimeEvent[] = []; + for (const fixtureEvent of response.events) { + const rawEvent: Record = { + ...(fixtureEvent as Record), + eventId: randomUUID(), + provider, + sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)), + createdAt: nowIso(), + }; + rawEvent.threadId = state.snapshot.threadId; + if (Object.hasOwn(rawEvent, "turnId")) { + rawEvent.turnId = turnId; + } + + const runtimeEvent = normalizeFixtureEvent(rawEvent); + const runtimeType = (runtimeEvent as { type: string }).type; + if (runtimeType === "content.delta") { + const payload = runtimeEvent.payload as { delta?: unknown } | undefined; + if (typeof payload?.delta === "string") { + assistantDeltas.push(payload.delta); + } + } else if (runtimeType === "message.delta") { + const legacyDelta = (runtimeEvent as { delta?: unknown }).delta; + if (typeof legacyDelta === "string") { + assistantDeltas.push(legacyDelta); + } } - } else if (runtimeType === "message.delta") { - const legacyDelta = (runtimeEvent as { delta?: unknown }).delta; - if (typeof legacyDelta === "string") { - assistantDeltas.push(legacyDelta); + if (runtimeEvent.type === "turn.completed") { + deferredTurnCompletedEvents.push(runtimeEvent); + continue; } + + yield* emit(runtimeEvent); } - if (runtimeEvent.type === "turn.completed") { - deferredTurnCompletedEvents.push(runtimeEvent); - continue; + + if (response.mutateWorkspace && state.session.cwd) { + yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount }); } - yield* emit(runtimeEvent); - } + const userItem = { + type: "userMessage", + content: [{ type: "text", text: input.input }], + } as const; + const assistantText = assistantDeltas.join(""); + const nextItems: Array = + assistantText.length > 0 + ? [userItem, { type: "agentMessage", text: assistantText }] + : [userItem]; + + const nextTurn: ProviderThreadTurnSnapshot = { + id: turnId, + items: nextItems, + }; - if (response.mutateWorkspace && state.session.cwd) { - yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount }); - } + state.snapshot = { + threadId: state.snapshot.threadId, + turns: [...state.snapshot.turns, nextTurn], + }; - const userItem = { - type: "userMessage", - content: [{ type: "text", text: input.input }], - } as const; - const assistantText = assistantDeltas.join(""); - const nextItems: Array = - assistantText.length > 0 - ? [userItem, { type: "agentMessage", text: assistantText }] - : [userItem]; - - const nextTurn: ProviderThreadTurnSnapshot = { - id: turnId, - items: nextItems, - }; - - state.snapshot = { - threadId: state.snapshot.threadId, - turns: [...state.snapshot.turns, nextTurn], - }; - - if (deferredTurnCompletedEvents.length === 0) { - yield* emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe(randomUUID()), - provider, - createdAt: nowIso(), + if (deferredTurnCompletedEvents.length === 0) { + yield* emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe(randomUUID()), + provider, + createdAt: nowIso(), + threadId: state.snapshot.threadId, + turnId, + payload: { + state: "completed", + }, + }); + } else { + for (const completedEvent of deferredTurnCompletedEvents) { + yield* emit(completedEvent); + } + } + + return { threadId: state.snapshot.threadId, turnId, - payload: { - state: "completed", - }, - }); - } else { - for (const completedEvent of deferredTurnCompletedEvents) { - yield* emit(completedEvent); - } + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( + threadId, + turnId, + ) => + sessions.has(threadId) + ? Effect.sync(() => { + const existing = interruptCallsBySession.get(threadId) ?? []; + existing.push(turnId); + interruptCallsBySession.set(threadId, existing); + }) + : missingSessionEffect(provider, threadId); + + const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + sessions.has(threadId) + ? Effect.sync(() => { + const existing = approvalResponsesBySession.get(threadId) ?? []; + existing.push({ + threadId, + requestId, + decision, + }); + approvalResponsesBySession.set(threadId, existing); + }) + : missingSessionEffect(provider, threadId); + + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( + threadId, + _requestId, + _answers, + ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId)); + + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => + Effect.sync(() => { + sessions.delete(threadId); + }); + + const listSessions: ProviderAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); + + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.succeed(sessions.has(threadId)); + + const readThread: ProviderAdapterShape["readThread"] = (threadId) => { + const state = sessions.get(threadId); + if (!state) { + return missingSessionEffect(provider, threadId); + } + return Effect.succeed(state.snapshot); + }; + + const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( + threadId, + numTurns, + ) => { + const state = sessions.get(threadId); + if (!state) { + return missingSessionEffect(provider, threadId); + } + if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider, + operation: "rollbackThread", + issue: "numTurns must be an integer between 0 and current turn count.", + }), + ); } - return { - threadId: state.snapshot.threadId, - turnId, - } satisfies ProviderTurnStartResult; - }); - - const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( - threadId, - turnId, - ) => - sessions.has(threadId) - ? Effect.sync(() => { - const existing = interruptCallsBySession.get(threadId) ?? []; - existing.push(turnId); - interruptCallsBySession.set(threadId, existing); - }) - : missingSessionEffect(provider, threadId); - - const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( - threadId, - requestId, - decision, - ) => - sessions.has(threadId) - ? Effect.sync(() => { - const existing = approvalResponsesBySession.get(threadId) ?? []; - existing.push({ - threadId, - requestId, - decision, - }); - approvalResponsesBySession.set(threadId, existing); - }) - : missingSessionEffect(provider, threadId); - - const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( - threadId, - _requestId, - _answers, - ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId)); - - const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => - Effect.sync(() => { - sessions.delete(threadId); - }); - - const listSessions: ProviderAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); - - const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => - Effect.succeed(sessions.has(threadId)); - - const readThread: ProviderAdapterShape["readThread"] = (threadId) => { - const state = sessions.get(threadId); - if (!state) { - return missingSessionEffect(provider, threadId); - } - return Effect.succeed(state.snapshot); - }; - - const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( - threadId, - numTurns, - ) => { - const state = sessions.get(threadId); - if (!state) { - return missingSessionEffect(provider, threadId); - } - if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider, - operation: "rollbackThread", - issue: "numTurns must be an integer between 0 and current turn count.", - }), + return Effect.sync(() => { + state.rollbackCalls.push(numTurns); + state.snapshot = { + threadId: state.snapshot.threadId, + turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns), + }; + state.turnCount = state.snapshot.turns.length; + return state.snapshot; + }); + }; + + const stopAll: ProviderAdapterShape["stopAll"] = () => + Effect.sync(() => { + sessions.clear(); + }); + + const adapter: ProviderAdapterShape = { + provider, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEvents), + }; + + const queueTurnResponse = ( + threadId: ThreadId, + response: TestTurnResponse, + ): Effect.Effect => + Effect.sync(() => sessions.get(threadId)).pipe( + Effect.flatMap((state) => + state + ? Effect.sync(() => { + state.queuedResponses.push(response); + }) + : Effect.fail(sessionNotFound(provider, threadId)), + ), ); - } - - return Effect.sync(() => { - state.rollbackCalls.push(numTurns); - state.snapshot = { - threadId: state.snapshot.threadId, - turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns), - }; - state.turnCount = state.snapshot.turns.length; - return state.snapshot; - }); - }; - - const stopAll: ProviderAdapterShape["stopAll"] = () => - Effect.sync(() => { - sessions.clear(); - }); - - const adapter: ProviderAdapterShape = { - provider, - capabilities: { - sessionModelSwitch: "in-session", - }, - startSession, - sendTurn, - interruptTurn, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - readThread, - rollbackThread, - stopAll, - streamEvents: Stream.fromQueue(runtimeEvents), - }; - - const queueTurnResponse = ( - threadId: ThreadId, - response: TestTurnResponse, - ): Effect.Effect => - Effect.sync(() => sessions.get(threadId)).pipe( - Effect.flatMap((state) => - state - ? Effect.sync(() => { - state.queuedResponses.push(response); - }) - : Effect.fail(sessionNotFound(provider, threadId)), - ), - ); - - const queueTurnResponseForNextSession = ( - response: TestTurnResponse, - ): Effect.Effect => - Effect.sync(() => { - queuedResponsesForNextSession.push(response); - }); - - const getRollbackCalls = (threadId: ThreadId): ReadonlyArray => { - const state = sessions.get(threadId); - if (!state) { - return []; - } - return [...state.rollbackCalls]; - }; - - const getStartCount = (): number => sessionCount; - - const getInterruptCalls = (threadId: ThreadId): ReadonlyArray => { - const calls = interruptCallsBySession.get(threadId); - if (!calls) { - return []; - } - return [...calls]; - }; - - const listActiveSessionIds = (): ReadonlyArray => - Array.from(sessions.values(), (state) => state.session.threadId); - - const getApprovalResponses = ( - threadId: ThreadId, - ): ReadonlyArray<{ - readonly threadId: ThreadId; - readonly requestId: ApprovalRequestId; - readonly decision: ProviderApprovalDecision; - }> => { - const responses = approvalResponsesBySession.get(threadId); - if (!responses) { - return []; - } - return [...responses]; - }; - - return { - adapter, - provider, - queueTurnResponse, - queueTurnResponseForNextSession, - getStartCount, - getRollbackCalls, - getInterruptCalls, - listActiveSessionIds, - getApprovalResponses, - } satisfies TestProviderAdapterHarness; -}); + + const queueTurnResponseForNextSession = ( + response: TestTurnResponse, + ): Effect.Effect => + Effect.sync(() => { + queuedResponsesForNextSession.push(response); + }); + + const getRollbackCalls = (threadId: ThreadId): ReadonlyArray => { + const state = sessions.get(threadId); + if (!state) { + return []; + } + return [...state.rollbackCalls]; + }; + + const getStartCount = (): number => sessionCount; + + const getInterruptCalls = (threadId: ThreadId): ReadonlyArray => { + const calls = interruptCallsBySession.get(threadId); + if (!calls) { + return []; + } + return [...calls]; + }; + + const listActiveSessionIds = (): ReadonlyArray => + Array.from(sessions.values(), (state) => state.session.threadId); + + const getApprovalResponses = ( + threadId: ThreadId, + ): ReadonlyArray<{ + readonly threadId: ThreadId; + readonly requestId: ApprovalRequestId; + readonly decision: ProviderApprovalDecision; + }> => { + const responses = approvalResponsesBySession.get(threadId); + if (!responses) { + return []; + } + return [...responses]; + }; + + return { + adapter, + provider, + queueTurnResponse, + queueTurnResponseForNextSession, + getStartCount, + getRollbackCalls, + getInterruptCalls, + listActiveSessionIds, + getApprovalResponses, + } satisfies TestProviderAdapterHarness; + }); diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 5ac660081b88..3fe21fb1eed0 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -6,7 +6,10 @@ import { Data, Effect, FileSystem, Logger, Option, Path } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { DEVELOPMENT_ICON_OVERRIDES, PUBLISH_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; +import { + DEVELOPMENT_ICON_OVERRIDES, + PUBLISH_ICON_OVERRIDES, +} from "../../../scripts/lib/brand-assets.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import rootPackageJson from "../../../package.json" with { type: "json" }; import serverPackageJson from "../package.json" with { type: "json" }; @@ -237,9 +240,7 @@ const publishCmd = Command.make( Effect.gen(function* () { yield* restorePublishIconOverrides(resource.iconBackups).pipe( Effect.catch((error) => - Effect.logError( - `[cli] Failed to restore publish icon overrides: ${String(error)}`, - ), + Effect.logError(`[cli] Failed to restore publish icon overrides: ${String(error)}`), ), ); yield* fs.rename(backupPath, packageJsonPath); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 48be2df8a6cf..3440e29fc3ee 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -12,8 +12,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts" const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; -const ATTACHMENT_ID_UUID_PATTERN = - "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; const ATTACHMENT_ID_PATTERN = new RegExp( `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, "i", diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 05fffd89ea1c..2dfe4689d74a 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -812,88 +812,82 @@ describe("respondToUserInput", () => { }); describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { - it( - "keeps prior thread history when resuming with a changed runtime mode", - async () => { - const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-")); - writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8"); + it("keeps prior thread history when resuming with a changed runtime mode", async () => { + const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-")); + writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8"); - const manager = new CodexAppServerManager(); + const manager = new CodexAppServerManager(); - try { - const firstSession = await manager.startSession({ - threadId: asThreadId("thread-live"), - provider: "codex", - cwd: workspaceDir, - runtimeMode: "full-access", - providerOptions: { - codex: { - ...(process.env.CODEX_BINARY_PATH - ? { binaryPath: process.env.CODEX_BINARY_PATH } - : {}), - ...(process.env.CODEX_HOME_PATH - ? { homePath: process.env.CODEX_HOME_PATH } - : {}), - }, + try { + const firstSession = await manager.startSession({ + threadId: asThreadId("thread-live"), + provider: "codex", + cwd: workspaceDir, + runtimeMode: "full-access", + providerOptions: { + codex: { + ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), }, - }); + }, + }); - const firstTurn = await manager.sendTurn({ - threadId: firstSession.threadId, - input: `Reply with exactly the word ALPHA ${randomUUID()}`, - }); + const firstTurn = await manager.sendTurn({ + threadId: firstSession.threadId, + input: `Reply with exactly the word ALPHA ${randomUUID()}`, + }); - expect(firstTurn.threadId).toBe(firstSession.threadId); + expect(firstTurn.threadId).toBe(firstSession.threadId); - await vi.waitFor(async () => { + await vi.waitFor( + async () => { const snapshot = await manager.readThread(firstSession.threadId); expect(snapshot.turns.length).toBeGreaterThan(0); - }, { timeout: 120_000, interval: 1_000 }); + }, + { timeout: 120_000, interval: 1_000 }, + ); - const firstSnapshot = await manager.readThread(firstSession.threadId); - const originalThreadId = firstSnapshot.threadId; - const originalTurnCount = firstSnapshot.turns.length; + const firstSnapshot = await manager.readThread(firstSession.threadId); + const originalThreadId = firstSnapshot.threadId; + const originalTurnCount = firstSnapshot.turns.length; - manager.stopSession(firstSession.threadId); + manager.stopSession(firstSession.threadId); - const resumedSession = await manager.startSession({ - threadId: firstSession.threadId, - provider: "codex", - cwd: workspaceDir, - runtimeMode: "approval-required", - resumeCursor: firstSession.resumeCursor, - providerOptions: { - codex: { - ...(process.env.CODEX_BINARY_PATH - ? { binaryPath: process.env.CODEX_BINARY_PATH } - : {}), - ...(process.env.CODEX_HOME_PATH - ? { homePath: process.env.CODEX_HOME_PATH } - : {}), - }, + const resumedSession = await manager.startSession({ + threadId: firstSession.threadId, + provider: "codex", + cwd: workspaceDir, + runtimeMode: "approval-required", + resumeCursor: firstSession.resumeCursor, + providerOptions: { + codex: { + ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), }, - }); + }, + }); - expect(resumedSession.threadId).toBe(originalThreadId); + expect(resumedSession.threadId).toBe(originalThreadId); - const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId); - expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); - expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); + const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId); + expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); + expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); - await manager.sendTurn({ - threadId: resumedSession.threadId, - input: `Reply with exactly the word BETA ${randomUUID()}`, - }); + await manager.sendTurn({ + threadId: resumedSession.threadId, + input: `Reply with exactly the word BETA ${randomUUID()}`, + }); - await vi.waitFor(async () => { + await vi.waitFor( + async () => { const snapshot = await manager.readThread(resumedSession.threadId); expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount); - }, { timeout: 120_000, interval: 1_000 }); - } finally { - manager.stopAll(); - rmSync(workspaceDir, { recursive: true, force: true }); - } - }, - 180_000, - ); + }, + { timeout: 120_000, interval: 1_000 }, + ); + } finally { + manager.stopAll(); + rmSync(workspaceDir, { recursive: true, force: true }); + } + }, 180_000); }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 9642f0b06a47..1cf2d0e0922b 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -363,8 +363,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const attachmentId = - `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); yield* fs.writeFile(imagePath, Buffer.from("hello")); @@ -392,9 +391,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), ), ), - Effect.ensuring( - fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)), - ), + Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), ); expect(generated.branch).toBe("fix/ui-regression"); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index d03ad60615fb..05b2ade03ebf 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -105,6 +105,10 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), listBranches: (input) => core.listBranches(input), createWorktree: (input) => core.createWorktree(input), + fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), + ensureRemote: (input) => core.ensureRemote(input), + fetchRemoteBranch: (input) => core.fetchRemoteBranch(input), + setBranchUpstream: (input) => core.setBranchUpstream(input), removeWorktree: (input) => core.removeWorktree(input), renameBranch: (input) => core.renameBranch(input), createBranch: (input) => core.createBranch(input), @@ -149,6 +153,13 @@ function createGitWorktree(input: Parameters[0]) }); } +function fetchGitPullRequestBranch(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.fetchPullRequestBranch(input); + }); +} + function removeGitWorktree(input: Parameters[0]) { return Effect.gen(function* () { const core = yield* GitCore; @@ -174,7 +185,7 @@ function pullGitBranch({ cwd }: { cwd: string }) { function initRepoWithCommit( cwd: string, ): Effect.Effect< - void, + { initialBranch: string }, GitCommandError | PlatformError.PlatformError, GitCore | GitService | FileSystem.FileSystem > { @@ -185,6 +196,8 @@ function initRepoWithCommit( yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = yield* git(cwd, ["branch", "--show-current"]); + return { initialBranch }; }); } @@ -426,7 +439,9 @@ it.layer(TestLayer)("git integration", (it) => { true, ); expect( - result.branches.some((branch) => branch.name === "feature/local-only" && !branch.isRemote), + result.branches.some( + (branch) => branch.name === "feature/local-only" && !branch.isRemote, + ), ).toBe(true); expect( result.branches.some( @@ -689,29 +704,27 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect( - "does not silently checkout a local branch when a remote ref no longer exists", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); + it.effect("does not silently checkout a local branch when a remote ref no longer exists", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); + yield* initRepoWithCommit(source); + const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); - yield* createGitBranch({ cwd: source, branch: "feature" }); + yield* createGitBranch({ cwd: source, branch: "feature" }); - const checkoutResult = yield* Effect.result( - checkoutGitBranch({ cwd: source, branch: "origin/feature" }), - ); - expect(checkoutResult._tag).toBe("Failure"); - expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); - }), + const checkoutResult = yield* Effect.result( + checkoutGitBranch({ cwd: source, branch: "origin/feature" }), + ); + expect(checkoutResult._tag).toBe("Failure"); + expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); + }), ); it.effect("checks out a remote tracking branch when remote name contains slashes", () => @@ -940,13 +953,7 @@ it.layer(TestLayer)("git integration", (it) => { }); expect(renamed.branch).toBe("feature/new-name"); - expect(renameArgs).toEqual([ - "branch", - "-m", - "--", - "feature/old-name", - "feature/new-name", - ]); + expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]); }), ); }); @@ -1006,6 +1013,28 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("creates a worktree for an existing branch when newBranch is omitted", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" }); + + const wtPath = path.join(tmp, "wt-existing"); + const result = yield* createGitWorktree({ + cwd: tmp, + branch: "feature/existing-worktree", + path: wtPath, + }); + + expect(result.worktree.path).toBe(wtPath); + expect(result.worktree.branch).toBe("feature/existing-worktree"); + const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); + expect(branchOutput).toBe("feature/existing-worktree"); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + }), + ); + it.effect("throws when new branch name already exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -1168,6 +1197,37 @@ it.layer(TestLayer)("git integration", (it) => { ); }); + describe("fetchPullRequestBranch", () => { + it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + const remoteDir = yield* makeTmpDir("git-remote-"); + yield* git(remoteDir, ["init", "--bare"]); + yield* git(tmp, ["remote", "add", "origin", remoteDir]); + yield* git(tmp, ["push", "-u", "origin", initialBranch]); + yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]); + yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n"); + yield* git(tmp, ["add", "pr-fetch.txt"]); + yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]); + yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]); + yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); + yield* git(tmp, ["checkout", initialBranch]); + + yield* fetchGitPullRequestBranch({ + cwd: tmp, + prNumber: 55, + branch: "feature/pr-fetch", + }); + + const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]); + expect(localBranches).toContain("feature/pr-fetch"); + const currentBranch = yield* git(tmp, ["branch", "--show-current"]); + expect(currentBranch).toBe(initialBranch); + }), + ); + }); + // ── Full flow: thread switching simulation ── describe("full flow: thread switching (checkout toggling)", () => { @@ -1257,6 +1317,27 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect( + "reuses an existing remote when the target URL only differs by a trailing slash after .git", + () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]); + + const remoteName = yield* core.ensureRemote({ + cwd: tmp, + preferredName: "origin", + url: "git@github.com:pingdotgg/t3code.git/", + }); + + expect(remoteName).toBe("origin"); + expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]); + }), + ); + it.effect("reports status details and dirty state", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index a288b2f3799c..05f458af63dc 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -100,6 +100,38 @@ function parseRemoteNames(stdout: string): ReadonlyArray { .toSorted((a, b) => b.length - a.length); } +function sanitizeRemoteName(value: string): string { + const sanitized = value + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized.length > 0 ? sanitized : "fork"; +} + +function normalizeRemoteUrl(value: string): string { + return value + .trim() + .replace(/\/+$/g, "") + .replace(/\.git$/i, "") + .toLowerCase(); +} + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + function parseRemoteRefWithRemoteNames( branchName: string, remoteNames: ReadonlyArray, @@ -418,6 +450,61 @@ const makeGitCore = Effect.gen(function* () { allowNonZeroExit: true, }).pipe(Effect.map((result) => result.code === 0)); + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => + runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), + ); + + const resolvePrimaryRemoteName = (cwd: string): Effect.Effect => + Effect.gen(function* () { + if (yield* originRemoteExists(cwd)) { + return "origin"; + } + const remotes = yield* listRemoteNames(cwd); + const [firstRemote] = remotes; + if (firstRemote) { + return firstRemote; + } + return yield* createGitCommandError( + "GitCore.resolvePrimaryRemoteName", + cwd, + ["remote"], + "No git remote is configured for this repository.", + ); + }); + + const ensureRemote: GitCoreShape["ensureRemote"] = (input) => + Effect.gen(function* () { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitCore.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; + } + } + + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } + + yield* runGit("GitCore.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }); + const resolveBaseBranchForNoUpstream = ( cwd: string, branch: string, @@ -1015,29 +1102,76 @@ const makeGitCore = Effect.gen(function* () { const createWorktree: GitCoreShape["createWorktree"] = (input) => Effect.gen(function* () { - const sanitizedBranch = input.newBranch.replace(/\//g, "-"); + const targetBranch = input.newBranch ?? input.branch; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; const worktreePath = input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); + const args = input.newBranch + ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] + : ["worktree", "add", worktreePath, input.branch]; - yield* executeGit( - "GitCore.createWorktree", - input.cwd, - ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], - { - fallbackErrorMessage: "git worktree add failed", - }, - ); + yield* executeGit("GitCore.createWorktree", input.cwd, args, { + fallbackErrorMessage: "git worktree add failed", + }); return { worktree: { path: worktreePath, - branch: input.newBranch, + branch: targetBranch, }, }; }); + const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => + Effect.gen(function* () { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitCore.fetchPullRequestBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", + }, + ); + }).pipe(Effect.asVoid); + + const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) => + Effect.gen(function* () { + yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitCore.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); + }).pipe(Effect.asVoid); + + const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => + runGit("GitCore.setBranchUpstream", input.cwd, [ + "branch", + "--set-upstream-to", + `${input.remoteName}/${input.remoteBranch}`, + input.branch, + ]); + const removeWorktree: GitCoreShape["removeWorktree"] = (input) => Effect.gen(function* () { const args = ["worktree", "remove"]; @@ -1197,6 +1331,10 @@ const makeGitCore = Effect.gen(function* () { readConfigValue, listBranches, createWorktree, + fetchPullRequestBranch, + ensureRemote, + fetchRemoteBranch, + setBranchUpstream, removeWorktree, renameBranch, createBranch, diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts new file mode 100644 index 000000000000..aafc796db300 --- /dev/null +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -0,0 +1,128 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { afterEach, expect, vi } from "vitest"; + +vi.mock("../../processRunner", () => ({ + runProcess: vi.fn(), +})); + +import { runProcess } from "../../processRunner"; +import { GitHubCli } from "../Services/GitHubCli.ts"; +import { GitHubCliLive } from "./GitHubCli.ts"; + +const mockedRunProcess = vi.mocked(runProcess); +const layer = it.layer(GitHubCliLive); + +afterEach(() => { + mockedRunProcess.mockReset(); +}); + +layer("GitHubCliLive", (it) => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + expect(mockedRunProcess).toHaveBeenCalledWith( + "gh", + [ + "pr", + "view", + "#42", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + expect.objectContaining({ cwd: "/repo" }), + ); + }), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/codething-mvp", + }); + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + }), + ); + + it.effect("surfaces a friendly error when the pull request is not found", () => + Effect.gen(function* () { + mockedRunProcess.mockRejectedValueOnce( + new Error( + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + ), + ); + + const error = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.getPullRequest({ + cwd: "/repo", + reference: "4888", + }); + }).pipe(Effect.flip); + + assert.equal(error.message.includes("Pull request not found"), true); + }), + ); +}); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index d0e6ebef6f6c..39d47bd63976 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,8 +1,14 @@ -import { Effect, Layer } from "effect"; +import { Effect, Layer, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; import { runProcess } from "../../processRunner"; import { GitHubCliError } from "../Errors.ts"; -import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; +import { + GitHubCli, + type GitHubRepositoryCloneUrls, + type GitHubCliShape, + type GitHubPullRequestSummary, +} from "../Services/GitHubCli.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -30,6 +36,19 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown }); } + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + return new GitHubCliError({ operation, detail: `GitHub CLI command failed: ${error.message}`, @@ -44,59 +63,102 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown }); } -function parseOpenPullRequests(raw: string): ReadonlyArray<{ - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; -}> { - const trimmed = raw.trim(); - if (trimmed.length === 0) return []; - - const parsed: unknown = JSON.parse(trimmed); - if (!Array.isArray(parsed)) { - throw new Error("GitHub CLI returned non-array JSON."); +function normalizePullRequestState(input: { + state?: string | null | undefined; + mergedAt?: string | null | undefined; +}): "open" | "closed" | "merged" { + const mergedAt = input.mergedAt; + const state = input.state; + if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { + return "merged"; } - - const result: Array<{ - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; - }> = []; - for (const entry of parsed) { - if (!entry || typeof entry !== "object") { - continue; - } - const record = entry as Record; - const number = record.number; - const title = record.title; - const url = record.url; - const baseRefName = record.baseRefName; - const headRefName = record.headRefName; - if ( - typeof number !== "number" || - !Number.isInteger(number) || - number <= 0 || - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - continue; - } - result.push({ - number, - title, - url, - baseRefName, - headRefName, - }); + if (state === "CLOSED") { + return "closed"; } + return "open"; +} + +const RawGitHubPullRequestSchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + baseRefName: TrimmedNonEmptyString, + headRefName: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + isCrossRepository: Schema.optional(Schema.Boolean), + headRepository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nameWithOwner: Schema.String, + }), + ), + ), + headRepositoryOwner: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.String, + }), + ), + ), +}); - return result; +const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, +}); + +function normalizePullRequestSummary( + raw: Schema.Schema.Type, +): GitHubPullRequestSummary { + const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null; + const headRepositoryOwnerLogin = + raw.headRepositoryOwner?.login ?? + (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") + ? (headRepositoryNameWithOwner.split("/")[0] ?? null) + : null); + return { + number: raw.number, + title: raw.title, + url: raw.url, + baseRefName: raw.baseRefName, + headRefName: raw.headRefName, + state: normalizePullRequestState(raw), + ...(typeof raw.isCrossRepository === "boolean" + ? { isCrossRepository: raw.isCrossRepository } + : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): GitHubRepositoryCloneUrls { + return { + nameWithOwner: raw.nameWithOwner, + url: raw.url, + sshUrl: raw.sshUrl, + }; +} + +function decodeGitHubJson( + raw: string, + schema: S, + operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitHubCliError({ + operation, + detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail, + cause: error, + }), + ), + ); } const makeGitHubCli = Effect.sync(() => { @@ -128,21 +190,56 @@ const makeGitHubCli = Effect.sync(() => { "number,title,url,baseRefName,headRefName", ], }).pipe( - Effect.map((result) => result.stdout), + Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.try({ - try: () => parseOpenPullRequests(raw), - catch: (error: unknown) => - new GitHubCliError({ - operation: "listOpenPullRequests", - detail: - error instanceof Error - ? `GitHub CLI returned invalid PR list JSON: ${error.message}` - : "GitHub CLI returned invalid PR list JSON.", - ...(error !== undefined ? { cause: error } : {}), - }), - }), + raw.length === 0 + ? Effect.succeed([]) + : decodeGitHubJson( + raw, + Schema.Array(RawGitHubPullRequestSchema), + "listOpenPullRequests", + "GitHub CLI returned invalid PR list JSON.", + ), ), + Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), + ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubPullRequestSchema, + "getPullRequest", + "GitHub CLI returned invalid pull request JSON.", + ), + ), + Effect.map(normalizePullRequestSummary), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubRepositoryCloneUrlsSchema, + "getRepositoryCloneUrls", + "GitHub CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), ), createPullRequest: (input) => execute({ @@ -170,6 +267,11 @@ const makeGitHubCli = Effect.sync(() => { return trimmed.length > 0 ? trimmed : null; }), ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + }).pipe(Effect.asVoid), } satisfies GitHubCliShape; return service; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 7b706a9dd00e..2bf9cdb81743 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -36,6 +37,18 @@ interface FakeGhScenario { prListSequence?: string[]; createdPrUrl?: string; defaultBranch?: string; + pullRequest?: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; + }; + repositoryCloneUrls?: Record; failWith?: GitHubCliError; } @@ -51,6 +64,31 @@ interface FakeGitTextGeneration { ) => Effect.Effect; } +type FakePullRequest = NonNullable; + +function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + }); + if (result.status === 0) { + return; + } + throw new GitHubCliError({ + operation: "execute", + detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, + }); +} + +function isGitHubCliError(error: unknown): error is GitHubCliError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + (error as { _tag?: unknown })._tag === "GitHubCliError" + ); +} + function makeTempDir( prefix: string, ): Effect.Effect { @@ -82,7 +120,11 @@ function runGit( function initRepo( cwd: string, -): Effect.Effect { +): Effect.Effect< + void, + PlatformError.PlatformError | GitCommandError, + FileSystem.FileSystem | Scope.Scope | GitService +> { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* runGit(cwd, ["init", "--initial-branch=main"]); @@ -112,9 +154,7 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ subject: "Implement stacked git actions", body: "", - ...(input.includeBranch - ? { branch: "feature/implement-stacked-git-actions" } - : {}), + ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), }), generatePrContent: () => Effect.succeed({ @@ -203,8 +243,33 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "pr" && args[1] === "view") { + const pullRequest: FakePullRequest = scenario.pullRequest ?? { + number: 101, + title: "Pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/101", + baseRefName: "main", + headRefName: "feature/pull-request", + state: "open", + }; return Effect.succeed({ - stdout: "", + stdout: + JSON.stringify({ + ...pullRequest, + ...(pullRequest.headRepositoryNameWithOwner + ? { + headRepository: { + nameWithOwner: pullRequest.headRepositoryNameWithOwner, + }, + } + : {}), + ...(pullRequest.headRepositoryOwnerLogin + ? { + headRepositoryOwner: { + login: pullRequest.headRepositoryOwnerLogin, + }, + } + : {}), + }) + "\n", stderr: "", code: 0, signal: null, @@ -212,7 +277,71 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }); } + if (args[0] === "pr" && args[1] === "checkout") { + return Effect.try({ + try: () => { + const headBranch = scenario.pullRequest?.headRefName; + if (headBranch) { + const existingBranch = spawnSync( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], + { + cwd: input.cwd, + encoding: "utf8", + }, + ); + if (existingBranch.status === 0) { + runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); + } else { + runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); + } + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }; + }, + catch: (error) => + isGitHubCliError(error) + ? error + : new GitHubCliError({ + operation: "execute", + detail: + error instanceof Error + ? `Failed to simulate gh checkout: ${error.message}` + : "Failed to simulate gh checkout.", + }), + }); + } + if (args[0] === "repo" && args[1] === "view") { + const repository = args[2]; + if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { + const cloneUrls = scenario.repositoryCloneUrls?.[repository]; + if (!cloneUrls) { + return Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected repository lookup: ${repository}`, + }), + ); + } + return Effect.succeed({ + stdout: + JSON.stringify({ + nameWithOwner: repository, + url: cloneUrls.url, + sshUrl: cloneUrls.sshUrl, + }) + "\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } return Effect.succeed({ stdout: `${scenario.defaultBranch ?? "main"}\n`, stderr: "", @@ -279,6 +408,27 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return value.length > 0 ? value : null; }), ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + }).pipe(Effect.asVoid), }, ghCalls, }; @@ -326,6 +476,17 @@ function createSessionTextGeneration( }; } +function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { + return manager.resolvePullRequest(input); +} + +function preparePullRequestThread( + manager: GitManagerShape, + input: { cwd: string; reference: string; mode: "local" | "worktree" }, +) { + return manager.preparePullRequestThread(input); +} + function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; @@ -647,9 +808,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { return { subject: "Implement stacked git actions", body: "", - ...(input.includeBranch - ? { branch: "feature/implement-stacked-git-actions" } - : {}), + ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), }; }), }, @@ -1015,4 +1174,538 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(errorMessage).toContain("gh auth login"); }), ); + + it.effect("resolves pull requests from #number references", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/resolve-pr", + state: "open", + }, + }, + }); + + const result = yield* resolvePullRequest(manager, { + cwd: repoDir, + reference: "#42", + }); + + expect(result.pullRequest).toEqual({ + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/resolve-pr", + state: "open", + }); + expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); + }), + ); + + it.effect("prepares pull request threads in local mode by checking out the PR branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); + fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); + yield* runGit(repoDir, ["add", "local.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 64, + title: "Local PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/64", + baseRefName: "main", + headRefName: "feature/pr-local", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "#64", + mode: "local", + }); + + expect(result.branch).toBe("feature/pr-local"); + expect(result.worktreePath).toBeNull(); + const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); + expect(branch).toBe("feature/pr-local"); + expect(ghCalls).toContain("pr checkout 64 --force"); + }), + ); + + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); + fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); + yield* runGit(repoDir, ["add", "worktree.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/77", + baseRefName: "main", + headRefName: "feature/pr-worktree", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/pr-worktree"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + const worktreeBranch = (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(); + expect(worktreeBranch).toBe("feature/pr-worktree"); + }), + ); + + it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); + fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); + yield* runGit(repoDir, ["add", "fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 81, + title: "Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/81", + baseRefName: "main", + headRefName: "feature/pr-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "81", + mode: "worktree", + }); + + expect(result.worktreePath).not.toBeNull(); + const upstreamRef = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); + expect(upstreamRef.startsWith("origin/")).toBe(false); + expect( + (yield* runGit(result.worktreePath as string, [ + "config", + "--get", + "remote.fork-seed.url", + ])).stdout.trim(), + ).toBe(forkDir); + }), + ); + + it.effect("preserves fork upstream tracking when preparing a local PR thread", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); + fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); + yield* runGit(repoDir, ["add", "local-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 82, + title: "Local Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/82", + baseRefName: "main", + headRefName: "feature/pr-local-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "82", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-fork"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("fork-seed/feature/pr-local-fork"); + }), + ); + + it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); + fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); + yield* runGit(repoDir, ["add", "derived-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); + yield* runGit(repoDir, [ + "push", + "-u", + "binbandit-seed", + "fix/git-action-default-without-origin", + ]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 642, + title: "fix: use commit as the default git action without origin", + url: "https://github.com/pingdotgg/t3code/pull/642", + baseRefName: "main", + headRefName: "fix/git-action-default-without-origin", + state: "open", + isCrossRepository: true, + headRepositoryOwnerLogin: "binbandit", + }, + repositoryCloneUrls: { + "binbandit/t3code": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "642", + mode: "local", + }); + + expect(result.branch).toBe("fix/git-action-default-without-origin"); + expect(result.worktreePath).toBeNull(); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("binbandit-seed/fix/git-action-default-without-origin"); + }), + ); + + it.effect("reuses an existing dedicated worktree for the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); + fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); + yield* runGit(repoDir, ["add", "existing.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Existing worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/78", + baseRefName: "main", + headRefName: "feature/pr-existing-worktree", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + }); + + expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( + fs.realpathSync.native(worktreePath), + ); + expect(result.branch).toBe("feature/pr-existing-worktree"); + }), + ); + + it.effect( + "does not block fork PR worktree prep when the fork head branch collides with root main", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); + yield* runGit(repoDir, ["add", "fork-main.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 91, + title: "Fork main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/91", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "91", + mode: "worktree", + }); + + expect(result.branch).toBe("t3code/pr-91/main"); + expect(result.worktreePath).not.toBeNull(); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); + expect( + (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(), + ).toBe("t3code/pr-91/main"); + }), + ); + + it.effect( + "does not overwrite an existing local main branch when preparing a fork PR worktree", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); + yield* runGit(repoDir, ["add", "fork-main-second.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 92, + title: "Fork main overwrite PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/92", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "92", + mode: "worktree", + }); + + expect(result.branch).toBe("t3code/pr-92/main"); + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); + expect( + (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(), + ).toBe("fork-seed/main"); + }), + ); + + it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); + fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); + yield* runGit(repoDir, ["add", "reused-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); + yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 83, + title: "Reused Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/83", + baseRefName: "main", + headRefName: "feature/pr-reused-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "83", + mode: "worktree", + }); + + expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( + fs.realpathSync.native(worktreePath), + ); + expect( + (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("fork-seed/feature/pr-reused-fork"); + }), + ); + + it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Root-only PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/79", + baseRefName: "main", + headRefName: "feature/pr-root-only", + state: "open", + }, + }, + }); + + const errorMessage = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("already checked out in the main repo"); + }), + ); }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index dbf10be3dbf2..871676785808 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,7 +1,12 @@ import { randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { + resolveAutoFeatureBranchName, + sanitizeBranchFragment, + sanitizeFeatureBranchName, +} from "@t3tools/shared/git"; import type { ProviderKind } from "@t3tools/contracts"; @@ -25,6 +30,61 @@ interface PullRequestInfo extends OpenPrInfo { updatedAt: string | null; } +interface ResolvedPullRequest { + number: number; + title: string; + url: string; + baseBranch: string; + headBranch: string; + state: "open" | "closed" | "merged"; +} + +interface PullRequestHeadRemoteInfo { + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; +} + +function parseRepositoryNameFromPullRequestUrl(url: string): string | null { + const trimmed = url.trim(); + const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); + const repositoryName = match?.[1]?.trim() ?? ""; + return repositoryName.length > 0 ? repositoryName : null; +} + +function resolveHeadRepositoryNameWithOwner( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string | null { + const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; + if (explicitRepository.length > 0) { + return explicitRepository; + } + + if (!pullRequest.isCrossRepository) { + return null; + } + + const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; + const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); + if (ownerLogin.length === 0 || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +function resolvePullRequestWorktreeLocalBranchName( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string { + if (!pullRequest.isCrossRepository) { + return pullRequest.headBranch; + } + + const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); + const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; + return `t3code/pr-${pullRequest.number}/${suffix}`; +} + function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; @@ -178,11 +238,161 @@ function toStatusPr(pr: PullRequestInfo): { }; } +function normalizePullRequestReference(reference: string): string { + const trimmed = reference.trim(); + const hashNumber = /^#(\d+)$/.exec(trimmed); + return hashNumber?.[1] ?? trimmed; +} + +function canonicalizeExistingPath(value: string): string { + try { + return realpathSync.native(value); + } catch { + return value; + } +} + +function toResolvedPullRequest(pr: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; +}): ResolvedPullRequest { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + state: pr.state ?? "open", + }; +} + +function shouldPreferSshRemote(url: string | null): boolean { + if (!url) return false; + const trimmed = url.trim(); + return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); +} + +function toPullRequestHeadRemoteInfo(pr: { + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; +}): PullRequestHeadRemoteInfo { + return { + ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), + ...(pr.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } + : {}), + ...(pr.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } + : {}), + }; +} + export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; const sessionTextGeneration = yield* SessionTextGeneration; + + const configurePullRequestHeadUpstream = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + Effect.gen(function* () { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + if (repositoryNameWithOwner.length === 0) { + return; + } + + const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + + const materializePullRequestHeadBranch = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + Effect.gen(function* () { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + + if (repositoryNameWithOwner.length === 0) { + yield* gitCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }); + return; + } + + const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }).pipe( + Effect.catch(() => + gitCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }), + ), + ); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -478,6 +688,166 @@ export const makeGitManager = Effect.gen(function* () { }; }); + const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced( + function* (input) { + const pullRequest = yield* gitHubCli + .getPullRequest({ + cwd: input.cwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + + return { pullRequest }; + }, + ); + + const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced( + function* (input) { + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* gitHubCli.getPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + }); + const pullRequest = toResolvedPullRequest(pullRequestSummary); + + if (input.mode === "local") { + yield* gitHubCli.checkoutPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + force: true, + }); + const details = yield* gitCore.statusDetails(input.cwd); + yield* configurePullRequestHeadUpstream( + input.cwd, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + }; + } + + const ensureExistingWorktreeUpstream = (worktreePath: string) => + Effect.gen(function* () { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); + + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + + const findLocalHeadBranch = (cwd: string) => + gitCore.listBranches({ cwd }).pipe( + Effect.map((result) => { + const localBranch = result.branches.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + return ( + result.branches.find( + (branch) => + !branch.isRemote && + branch.name === pullRequest.headBranch && + branch.worktreePath !== null && + canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, + ) ?? null + ); + }), + ); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); + return { + pullRequest, + branch: existingBranchBeforeFetch.name, + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + if (existingBranchBeforeFetch && !existingBranchBeforeFetch.worktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "A local branch with this name already exists. Delete it or check it out in a worktree before creating a PR thread.", + ); + } + + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, + ); + + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); + return { + pullRequest, + branch: existingBranchAfterFetch.name, + worktreePath: existingBranchAfterFetch.worktreePath, + }; + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + const worktree = yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: localPullRequestBranch, + path: null, + }); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + + return { + pullRequest, + branch: worktree.worktree.branch, + worktreePath: worktree.worktree.path, + }; + }, + ); + const runFeatureBranchStep = ( cwd: string, branch: string | null, @@ -581,6 +951,8 @@ export const makeGitManager = Effect.gen(function* () { return { status, + resolvePullRequest, + preparePullRequestThread, runStackedAction, } satisfies GitManagerShape; }); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index c60b9bd3e903..502ac349dc95 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -56,6 +56,32 @@ export interface GitRenameBranchResult { branch: string; } +export interface GitFetchPullRequestBranchInput { + cwd: string; + prNumber: number; + branch: string; +} + +export interface GitEnsureRemoteInput { + cwd: string; + preferredName: string; + url: string; +} + +export interface GitFetchRemoteBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; + localBranch: string; +} + +export interface GitSetBranchUpstreamInput { + cwd: string; + branch: string; + remoteName: string; + remoteBranch: string; +} + /** * GitCoreShape - Service API for low-level Git repository interactions. */ @@ -129,6 +155,32 @@ export interface GitCoreShape { input: GitCreateWorktreeInput, ) => Effect.Effect; + /** + * Materialize a GitHub pull request head as a local branch without switching checkout. + */ + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + + /** + * Ensure a named remote exists for the provided URL, returning the reused or created remote name. + */ + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + + /** + * Fetch a remote branch into a local branch without checkout. + */ + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + + /** + * Set the upstream tracking branch for a local branch. + */ + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + /** * Remove an existing worktree. */ diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index db0ed95aa94c..aa95bf628a7b 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -17,6 +17,16 @@ export interface GitHubPullRequestSummary { readonly url: string; readonly baseRefName: string; readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; } /** @@ -41,6 +51,22 @@ export interface GitHubCliShape { readonly limit?: number; }) => Effect.Effect, GitHubCliError>; + /** + * Resolve a pull request by URL, number, or branch-ish identifier. + */ + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + /** + * Resolve clone URLs for a GitHub repository. + */ + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + /** * Create a pull request from branch context and body file. */ @@ -58,6 +84,15 @@ export interface GitHubCliShape { readonly getDefaultBranch: (input: { readonly cwd: string; }) => Effect.Effect; + + /** + * Checkout a pull request into the current repository worktree. + */ + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; } /** diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index e3f90202dd92..650581c71669 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -7,6 +7,10 @@ * @module GitManager */ import { + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullRequestRefInput, + GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStatusInput, @@ -27,6 +31,20 @@ export interface GitManagerShape { input: GitStatusInput, ) => Effect.Effect; + /** + * Resolve a pull request by URL/number against the current repository. + */ + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + + /** + * Prepare a new thread workspace from a pull request in local or worktree mode. + */ + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + /** * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). * When `featureBranch` is set, creates and checks out a feature branch first. diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 4a10d5816b4f..6954cafc5983 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -211,7 +211,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle")); - assert.isFalse(configState.keybindings.some((entry) => String(entry.command) === "invalid.command")); + assert.isFalse( + configState.keybindings.some((entry) => String(entry.command) === "invalid.command"), + ); assert.deepEqual(configState.issues, [ { kind: "keybindings.invalid-entry", diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index dfa26938f8cd..84dff15f3ccd 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -101,7 +101,7 @@ describe("resolveEditorLaunch", () => { }), ); - it.effect("uses open -a on macOS for terminal editors like Ghostty", () => + it.effect("uses open -na on macOS for terminal editors like Ghostty", () => Effect.gen(function* () { const ghosttyMac = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "ghostty" }, @@ -109,7 +109,7 @@ describe("resolveEditorLaunch", () => { ); assert.deepEqual(ghosttyMac, { command: "open", - args: ["-a", "Ghostty", "--args", "--working-directory=/tmp/workspace"], + args: ["-na", "Ghostty", "--args", "--working-directory=/tmp/workspace"], }); const ghosttyLinux = yield* resolveEditorLaunch( @@ -123,6 +123,25 @@ describe("resolveEditorLaunch", () => { }), ); + it.effect("uses the containing directory when terminal editors receive a file path", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-ghostty-")); + const filePath = path.join(tempDir, "nested", "AGENTS.md"); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "# test\n", "utf8"); + + const ghosttyFileTarget = yield* resolveEditorLaunch( + { cwd: `${filePath}:48`, editor: "ghostty" }, + "linux", + ); + + assert.deepEqual(ghosttyFileTarget, { + command: "ghostty", + args: [`--working-directory=${path.dirname(filePath)}`], + }); + }), + ); + it.effect("uses --goto when editor supports line/column suffixes", () => // Use "linux" to avoid macOS .app fallback logic for deterministic results. Effect.gen(function* () { diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e6d5a9260c24..dd683f91dd6b 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -9,7 +9,7 @@ import { spawn } from "node:child_process"; import { accessSync, constants, existsSync, statSync } from "node:fs"; import os from "node:os"; -import { extname, join } from "node:path"; +import { dirname, extname, join } from "node:path"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; @@ -50,6 +50,24 @@ function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { /** Editors that are terminals requiring --working-directory instead of a positional path arg. */ const WORKING_DIRECTORY_EDITORS = new Set(["ghostty"]); +function stripLineColumnSuffix(target: string): string { + return target.replace(LINE_COLUMN_SUFFIX_PATTERN, ""); +} + +function resolveWorkingDirectoryTarget(target: string): string { + const normalizedTarget = stripLineColumnSuffix(target); + + try { + const stats = statSync(normalizedTarget); + return stats.isDirectory() ? normalizedTarget : dirname(normalizedTarget); + } catch { + if (normalizedTarget !== target) { + return dirname(normalizedTarget); + } + return normalizedTarget; + } +} + /** * Map of editor IDs to their macOS application names. * Used both for `open -a ` launching and for detecting availability @@ -262,19 +280,21 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( return { command: editorDef.command, args: ["--goto", input.cwd] }; } if (WORKING_DIRECTORY_EDITORS.has(editorDef.id)) { - // On macOS, use `open -a ` so the running .app instance receives - // the new-window request properly (the bare CLI spawned detached often - // fails to communicate with the single-instance app). + const workingDirectory = resolveWorkingDirectoryTarget(input.cwd); + // On macOS, use `open -na ` so the running .app instance opens a + // new window/tab with the given working directory. The `-n` flag is + // required: without it `open -a` merely activates the existing instance + // and the `--args` are silently ignored. if (platform === "darwin") { const macApp = MAC_APP_NAMES[editorDef.id]; if (macApp) { return { command: "open", - args: ["-a", macApp, "--args", `--working-directory=${input.cwd}`], + args: ["-na", macApp, "--args", `--working-directory=${workingDirectory}`], }; } } - return { command: editorDef.command, args: [`--working-directory=${input.cwd}`] }; + return { command: editorDef.command, args: [`--working-directory=${workingDirectory}`] }; } // On macOS, fall back to `open -a ` when the CLI tool is not in // PATH but the .app bundle is installed (e.g. app installed via DMG @@ -311,9 +331,7 @@ export const launchDetached = (launch: EditorLaunch) => }); } catch (error) { return resume( - Effect.fail( - new OpenError({ message: "failed to spawn detached process", cause: error }), - ), + Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), ); } diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1905cdf71e8c..83ca1a8f6775 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -69,7 +69,7 @@ function createProviderServiceHarness( Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect; const listSessions = () => hasSession - ? Effect.succeed([ + ? Effect.succeed([ { provider: providerName, status: "ready", @@ -351,7 +351,7 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-1"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), @@ -366,7 +366,7 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-1"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), @@ -427,7 +427,7 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-main"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-main"), @@ -443,7 +443,7 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-aux"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-aux"), @@ -461,7 +461,7 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-main"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-main"), @@ -562,7 +562,7 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-missing-baseline"), @@ -651,7 +651,7 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-missing-cwd"), @@ -697,7 +697,7 @@ describe("CheckpointReactor", () => { type: "checkpoint.captured", eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-3"), @@ -747,7 +747,7 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-runtime-failure"), @@ -758,7 +758,7 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), provider: "codex", - + createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-after-runtime-failure"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 52243248fb03..da0e08b93100 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -115,9 +115,7 @@ const make = Effect.gen(function* () { const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( threadId: ThreadId, - ): Effect.fn.Return< - Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> - > { + ): Effect.fn.Return> { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); @@ -133,9 +131,7 @@ const make = Effect.gen(function* () { }; if (thread) { - const projectedSession = sessions.find( - (session) => session.threadId === thread.id, - ); + const projectedSession = sessions.find((session) => session.threadId === thread.id); const fromProjected = findSessionWithCwd(projectedSession); if (Option.isSome(fromProjected)) { return fromProjected; @@ -306,9 +302,7 @@ const make = Effect.gen(function* () { } const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.id === event.threadId, - ); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); if (!thread) { return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 6e4e824f3968..d15b2efa2e9f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -54,9 +54,7 @@ const runWithProjectionPipelineLayer = ( (runtime) => Effect.promise(() => runtime.dispose()), ); -const projectionLayer = it.layer( - makeProjectionPipelineTestLayer(process.cwd()), -); +const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd())); projectionLayer("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => @@ -450,153 +448,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect( - "overwrites stored attachment references when a message updates attachments", - () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")), - ).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const later = new Date(Date.now() + 1_000).toISOString(); - - yield* eventStore.append({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-overwrite-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-1"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-overwrite"), - title: "Project Overwrite", - workspaceRoot: "/tmp/project-overwrite", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); + it.effect("overwrites stored attachment references when a message updates attachments", () => + Effect.sync(() => + fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")), + ).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const later = new Date(Date.now() + 1_000).toISOString(); - yield* eventStore.append({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-overwrite-2"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-2"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - projectId: ProjectId.makeUnsafe("project-overwrite"), - title: "Thread Overwrite", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-overwrite-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Project Overwrite", + workspaceRoot: "/tmp/project-overwrite", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-3"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-3"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), - role: "user", - text: "first image", - attachments: [ - { - type: "image", - id: "thread-overwrite-att-1", - name: "file.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-overwrite-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Thread Overwrite", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-4"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: later, - commandId: CommandId.makeUnsafe("cmd-overwrite-4"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), - role: "user", - text: "", - attachments: [ - { - type: "image", - id: "thread-overwrite-att-2", - name: "file.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: later, - }, - }); + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-3"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "first image", + attachments: [ + { + type: "image", + id: "thread-overwrite-att-1", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - yield* projectionPipeline.bootstrap; + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: later, + commandId: CommandId.makeUnsafe("cmd-overwrite-4"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "", + attachments: [ + { + type: "image", + id: "thread-overwrite-att-2", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: later, + }, + }); + + yield* projectionPipeline.bootstrap; - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` SELECT attachments_json AS "attachmentsJson" FROM projection_thread_messages WHERE message_id = 'message-overwrite' `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ - { - type: "image", - id: "thread-overwrite-att-2", - name: "file.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring( - Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true })), - ), - ), + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-overwrite-att-2", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), ), ), + ), ); it.effect("does not persist attachment files when projector transaction rolls back", () => @@ -711,11 +705,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { `; assert.equal(rows[0]?.count ?? 0, 0); - const attachmentPath = path.join( - stateDir, - "attachments", - "thread-rollback-att-1.png", - ); + const attachmentPath = path.join(stateDir, "attachments", "thread-rollback-att-1.png"); assert.equal(fs.existsSync(attachmentPath), false); yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; }).pipe( @@ -747,189 +737,189 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-revert-files-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-revert-files"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-revert-files"), - title: "Project Revert Files", - workspaceRoot: "/tmp/project-revert-files", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); + type: "project.created", + eventId: EventId.makeUnsafe("evt-revert-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-revert-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Project Revert Files", + workspaceRoot: "/tmp/project-revert-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-revert-files-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.makeUnsafe("project-revert-files"), - title: "Thread Revert Files", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-revert-files-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Thread Revert Files", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); - yield* appendAndProject({ - type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), - metadata: {}, - payload: { - threadId, - turnId: TurnId.makeUnsafe("turn-keep"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), - status: "ready", - files: [], - assistantMessageId: MessageId.makeUnsafe("message-keep"), - completedAt: now, - }, - }); + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.makeUnsafe("turn-keep"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe( + "refs/t3/checkpoints/thread-revert-files/turn/1", + ), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-keep"), + completedAt: now, + }, + }); - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-4"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-keep"), - role: "assistant", - text: "Keep", - attachments: [ - { - type: "image", - id: keepAttachmentId, - name: "keep.png", - mimeType: "image/png", - sizeBytes: 5, + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-keep"), + role: "assistant", + text: "Keep", + attachments: [ + { + type: "image", + id: keepAttachmentId, + name: "keep.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: TurnId.makeUnsafe("turn-keep"), + streaming: false, + createdAt: now, + updatedAt: now, }, - ], - turnId: TurnId.makeUnsafe("turn-keep"), - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); + }); - yield* appendAndProject({ - type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-5"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-5"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), - metadata: {}, - payload: { - threadId, - turnId: TurnId.makeUnsafe("turn-remove"), - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), - status: "ready", - files: [], - assistantMessageId: MessageId.makeUnsafe("message-remove"), - completedAt: now, - }, - }); + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-5"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-5"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.makeUnsafe("turn-remove"), + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.makeUnsafe( + "refs/t3/checkpoints/thread-revert-files/turn/2", + ), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-remove"), + completedAt: now, + }, + }); - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-6"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-6"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-remove"), - role: "assistant", - text: "Remove", - attachments: [ - { - type: "image", - id: removeAttachmentId, - name: "remove.png", - mimeType: "image/png", - sizeBytes: 5, + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-6"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-6"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-remove"), + role: "assistant", + text: "Remove", + attachments: [ + { + type: "image", + id: removeAttachmentId, + name: "remove.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: TurnId.makeUnsafe("turn-remove"), + streaming: false, + createdAt: now, + updatedAt: now, }, - ], - turnId: TurnId.makeUnsafe("turn-remove"), - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); + }); - const keepPath = path.join( - stateDir, - "attachments", - `${keepAttachmentId}.png`, - ); - const removePath = path.join( - stateDir, - "attachments", - `${removeAttachmentId}.png`, - ); - fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); - fs.writeFileSync(keepPath, Buffer.from("keep")); - fs.writeFileSync(removePath, Buffer.from("remove")); - const otherThreadPath = path.join(stateDir, "attachments", `${otherThreadAttachmentId}.png`); - fs.writeFileSync(otherThreadPath, Buffer.from("other")); - assert.equal(fs.existsSync(keepPath), true); - assert.equal(fs.existsSync(removePath), true); - assert.equal(fs.existsSync(otherThreadPath), true); + const keepPath = path.join(stateDir, "attachments", `${keepAttachmentId}.png`); + const removePath = path.join(stateDir, "attachments", `${removeAttachmentId}.png`); + fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); + fs.writeFileSync(keepPath, Buffer.from("keep")); + fs.writeFileSync(removePath, Buffer.from("remove")); + const otherThreadPath = path.join( + stateDir, + "attachments", + `${otherThreadAttachmentId}.png`, + ); + fs.writeFileSync(otherThreadPath, Buffer.from("other")); + assert.equal(fs.existsSync(keepPath), true); + assert.equal(fs.existsSync(removePath), true); + assert.equal(fs.existsSync(otherThreadPath), true); - yield* appendAndProject({ - type: "thread.reverted", - eventId: EventId.makeUnsafe("evt-revert-files-7"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-7"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), - metadata: {}, - payload: { - threadId, - turnCount: 1, - }, - }); + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.makeUnsafe("evt-revert-files-7"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-7"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), + metadata: {}, + payload: { + threadId, + turnCount: 1, + }, + }); assert.equal(fs.existsSync(keepPath), true); assert.equal(fs.existsSync(removePath), false); @@ -962,107 +952,107 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-delete-files-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-delete-files"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-delete-files"), - title: "Project Delete Files", - workspaceRoot: "/tmp/project-delete-files", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); + type: "project.created", + eventId: EventId.makeUnsafe("evt-delete-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-delete-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Project Delete Files", + workspaceRoot: "/tmp/project-delete-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-delete-files-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.makeUnsafe("project-delete-files"), - title: "Thread Delete Files", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-delete-files-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Thread Delete Files", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-delete-files-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-delete-files"), - role: "user", - text: "Delete", - attachments: [ - { - type: "image", - id: attachmentId, - name: "delete.png", - mimeType: "image/png", - sizeBytes: 5, + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-delete-files-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-delete-files"), + role: "user", + text: "Delete", + attachments: [ + { + type: "image", + id: attachmentId, + name: "delete.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); + }); - const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`); - const otherThreadAttachmentPath = path.join( - stateDir, - "attachments", - `${otherThreadAttachmentId}.png`, - ); - fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); - fs.writeFileSync(threadAttachmentPath, Buffer.from("delete")); - fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread")); - assert.equal(fs.existsSync(threadAttachmentPath), true); - assert.equal(fs.existsSync(otherThreadAttachmentPath), true); + const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`); + const otherThreadAttachmentPath = path.join( + stateDir, + "attachments", + `${otherThreadAttachmentId}.png`, + ); + fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); + fs.writeFileSync(threadAttachmentPath, Buffer.from("delete")); + fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread")); + assert.equal(fs.existsSync(threadAttachmentPath), true); + assert.equal(fs.existsSync(otherThreadAttachmentPath), true); - yield* appendAndProject({ - type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-delete-files-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-4"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), - metadata: {}, - payload: { - threadId, - deletedAt: now, - }, - }); + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-delete-files-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), + metadata: {}, + payload: { + threadId, + deletedAt: now, + }, + }); assert.equal(fs.existsSync(threadAttachmentPath), false); assert.equal(fs.existsSync(otherThreadAttachmentPath), true); @@ -1075,7 +1065,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { ); it.effect("ignores unsafe thread ids for attachment cleanup paths", () => - Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-"))).pipe( + Effect.sync(() => + fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")), + ).pipe( Effect.flatMap((stateDir) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 24b81d514ab1..6ae94105a671 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -78,9 +78,8 @@ interface AttachmentSideEffects { } const materializeAttachmentsForProjection = Effect.fn( - (input: { - readonly attachments: ReadonlyArray; - }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), + (input: { readonly attachments: ReadonlyArray }) => + Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), ); function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { @@ -336,7 +335,6 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid }, { concurrency: 1 }, ); - }); const makeOrchestrationProjectionPipeline = Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e7e9cd4e1271..fc7db5480225 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,11 +1,4 @@ -import { - CheckpointRef, - EventId, - MessageId, - ProjectId, - ThreadId, - TurnId, -} from "@t3tools/contracts"; +import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d65016c3aa3a..53edee1cd04f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -41,7 +41,10 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -async function waitFor(predicate: () => boolean | Promise, timeoutMs = 2000): Promise { +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 2000, +): Promise { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { if (await predicate()) { @@ -103,7 +106,10 @@ describe("ProviderCommandReactor", () => { ? input.resumeCursor : undefined; const model = - typeof input === "object" && input !== null && "model" in input && typeof input.model === "string" + typeof input === "object" && + input !== null && + "model" in input && + typeof input.model === "string" ? input.model : undefined; const threadId = @@ -655,7 +661,9 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); return thread?.runtimeMode === "approval-required"; }); await waitFor(() => harness.startSession.mock.calls.length === 2); @@ -804,7 +812,9 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); return thread?.runtimeMode === "approval-required"; }); await waitFor(() => harness.startSession.mock.calls.length === 2); @@ -1008,9 +1018,13 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); if (!thread) return false; - return thread.activities.some((activity) => activity.kind === "provider.approval.respond.failed"); + return thread.activities.some( + (activity) => activity.kind === "provider.approval.respond.failed", + ); }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bd658ce0c6af..f8b52fda9b55 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -5,7 +5,6 @@ import { type OrchestrationEvent, type ProviderModelOptions, ProviderKind, - type ProviderServiceTier, type ProviderStartOptions, type OrchestrationSession, ThreadId, @@ -210,7 +209,6 @@ const make = Effect.gen(function* () { readonly provider?: ProviderKind; readonly model?: string; readonly modelOptions?: ProviderModelOptions; - readonly serviceTier?: ProviderServiceTier | null; readonly providerOptions?: ProviderStartOptions; }, ) { @@ -233,9 +231,9 @@ const make = Effect.gen(function* () { }); const resolveActiveSession = (threadId: ThreadId) => - providerService.listSessions().pipe( - Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)), - ); + providerService + .listSessions() + .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); const effectiveProviderOptions = options?.providerOptions ?? threadProviderOptions.get(threadId); @@ -249,12 +247,11 @@ const make = Effect.gen(function* () { }) => providerService.startSession(threadId, { threadId, - ...(input?.provider ?? preferredProvider + ...((input?.provider ?? preferredProvider) ? { provider: input?.provider ?? preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(desiredModel ? { model: desiredModel } : {}), - ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), ...(effectiveProviderOptions !== undefined ? { providerOptions: effectiveProviderOptions } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), @@ -281,16 +278,15 @@ const make = Effect.gen(function* () { thread.session && thread.session.status !== "stopped" ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; - const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; + const providerChanged = + options?.provider !== undefined && options.provider !== currentProvider; const activeSession = yield* resolveActiveSession(existingSessionThreadId); const sessionModelSwitch = currentProvider === undefined ? "in-session" : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; - const modelChanged = - options?.model !== undefined && options.model !== activeSession?.model; - const shouldRestartForModelChange = - modelChanged && sessionModelSwitch === "restart-session"; + const modelChanged = options?.model !== undefined && options.model !== activeSession?.model; + const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { return existingSessionThreadId; @@ -341,7 +337,6 @@ const make = Effect.gen(function* () { readonly attachments?: ReadonlyArray; readonly provider?: ProviderKind; readonly model?: string; - readonly serviceTier?: ProviderServiceTier | null; readonly modelOptions?: ProviderModelOptions; readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; @@ -357,28 +352,27 @@ const make = Effect.gen(function* () { yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; - const activeSession = yield* providerService.listSessions().pipe( - Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)), - ); + const activeSession = yield* providerService + .listSessions() + .pipe( + Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)), + ); const sessionModelSwitch = activeSession === undefined ? "in-session" : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; - const modelForTurn = - sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; + const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; yield* providerService.sendTurn({ threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), - ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); @@ -493,9 +487,12 @@ const make = Effect.gen(function* () { ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), - ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), - ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), - ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}), + ...(event.payload.modelOptions !== undefined + ? { modelOptions: event.payload.modelOptions } + : {}), + ...(event.payload.providerOptions !== undefined + ? { providerOptions: event.payload.providerOptions } + : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); @@ -714,9 +711,13 @@ const make = Effect.gen(function* () { return; } const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); - yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { - ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } : {}), - }); + yield* ensureSessionForThread( + event.payload.threadId, + event.occurredAt, + cachedProviderOptions !== undefined + ? { providerOptions: cachedProviderOptions } + : undefined, + ); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9c9ceb4ece8a..34b1199e7b00 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -379,7 +379,9 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise(Effect.sleep("40 millis")); const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); - const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const midThread = midReadModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle"); @@ -696,7 +698,9 @@ describe("ProviderRuntimeIngestion", () => { const proposedPlan = thread.proposedPlans.find( (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final", ); - expect(proposedPlan?.planMarkdown).toBe("## Ship plan\n\n- wire projection\n- render follow-up"); + expect(proposedPlan?.planMarkdown).toBe( + "## Ship plan\n\n- wire projection\n- render follow-up", + ); }); it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => { @@ -759,7 +763,8 @@ describe("ProviderRuntimeIngestion", () => { ), ); const proposedPlan = thread.proposedPlans.find( - (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-buffer", + (entry: ProviderRuntimeTestProposedPlan) => + entry.id === "plan:thread-1:turn:turn-plan-buffer", ); expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); }); @@ -1423,6 +1428,50 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime exploded"); }); + it("keeps the session running when a runtime.warning arrives during an active turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-warning-turn-started"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-warning"), + payload: {}, + }); + + harness.emit({ + type: "runtime.warning", + eventId: asEventId("evt-warning-runtime"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-warning"), + payload: { + message: "Reconnecting... 2/5", + detail: { + willRetry: true, + }, + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "running" && + entry.session?.activeTurnId === "turn-warning" && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-warning-runtime" && activity.kind === "runtime.warning", + ), + ); + expect(thread.session?.status).toBe("running"); + expect(thread.session?.activeTurnId).toBe("turn-warning"); + expect(thread.session?.lastError).toBeNull(); + }); + it("maps session/thread lifecycle and item.started into session/activity projections", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 20c96116ee45..85f5b7983fdd 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -224,9 +224,9 @@ function runtimeEventToActivities( ? "Command approval requested" : requestKind === "file-read" ? "File-read approval requested" - : requestKind === "file-change" - ? "File-change approval requested" - : "Approval requested", + : requestKind === "file-change" + ? "File-change approval requested" + : "Approval requested", payload: { requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), @@ -312,7 +312,9 @@ function runtimeEventToActivities( summary: "Plan updated", payload: { plan: event.payload.plan, - ...(event.payload.explanation !== undefined ? { explanation: event.payload.explanation } : {}), + ...(event.payload.explanation !== undefined + ? { explanation: event.payload.explanation } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -372,7 +374,9 @@ function runtimeEventToActivities( payload: { taskId: event.payload.taskId, ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), - ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), + ...(event.payload.description + ? { detail: truncateDetail(event.payload.description) } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -551,11 +555,7 @@ const make = Effect.gen(function* () { return isGitRepository(workspaceCwd); }); - const rememberAssistantMessageId = ( - threadId: ThreadId, - turnId: TurnId, - messageId: MessageId, - ) => + const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => Cache.set( @@ -573,11 +573,7 @@ const make = Effect.gen(function* () { ), ); - const forgetAssistantMessageId = ( - threadId: ThreadId, - turnId: TurnId, - messageId: MessageId, - ) => + const forgetAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => Option.match(existingIds, { @@ -662,7 +658,8 @@ const make = Effect.gen(function* () { const existing = Option.getOrUndefined(existingEntry); return Cache.set(bufferedProposedPlanById, planId, { text: `${existing?.text ?? ""}${delta}`, - createdAt: existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt, + createdAt: + existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt, }); }), ); @@ -1126,8 +1123,8 @@ const make = Effect.gen(function* () { : event.type === "turn.completed" && runtimeTurnState(event) === "failed" ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed") : status === "ready" - ? null - : (thread.session?.lastError ?? null); + ? null + : (thread.session?.lastError ?? null); if (shouldApplyThreadLifecycle) { const turnUsagePayload = @@ -1374,9 +1371,7 @@ const make = Effect.gen(function* () { const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD ? true - : activeTurnId === null || - eventTurnId === undefined || - sameId(activeTurnId, eventTurnId); + : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 88e6093c031b..744ca9ef43e9 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -301,9 +301,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.message.messageId, ...(command.provider !== undefined ? { provider: command.provider } : {}), ...(command.model !== undefined ? { model: command.model } : {}), - ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), - ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), + ...(command.providerOptions !== undefined + ? { providerOptions: command.providerOptions } + : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 67d7f287e1a0..c55bf5910c0c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -304,12 +304,7 @@ export function projectEvent( ); case "thread.runtime-mode-set": - return decodeForEvent( - ThreadRuntimeModeSetPayload, - event.payload, - event.type, - "payload", - ).pipe( + return decodeForEvent(ThreadRuntimeModeSetPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 24446e04dc38..3d103592f957 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -70,9 +70,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => upsertProjectionThreadProposedPlanRow(row).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query"), - ), + Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), ); const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts index 34a430d9742f..a346e513ebfc 100644 --- a/apps/server/src/projectFaviconRoute.test.ts +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -20,9 +20,7 @@ function makeTempDir(prefix: string): string { return dir; } -async function withRouteServer( - run: (baseUrl: string) => Promise, -): Promise { +async function withRouteServer(run: (baseUrl: string) => Promise): Promise { const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (tryHandleProjectFaviconRequest(url, res)) { @@ -104,7 +102,10 @@ describe("tryHandleProjectFaviconRequest", () => { const projectDir = makeTempDir("t3code-favicon-route-source-"); const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.writeFileSync(path.join(projectDir, "index.html"), ''); + fs.writeFileSync( + path.join(projectDir, "index.html"), + '', + ); fs.writeFileSync(iconPath, "brand", "utf8"); await withRouteServer(async (baseUrl) => { @@ -120,7 +121,10 @@ describe("tryHandleProjectFaviconRequest", () => { const projectDir = makeTempDir("t3code-favicon-route-html-order-"); const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.writeFileSync(path.join(projectDir, "index.html"), ''); + fs.writeFileSync( + path.join(projectDir, "index.html"), + '', + ); fs.writeFileSync(iconPath, "brand-html-order", "utf8"); await withRouteServer(async (baseUrl) => { diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 191561f55b7f..1c297fd1a495 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -215,6 +215,7 @@ interface ClaudeSessionContext { }>; readonly inFlightTools: Map; turnState: ClaudeTurnState | undefined; + lastTurnId: TurnId | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; stopped: boolean; @@ -823,12 +824,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const turnState = context.turnState; if (!turnState) { const stamp = yield* makeEventStamp(); + const fallbackTurnId = context.lastTurnId; yield* offerRuntimeEvent({ type: "turn.completed", eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, + ...(fallbackTurnId ? { turnId: fallbackTurnId } : {}), payload: { state: status, ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), @@ -841,8 +844,20 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { : {}), ...(errorMessage ? { errorMessage } : {}), }, - providerRefs: {}, + providerRefs: { + ...(fallbackTurnId ? { providerTurnId: String(fallbackTurnId) } : {}), + }, }); + + const updatedAt = yield* nowIso; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + lastError: status === "failed" && errorMessage ? errorMessage : undefined, + }; + yield* updateResumeCursor(context); return; } @@ -1490,7 +1505,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* Queue.shutdown(context.promptQueue); - context.query.close(); + // The SDK may throw if internal session files were already cleaned up + yield* Effect.sync(() => context.query.close()).pipe(Effect.ignore); const updatedAt = yield* nowIso; context.session = { @@ -1805,6 +1821,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { turns: [], inFlightTools, turnState: undefined, + lastTurnId: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, @@ -1899,6 +1916,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const updatedAt = yield* nowIso; context.turnState = turnState; + context.lastTurnId = turnId; context.session = { ...context.session, status: "running", diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 24d25fc96c21..394b27545633 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -476,6 +476,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps retryable Codex error notifications to runtime.warning", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-retryable-error"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "error", + turnId: asTurnId("turn-1"), + payload: { + error: { + message: "Reconnecting... 2/5", + }, + willRetry: true, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "runtime.warning"); + if (firstEvent.value.type !== "runtime.warning") { + return; + } + assert.equal(firstEvent.value.turnId, "turn-1"); + assert.equal(firstEvent.value.payload.message, "Reconnecting... 2/5"); + }), + ); + it.effect("preserves request type when mapping serverRequest/resolved", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 887df05b6363..c05190887e60 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -400,7 +400,9 @@ function asRuntimeTaskId(taskId: string): RuntimeTaskId { return RuntimeTaskId.makeUnsafe(taskId); } -function codexEventMessage(payload: Record | undefined): Record | undefined { +function codexEventMessage( + payload: Record | undefined, +): Record | undefined { return asObject(payload?.msg); } @@ -1034,7 +1036,9 @@ function mapToRuntimeEvents( type: "content.delta", payload: { streamKind: - asNumber(msg?.summary_index) !== undefined ? "reasoning_summary_text" : "reasoning_text", + asNumber(msg?.summary_index) !== undefined + ? "reasoning_summary_text" + : "reasoning_text", delta, ...(asNumber(msg?.summary_index) !== undefined ? { summaryIndex: asNumber(msg?.summary_index) } @@ -1189,13 +1193,14 @@ function mapToRuntimeEvents( if (event.method === "error") { const message = asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error"; + const willRetry = payload?.willRetry === true; return [ { - type: "runtime.error", + type: willRetry ? "runtime.warning" : "runtime.error", ...runtimeEventBase(event, canonicalThreadId), payload: { message, - class: "provider_error", + ...(!willRetry ? { class: "provider_error" as const } : {}), ...(event.payload !== undefined ? { detail: event.payload } : {}), }, }, @@ -1310,9 +1315,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => detail: toMessage(cause, "Failed to start Codex adapter session."), cause, }), - }).pipe( - Effect.map((session) => session), - ); + }).pipe(Effect.map((session) => session)); }; const sendTurn: CodexAdapterShape["sendTurn"] = (input) => @@ -1351,7 +1354,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions?.codex?.reasoningEffort !== undefined ? { effort: input.modelOptions.codex.reasoningEffort } : {}), diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index b1a64cbb6195..9574258d3fdd 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -44,7 +44,11 @@ import { recordTurnUsage, type CopilotTurnTrackingState, } from "./copilotTurnTracking.ts"; -import { normalizeCopilotCliPathOverride, resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; +import { + normalizeCopilotCliPathOverride, + resolveBundledCopilotCliPath, + withSanitizedCopilotDesktopEnv, +} from "./copilotCliPath.ts"; import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { toMessage } from "../toMessage.ts"; import type { @@ -1027,7 +1031,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } yield* Effect.tryPromise({ - try: () => input.client.start(), + try: () => withSanitizedCopilotDesktopEnv(() => input.client.start()), catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, @@ -1039,7 +1043,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const supportedModels = mapSupportedModelsById( yield* Effect.tryPromise({ - try: () => input.client.listModels(), + try: () => withSanitizedCopilotDesktopEnv(() => input.client.listModels()), catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, @@ -1116,14 +1120,16 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => record.pendingApprovalResolvers, record.pendingUserInputResolvers, ); - const nextSession = await record.client.resumeSession(sessionId, { - ...handlers, - ...(input.model ? { model: input.model } : {}), - ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}), - ...(record.cwd ? { workingDirectory: record.cwd } : {}), - ...(record.configDir ? { configDir: record.configDir } : {}), - streaming: true, - }); + const nextSession = await withSanitizedCopilotDesktopEnv(() => + record.client.resumeSession(sessionId, { + ...handlers, + ...(input.model ? { model: input.model } : {}), + ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}), + ...(record.cwd ? { workingDirectory: record.cwd } : {}), + ...(record.configDir ? { configDir: record.configDir } : {}), + streaming: true, + }), + ); record.session = nextSession; record.interactionMode = undefined; @@ -1291,23 +1297,27 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const session = yield* Effect.tryPromise({ try: async () => { if (resumeSessionId) { - return client.resumeSession(resumeSessionId, { + return withSanitizedCopilotDesktopEnv(() => + client.resumeSession(resumeSessionId, { + ...handlers, + ...(input.model ? { model: input.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }), + ); + } + return withSanitizedCopilotDesktopEnv(() => + client.createSession({ ...handlers, ...(input.model ? { model: input.model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), ...(input.cwd ? { workingDirectory: input.cwd } : {}), ...(configDir ? { configDir } : {}), streaming: true, - }); - } - return client.createSession({ - ...handlers, - ...(input.model ? { model: input.model } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - ...(input.cwd ? { workingDirectory: input.cwd } : {}), - ...(configDir ? { configDir } : {}), - streaming: true, - }); + }), + ); }, catch: (cause) => new ProviderAdapterProcessError({ @@ -1675,8 +1685,10 @@ export async function fetchCopilotModels(): Promise< logLevel: "error", }); try { - await client.start(); - const models = await client.listModels().catch(() => undefined); + await withSanitizedCopilotDesktopEnv(() => client.start()); + const models = await withSanitizedCopilotDesktopEnv(() => + client.listModels().catch(() => undefined), + ); if (!models || models.length === 0) return null; return models.map((m: { id: string; name: string }) => ({ slug: m.id, @@ -1711,8 +1723,10 @@ export async function fetchCopilotUsage(): Promise<{ logLevel: "error", }); try { - await client.start(); - const quota = await (client as unknown as { rpc: { account: { getQuota: () => Promise<{ quotaSnapshots?: unknown }> } } }).rpc.account.getQuota().catch(() => undefined); + await withSanitizedCopilotDesktopEnv(() => client.start()); + const quota = await withSanitizedCopilotDesktopEnv(() => + (client as unknown as { rpc: { account: { getQuota: () => Promise<{ quotaSnapshots?: unknown }> } } }).rpc.account.getQuota().catch(() => undefined), + ); if (!quota?.quotaSnapshots) return { provider: "copilot" }; const quotas = Object.entries( quota.quotaSnapshots as Record< diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 90df9b691fe9..964b45ba49b6 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -132,30 +132,27 @@ it.effect("returns unauthenticated when auth probe reports login required", () = ), ); -it.effect( - "returns unauthenticated when login status output includes 'not logged in'", - () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), +it.effect("returns unauthenticated when login status output includes 'not logged in'", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), ), + ), ); it.effect("returns warning when login status command is unsupported", () => diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 816d88a92fda..16082ae529b7 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -16,10 +16,10 @@ import type { ServerProviderStatusState, } from "@t3tools/contracts"; import { CopilotClient, type ModelInfo } from "@github/copilot-sdk"; -import { Effect, Layer, Option, Result, Stream } from "effect"; +import { Array, Effect, Fiber, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; +import { resolveBundledCopilotCliPath, withSanitizedCopilotDesktopEnv } from "./copilotCliPath.ts"; import { formatCodexCliUpgradeMessage, @@ -479,17 +479,21 @@ export const checkCopilotProviderStatus: Effect.Effect = E logLevel: "error", }); try { - await client.start(); - const [status, authStatus] = await Promise.all([ - (client as unknown as { getStatus(): Promise<{ version?: string }> }).getStatus().catch(() => undefined), - (client as unknown as { getAuthStatus(): Promise<{ isAuthenticated?: boolean; statusMessage?: string }> }).getAuthStatus().catch(() => undefined), - ]); + await withSanitizedCopilotDesktopEnv(() => client.start()); + const [status, authStatus] = await withSanitizedCopilotDesktopEnv(() => + Promise.all([ + (client as unknown as { getStatus(): Promise<{ version?: string }> }).getStatus().catch(() => undefined), + (client as unknown as { getAuthStatus(): Promise<{ isAuthenticated?: boolean; statusMessage?: string }> }).getAuthStatus().catch(() => undefined), + ]), + ); const [models, quota] = authStatus?.isAuthenticated === true - ? await Promise.all([ - client.listModels().catch(() => undefined), - (client as unknown as { rpc: { account: { getQuota: () => Promise<{ quotaSnapshots?: unknown }> } } }).rpc.account.getQuota().catch(() => undefined), - ]) + ? await withSanitizedCopilotDesktopEnv(() => + Promise.all([ + client.listModels().catch(() => undefined), + (client as unknown as { rpc: { account: { getQuota: () => Promise<{ quotaSnapshots?: unknown }> } } }).rpc.account.getQuota().catch(() => undefined), + ]), + ) : [undefined, undefined]; return { status, authStatus, models, quota }; } finally { @@ -565,12 +569,13 @@ export const checkCopilotProviderStatus: Effect.Effect = E export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const [codexStatus, geminiCliStatus, copilotStatus] = yield* Effect.all( + const healthCheckFiber = yield* Effect.all( [checkCodexProviderStatus, checkGeminiCliProviderStatus, checkCopilotProviderStatus], { concurrency: "unbounded" }, - ); + ).pipe(Effect.forkScoped); + return { - getStatuses: Effect.succeed([codexStatus, geminiCliStatus, copilotStatus]), + getStatuses: Fiber.join(healthCheckFiber), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index aaee8d6b8621..ee183a9e754b 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -663,7 +663,6 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); } } - }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 05b0a6f54293..3aea51e22148 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -155,7 +155,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => canonicalEventLogger - ? canonicalEventLogger.write(canonicalEvent, null) + ? canonicalEventLogger.write(canonicalEvent, null).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to write canonical provider event", { cause }), + ), + ) : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), @@ -206,7 +210,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); if (hasActiveSession) { const activeSessions = yield* adapter.listSessions(); - const existing = activeSessions.find((session) => session.threadId === input.binding.threadId); + const existing = activeSessions.find( + (session) => session.threadId === input.binding.threadId, + ); if (existing) { const existingProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); yield* upsertSessionBinding( @@ -313,11 +319,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } - yield* upsertSessionBinding( - session, - threadId, - parsed.providerOptions !== undefined ? { providerOptions: parsed.providerOptions } : undefined, - ); + yield* upsertSessionBinding(session, threadId, { + ...(input.providerOptions !== undefined + ? { providerOptions: input.providerOptions } + : {}), + }); yield* analytics.record("provider.session.started", { provider: session.provider, runtimeMode: input.runtimeMode, @@ -458,23 +464,27 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const listSessions: ProviderServiceShape["listSessions"] = () => Effect.gen(function* () { - const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions()); + const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => + adapter.listSessions(), + ); const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); - const persistedBindings = yield* directory - .listThreadIds() - .pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - directory.getBinding(threadId).pipe( - Effect.orElseSucceed(() => Option.none()), - ), - { concurrency: "unbounded" }, - ), + const persistedBindings = yield* directory.listThreadIds().pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + directory + .getBinding(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())), + { concurrency: "unbounded" }, ), - Effect.orElseSucceed(() => [] as Array>), - ); + ), + Effect.catchCause((cause) => { + return Effect.logWarning("failed to list persisted thread bindings", { cause }).pipe( + Effect.map(() => [] as Array>), + ); + }), + ); const bindingsByThreadId = new Map(); for (const bindingOption of persistedBindings) { const binding = Option.getOrUndefined(bindingOption); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index da0ab2529400..937a883dbfe6 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -2,10 +2,7 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Option } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { - ProviderSessionDirectoryPersistenceError, - ProviderValidationError, -} from "../Errors.ts"; +import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, @@ -147,7 +144,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () { const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => repository .deleteByThreadId({ threadId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId"))); + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), + ); const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( diff --git a/apps/server/src/provider/Layers/copilotCliPath.test.ts b/apps/server/src/provider/Layers/copilotCliPath.test.ts new file mode 100644 index 000000000000..b3e744cf4fc7 --- /dev/null +++ b/apps/server/src/provider/Layers/copilotCliPath.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { resolveBundledCopilotCliPathFrom, withSanitizedCopilotDesktopEnv } from "./copilotCliPath.ts"; + +describe("resolveBundledCopilotCliPathFrom", () => { + it("prefers the unpacked desktop resources path when available", () => { + const existingPaths = new Set([ + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/node_modules/@github/copilot-darwin-arm64/copilot", + ]); + + const resolved = resolveBundledCopilotCliPathFrom({ + currentDir: + "/Applications/T3 Code.app/Contents/Resources/app.asar/apps/server/dist/provider/Layers", + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + platform: "darwin", + arch: "arm64", + exists: (candidate) => existingPaths.has(candidate), + }); + + expect(resolved).toBe( + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/node_modules/@github/copilot-darwin-arm64/copilot", + ); + }); + + it("falls back to app.asar.unpacked relative to the bundled server when resourcesPath is absent", () => { + const existingPaths = new Set([ + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/node_modules/@github/copilot-darwin-arm64/copilot", + ]); + + const resolved = resolveBundledCopilotCliPathFrom({ + currentDir: + "/Applications/T3 Code.app/Contents/Resources/app.asar/apps/server/dist/provider/Layers", + platform: "darwin", + arch: "arm64", + exists: (candidate) => existingPaths.has(candidate), + }); + + expect(resolved).toBe( + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/node_modules/@github/copilot-darwin-arm64/copilot", + ); + }); + + it("returns undefined when only the npm loader is present", () => { + const existingPaths = new Set([ + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/node_modules/@github/copilot/npm-loader.js", + ]); + + const resolved = resolveBundledCopilotCliPathFrom({ + currentDir: + "/Applications/T3 Code.app/Contents/Resources/app.asar/apps/server/dist/provider/Layers", + platform: "darwin", + arch: "arm64", + exists: (candidate) => existingPaths.has(candidate), + }); + + expect(resolved).toBeUndefined(); + }); +}); + +describe("withSanitizedCopilotDesktopEnv", () => { + it("strips Electron-specific env vars during desktop Copilot operations and restores them after", async () => { + const originalMode = process.env.T3CODE_MODE; + const originalRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const originalRendererPort = process.env.ELECTRON_RENDERER_PORT; + const originalClaudeCode = process.env.CLAUDECODE; + + process.env.T3CODE_MODE = "desktop"; + process.env.ELECTRON_RUN_AS_NODE = "1"; + process.env.ELECTRON_RENDERER_PORT = "5173"; + process.env.CLAUDECODE = "1"; + + try { + await withSanitizedCopilotDesktopEnv(async () => { + expect(process.env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(process.env.ELECTRON_RENDERER_PORT).toBeUndefined(); + expect(process.env.CLAUDECODE).toBeUndefined(); + }); + + expect(process.env.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(process.env.ELECTRON_RENDERER_PORT).toBe("5173"); + expect(process.env.CLAUDECODE).toBe("1"); + } finally { + if (originalMode === undefined) delete process.env.T3CODE_MODE; + else process.env.T3CODE_MODE = originalMode; + if (originalRunAsNode === undefined) delete process.env.ELECTRON_RUN_AS_NODE; + else process.env.ELECTRON_RUN_AS_NODE = originalRunAsNode; + if (originalRendererPort === undefined) delete process.env.ELECTRON_RENDERER_PORT; + else process.env.ELECTRON_RENDERER_PORT = originalRendererPort; + if (originalClaudeCode === undefined) delete process.env.CLAUDECODE; + else process.env.CLAUDECODE = originalClaudeCode; + } + }); +}); diff --git a/apps/server/src/provider/Layers/copilotCliPath.ts b/apps/server/src/provider/Layers/copilotCliPath.ts index e791827720b6..a73d67575e40 100644 --- a/apps/server/src/provider/Layers/copilotCliPath.ts +++ b/apps/server/src/provider/Layers/copilotCliPath.ts @@ -7,6 +7,12 @@ const require = createRequire(import.meta.url); const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); const GITHUB_SCOPE_DIR = "@github"; const COPILOT_PATHLESS_COMMAND_PATTERN = /^copilot(?:\.(?:exe|cmd|bat))?$/i; +const COPILOT_DESKTOP_ENV_BLOCKLIST = [ + "ELECTRON_RUN_AS_NODE", + "ELECTRON_RENDERER_PORT", + "CLAUDECODE", +] as const; +let copilotDesktopEnvChain: Promise = Promise.resolve(); function dedupePaths(paths: ReadonlyArray): string[] { const resolved: string[] = []; @@ -49,6 +55,40 @@ export function normalizeCopilotCliPathOverride(value: string | null | undefined return trimmed; } +function isDesktopRuntime(): boolean { + return process.env.T3CODE_MODE === "desktop"; +} + +export async function withSanitizedCopilotDesktopEnv(operation: () => Promise): Promise { + if (!isDesktopRuntime()) { + return operation(); + } + + const run = async () => { + const previousValues = new Map(); + for (const key of COPILOT_DESKTOP_ENV_BLOCKLIST) { + previousValues.set(key, process.env[key]); + delete process.env[key]; + } + + try { + return await operation(); + } finally { + for (const [key, value] of previousValues) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + }; + + const result = copilotDesktopEnvChain.then(run, run); + copilotDesktopEnvChain = result.then(() => undefined, () => undefined); + return result; +} + function resolveGithubScopeDirFromSdkEntrypoint(sdkEntrypoint: string | undefined): string | undefined { if (!sdkEntrypoint) return undefined; return join(dirname(dirname(sdkEntrypoint)), ".."); @@ -63,6 +103,8 @@ function resolveNodeModulesRoots(input: { return dedupePaths([ input.resourcesPath ? join(input.resourcesPath, "app.asar.unpacked/node_modules") : undefined, input.resourcesPath ? join(input.resourcesPath, "node_modules") : undefined, + join(input.currentDir, "../../../../../app.asar.unpacked/node_modules"), + join(input.currentDir, "../../../../../../app.asar.unpacked/node_modules"), join(input.currentDir, "../../../node_modules"), join(input.currentDir, "../../../../../node_modules"), githubScopeDir ? join(githubScopeDir, "..") : undefined, @@ -128,15 +170,6 @@ export function resolveBundledCopilotCliPathFrom(input: { } } - const npmLoaderCandidates = dedupePaths( - nodeModulesRoots.map((root) => join(root, GITHUB_SCOPE_DIR, "copilot", "npm-loader.js")), - ); - for (const candidate of npmLoaderCandidates) { - if (exists(candidate)) { - return candidate; - } - } - const githubScopeDir = resolveGithubScopeDirFromSdkEntrypoint(sdkEntrypoint); if (!githubScopeDir) { return undefined; @@ -151,8 +184,7 @@ export function resolveBundledCopilotCliPathFrom(input: { } } - const sdkSiblingLoaderPath = join(githubScopeDir, "copilot", "npm-loader.js"); - return exists(sdkSiblingLoaderPath) ? sdkSiblingLoaderPath : undefined; + return undefined; } export function resolveBundledCopilotCliPath(): string | undefined { diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 67755b538340..38a05f75748d 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -66,10 +66,7 @@ export interface ProviderAdapterShape { /** * Interrupt an active turn. */ - readonly interruptTurn: ( - threadId: ThreadId, - turnId?: TurnId, - ) => Effect.Effect; + readonly interruptTurn: (threadId: ThreadId, turnId?: TurnId) => Effect.Effect; /** * Respond to an interactive approval request. @@ -107,9 +104,7 @@ export interface ProviderAdapterShape { /** * Read a provider thread snapshot. */ - readonly readThread: ( - threadId: ThreadId, - ) => Effect.Effect; + readonly readThread: (threadId: ThreadId) => Effect.Effect; /** * Roll back a provider thread by N turns. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 80d57d7c6f9c..ecb4c09ef6ab 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1236,21 +1236,19 @@ describe("WebSocket Server", () => { return event.type === "thread.session-set"; }); - emitRuntimeEvent( - { - type: "content.delta", - eventId: asEventId("evt-ws-runtime-message-delta"), - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - turnId: asTurnId("turn-1"), - itemId: asProviderItemId("item-1"), - payload: { - streamKind: "assistant_text", - delta: "hello from runtime", - }, - } as unknown as ProviderRuntimeEvent, - ); + emitRuntimeEvent({ + type: "content.delta", + eventId: asEventId("evt-ws-runtime-message-delta"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-1"), + itemId: asProviderItemId("item-1"), + payload: { + streamKind: "assistant_text", + delta: "hello from runtime", + }, + } as unknown as ProviderRuntimeEvent); const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; @@ -1568,7 +1566,9 @@ describe("WebSocket Server", () => { }); expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("Workspace file path must stay within the project root."); + expect(response.error?.message).toContain( + "Workspace file path must stay within the project root.", + ); expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); }); @@ -1638,7 +1638,14 @@ describe("WebSocket Server", () => { const status = vi.fn(() => Effect.succeed(statusResult)); const runStackedAction = vi.fn(() => Effect.void as any); - const gitManager: GitManagerShape = { status, runStackedAction }; + const resolvePullRequest = vi.fn(() => Effect.void as any); + const preparePullRequestThread = vi.fn(() => Effect.void as any); + const gitManager: GitManagerShape = { + status, + resolvePullRequest, + preparePullRequestThread, + runStackedAction, + }; server = await createTestServer({ cwd: "/test", gitManager }); const addr = server.address(); @@ -1656,6 +1663,63 @@ describe("WebSocket Server", () => { expect(status).toHaveBeenCalledWith({ cwd: "/test" }); }); + it("supports git pull request routing over websocket", async () => { + const resolvePullRequestResult = { + pullRequest: { + number: 42, + title: "PR thread flow", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/pr-threads", + state: "open" as const, + }, + }; + const preparePullRequestThreadResult = { + ...resolvePullRequestResult, + branch: "feature/pr-threads", + worktreePath: "/tmp/pr-threads", + }; + + const gitManager: GitManagerShape = { + status: vi.fn(() => Effect.void as any), + resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), + preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), + runStackedAction: vi.fn(() => Effect.void as any), + }; + + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { + cwd: "/test", + reference: "#42", + }); + expect(resolveResponse.error).toBeUndefined(); + expect(resolveResponse.result).toEqual(resolvePullRequestResult); + + const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, { + cwd: "/test", + reference: "42", + mode: "worktree", + }); + expect(prepareResponse.error).toBeUndefined(); + expect(prepareResponse.result).toEqual(preparePullRequestThreadResult); + expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({ + cwd: "/test", + reference: "#42", + }); + expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({ + cwd: "/test", + reference: "42", + mode: "worktree", + }); + }); + it("returns errors from git.runStackedAction", async () => { const runStackedAction = vi.fn(() => Effect.fail( @@ -1667,6 +1731,8 @@ describe("WebSocket Server", () => { ); const gitManager: GitManagerShape = { status: vi.fn(() => Effect.void as any), + resolvePullRequest: vi.fn(() => Effect.void as any), + preparePullRequestThread: vi.fn(() => Effect.void as any), runStackedAction, }; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 83037a4badcc..68dc6f2011fb 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -30,6 +30,7 @@ import { type ProviderListModelsResult, type ProviderModelOption, type ProviderUsageResult, + ServerProviderStatus, } from "@t3tools/contracts"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { @@ -211,8 +212,7 @@ function stripRequestTag(body: T) { function messageFromCause(cause: Cause.Cause): string { const squashed = Cause.squash(cause); - const message = - squashed instanceof Error ? squashed.message.trim() : String(squashed).trim(); + const message = squashed instanceof Error ? squashed.message.trim() : String(squashed).trim(); return message.length > 0 ? message : Cause.pretty(cause); } @@ -282,8 +282,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - const providerStatuses = yield* providerHealth.getStatuses; - const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); @@ -345,10 +343,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } satisfies OrchestrationCommand; } - if ( - input.command.type === "project.meta.update" && - input.command.workspaceRoot !== undefined - ) { + if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { return { ...input.command, workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), @@ -631,6 +626,23 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); + // Push updated provider statuses to connected clients once background health checks finish. + let providers: ReadonlyArray = []; + yield* providerHealth.getStatuses.pipe( + Effect.flatMap((statuses) => { + providers = statuses; + return broadcastPush({ + type: "push", + channel: WS_CHANNELS.serverConfigUpdated, + data: { + issues: [], + providers: statuses, + }, + }); + }), + Effect.forkIn(subscriptionsScope), + ); + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => broadcastPush({ type: "push", @@ -645,7 +657,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< channel: WS_CHANNELS.serverConfigUpdated, data: { issues: event.issues, - providers: providerStatuses, + providers, }, }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -791,14 +803,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< relativePath: body.relativePath, path, }); - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( - Effect.mapError( - (cause) => - new RouteRequestError({ - message: `Failed to prepare workspace path: ${String(cause)}`, - }), - ), - ); + yield* fileSystem + .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to prepare workspace path: ${String(cause)}`, + }), + ), + ); yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( Effect.mapError( (cause) => @@ -830,6 +844,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* gitManager.runStackedAction(body); } + case WS_METHODS.gitResolvePullRequest: { + const body = stripRequestTag(request.body); + return yield* gitManager.resolvePullRequest(body); + } + + case WS_METHODS.gitPreparePullRequestThread: { + const body = stripRequestTag(request.body); + return yield* gitManager.preparePullRequestThread(body); + } + case WS_METHODS.gitListBranches: { const body = stripRequestTag(request.body); return yield* git.listBranches(body); @@ -1014,7 +1038,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: providerStatuses, + providers, availableEditors, }; diff --git a/apps/web/package.json b/apps/web/package.json index 087c6df5667d..6e155e0c5de6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,10 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 2ccb32fba3e7..5ef8b4ba1e93 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -5,8 +5,6 @@ import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs, - resolveAppServiceTier, - shouldShowFastTierIcon, resolveAppModelSelection, } from "./appSettings"; @@ -137,23 +135,13 @@ describe("resolveAppModelSelection", () => { describe("getSlashModelOptions", () => { it("includes saved custom model slugs for /model command suggestions", () => { - const options = getSlashModelOptions( - "codex", - ["custom/internal-model"], - "", - "gpt-5.3-codex", - ); + const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex"); expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); }); it("filters slash-model suggestions across built-in and custom model names", () => { - const options = getSlashModelOptions( - "codex", - ["openai/gpt-oss-120b"], - "oss", - "gpt-5.3-codex", - ); + const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); @@ -167,40 +155,32 @@ describe("getSlashModelOptions", () => { }); }); -describe("resolveAppServiceTier", () => { - it("maps automatic to no override", () => { - expect(resolveAppServiceTier("auto")).toBeNull(); - }); - - it("preserves explicit service tier overrides", () => { - expect(resolveAppServiceTier("fast")).toBe("fast"); - expect(resolveAppServiceTier("flex")).toBe("flex"); - }); -}); - describe("getAppSettingsSnapshot", () => { it("defaults provider logos to color", () => { - expect(getAppSettingsSnapshot().grayscaleProviderLogos).toBe(false); + expect(getAppSettingsSnapshot().providerLogoAppearance).toBe("original"); }); - it("hydrates a persisted grayscale provider logo preference", () => { + it("hydrates a persisted provider logo appearance preference", () => { const persistedSettings = { ...getAppSettingsSnapshot(), - grayscaleProviderLogos: true, + providerLogoAppearance: "accent", }; localStorage.setItem( APP_SETTINGS_STORAGE_KEY, JSON.stringify(persistedSettings), ); - expect(getAppSettingsSnapshot().grayscaleProviderLogos).toBe(true); + expect(getAppSettingsSnapshot().providerLogoAppearance).toBe("accent"); }); -}); -describe("shouldShowFastTierIcon", () => { - it("shows the fast-tier icon only for gpt-5.4 on fast tier", () => { - expect(shouldShowFastTierIcon("gpt-5.4", "fast")).toBe(true); - expect(shouldShowFastTierIcon("gpt-5.4", "auto")).toBe(false); - expect(shouldShowFastTierIcon("gpt-5.3-codex", "fast")).toBe(false); + it("migrates the legacy grayscale provider logo preference", () => { + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ + grayscaleProviderLogos: true, + }), + ); + + expect(getAppSettingsSnapshot().providerLogoAppearance).toBe("grayscale"); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 805717635b2c..a3919ca6bcbb 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,32 +1,32 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; -import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; +import { type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export const APP_SERVICE_TIER_OPTIONS = [ +export const APP_PROVIDER_LOGO_APPEARANCE_OPTIONS = [ { - value: "auto", - label: "Automatic", - description: "Use Codex defaults without forcing a service tier.", + value: "original", + label: "Default color", + description: "Use each provider's native logo colors.", }, { - value: "fast", - label: "Fast", - description: "Request the fast service tier when the model supports it.", + value: "grayscale", + label: "Grayscale", + description: "Desaturate provider logos while keeping their original shapes.", }, { - value: "flex", - label: "Flex", - description: "Request the flex service tier when the model supports it.", + value: "accent", + label: "Accent color", + description: "Tint every provider logo with your global or per-provider accent color.", }, ] as const; -export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"]; -const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); -const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); +export type AppProviderLogoAppearance = + (typeof APP_PROVIDER_LOGO_APPEARANCE_OPTIONS)[number]["value"]; +const AppProviderLogoAppearanceSchema = Schema.Literals(["original", "grayscale", "accent"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), @@ -55,7 +55,6 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), - codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), @@ -80,6 +79,9 @@ const AppSettingsSchema = Schema.Struct({ customKiloModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + providerLogoAppearance: AppProviderLogoAppearanceSchema.pipe( + Schema.withConstructorDefault(() => Option.some("original")), + ), grayscaleProviderLogos: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), @@ -98,27 +100,6 @@ export interface AppModelOption { isCustom: boolean; } -export interface BuiltInAppModelOption { - slug: string; - name: string; -} - -export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null { - return serviceTier === "auto" ? null : serviceTier; -} - -export function shouldShowFastTierIcon( - model: string | null | undefined, - serviceTier: AppServiceTier, -): boolean { - const normalizedModel = normalizeModelSlug(model); - return ( - resolveAppServiceTier(serviceTier) === "fast" && - normalizedModel !== null && - MODELS_WITH_FAST_SUPPORT.has(normalizedModel) - ); -} - const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); let listeners: Array<() => void> = []; @@ -268,13 +249,29 @@ function emitChange(): void { } } +function migratePersistedAppSettings(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + + const settings = { ...(value as Record) }; + if (settings.providerLogoAppearance === undefined && settings.grayscaleProviderLogos === true) { + settings.providerLogoAppearance = "grayscale"; + } + + return settings; +} + function parsePersistedSettings(value: string | null): AppSettings { if (!value) { return DEFAULT_APP_SETTINGS; } try { - return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); + const parsed = JSON.parse(value) as unknown; + return normalizeAppSettings( + AppSettingsSchema.makeUnsafe(migratePersistedAppSettings(parsed) as Record), + ); } catch { return DEFAULT_APP_SETTINGS; } diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ea164b363048..b61e11c73f08 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + resolveBranchSelectionTarget, resolveDraftEnvModeAfterBranchChange, resolveBranchToolbarValue, } from "./BranchToolbar.logic"; @@ -194,3 +195,73 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { ]); }); }); + +describe("resolveBranchSelectionTarget", () => { + it("reuses an existing secondary worktree for the selected branch", () => { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd: "/repo", + activeWorktreePath: "/repo/.t3/worktrees/feature-a", + branch: { + isDefault: false, + worktreePath: "/repo/.t3/worktrees/feature-b", + }, + }), + ).toEqual({ + checkoutCwd: "/repo/.t3/worktrees/feature-b", + nextWorktreePath: "/repo/.t3/worktrees/feature-b", + reuseExistingWorktree: true, + }); + }); + + it("switches back to the main repo when the branch already lives there", () => { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd: "/repo", + activeWorktreePath: "/repo/.t3/worktrees/feature-a", + branch: { + isDefault: true, + worktreePath: "/repo", + }, + }), + ).toEqual({ + checkoutCwd: "/repo", + nextWorktreePath: null, + reuseExistingWorktree: true, + }); + }); + + it("checks out the default branch in the main repo when leaving a secondary worktree", () => { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd: "/repo", + activeWorktreePath: "/repo/.t3/worktrees/feature-a", + branch: { + isDefault: true, + worktreePath: null, + }, + }), + ).toEqual({ + checkoutCwd: "/repo", + nextWorktreePath: null, + reuseExistingWorktree: false, + }); + }); + + it("keeps checkout in the current worktree for non-default branches", () => { + expect( + resolveBranchSelectionTarget({ + activeProjectCwd: "/repo", + activeWorktreePath: "/repo/.t3/worktrees/feature-a", + branch: { + isDefault: false, + worktreePath: null, + }, + }), + ).toEqual({ + checkoutCwd: "/repo/.t3/worktrees/feature-a", + nextWorktreePath: "/repo/.t3/worktrees/feature-a", + reuseExistingWorktree: false, + }); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 1b48970bbfc0..64ec2956c6da 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -88,3 +88,32 @@ export function dedupeRemoteBranchesWithLocalMatches( return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } + +export function resolveBranchSelectionTarget(input: { + activeProjectCwd: string; + activeWorktreePath: string | null; + branch: Pick; +}): { + checkoutCwd: string; + nextWorktreePath: string | null; + reuseExistingWorktree: boolean; +} { + const { activeProjectCwd, activeWorktreePath, branch } = input; + + if (branch.worktreePath) { + return { + checkoutCwd: branch.worktreePath, + nextWorktreePath: branch.worktreePath === activeProjectCwd ? null : branch.worktreePath, + reuseExistingWorktree: true, + }; + } + + const nextWorktreePath = + activeWorktreePath !== null && branch.isDefault ? null : activeWorktreePath; + + return { + checkoutCwd: nextWorktreePath ?? activeProjectCwd, + nextWorktreePath, + reuseExistingWorktree: false, + }; +} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d1c68d321dff..b2c60b24292e 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -17,6 +17,7 @@ interface BranchToolbarProps { threadId: ThreadId; onEnvModeChange: (mode: EnvMode) => void; envLocked: boolean; + onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -24,6 +25,7 @@ export default function BranchToolbar({ threadId, onEnvModeChange, envLocked, + onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { const threads = useStore((store) => store.threads); @@ -128,6 +130,7 @@ export default function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} envLocked={envLocked} onSetThreadBranch={setThreadBranch} + {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ba27e383d8c8..94462791615d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,7 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { + type CSSProperties, useCallback, + useDeferredValue, useEffect, useMemo, useOptimistic, @@ -12,12 +14,19 @@ import { useTransition, } from "react"; -import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; +import { + gitBranchesQueryOptions, + gitQueryKeys, + gitStatusQueryOptions, + invalidateGitQueries, +} from "../lib/gitReactQuery"; import { readNativeApi } from "../nativeApi"; +import { parsePullRequestReference } from "../pullRequestReference"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, EnvMode, + resolveBranchSelectionTarget, resolveBranchToolbarValue, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; @@ -40,6 +49,7 @@ interface BranchToolbarBranchSelectorProps { effectiveEnvMode: EnvMode; envLocked: boolean; onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; + onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -70,18 +80,22 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode, envLocked, onSetThreadBranch, + onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); + const deferredBranchQuery = useDeferredValue(branchQuery); const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); + const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); const branches = useMemo( () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), [branchesQuery.data?.branches], ); - const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; + const currentGitBranch = + branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; const canonicalActiveBranch = resolveBranchToolbarValue({ envMode: effectiveEnvMode, activeWorktreePath, @@ -94,34 +108,44 @@ export function BranchToolbarBranchSelector({ [branches], ); const trimmedBranchQuery = branchQuery.trim(); - const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); - const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; + const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); + const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); + const prReference = parsePullRequestReference(trimmedBranchQuery); + const isSelectingWorktreeBase = + effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; + const checkoutPullRequestItemValue = + prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; + const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; - const branchPickerItems = useMemo( - () => - createBranchItemValue && !hasExactBranchMatch - ? [...branchNames, createBranchItemValue] - : branchNames, - [branchNames, createBranchItemValue, hasExactBranchMatch], - ); + const branchPickerItems = useMemo(() => { + const items = [...branchNames]; + if (createBranchItemValue && !hasExactBranchMatch) { + items.push(createBranchItemValue); + } + if (checkoutPullRequestItemValue) { + items.unshift(checkoutPullRequestItemValue); + } + return items; + }, [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch]); const filteredBranchPickerItems = useMemo( () => - normalizedBranchQuery.length === 0 + normalizedDeferredBranchQuery.length === 0 ? branchPickerItems : branchPickerItems.filter((itemValue) => { if (createBranchItemValue && itemValue === createBranchItemValue) return true; - return itemValue.toLowerCase().includes(normalizedBranchQuery); + return itemValue.toLowerCase().includes(normalizedDeferredBranchQuery); }), - [branchPickerItems, createBranchItemValue, normalizedBranchQuery], + [branchPickerItems, createBranchItemValue, normalizedDeferredBranchQuery], ); const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( canonicalActiveBranch, (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, ); const [isBranchActionPending, startBranchActionTransition] = useTransition(); + const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { @@ -135,17 +159,22 @@ export function BranchToolbarBranchSelector({ if (!api || !branchCwd || isBranchActionPending) return; // In new-worktree mode, selecting a branch sets the base branch. - if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { + if (isSelectingWorktreeBase) { onSetThreadBranch(branch.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } + const selectionTarget = resolveBranchSelectionTarget({ + activeProjectCwd, + activeWorktreePath, + branch, + }); + // If the branch already lives in a worktree, point the thread there. - if (branch.worktreePath) { - const isMainWorktree = branch.worktreePath === activeProjectCwd; - onSetThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); + if (selectionTarget.reuseExistingWorktree) { + onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -161,7 +190,7 @@ export function BranchToolbarBranchSelector({ runBranchAction(async () => { setOptimisticBranch(selectedBranchName); try { - await api.git.checkout({ cwd: branchCwd, branch: branch.name }); + await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); await invalidateGitQueries(queryClient); } catch (error) { toastManager.add({ @@ -181,7 +210,7 @@ export function BranchToolbarBranchSelector({ } setOptimisticBranch(nextBranchName); - onSetThreadBranch(nextBranchName, activeWorktreePath); + onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); }); }; @@ -258,10 +287,11 @@ export function BranchToolbarBranchSelector({ const branchListScrollElementRef = useRef(null); const branchListVirtualizer = useVirtualizer({ count: filteredBranchPickerItems.length, - estimateSize: () => 28, + estimateSize: (index) => + filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28, getScrollElement: () => branchListScrollElementRef.current, overscan: 12, - enabled: isBranchMenuOpen, + enabled: isBranchMenuOpen && shouldVirtualizeBranchList, initialRect: { height: 224, width: 0, @@ -280,11 +310,16 @@ export function BranchToolbarBranchSelector({ ); useEffect(() => { - if (!isBranchMenuOpen) return; + if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; queueMicrotask(() => { branchListVirtualizer.measure(); }); - }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); + }, [ + branchListVirtualizer, + filteredBranchPickerItems.length, + isBranchMenuOpen, + shouldVirtualizeBranchList, + ]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -292,12 +327,84 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, }); + function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { + if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { + return ( + { + if (!prReference || !onCheckoutPullRequestRequest) { + return; + } + setIsBranchMenuOpen(false); + setBranchQuery(""); + onComposerFocusRequest?.(); + onCheckoutPullRequestRequest(prReference); + }} + > +
+ Checkout Pull Request + {prReference} +
+
+ ); + } + if (createBranchItemValue && itemValue === createBranchItemValue) { + return ( + createBranch(trimmedBranchQuery)} + > + Create new branch "{trimmedBranchQuery}" + + ); + } + + const branch = branchByName.get(itemValue); + if (!branch) return null; + + const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd; + const badge = branch.current + ? "current" + : hasSecondaryWorktree + ? "worktree" + : branch.isRemote + ? "remote" + : branch.isDefault + ? "default" + : null; + return ( + selectBranch(branch)} + > +
+ {itemValue} + {badge && {badge}} +
+
+ ); + } + return ( { if (!isBranchMenuOpen || eventDetails.index < 0) return; branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); @@ -309,12 +416,12 @@ export function BranchToolbarBranchSelector({ } className="text-muted-foreground/70 hover:text-foreground/80" - disabled={branchesQuery.isLoading || isBranchActionPending} + disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} > {triggerLabel} - +
No branches found. -
- {virtualBranchRows.map((virtualRow) => { - const itemValue = filteredBranchPickerItems[virtualRow.index]; - if (!itemValue) return null; - if (createBranchItemValue && itemValue === createBranchItemValue) { - return ( - createBranch(trimmedBranchQuery)} - > - Create new branch "{trimmedBranchQuery}" - - ); - } - - const branch = branchByName.get(itemValue); - if (!branch) return null; - - const hasSecondaryWorktree = - branch.worktreePath && branch.worktreePath !== activeProjectCwd; - const badge = branch.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : branch.isRemote - ? "remote" - : branch.isDefault - ? "default" - : null; - return ( - selectBranch(branch)} - > -
- {itemValue} - {badge && ( - {badge} - )} -
-
- ); - })} -
+ {shouldVirtualizeBranchList ? ( +
+ {virtualBranchRows.map((virtualRow) => { + const itemValue = filteredBranchPickerItems[virtualRow.index]; + if (!itemValue) return null; + return renderPickerItem(itemValue, virtualRow.index, { + position: "absolute", + top: 0, + left: 0, + width: "100%", + transform: `translateY(${virtualRow.start}px)`, + }); + })} +
+ ) : ( + filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) + )}
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e2fd573fe8ce..8e02af232c45 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -256,6 +256,61 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-plan-target" as MessageId, + targetText: "plan thread", + }); + const planMarkdown = [ + "# Ship plan mode follow-up", + "", + "- Step 1: capture the thread-open trace", + "- Step 2: identify the main-thread bottleneck", + "- Step 3: keep collapsed cards cheap", + "- Step 4: render the full markdown only on demand", + "- Step 5: preserve export and save actions", + "- Step 6: add regression coverage", + "- Step 7: verify route transitions stay responsive", + "- Step 8: confirm no server-side work changed", + "- Step 9: confirm short plans still render normally", + "- Step 10: confirm long plans stay collapsed by default", + "- Step 11: confirm preview text is still useful", + "- Step 12: confirm plan follow-up flow still works", + "- Step 13: confirm timeline virtualization still behaves", + "- Step 14: confirm theme styling still looks correct", + "- Step 15: confirm save dialog behavior is unchanged", + "- Step 16: confirm download behavior is unchanged", + "- Step 17: confirm code fences do not parse until expand", + "- Step 18: confirm preview truncation ends cleanly", + "- Step 19: confirm markdown links still open in editor after expand", + "- Step 20: confirm deep hidden detail only appears after expand", + "", + "```ts", + "export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';", + "```", + ].join("\n"); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + proposedPlans: [ + { + id: "plan-browser-test", + turnId: null, + planMarkdown, + createdAt: isoAt(1_000), + updatedAt: isoAt(1_001), + }, + ], + updatedAt: isoAt(1_001), + }) + : thread, + ), + }; +} + function resolveWsRpc(tag: string): unknown { if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; @@ -359,9 +414,9 @@ async function setViewport(viewport: ViewportSpec): Promise { async function waitForProductionStyles(): Promise { await vi.waitFor( () => { - expect(getComputedStyle(document.documentElement).getPropertyValue("--background").trim()).not.toBe( - "", - ); + expect( + getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), + ).not.toBe(""); expect(getComputedStyle(document.body).marginTop).toBe("0px"); }, { @@ -399,7 +454,9 @@ async function waitForComposerEditor(): Promise { ); } -async function waitForInteractionModeButton(expectedLabel: "Chat" | "Plan"): Promise { +async function waitForInteractionModeButton( + expectedLabel: "Chat" | "Plan", +): Promise { return waitForElement( () => Array.from(document.querySelectorAll("button")).find( @@ -642,7 +699,9 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const measurements: Array = []; + const measurements: Array< + UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } + > = []; for (const viewport of TEXT_VIEWPORT_MATRIX) { await mounted.setViewport(viewport); @@ -659,7 +718,10 @@ describe("ChatView timeline estimator parity (full app)", () => { measurements.push({ ...measurement, viewport, estimatedHeightPx }); } - expect(new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))).size).toBeGreaterThanOrEqual(3); + expect( + new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))) + .size, + ).toBeGreaterThanOrEqual(3); const byMeasuredWidth = measurements.toSorted( (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, @@ -701,7 +763,8 @@ describe("ChatView timeline estimator parity (full app)", () => { { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, ); - const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; + const measuredDeltaPx = + mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; expect(measuredDeltaPx).toBeGreaterThan(0); expect(estimatedDeltaPx).toBeGreaterThan(0); @@ -789,7 +852,9 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const openRequest = wsRequests.find((request) => request._tag === WS_METHODS.shellOpenInEditor); + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", @@ -867,4 +932,41 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("keeps long proposed plans lightweight until the user expands them", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithLongProposedPlan(), + }); + + try { + await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Expand plan", + ) as HTMLButtonElement | null, + "Unable to find Expand plan button.", + ); + + expect(document.body.textContent).not.toContain("deep hidden detail only after expand"); + + const expandButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Expand plan", + ) as HTMLButtonElement | null, + "Unable to find Expand plan button.", + ); + expandButton.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("deep hidden detail only after expand"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5bec06ecdaf9..bc6448bbe523 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -64,7 +64,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; -import { inferProviderForThreadModel } from "../lib/threadProvider"; +import { resolveDraftThreadDefaults } from "../lib/threadDraftDefaults"; import { type ComposerSlashCommand, type ComposerTrigger, @@ -86,6 +86,7 @@ import { type PendingUserInput, PROVIDER_OPTIONS, deriveWorkLogEntries, + hasToolActivityForTurn, hasToolActivitySince, isLatestTurnSettled, formatElapsed, @@ -101,6 +102,7 @@ import { } from "../pendingUserInput"; import { useStore } from "../store"; import { + buildCollapsedProposedPlanPreviewMarkdown, buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, @@ -108,6 +110,7 @@ import { normalizePlanMarkdownForExport, proposedPlanTitle, resolvePlanFollowUpSubmission, + stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { @@ -166,9 +169,9 @@ import { Undo2Icon, WrenchIcon, XIcon, + ZapIcon, CopyIcon, CheckIcon, - ZapIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -208,7 +211,7 @@ import { WindsurfIcon, Zed, } from "./Icons"; -import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; +import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Command, CommandItem, CommandList } from "./ui/command"; @@ -235,14 +238,7 @@ import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { - getAppModelOptions, - resolveAppModelSelection, - resolveAppServiceTier, - shouldShowFastTierIcon, - type AppServiceTier, - useAppSettings, -} from "../appSettings"; +import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -251,9 +247,11 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; +import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { estimateTimelineMessageHeight } from "./timelineHeight"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -461,7 +459,10 @@ function buildExpandedImagePreview( function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, - fallbackModel: string, + defaults: { + readonly provider: ProviderKind; + readonly model: string; + }, error: string | null, ): Thread { return { @@ -469,11 +470,8 @@ function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", - provider: inferProviderForThreadModel({ - model: fallbackModel, - sessionProviderName: null, - }), - model: fallbackModel, + provider: defaults.provider, + model: defaults.model, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, session: null, @@ -545,11 +543,15 @@ type ComposerCommandItem = model: ModelSlug; label: string; description: string; - showFastBadge: boolean; }; type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; +interface PullRequestDialogState { + initialReference: string | null; + key: number; +} + function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -569,7 +571,7 @@ function readFileAsDataUrl(file: File): Promise { function buildTemporaryWorktreeBranchName(): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. - const token = crypto.randomUUID().slice(0, 8).toLowerCase(); + const token = randomUUID().slice(0, 8).toLowerCase(); return `${WORKTREE_BRANCH_PREFIX}/${token}`; } @@ -656,9 +658,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { ) : null} - {props.item.type === "model" && props.item.showFastBadge ? ( - - ) : null} {props.item.label} {props.item.description} @@ -731,7 +730,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); - const prompt = composerDraft.prompt; + const persistedPrompt = composerDraft.prompt; const composerImages = composerDraft.images; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -758,18 +757,23 @@ export default function ChatView({ threadId }: ChatViewProps) { const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const getDraftThreadByProjectId = useComposerDraftStore( + (store) => store.getDraftThreadByProjectId, + ); + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const clearProjectDraftThreadId = useComposerDraftStore( + (store) => store.clearProjectDraftThreadId, + ); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); + const [prompt, setPromptState] = useState(() => persistedPrompt); + const [hasPromptText, setHasPromptText] = useState(() => persistedPrompt.trim().length > 0); const promptRef = useRef(prompt); + const promptSyncPendingRef = useRef(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); - const [planSidebarOpen, setPlanSidebarOpen] = useState(false); - // Tracks whether the user explicitly dismissed the sidebar for the active turn. - const planSidebarDismissedForTurnRef = useRef(null); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; @@ -790,13 +794,23 @@ export default function ChatView({ threadId }: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); + // Tracks whether the user explicitly dismissed the sidebar for the active turn. + const planSidebarDismissedForTurnRef = useRef(null); + // When set, the thread-change reset effect will open the sidebar instead of closing it. + // Used by "Implement in new thread" to carry the sidebar-open intent across navigation. + const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [pullRequestDialogState, setPullRequestDialogState] = + useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); const [composerCursor, setComposerCursor] = useState(() => prompt.length); + const composerCursorRef = useRef(prompt.length); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); @@ -847,9 +861,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const setPrompt = useCallback( (nextPrompt: string) => { - setComposerDraftPrompt(threadId, nextPrompt); + promptRef.current = nextPrompt; + promptSyncPendingRef.current = true; + setPromptState(nextPrompt); + setHasPromptText(nextPrompt.trim().length > 0); }, - [setComposerDraftPrompt, threadId], + [], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { @@ -872,6 +889,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); + const draftThreadDefaults = useMemo( + () => + resolveDraftThreadDefaults({ + threads, + projectId: draftThread?.projectId, + fallbackModel: fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, + }), + [draftThread?.projectId, fallbackDraftProject?.model, threads], + ); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -879,11 +905,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, + draftThreadDefaults, localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.model, localDraftError, threadId], + [draftThread, draftThreadDefaults, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = @@ -892,12 +918,93 @@ export default function ChatView({ threadId }: ChatViewProps) { composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; + const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); + const openPullRequestDialog = useCallback( + (reference?: string) => { + if (!canCheckoutPullRequestIntoThread) { + return; + } + setPullRequestDialogState({ + initialReference: reference ?? null, + key: Date.now(), + }); + setComposerHighlightedItemId(null); + }, + [canCheckoutPullRequestIntoThread], + ); + + const closePullRequestDialog = useCallback(() => { + setPullRequestDialogState(null); + }, []); + + const openOrReuseProjectDraftThread = useCallback( + async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { + if (!activeProject) { + throw new Error("No active project is available for this pull request."); + } + const storedDraftThread = getDraftThreadByProjectId(activeProject.id); + if (storedDraftThread) { + setDraftThreadContext(storedDraftThread.threadId, input); + setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); + if (storedDraftThread.threadId !== threadId) { + await navigate({ + to: "/$threadId", + params: { threadId: storedDraftThread.threadId }, + }); + } + return; + } + + const activeDraftThread = getDraftThread(threadId); + if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { + setDraftThreadContext(threadId, input); + setProjectDraftThreadId(activeProject.id, threadId, input); + return; + } + + clearProjectDraftThreadId(activeProject.id); + const nextThreadId = newThreadId(); + setProjectDraftThreadId(activeProject.id, nextThreadId, { + createdAt: new Date().toISOString(), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + ...input, + }); + await navigate({ + to: "/$threadId", + params: { threadId: nextThreadId }, + }); + }, + [ + activeProject, + clearProjectDraftThreadId, + getDraftThread, + getDraftThreadByProjectId, + isServerThread, + navigate, + setDraftThreadContext, + setProjectDraftThreadId, + threadId, + ], + ); + + const handlePreparedPullRequestThread = useCallback( + async (input: { branch: string; worktreePath: string | null }) => { + await openOrReuseProjectDraftThread({ + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.worktreePath ? "worktree" : "local", + }); + }, + [openOrReuseProjectDraftThread], + ); + useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; @@ -924,12 +1031,11 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread.messages.length > 0 || activeThread.session !== null), ); - const selectedServiceTierSetting = settings.codexServiceTier; - const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = + lockedProvider ?? selectedProviderByThreadId ?? activeThread?.provider ?? "codex"; const assistantDeliveryMode = settings.enableAssistantStreaming || selectedProvider === "cursor" ? "streaming" @@ -1096,6 +1202,7 @@ export default function ChatView({ threadId }: ChatViewProps) { sendStartedAt, ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const activeLatestTurnId = activeLatestTurn?.turnId; const latestUserMessageCreatedAt = useMemo( () => [...(activeThread?.messages ?? [])].toReversed().find((message) => message.role === "user") @@ -1103,12 +1210,20 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread?.messages], ); const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, undefined, latestUserMessageCreatedAt), - [latestUserMessageCreatedAt, threadActivities], + () => + deriveWorkLogEntries( + threadActivities, + activeLatestTurnId ?? undefined, + activeLatestTurnId ? undefined : latestUserMessageCreatedAt, + ), + [activeLatestTurnId, latestUserMessageCreatedAt, threadActivities], ); const latestTurnHasToolActivity = useMemo( - () => hasToolActivitySince(threadActivities, latestUserMessageCreatedAt), - [latestUserMessageCreatedAt, threadActivities], + () => + activeLatestTurnId + ? hasToolActivityForTurn(threadActivities, activeLatestTurnId) + : hasToolActivitySince(threadActivities, latestUserMessageCreatedAt), + [activeLatestTurnId, latestUserMessageCreatedAt, threadActivities], ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), @@ -1175,6 +1290,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); + const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; useEffect(() => { if (!activePendingProgress) { return; @@ -1517,10 +1633,8 @@ export default function ChatView({ threadId }: ChatViewProps) { model: slug, label: pricingTier ? `${name} ${formatPricingTier(pricingTier)}` : name, description: `${providerLabel} · ${slug}`, - showFastBadge: - provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting), })); - }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]); + }, [composerTrigger, searchableModelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -1639,13 +1753,13 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; - const terminalId = `terminal-${crypto.randomUUID()}`; + const terminalId = `terminal-${randomUUID()}`; storeSplitTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]); const createNewTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; - const terminalId = `terminal-${crypto.randomUUID()}`; + const terminalId = `terminal-${randomUUID()}`; storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]); @@ -1714,7 +1828,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const shouldCreateNewTerminal = wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; const targetTerminalId = shouldCreateNewTerminal - ? `terminal-${crypto.randomUUID()}` + ? `terminal-${randomUUID()}` : baseTerminalId; setTerminalOpen(true); @@ -1953,6 +2067,24 @@ export default function ChatView({ threadId }: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); + const toggleRuntimeMode = useCallback(() => { + void handleRuntimeModeChange( + runtimeMode === "full-access" ? "approval-required" : "full-access", + ); + }, [handleRuntimeModeChange, runtimeMode]); + const togglePlanSidebar = useCallback(() => { + setPlanSidebarOpen((open) => { + if (open) { + const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + if (turnKey) { + planSidebarDismissedForTurnRef.current = turnKey; + } + } else { + planSidebarDismissedForTurnRef.current = null; + } + return !open; + }); + }, [activePlan?.turnId, activeProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2054,6 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { "button, summary, [role='button'], [data-scroll-anchor-target]", ); if (!trigger || !scrollContainer.contains(trigger)) return; + if (trigger.closest("[data-scroll-anchor-ignore]")) return; pendingInteractionAnchorRef.current = { element: trigger, @@ -2168,14 +2301,25 @@ export default function ChatView({ threadId }: ChatViewProps) { useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; + const measureComposerFormWidth = () => composerForm.clientWidth; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; + setIsComposerFooterCompact( + shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }), + ); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; + const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }); + setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); + const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; @@ -2189,7 +2333,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { observer.disconnect(); }; - }, [activeThread?.id, scheduleStickToBottom]); + }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -2200,6 +2344,18 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleStickToBottom(); }, [phase, scheduleStickToBottom, timelineEntries]); + useEffect(() => { + setExpandedWorkGroups({}); + setPullRequestDialogState(null); + if (planSidebarOpenOnNextThreadRef.current) { + planSidebarOpenOnNextThreadRef.current = false; + setPlanSidebarOpen(true); + } else { + setPlanSidebarOpen(false); + } + planSidebarDismissedForTurnRef.current = null; + }, [activeThread?.id]); + useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); @@ -2268,9 +2424,40 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { promptRef.current = prompt; + setHasPromptText(prompt.trim().length > 0); setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); }, [prompt]); + useEffect(() => { + composerCursorRef.current = composerCursor; + }, [composerCursor]); + + useEffect(() => { + // Always reset pending sync when the thread changes so stale debounce + // timeouts from the previous thread cannot overwrite the new thread's draft. + promptSyncPendingRef.current = false; + + if (persistedPrompt === promptRef.current) { + return; + } + promptRef.current = persistedPrompt; + setPromptState(persistedPrompt); + setHasPromptText(persistedPrompt.trim().length > 0); + }, [persistedPrompt, threadId]); + + useEffect(() => { + if (prompt === persistedPrompt) { + promptSyncPendingRef.current = false; + return; + } + const timeout = window.setTimeout(() => { + setComposerDraftPrompt(threadId, prompt); + }, 180); + return () => { + window.clearTimeout(timeout); + }; + }, [persistedPrompt, prompt, setComposerDraftPrompt, threadId]); + useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -2580,7 +2767,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const previewUrl = URL.createObjectURL(file); nextImages.push({ type: "image", - id: crypto.randomUUID(), + id: randomUUID(), name: file.name || "image", mimeType: file.type, sizeBytes: file.size, @@ -2705,13 +2892,15 @@ export default function ChatView({ threadId }: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const trimmed = prompt.trim(); + const trimmed = promptRef.current.trim(); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; + promptSyncPendingRef.current = false; + setPromptState(""); clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2727,6 +2916,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; + promptSyncPendingRef.current = false; + setPromptState(""); clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2794,6 +2985,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError(threadIdForSend, null); promptRef.current = ""; + promptSyncPendingRef.current = false; + setPromptState(""); clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2924,13 +3117,10 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, model: selectedModel || undefined, - serviceTier: selectedServiceTier, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), provider: selectedProvider, assistantDeliveryMode, runtimeMode, @@ -3084,6 +3274,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }, })); promptRef.current = ""; + promptSyncPendingRef.current = false; + setPromptState(""); setComposerCursor(0); setComposerTrigger(null); }, @@ -3212,7 +3404,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, - serviceTier: selectedServiceTier, + ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), @@ -3258,7 +3450,6 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelOptionsForDispatch, providerOptionsForDispatch, selectedProvider, - selectedServiceTier, setComposerDraftInteractionMode, setThreadError, assistantDeliveryMode, @@ -3327,7 +3518,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, - serviceTier: selectedServiceTier, + ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), @@ -3400,7 +3591,6 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelOptionsForDispatch, providerOptionsForDispatch, selectedProvider, - selectedServiceTier, assistantDeliveryMode, syncServerReadModel, ]); @@ -3557,8 +3747,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (editorSnapshot) { return editorSnapshot; } - return { value: promptRef.current, cursor: composerCursor }; - }, [composerCursor]); + return { value: promptRef.current, cursor: composerCursorRef.current }; + }, []); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number }; @@ -3667,8 +3857,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } promptRef.current = nextPrompt; - setPrompt(nextPrompt); - setComposerCursor(nextCursor); + promptSyncPendingRef.current = true; + setPromptState((current) => (current === nextPrompt ? current : nextPrompt)); + composerCursorRef.current = nextCursor; + setHasPromptText((current) => { + const next = nextPrompt.trim().length > 0; + return current === next ? current : next; + }); setComposerTrigger( cursorAdjacentToMention ? null @@ -3682,7 +3877,6 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, onChangeActivePendingUserInputCustomAnswer, - setPrompt, ], ); @@ -3723,6 +3917,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } return false; }; + const onToggleWorkGroup = useCallback((groupId: string) => { + setExpandedWorkGroups((existing) => ({ + ...existing, + [groupId]: !existing[groupId], + })); + }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -3846,284 +4046,255 @@ export default function ChatView({ threadId }: ChatViewProps) { onDismiss={() => setThreadError(activeThread.id, null)} /> {/* Main content area with optional plan sidebar */} -
+
{/* Chat column */}
- - {/* Messages */} -
- 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - workspaceRoot={activeProject?.cwd ?? undefined} - /> -
- - {/* Input bar */} -
-
+ {/* Messages */}
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} + 0} + isWorking={isWorking} + activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnStartedAt={activeWorkStartedAt} + scrollContainer={messagesScrollElement} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + nowIso={nowIso} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onExpandTimelineImage} + markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} + workspaceRoot={activeProject?.cwd ?? undefined} + /> +
- {/* Textarea area */} -
+ - {composerMenuOpen && !isComposerApprovalState && ( -
- -
- )} - - {!isComposerApprovalState && pendingUserInputs.length === 0 && composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} - -
+
+ {activePendingApproval ? ( +
+ +
+ ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} - {/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : ( -
-
- {/* Provider/model picker */} - {cursorModelSelectionLockedReason ? ( - - - - - } + {/* Textarea area */} +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+ - - {cursorModelSelectionLockedReason} - - - ) : ( - +
)} - {selectedProvider === "cursor" ? ( - <> - {hasSelectedCursorTraits && ( - - )} - - {selectedCursorModel && - selectedCursorModelCapabilities && - hasSelectedCursorTraits && ( - <> - {cursorModelSelectionLockedReason ? ( + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.length > 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( - + + } /> - {cursorModelSelectionLockedReason} + Draft attachment could not be saved locally and may be lost on + navigation. - ) : ( + )} + +
+ ))} +
+ )} + +
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : ( +
+
+ {/* Provider/model picker */} + + + {isComposerFooterCompact ? ( + <> + {selectedProvider === "cursor" && + hasSelectedCursorTraits && + selectedCursorModel && + selectedCursorModelCapabilities && ( )} - - )} - - ) : selectedProvider === "codex" && selectedEffort != null ? ( - <> - - - - ) : selectedProvider === "claudeCode" && supportsClaudeCodeEffort && selectedClaudeCodeEffort != null ? ( - <> - - - - ) : null} - - {/* Divider */} - - - {/* Interaction mode toggle */} - - - {/* Divider */} - - - {/* Runtime mode toggle */} - - - {/* Plan sidebar toggle */} - {(activePlan || activeProposedPlan || planSidebarOpen) ? ( - <> - - - - ) : null} -
+ > + + + {interactionMode === "plan" ? "Plan" : "Chat"} + + - {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - Preparing worktree... - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} - + + + + + {activePlan || activeProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )}
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( - - ) : ( -
+ {isPreparingWorktree ? ( + + Preparing worktree... + + ) : null} + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} - - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in new thread - - -
- ) - ) : ( - + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( + + ) : ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in new thread + + + +
+ ) ) : ( - - )} - - ) - ) : null} -
+ {isConnecting || isSendBusy ? ( + + ) : ( + + )} + + ) + ) : null} +
+
+ )}
- )} +
- -
-
{/* end chat column */} + {isGitRepo && ( + + )} + {pullRequestDialogState ? ( + { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} + /> + ) : null} +
+ {/* end chat column */} {/* Plan sidebar */} {planSidebarOpen ? ( @@ -4426,16 +4671,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }} /> ) : null} -
{/* end horizontal flex container */} - - {isGitRepo && ( - - )} +
+ {/* end horizontal flex container */} {(() => { if (!terminalState.terminalOpen || !activeProject) { @@ -4595,7 +4832,7 @@ const ChatHeader = memo(function ChatHeader({ {activeThreadTitle} {activeProjectName && ( - + {activeProjectName} )} @@ -4885,10 +5122,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement - ) { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } // If the user has started typing a custom answer in the contenteditable @@ -4963,12 +5197,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}
- {isSelected ? ( - - ) : null} + {isSelected ? : null} ); })} @@ -5179,6 +5413,10 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; const lineCount = planMarkdown.split("\n").length; const canCollapse = planMarkdown.length > 900 || lineCount > 20; + const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); + const collapsedPreview = canCollapse + ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) + : null; const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); const saveContents = normalizePlanMarkdownForExport(planMarkdown); @@ -5268,14 +5506,23 @@ const ProposedPlanCard = memo(function ProposedPlanCard({
- + {canCollapse && !expanded ? ( + + ) : ( + + )} {canCollapse && !expanded ? (
) : null}
{canCollapse ? (
-
@@ -5344,6 +5591,8 @@ interface MessagesTimelineProps { completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; + expandedWorkGroups: Record; + onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; @@ -5396,6 +5645,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, + expandedWorkGroups, + onToggleWorkGroup, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, @@ -6128,7 +6379,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; - serviceTierSetting: AppServiceTier; + compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { @@ -6155,12 +6406,17 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { + + + + + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts new file mode 100644 index 000000000000..634d91e9dcfb --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { + hasUnseenCompletion, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; + +function makeLatestTurn(overrides?: { + completedAt?: string | null; + startedAt?: string | null; +}): Parameters[0]["latestTurn"] { + return { + turnId: "turn-1" as never, + state: "completed", + assistantMessageId: null, + requestedAt: "2026-03-09T10:00:00.000Z", + startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", + completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", + }; +} + +describe("hasUnseenCompletion", () => { + it("returns true when a thread completed after its last visit", () => { + expect( + hasUnseenCompletion({ + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + proposedPlans: [], + session: null, + }), + ).toBe(true); + }); +}); + +describe("shouldClearThreadSelectionOnMouseDown", () => { + it("preserves selection for thread items", () => { + const child = { + closest: (selector: string) => + selector.includes("[data-thread-item]") ? ({} as Element) : null, + } as unknown as HTMLElement; + + expect(shouldClearThreadSelectionOnMouseDown(child)).toBe(false); + }); + + it("preserves selection for thread list toggle controls", () => { + const selectionSafe = { + closest: (selector: string) => + selector.includes("[data-thread-selection-safe]") ? ({} as Element) : null, + } as unknown as HTMLElement; + + expect(shouldClearThreadSelectionOnMouseDown(selectionSafe)).toBe(false); + }); + + it("clears selection for unrelated sidebar clicks", () => { + const unrelated = { + closest: () => null, + } as unknown as HTMLElement; + + expect(shouldClearThreadSelectionOnMouseDown(unrelated)).toBe(true); + }); +}); + +describe("resolveThreadStatusPill", () => { + const baseThread = { + interactionMode: "plan" as const, + latestTurn: null, + lastVisitedAt: undefined, + proposedPlans: [], + session: { + provider: "codex" as const, + status: "running" as const, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + orchestrationStatus: "running" as const, + }, + }; + + it("shows pending approval before all other statuses", () => { + expect( + resolveThreadStatusPill({ + thread: baseThread, + hasPendingApprovals: true, + hasPendingUserInput: true, + }), + ).toMatchObject({ label: "Pending Approval", pulse: false }); + }); + + it("shows awaiting input when plan mode is blocked on user answers", () => { + expect( + resolveThreadStatusPill({ + thread: baseThread, + hasPendingApprovals: false, + hasPendingUserInput: true, + }), + ).toMatchObject({ label: "Awaiting Input", pulse: false }); + }); + + it("falls back to working when the thread is actively running without blockers", () => { + expect( + resolveThreadStatusPill({ + thread: baseThread, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Working", pulse: true }); + }); + + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + latestTurn: makeLatestTurn(), + proposedPlans: [ + { + id: "plan-1" as never, + turnId: "turn-1" as never, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + planMarkdown: "# Plan", + }, + ], + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "ready", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Plan Ready", pulse: false }); + }); + + it("shows completed when there is an unseen completion and no active blocker", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "ready", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Completed", pulse: false }); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts new file mode 100644 index 000000000000..f1afa0f6462c --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.ts @@ -0,0 +1,107 @@ +import type { Thread } from "../types"; +import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; + +export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; + +export interface ThreadStatusPill { + label: + | "Working" + | "Connecting" + | "Completed" + | "Pending Approval" + | "Awaiting Input" + | "Plan Ready"; + colorClass: string; + dotClass: string; + pulse: boolean; +} + +type ThreadStatusInput = Pick< + Thread, + "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" +>; + +export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { + if (!thread.latestTurn?.completedAt) return false; + const completedAt = Date.parse(thread.latestTurn.completedAt); + if (Number.isNaN(completedAt)) return false; + if (!thread.lastVisitedAt) return true; + + const lastVisitedAt = Date.parse(thread.lastVisitedAt); + if (Number.isNaN(lastVisitedAt)) return true; + return completedAt > lastVisitedAt; +} + +export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { + if (target === null) return true; + return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); +} + +export function resolveThreadStatusPill(input: { + thread: ThreadStatusInput; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; +}): ThreadStatusPill | null { + const { hasPendingApprovals, hasPendingUserInput, thread } = input; + + if (hasPendingApprovals) { + return { + label: "Pending Approval", + colorClass: "text-amber-600 dark:text-amber-300/90", + dotClass: "bg-amber-500 dark:bg-amber-300/90", + pulse: false, + }; + } + + if (hasPendingUserInput) { + return { + label: "Awaiting Input", + colorClass: "text-indigo-600 dark:text-indigo-300/90", + dotClass: "bg-indigo-500 dark:bg-indigo-300/90", + pulse: false, + }; + } + + if (thread.session?.status === "running") { + return { + label: "Working", + colorClass: "text-sky-600 dark:text-sky-300/80", + dotClass: "bg-sky-500 dark:bg-sky-300/80", + pulse: true, + }; + } + + if (thread.session?.status === "connecting") { + return { + label: "Connecting", + colorClass: "text-sky-600 dark:text-sky-300/80", + dotClass: "bg-sky-500 dark:bg-sky-300/80", + pulse: true, + }; + } + + const hasPlanReadyPrompt = + !hasPendingUserInput && + thread.interactionMode === "plan" && + isLatestTurnSettled(thread.latestTurn, thread.session) && + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null; + if (hasPlanReadyPrompt) { + return { + label: "Plan Ready", + colorClass: "text-violet-600 dark:text-violet-300/90", + dotClass: "bg-violet-500 dark:bg-violet-300/90", + pulse: false, + }; + } + + if (hasUnseenCompletion(thread)) { + return { + label: "Completed", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + dotClass: "bg-emerald-500 dark:bg-emerald-300/90", + pulse: false, + }; + } + + return null; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9791fda3027d..b79563637dac 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,15 +5,31 @@ import { GitPullRequestIcon, PlusIcon, RocketIcon, - SearchIcon, SettingsIcon, + SearchIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react"; +import { + DndContext, + type DragCancelEvent, + type CollisionDetection, + PointerSensor, + type DragStartEvent, + closestCorners, + pointerWithin, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; import { + DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, type ProviderKind, @@ -30,17 +46,17 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { resolveThreadProvider } from "../lib/threadProvider"; -import { newCommandId, newProjectId } from "../lib/utils"; +import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; import { useProjectThreadNavigation } from "../hooks/useProjectThreadNavigation"; import { type Thread } from "../types"; -import { derivePendingApprovals } from "../session-logic"; +import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { providerGetUsageQueryOptions } from "../lib/providerReactQuery"; import { readNativeApi } from "../nativeApi"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -56,7 +72,7 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; +import { Collapsible, CollapsibleContent } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -74,30 +90,11 @@ import { SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { - type Icon, - OpenAI, - GitHubIcon, - ClaudeAI, - CursorIcon, - OpenCodeIcon, - Gemini, - AmpIcon, - KiloIcon, -} from "./Icons"; - -const PROVIDER_ICON_BY_PROVIDER: Record = { - codex: OpenAI, - copilot: GitHubIcon, - claudeCode: ClaudeAI, - cursor: CursorIcon, - opencode: OpenCodeIcon, - geminiCli: Gemini, - amp: AmpIcon, - kilo: KiloIcon, -}; +import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { ProviderLogo } from "./ProviderLogo"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -128,13 +125,6 @@ function formatRelativeTime(iso: string): string { return `${Math.floor(hours / 24)}d ago`; } -interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Pending Approval"; - colorClass: string; - dotClass: string; - pulse: boolean; -} - interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -150,57 +140,6 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; -function hasUnseenCompletion(thread: Thread): boolean { - if (!thread.latestTurn?.completedAt) return false; - const completedAt = Date.parse(thread.latestTurn.completedAt); - if (Number.isNaN(completedAt)) return false; - if (!thread.lastVisitedAt) return true; - - const lastVisitedAt = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(lastVisitedAt)) return true; - return completedAt > lastVisitedAt; -} - -function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { - if (hasPendingApprovals) { - return { - label: "Pending Approval", - colorClass: "text-amber-600 dark:text-amber-300/90", - dotClass: "bg-amber-500 dark:bg-amber-300/90", - pulse: false, - }; - } - - if (thread.session?.status === "running") { - return { - label: "Working", - colorClass: "text-sky-600 dark:text-sky-300/80", - dotClass: "bg-sky-500 dark:bg-sky-300/80", - pulse: true, - }; - } - - if (thread.session?.status === "connecting") { - return { - label: "Connecting", - colorClass: "text-sky-600 dark:text-sky-300/80", - dotClass: "bg-sky-500 dark:bg-sky-300/80", - pulse: true, - }; - } - - if (hasUnseenCompletion(thread)) { - return { - label: "Completed", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - dotClass: "bg-emerald-500 dark:bg-emerald-300/90", - pulse: false, - }; - } - - return null; -} - function terminalStatusFromRunningIds( runningTerminalIds: string[], ): TerminalStatusIndicator | null { @@ -362,7 +301,9 @@ function formatUsageResetLabel(resetDate: string): string { function formatUsagePercentLabel(quota: ProviderUsageQuota, percentUsed: number): string { if (quota.percentUsed != null) { - return `${String(quota.percentUsed)}%`; + return `${new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }).format(quota.percentUsed)}%`; } return `${Math.round(percentUsed)}%`; } @@ -722,14 +663,44 @@ function ProviderUsageSection() { {!collapsed &&
{entries}
}
); + +} + +type SortableProjectHandleProps = Pick, "attributes" | "listeners">; + +function SortableProjectItem({ + projectId, + children, +}: { + projectId: ProjectId; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = + useSortable({ id: projectId }); + return ( +
  • + {children({ attributes, listeners })} +
  • + ); } export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); - const moveProject = useStore((store) => store.moveProject); const toggleProject = useStore((store) => store.toggleProject); + const reorderProjects = useStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, @@ -737,6 +708,8 @@ export default function Sidebar() { const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -750,8 +723,6 @@ export default function Sidebar() { strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const { openOrCreateThread: handleNewThread, openProject: focusMostRecentThreadForProject } = - useProjectThreadNavigation(routeThreadId); const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(), select: (config) => config.keybindings, @@ -766,19 +737,23 @@ export default function Sidebar() { const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); + const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const [threadSearchQuery, setThreadSearchQuery] = useState(""); - const [draggingProjectId, setDraggingProjectId] = useState(null); - const draggingProjectIdRef = useRef(null); - const [projectDropTarget, setProjectDropTarget] = useState<{ - projectId: ProjectId; - position: "before" | "after"; - } | null>(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const shouldBrowseForProjectImmediately = isElectron; + const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -786,6 +761,13 @@ export default function Sidebar() { } return map; }, [threads]); + const pendingUserInputByThreadId = useMemo(() => { + const map = new Map(); + for (const thread of threads) { + map.set(thread.id, derivePendingUserInputs(thread.activities).length > 0); + } + return map; + }, [threads]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -860,59 +842,99 @@ export default function Sidebar() { }); }, []); - const getProjectDropPosition = useCallback( - (event: DragEvent): "before" | "after" => { - const bounds = event.currentTarget.getBoundingClientRect(); - return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after"; - }, - [], - ); - - const handleProjectDragStart = useCallback((event: DragEvent, projectId: ProjectId) => { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", String(projectId)); - draggingProjectIdRef.current = projectId; - setDraggingProjectId(projectId); - setProjectDropTarget({ projectId, position: "after" }); - }, []); + const handleNewThread = useCallback( + ( + projectId: ProjectId, + options?: { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + }, + ): Promise => { + const hasBranchOption = options?.branch !== undefined; + const hasWorktreePathOption = options?.worktreePath !== undefined; + const hasEnvModeOption = options?.envMode !== undefined; + const storedDraftThread = getDraftThreadByProjectId(projectId); + if (storedDraftThread) { + return (async () => { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(storedDraftThread.threadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, storedDraftThread.threadId); + if (routeThreadId === storedDraftThread.threadId) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: storedDraftThread.threadId }, + }); + })(); + } + clearProjectDraftThreadId(projectId); - const handleProjectDragOver = useCallback( - (event: DragEvent, projectId: ProjectId) => { - if (!draggingProjectIdRef.current) { - return; + const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; + if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(routeThreadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, routeThreadId); + return Promise.resolve(); } - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - setProjectDropTarget({ - projectId, - position: getProjectDropPosition(event), - }); + const threadId = newThreadId(); + const createdAt = new Date().toISOString(); + return (async () => { + setProjectDraftThreadId(projectId, threadId, { + createdAt, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + envMode: options?.envMode ?? "local", + runtimeMode: DEFAULT_RUNTIME_MODE, + }); + + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + })(); }, - [getProjectDropPosition], + [ + clearProjectDraftThreadId, + getDraftThreadByProjectId, + navigate, + getDraftThread, + routeThreadId, + setDraftThreadContext, + setProjectDraftThreadId, + ], ); - const handleProjectDrop = useCallback( - (event: DragEvent, targetProjectId: ProjectId) => { - const currentDraggingId = draggingProjectIdRef.current; - if (!currentDraggingId) { - return; - } - event.preventDefault(); - const position = getProjectDropPosition(event); - moveProject(currentDraggingId, targetProjectId, position); - draggingProjectIdRef.current = null; - setDraggingProjectId(null); - setProjectDropTarget(null); + const focusMostRecentThreadForProject = useCallback( + (projectId: ProjectId) => { + const latestThread = threads + .filter((thread) => thread.projectId === projectId) + .toSorted((a, b) => { + const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (byDate !== 0) return byDate; + return b.id.localeCompare(a.id); + })[0]; + if (!latestThread) return; + + void navigate({ + to: "/$threadId", + params: { threadId: latestThread.id }, + }); }, - [getProjectDropPosition, moveProject], + [navigate, threads], ); - const clearProjectDragState = useCallback(() => { - draggingProjectIdRef.current = null; - setDraggingProjectId(null); - setProjectDropTarget(null); - }, []); - const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); @@ -930,7 +952,7 @@ export default function Sidebar() { const existing = projects.find((project) => project.cwd === cwd); if (existing) { - await focusMostRecentThreadForProject(existing.id); + focusMostRecentThreadForProject(existing.id); finishAddingProject(); return; } @@ -950,21 +972,37 @@ export default function Sidebar() { }); await handleNewThread(projectId).catch(() => undefined); } catch (error) { + const description = + error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); - setAddProjectError( - error instanceof Error ? error.message : "An error occurred while adding the project.", - ); + if (shouldBrowseForProjectImmediately) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description, + }); + } else { + setAddProjectError(description); + } return; } finishAddingProject(); }, - [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], + [ + focusMostRecentThreadForProject, + handleNewThread, + isAddingProject, + projects, + shouldBrowseForProjectImmediately, + ], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; + const canAddProject = newCwd.trim().length > 0 && !isAddingProject; + const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; @@ -977,12 +1015,21 @@ export default function Sidebar() { } if (pickedPath) { await addProjectFromPath(pickedPath); - } else { + } else if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); }; + const handleStartAddProject = () => { + setAddProjectError(null); + if (shouldBrowseForProjectImmediately) { + void handlePickFolder(); + return; + } + setAddingProject((prev) => !prev); + }; + const cancelRename = useCallback(() => { setRenamingThreadId(null); renamingInputRef.current = null; @@ -1032,68 +1079,45 @@ export default function Sidebar() { [], ); - const handleThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { + /** + * Delete a single thread: stop session, close terminal, dispatch delete, + * clean up drafts/state, and optionally remove orphaned worktree. + * Callers handle thread-level confirmation; this still prompts for worktree removal. + */ + const deleteThread = useCallback( + async ( + threadId: ThreadId, + opts: { + deletedThreadIds?: ReadonlySet; + processedWorktreePaths?: Set; + } = {}, + ): Promise => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); const thread = threads.find((t) => t.id === threadId); if (!thread) return; - if (clicked === "rename") { - setRenamingThreadId(threadId); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; - return; - } - - if (clicked === "mark-unread") { - markThreadUnread(threadId); - return; - } - if (clicked === "copy-thread-id") { - try { - await copyTextToClipboard(threadId); - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: threadId, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - return; - } - if (clicked !== "delete") return; - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) { - return; - } - } const threadProject = projects.find((project) => project.id === thread.projectId); - const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId); + // When bulk-deleting, exclude the other threads being deleted so + // getOrphanedWorktreePathForThread correctly detects that no surviving + // threads will reference this worktree. + const deletedIds = opts.deletedThreadIds; + const survivingThreads = + deletedIds && deletedIds.size > 0 + ? threads.filter((t) => t.id === threadId || !deletedIds.has(t.id)) + : threads; + const processedWorktrees = opts.processedWorktreePaths; + const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const alreadyProcessed = + orphanedWorktreePath !== null && processedWorktrees?.has(orphanedWorktreePath) === true; + if (orphanedWorktreePath !== null && processedWorktrees) { + processedWorktrees.add(orphanedWorktreePath); + } + const canDeleteWorktree = + orphanedWorktreePath !== null && threadProject !== undefined && !alreadyProcessed; const shouldDeleteWorktree = canDeleteWorktree && (await api.dialogs.confirm( @@ -1117,16 +1141,15 @@ export default function Sidebar() { } try { - await api.terminal.close({ - threadId, - deleteHistory: true, - }); + await api.terminal.close({ threadId, deleteHistory: true }); } catch { // Terminal may already be closed } + const allDeletedIds = deletedIds ?? new Set(); const shouldNavigateToFallback = routeThreadId === threadId; - const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null; + const fallbackThreadId = + threads.find((entry) => entry.id !== threadId && !allDeletedIds.has(entry.id))?.id ?? null; await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), @@ -1173,11 +1196,9 @@ export default function Sidebar() { } }, [ - appSettings.confirmThreadDelete, clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState, - markThreadUnread, navigate, projects, removeWorktreeMutation, @@ -1186,6 +1207,158 @@ export default function Sidebar() { ], ); + const handleThreadContextMenu = useCallback( + async (threadId: ThreadId, position: { x: number; y: number }) => { + const api = readNativeApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + const thread = threads.find((t) => t.id === threadId); + if (!thread) return; + + if (clicked === "rename") { + setRenamingThreadId(threadId); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadId); + return; + } + if (clicked === "copy-thread-id") { + try { + await copyTextToClipboard(threadId); + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: threadId, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } + if (clicked !== "delete") return; + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + await deleteThread(threadId); + }, + [appSettings.confirmThreadDelete, deleteThread, markThreadUnread, threads], + ); + + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readNativeApi(); + if (!api) return; + const ids = [...selectedThreadIds]; + if (ids.length === 0) return; + const count = ids.length; + + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ); + + if (clicked === "mark-unread") { + for (const id of ids) { + markThreadUnread(id); + } + clearSelection(); + return; + } + + if (clicked !== "delete") return; + + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedIds = new Set(ids); + const processedWorktreePaths = new Set(); + for (const id of ids) { + await deleteThread(id, { deletedThreadIds: deletedIds, processedWorktreePaths }); + } + removeFromSelection(ids); + }, + [ + appSettings.confirmThreadDelete, + clearSelection, + deleteThread, + markThreadUnread, + removeFromSelection, + selectedThreadIds, + ], + ); + + const handleThreadClick = useCallback( + (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadId); + return; + } + + if (isShiftClick) { + event.preventDefault(); + rangeSelectTo(threadId, orderedProjectThreadIds); + return; + } + + // Plain click — clear selection, set anchor for future shift-clicks, and navigate + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(threadId); + void navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [ + clearSelection, + navigate, + rangeSelectTo, + selectedThreadIds.size, + setSelectionAnchor, + toggleThreadSelection, + ], + ); + const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -1244,8 +1417,88 @@ export default function Sidebar() { ], ); + const projectDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const projectCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + + return closestCorners(args); + }, []); + + const handleProjectDragEnd = useCallback( + (event: DragEndEvent) => { + dragInProgressRef.current = false; + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeProject = projects.find((project) => project.id === active.id); + const overProject = projects.find((project) => project.id === over.id); + if (!activeProject || !overProject) return; + reorderProjects(activeProject.id, overProject.id); + }, + [projects, reorderProjects], + ); + + const handleProjectDragStart = useCallback((_event: DragStartEvent) => { + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, []); + + const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { + dragInProgressRef.current = false; + }, []); + + const handleProjectTitlePointerDownCapture = useCallback(() => { + suppressProjectClickAfterDragRef.current = false; + }, []); + + const handleProjectTitleClick = useCallback( + (event: React.MouseEvent, projectId: ProjectId) => { + if (dragInProgressRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (suppressProjectClickAfterDragRef.current) { + // Consume the synthetic click emitted after a drag release. + suppressProjectClickAfterDragRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } + if (selectedThreadIds.size > 0) { + clearSelection(); + } + toggleProject(projectId); + }, + [clearSelection, selectedThreadIds.size, toggleProject], + ); + + const handleProjectTitleKeyDown = useCallback( + (event: React.KeyboardEvent, projectId: ProjectId) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (dragInProgressRef.current) { + return; + } + toggleProject(projectId); + }, + [toggleProject], + ); + useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && selectedThreadIds.size > 0) { + event.preventDefault(); + clearSelection(); + return; + } + const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) : undefined; @@ -1270,11 +1523,29 @@ export default function Sidebar() { }); }; + const onMouseDown = (event: globalThis.MouseEvent) => { + if (selectedThreadIds.size === 0) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) return; + clearSelection(); + }; + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("mousedown", onMouseDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("mousedown", onMouseDown); }; - }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); + }, [ + clearSelection, + getDraftThread, + handleNewThread, + keybindings, + projects, + routeThreadId, + selectedThreadIds.size, + threads, + ]); useEffect(() => { if (!isElectron) return; @@ -1428,6 +1699,7 @@ export default function Sidebar() { }); }, []); + const showHeaderSettingsButton = !isElectron && !isOnSettings; const wordmark = (
    @@ -1439,14 +1711,16 @@ export default function Sidebar() { {APP_STAGE_LABEL} - + {showHeaderSettingsButton ? ( + + ) : null}
    ); @@ -1519,21 +1793,23 @@ export default function Sidebar() { @@ -1636,336 +1912,356 @@ export default function Sidebar() {

    )} - - {projects.map((project) => { - const projectThreads = threads - .filter((thread) => thread.projectId === project.id) - .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - }); - const filteredProjectThreads = hasActiveThreadSearch - ? projectThreads.filter((thread) => - threadTitleMatchesSearch(thread, normalizedThreadSearchQuery), - ) - : projectThreads; - if (hasActiveThreadSearch && filteredProjectThreads.length === 0) { - return null; - } - const isThreadSearchFiltering = hasActiveThreadSearch; - const isThreadListExpanded = - isThreadSearchFiltering || expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = - !isThreadSearchFiltering && filteredProjectThreads.length > THREAD_PREVIEW_LIMIT; - const visibleThreads = - hasHiddenThreads && !isThreadListExpanded - ? filteredProjectThreads.slice(0, THREAD_PREVIEW_LIMIT) - : filteredProjectThreads; - const isProjectOpen = project.expanded || isThreadSearchFiltering; - - return ( - { - if (isThreadSearchFiltering || open === project.expanded) return; - toggleProject(project.id); - }} - > - -
    { - handleProjectDragStart(event, project.id); - }} - onDragEnd={clearProjectDragState} - onDragOver={(event) => { - handleProjectDragOver(event, project.id); - }} - onDragLeave={(event) => { - if ( - !event.currentTarget.contains( - event.relatedTarget as Node | null, - ) - ) { - setProjectDropTarget(null); - } - }} - onDrop={(event) => { - handleProjectDrop(event, project.id); - }} - > - {projectDropTarget?.projectId === project.id ? ( -
    - ) : null} - - } - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - - - - } - showOnHover - className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { + + + project.id)} + strategy={verticalListSortingStrategy} + > + {projects.map((project) => { + const projectThreads = threads + .filter((thread) => thread.projectId === project.id) + .toSorted((a, b) => { + const byDate = + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (byDate !== 0) return byDate; + return b.id.localeCompare(a.id); + }); + const filteredProjectThreads = hasActiveThreadSearch + ? projectThreads.filter((thread) => + threadTitleMatchesSearch(thread, normalizedThreadSearchQuery), + ) + : projectThreads; + if (hasActiveThreadSearch && filteredProjectThreads.length === 0) { + return null; + } + const isThreadSearchFiltering = hasActiveThreadSearch; + const isProjectOpen = project.expanded || isThreadSearchFiltering; + const isThreadListExpanded = + isThreadSearchFiltering || expandedThreadListsByProject.has(project.id); + const hasHiddenThreads = + !isThreadSearchFiltering && filteredProjectThreads.length > THREAD_PREVIEW_LIMIT; + const visibleThreads = + hasHiddenThreads && !isThreadListExpanded + ? filteredProjectThreads.slice(0, THREAD_PREVIEW_LIMIT) + : filteredProjectThreads; + const orderedProjectThreadIds = visibleThreads.map((t) => t.id); + + return ( + + {(dragHandleProps) => ( + +
    + handleProjectTitleClick(event, project.id)} + onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} + onContextMenu={(event) => { event.preventDefault(); - event.stopPropagation(); - void handleNewThread(project.id); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); }} > - - - } - /> - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - -
    - - - - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const threadStatus = threadStatusPill( - thread, - pendingApprovalByThreadId.get(thread.id) === true, - ); - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); - const provider = thread.provider ?? resolveThreadProvider(thread); - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider]; - - return ( - - } - size="sm" - isActive={isActive} - className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ - isActive - ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" - : "text-muted-foreground" + { - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > -
    - {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && ( - - + + + {project.name} + + + + - {threadStatus.label} - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } + } + showOnHover + className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(project.id); + }} + > + + + } + /> + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
    + + + + {visibleThreads.map((thread) => { + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const threadStatus = resolveThreadStatusPill({ + thread, + hasPendingApprovals: + pendingApprovalByThreadId.get(thread.id) === true, + hasPendingUserInput: + pendingUserInputByThreadId.get(thread.id) === true, + }); + const prStatus = prStatusIndicator( + prByThreadId.get(thread.id) ?? null, + ); + const terminalStatus = terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id) + .runningTerminalIds, + ); + const provider = thread.provider ?? resolveThreadProvider(thread); + + + return ( + + } + size="sm" + isActive={isActive} + className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${ + isSelected + ? "bg-primary/15 text-foreground dark:bg-primary/10" + : isActive + ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" + : "text-muted-foreground" + }`} + onClick={(event) => { + handleThreadClick( + event, + thread.id, + orderedProjectThreadIds, + ); }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); } + setSelectionAnchor(thread.id); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); + onContextMenu={(event) => { + event.preventDefault(); + if ( + selectedThreadIds.size > 0 && + selectedThreadIds.has(thread.id) + ) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); } }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {thread.title} - - )} -
    -
    - {terminalStatus && ( - - - - )} - - {formatRelativeTime(thread.createdAt)} - - - + {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + + {prStatus.tooltip} + + + )} + {threadStatus && ( + + + + {threadStatus.label} + + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {thread.title} + + )} +
    +
    + {terminalStatus && ( + + + + )} - + {formatRelativeTime(thread.createdAt)} - } - /> - {provider} - -
    - - - ); - })} - {hasHiddenThreads && !isThreadListExpanded && ( - - } - 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={() => { - expandThreadListForProject(project.id); - }} - > - Show more - - - )} - {hasHiddenThreads && isThreadListExpanded && ( - - } - 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={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - - - )} - - - - - ); - })} - - - {hasActiveThreadSearch && matchingThreadCount === 0 && ( -
    - No matching threads. -
    - )} - {projects.length === 0 && !addingProject && ( + + + + + } + /> + {provider} + +
    + + + ); + })} + + {hasHiddenThreads && !isThreadListExpanded && ( + + } + 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={() => { + expandThreadListForProject(project.id); + }} + > + Show more + + + )} + {hasHiddenThreads && isThreadListExpanded && ( + + } + 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={() => { + collapseThreadListForProject(project.id); + }} + > + Show less + + + )} + + +
    + )} + + ); + })} + +
    + + + {projects.length === 0 && !shouldShowProjectPathEntry && (
    No projects yet
    diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c47ab076c78c..21ee71b6e4fd 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -31,6 +31,7 @@ import { resolveAccentColorRgba, } from "../accentColor"; import { readNativeApi } from "~/nativeApi"; +import { resolveTerminalFontFamily } from "../lib/terminalFont"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -185,7 +186,7 @@ function TerminalViewport({ lineHeight: 1.2, fontSize: 12, scrollback: 5_000, - fontFamily: '"Geist Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: resolveTerminalFontFamily(), theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts new file mode 100644 index 000000000000..717e2623d4f7 --- /dev/null +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, + COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, + shouldUseCompactComposerFooter, +} from "./composerFooterLayout"; + +describe("shouldUseCompactComposerFooter", () => { + it("stays expanded without a measured width", () => { + expect(shouldUseCompactComposerFooter(null)).toBe(false); + }); + + it("switches to compact mode below the breakpoint", () => { + expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX - 1)).toBe(true); + }); + + it("stays expanded at and above the breakpoint", () => { + expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false); + expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(false); + }); + + it("uses a higher breakpoint for wide action states", () => { + expect( + shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, { + hasWideActions: true, + }), + ).toBe(true); + expect( + shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, { + hasWideActions: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts new file mode 100644 index 000000000000..3cf994fc142c --- /dev/null +++ b/apps/web/src/components/composerFooterLayout.ts @@ -0,0 +1,12 @@ +export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; +export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 720; + +export function shouldUseCompactComposerFooter( + width: number | null, + options?: { hasWideActions?: boolean }, +): boolean { + const breakpoint = options?.hasWideActions + ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX + : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX; + return width !== null && width < breakpoint; +} diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 7fd362bf1dac..984eebd6b1bd 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -31,7 +31,11 @@ const baseState: DesktopUpdateState = { describe("desktop update button state", () => { it("shows a download action when an update is available", () => { - const state: DesktopUpdateState = { ...baseState, status: "available", availableVersion: "1.1.0" }; + const state: DesktopUpdateState = { + ...baseState, + status: "available", + availableVersion: "1.1.0", + }; expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); }); diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index 180d0a95b38f..e5f3db03c3f5 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -22,7 +22,7 @@ function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Pr return ( @@ -22,14 +24,20 @@ function ScrollArea({ scrollFade && "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5", + hideScrollbars && + "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", )} data-slot="scroll-area-viewport" > {children} - - - + {!hideScrollbars && ( + <> + + + + + )} ); } diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index ba77bddc2798..ab9c9bb1867f 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -595,7 +595,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { return (
    ) { return ( - +
    ) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full items-center gap-2 rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -944,7 +944,7 @@ function SidebarMenuSubButton({ }) { const defaultProps = { className: cn( - "-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "-translate-x-px flex h-7 min-w-0 items-center gap-2 rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", size === "sm" && "text-xs", size === "md" && "text-sm", diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index def62c884fef..4e4595f73f3d 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "vitest"; -import { shouldHideCollapsedToastContent } from "./toast.logic"; +import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; describe("shouldHideCollapsedToastContent", () => { it("keeps a single visible toast readable", () => { @@ -14,3 +14,50 @@ describe("shouldHideCollapsedToastContent", () => { assert.equal(shouldHideCollapsedToastContent(1, 3), true); }); }); + +describe("buildVisibleToastLayout", () => { + it("computes indices and offsets from the visible subset", () => { + const visibleToasts = [ + { id: "a", height: 48 }, + { id: "b", height: 72 }, + { id: "c", height: 24 }, + ]; + + const layout = buildVisibleToastLayout(visibleToasts); + + assert.equal(layout.frontmostHeight, 48); + assert.deepEqual( + layout.items.map(({ toast, visibleIndex, offsetY }) => ({ + id: toast.id, + visibleIndex, + offsetY, + })), + [ + { id: "a", visibleIndex: 0, offsetY: 0 }, + { id: "b", visibleIndex: 1, offsetY: 48 }, + { id: "c", visibleIndex: 2, offsetY: 120 }, + ], + ); + }); + + it("treats missing heights as zero", () => { + const layout = buildVisibleToastLayout([ + { id: "a" }, + { id: "b", height: undefined }, + { id: "c", height: 30 }, + ]); + + assert.equal(layout.frontmostHeight, 0); + assert.deepEqual( + layout.items.map(({ toast, offsetY }) => ({ + id: toast.id, + offsetY, + })), + [ + { id: "a", offsetY: 0 }, + { id: "b", offsetY: 0 }, + { id: "c", offsetY: 0 }, + ], + ); + }); +}); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index 500203033abb..eaa4e0db4fe3 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -7,3 +7,40 @@ export function shouldHideCollapsedToastContent( if (visibleToastCount <= 1) return false; return visibleToastIndex > 0; } + +type ToastWithHeight = { + height?: number | null | undefined; +}; + +type VisibleToastLayoutItem = { + toast: TToast; + visibleIndex: number; + offsetY: number; +}; + +export function buildVisibleToastLayout( + visibleToasts: readonly (TToast & ToastWithHeight)[], +): { + frontmostHeight: number; + items: VisibleToastLayoutItem[]; +} { + let offsetY = 0; + + return { + frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height), + items: visibleToasts.map((toast, visibleIndex) => { + const item = { + toast, + visibleIndex, + offsetY, + }; + + offsetY += normalizeToastHeight(toast.height); + return item; + }), + }; +} + +function normalizeToastHeight(height: number | null | undefined): number { + return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index f7035d87472d..768a083e2e05 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,7 +1,7 @@ "use client"; import { Toast } from "@base-ui/react/toast"; -import { useEffect } from "react"; +import { useEffect, type CSSProperties } from "react"; import { useParams } from "@tanstack/react-router"; import { ThreadId } from "@t3tools/contracts"; import { @@ -14,7 +14,7 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; -import { shouldHideCollapsedToastContent } from "./toast.logic"; +import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { threadId?: ThreadId | null; @@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { const visibleToasts = toasts.filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId), ); + const visibleToastLayout = buildVisibleToastLayout(visibleToasts); useEffect(() => { const activeToastIds = new Set(toasts.map((toast) => toast.id)); @@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { )} data-position={position} data-slot="toast-viewport" + style={ + { + "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`, + } as CSSProperties + } > - {visibleToasts.map((toast, visibleIndex) => { + {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const hideCollapsedContent = shouldHideCollapsedToastContent( visibleIndex, - visibleToasts.length, + visibleToastLayout.items.length, ); return ( @@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { )} data-position={position} key={toast.id} + style={ + { + "--toast-index": visibleIndex, + "--toast-offset-y": `${offsetY}px`, + } as CSSProperties + } swipeDirection={ position.includes("center") ? [isTop ? "up" : "down"] diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index f2e367bcf037..70b3567c3f4f 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -61,7 +61,9 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) return expandedCursor; } -function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number { +function collapsedSegmentLength( + segment: { type: "text"; text: string } | { type: "mention" }, +): number { return segment.type === "mention" ? 1 : segment.text.length; } @@ -160,15 +162,16 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } -export function parseStandaloneComposerSlashCommand(text: string): Exclude< - ComposerSlashCommand, - "model" -> | null { +export function parseStandaloneComposerSlashCommand( + text: string, +): Exclude | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; } - return match[1]?.toLowerCase() === "plan" ? "plan" : "default"; + const command = match[1]?.toLowerCase(); + if (command === "plan") return "plan"; + return "default"; } export function replaceTextRange( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index ce0113058aeb..489173924bcb 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,7 +1,11 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; +import { + type ComposerImageAttachment, + createDebouncedStorage, + useComposerDraftStore, +} from "./composerDraftStore"; function makeImage(input: { id: string; @@ -248,9 +252,9 @@ describe("composerDraftStore project draft thread mapping", () => { store.clearProjectDraftThreadId(projectId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId).toBe( - threadId, - ); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, + ).toBe(threadId); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); }); @@ -376,7 +380,9 @@ describe("composerDraftStore setModel", () => { store.setModel(threadId, "gpt-5.3-codex"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex"); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe( + "gpt-5.3-codex", + ); }); }); @@ -451,3 +457,125 @@ describe("composerDraftStore runtime and interaction settings", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// createDebouncedStorage +// --------------------------------------------------------------------------- + +function createMockStorage() { + const store = new Map(); + return { + getItem: vi.fn((name: string) => store.get(name) ?? null), + setItem: vi.fn((name: string, value: string) => { + store.set(name, value); + }), + removeItem: vi.fn((name: string) => { + store.delete(name); + }), + }; +} + +describe("createDebouncedStorage", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("delegates getItem immediately", () => { + const base = createMockStorage(); + base.getItem.mockReturnValueOnce("value"); + const storage = createDebouncedStorage(base); + + expect(storage.getItem("key")).toBe("value"); + expect(base.getItem).toHaveBeenCalledWith("key"); + }); + + it("does not write to base storage until the debounce fires", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + expect(base.setItem).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(299); + expect(base.setItem).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + }); + + it("only writes the last value when setItem is called rapidly", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.setItem("key", "v2"); + storage.setItem("key", "v3"); + + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v3"); + }); + + it("removeItem cancels a pending setItem write", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + + vi.advanceTimersByTime(300); + expect(base.setItem).not.toHaveBeenCalled(); + expect(base.removeItem).toHaveBeenCalledWith("key"); + }); + + it("flush writes the pending value immediately", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + expect(base.setItem).not.toHaveBeenCalled(); + + storage.flush(); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + + // Timer should be cancelled; no duplicate write. + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + }); + + it("flush is a no-op when nothing is pending", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.flush(); + expect(base.setItem).not.toHaveBeenCalled(); + }); + + it("flush after removeItem is a no-op", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + storage.flush(); + + expect(base.setItem).not.toHaveBeenCalled(); + }); + + it("setItem works normally after removeItem cancels a pending write", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + storage.setItem("key", "v2"); + + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v2"); + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index cb45725bfb6e..69bdb11fe810 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -12,17 +12,55 @@ import { type RuntimeMode, } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { - DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, - type ChatImageAttachment, -} from "./types"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; export type DraftThreadEnvMode = "local" | "worktree"; +const COMPOSER_PERSIST_DEBOUNCE_MS = 300; + +interface DebouncedStorage extends StateStorage { + flush: () => void; +} + +export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage { + const debouncedSetItem = new Debouncer( + (name: string, value: string) => { + baseStorage.setItem(name, value); + }, + { wait: COMPOSER_PERSIST_DEBOUNCE_MS }, + ); + + return { + getItem: (name) => baseStorage.getItem(name), + setItem: (name, value) => { + debouncedSetItem.maybeExecute(name, value); + }, + removeItem: (name) => { + debouncedSetItem.cancel(); + baseStorage.removeItem(name); + }, + flush: () => { + debouncedSetItem.flush(); + }, + }; +} + +const composerDebouncedStorage: DebouncedStorage = + typeof localStorage !== "undefined" + ? createDebouncedStorage(localStorage) + : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; + +// Flush pending composer draft writes before page unload to prevent data loss. +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + composerDebouncedStorage.flush(); + }); +} + export interface PersistedComposerImageAttachment { id: string; name: string; @@ -477,8 +515,7 @@ function hydreatePersistedComposerImageAttachment( attachment: PersistedComposerImageAttachment, ): File | null { const commaIndex = attachment.dataUrl.indexOf(","); - const header = - commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex); + const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex); const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1); if (payload.length === 0) { return null; @@ -588,7 +625,8 @@ export const useComposerDraftStore = create()( const nextDraftThread: DraftThreadState = { projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), - runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode: + options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: options?.interactionMode ?? existingThread?.interactionMode ?? @@ -656,7 +694,9 @@ export const useComposerDraftStore = create()( return state; } const nextWorktreePath = - options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null); + options.worktreePath === undefined + ? existing.worktreePath + : (options.worktreePath ?? null); const nextDraftThread: DraftThreadState = { projectId: nextProjectId, createdAt: @@ -668,8 +708,7 @@ export const useComposerDraftStore = create()( branch: options.branch === undefined ? existing.branch : (options.branch ?? null), worktreePath: nextWorktreePath, envMode: - options.envMode ?? - (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), + options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), }; const isUnchanged = nextDraftThread.projectId === existing.projectId && @@ -790,6 +829,9 @@ export const useComposerDraftStore = create()( } set((state) => { const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); + if (existing.prompt === prompt) { + return state; + } const nextDraft: ComposerThreadDraftState = { ...existing, prompt, @@ -1226,7 +1268,7 @@ export const useComposerDraftStore = create()( { name: COMPOSER_DRAFT_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => composerDebouncedStorage), partialize: (state) => { const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index acb2722c1425..160efb85210a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -118,8 +118,25 @@ --info-foreground: color-mix(in srgb, var(--accent-color) 78%, var(--color-white)); --success: var(--color-emerald-500); --success-foreground: var(--color-emerald-400); - --warning: var(--color-amber-500); - --warning-foreground: var(--color-amber-400); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-400); + --terminal-font-family: + "Symbols Nerd Font Mono", + "Symbols Nerd Font", + "JetBrainsMono Nerd Font Mono", + "JetBrainsMonoNL Nerd Font Mono", + "Hack Nerd Font Mono", + "SauceCodePro Nerd Font Mono", + "FiraCode Nerd Font Mono", + "MesloLGS NF", + "CaskaydiaMono Nerd Font Mono", + "Geist Mono", + "SF Mono", + "SFMono-Regular", + Consolas, + "Liberation Mono", + Menlo, + monospace; } } diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index c760d3454948..964d14fb8be0 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { gitMutationKeys, + gitPreparePullRequestThreadMutationOptions, gitPullMutationOptions, gitRunStackedActionMutationOptions, } from "./gitReactQuery"; @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { it("scopes pull keys by cwd", () => { expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); }); + + it("scopes pull request thread preparation keys by cwd", () => { + expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( + gitMutationKeys.preparePullRequestThread("/repo/b"), + ); + }); }); describe("git mutation options", () => { @@ -30,4 +37,12 @@ describe("git mutation options", () => { const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); }); + + it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { + const options = gitPreparePullRequestThreadMutationOptions({ + cwd: "/repo/a", + queryClient, + }); + expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a")); + }); }); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 59b1174eac7e..464f5d2c7ed4 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -18,6 +18,8 @@ export const gitMutationKeys = { checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, + preparePullRequestThread: (cwd: string | null) => + ["git", "mutation", "prepare-pull-request-thread", cwd] as const, }; export function invalidateGitQueries(queryClient: QueryClient) { @@ -56,6 +58,28 @@ export function gitBranchesQueryOptions(cwd: string | null) { }); } +export function gitResolvePullRequestQueryOptions(input: { + cwd: string | null; + reference: string | null; +}) { + const hasCwd = input.cwd != null && input.cwd.trim().length > 0; + const hasReference = input.reference != null && input.reference.trim().length > 0; + return queryOptions({ + queryKey: ["git", "pull-request", input.cwd, input.reference] as const, + queryFn: async () => { + const api = ensureNativeApi(); + if (!hasCwd || !hasReference) { + throw new Error("Pull request lookup is unavailable."); + } + return api.git.resolvePullRequest({ cwd: input.cwd!, reference: input.reference! }); + }, + enabled: hasCwd && hasReference, + staleTime: 30_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { return mutationOptions({ mutationKey: gitMutationKeys.init(input.cwd), @@ -174,3 +198,25 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie }, }); } + +export function gitPreparePullRequestThreadMutationOptions(input: { + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => { + const api = ensureNativeApi(); + if (!input.cwd || reference.trim().length === 0) + throw new Error("Pull request thread preparation is unavailable."); + return api.git.preparePullRequestThread({ + cwd: input.cwd, + reference, + mode, + }); + }, + mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), + onSettled: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} diff --git a/apps/web/src/lib/terminalFont.ts b/apps/web/src/lib/terminalFont.ts new file mode 100644 index 000000000000..3f688a2698e9 --- /dev/null +++ b/apps/web/src/lib/terminalFont.ts @@ -0,0 +1,31 @@ +const DEFAULT_TERMINAL_FONT_FAMILY = [ + '"Symbols Nerd Font Mono"', + '"Symbols Nerd Font"', + '"JetBrainsMono Nerd Font Mono"', + '"JetBrainsMonoNL Nerd Font Mono"', + '"Hack Nerd Font Mono"', + '"SauceCodePro Nerd Font Mono"', + '"FiraCode Nerd Font Mono"', + '"MesloLGS NF"', + '"CaskaydiaMono Nerd Font Mono"', + '"Geist Mono"', + '"SF Mono"', + '"SFMono-Regular"', + "Consolas", + '"Liberation Mono"', + "Menlo", + "monospace", +].join(", "); + +export function resolveTerminalFontFamily(): string { + if (typeof window === "undefined") { + return DEFAULT_TERMINAL_FONT_FAMILY; + } + + const configured = getComputedStyle(document.documentElement) + .getPropertyValue("--terminal-font-family") + .trim(); + + return configured.length > 0 ? configured : DEFAULT_TERMINAL_FONT_FAMILY; +} + diff --git a/apps/web/src/lib/threadDraftDefaults.test.ts b/apps/web/src/lib/threadDraftDefaults.test.ts new file mode 100644 index 000000000000..49c1a273e535 --- /dev/null +++ b/apps/web/src/lib/threadDraftDefaults.test.ts @@ -0,0 +1,138 @@ +import { ProjectId, ThreadId, type TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; +import { resolveDraftThreadDefaults } from "./threadDraftDefaults"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "../types"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + provider: "codex", + model: "gpt-5.4", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + latestTurn: null, + lastVisitedAt: undefined, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +function completedTurn(completedAt: string) { + return { + turnId: "turn-1" as TurnId, + state: "completed" as const, + requestedAt: completedAt, + startedAt: completedAt, + completedAt, + assistantMessageId: null, + }; +} + +describe("resolveDraftThreadDefaults", () => { + it("prefers the most recent thread from the active project", () => { + const defaults = resolveDraftThreadDefaults({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-project"), + projectId: ProjectId.makeUnsafe("project-1"), + provider: "cursor", + model: "composer-1.5", + createdAt: "2026-03-08T10:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-other-project"), + projectId: ProjectId.makeUnsafe("project-2"), + provider: "claudeCode", + model: "claude-sonnet-4-6", + createdAt: "2026-03-09T10:00:00.000Z", + }), + ], + projectId: ProjectId.makeUnsafe("project-1"), + fallbackModel: "gpt-5.4", + }); + + expect(defaults).toEqual({ + provider: "cursor", + model: "composer-1.5", + }); + }); + + it("falls back to the most recent thread overall when the project has no history", () => { + const defaults = resolveDraftThreadDefaults({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-older"), + provider: "codex", + model: "gpt-5.4", + createdAt: "2026-03-08T10:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-newest"), + projectId: ProjectId.makeUnsafe("project-2"), + provider: "claudeCode", + model: "claude-sonnet-4-6", + createdAt: "2026-03-09T10:00:00.000Z", + }), + ], + projectId: ProjectId.makeUnsafe("project-3"), + fallbackModel: "gpt-5.4", + }); + + expect(defaults).toEqual({ + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + }); + + it("uses the most recently visited conversation instead of the newest created thread", () => { + const defaults = resolveDraftThreadDefaults({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-revisited"), + provider: "cursor", + model: "composer-1.5", + createdAt: "2026-03-01T10:00:00.000Z", + lastVisitedAt: "2026-03-09T11:00:00.000Z", + latestTurn: completedTurn("2026-03-09T10:59:00.000Z"), + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-newer"), + provider: "claudeCode", + model: "claude-sonnet-4-6", + createdAt: "2026-03-08T10:00:00.000Z", + }), + ], + projectId: ProjectId.makeUnsafe("project-1"), + fallbackModel: "gpt-5.4", + }); + + expect(defaults).toEqual({ + provider: "cursor", + model: "composer-1.5", + }); + }); + + it("falls back to the supplied model when there is no previous conversation", () => { + const defaults = resolveDraftThreadDefaults({ + threads: [], + projectId: ProjectId.makeUnsafe("project-1"), + fallbackModel: "claude-sonnet-4-6", + }); + + expect(defaults).toEqual({ + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + }); +}); diff --git a/apps/web/src/lib/threadDraftDefaults.ts b/apps/web/src/lib/threadDraftDefaults.ts new file mode 100644 index 000000000000..ab888c9fdfc6 --- /dev/null +++ b/apps/web/src/lib/threadDraftDefaults.ts @@ -0,0 +1,67 @@ +import { type ProjectId, type ProviderKind } from "@t3tools/contracts"; +import { resolveModelSlugForProvider } from "@t3tools/shared/model"; +import type { Thread } from "../types"; +import { inferProviderForThreadModel } from "./threadProvider"; + +export interface DraftThreadDefaults { + readonly provider: ProviderKind; + readonly model: string; +} + +function timestampOrNaN(value: string | null | undefined): number { + if (!value) return Number.NaN; + return Date.parse(value); +} + +function threadRecencyTimestamp(thread: Pick): number { + return ( + [thread.lastVisitedAt, thread.latestTurn?.completedAt, thread.createdAt] + .map((value) => timestampOrNaN(value)) + .find((value) => Number.isFinite(value)) ?? 0 + ); +} + +function compareThreadsByRecency(left: Thread, right: Thread): number { + const byRecency = threadRecencyTimestamp(right) - threadRecencyTimestamp(left); + if (byRecency !== 0) return byRecency; + return right.id.localeCompare(left.id); +} + +function latestThread(threads: ReadonlyArray, projectId?: ProjectId | null): Thread | null { + const matchingThreads = + projectId === undefined || projectId === null + ? threads + : threads.filter((thread) => thread.projectId === projectId); + return matchingThreads.toSorted(compareThreadsByRecency)[0] ?? null; +} + +export function resolveDraftThreadDefaults(input: { + readonly threads: ReadonlyArray; + readonly projectId: ProjectId | null | undefined; + readonly fallbackModel: string; +}): DraftThreadDefaults { + const recentThread = + latestThread(input.threads, input.projectId) ?? latestThread(input.threads) ?? null; + if (!recentThread) { + const fallbackProvider = inferProviderForThreadModel({ + model: input.fallbackModel, + sessionProviderName: null, + }); + return { + provider: fallbackProvider, + model: resolveModelSlugForProvider(fallbackProvider, input.fallbackModel), + }; + } + + const provider = + recentThread.provider ?? + inferProviderForThreadModel({ + model: recentThread.model, + sessionProviderName: recentThread.session?.provider ?? null, + }); + + return { + provider, + model: resolveModelSlugForProvider(provider, recentThread.model), + }; +} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 5778dca3aff8..6389fc3eea39 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -91,7 +91,9 @@ describe("buildTurnDiffTree", () => { }); it("normalizes file paths with windows separators", () => { - const tree = buildTurnDiffTree([{ path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }]); + const tree = buildTurnDiffTree([ + { path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }, + ]); expect(tree).toEqual([ { @@ -157,7 +159,8 @@ describe("buildTurnDiffTree", () => { expect(tree).toHaveLength(2); const directoryNodes = tree.filter( - (node): node is Extract<(typeof tree)[number], { kind: "directory" }> => node.kind === "directory", + (node): node is Extract<(typeof tree)[number], { kind: "directory" }> => + node.kind === "directory", ); expect(directoryNodes.map((node) => node.name).toSorted()).toEqual([" a", "a"]); expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 7684e68d7720..08a5de91e661 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,6 +1,8 @@ import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; import { type CxOptions, cx } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; +import * as Random from "effect/Random"; +import * as Effect from "effect/Effect"; export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); @@ -14,10 +16,17 @@ export function isWindowsPlatform(platform: string): boolean { return /^win(dows)?/i.test(platform); } -export const newCommandId = (): CommandId => CommandId.makeUnsafe(crypto.randomUUID()); +export function randomUUID(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return Effect.runSync(Random.nextUUIDv4); +} + +export const newCommandId = (): CommandId => CommandId.makeUnsafe(randomUUID()); -export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(crypto.randomUUID()); +export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); -export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID()); +export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); -export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID()); +export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index dd592bd62bf6..86e41285ad6a 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -95,9 +95,7 @@ export function derivePendingUserInputProgress( questionIndex: number, ): PendingUserInputProgress { const normalizedQuestionIndex = - questions.length === 0 - ? 0 - : Math.max(0, Math.min(questionIndex, questions.length - 1)); + questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1)); const activeQuestion = questions[normalizedQuestionIndex] ?? null; const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft); diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index b8431bdbcc4d..63f95d4137b7 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { + buildCollapsedProposedPlanPreviewMarkdown, buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, proposedPlanTitle, resolvePlanFollowUpSubmission, + stripDisplayedPlanMarkdown, } from "./proposedPlan"; describe("proposedPlanTitle", () => { @@ -26,6 +28,41 @@ describe("buildPlanImplementationPrompt", () => { }); }); +describe("buildCollapsedProposedPlanPreviewMarkdown", () => { + it("drops the redundant title heading and preserves the following markdown lines", () => { + expect( + buildCollapsedProposedPlanPreviewMarkdown( + "# Integrate RPC\n\n## Summary\n\n- step 1\n- step 2", + { + maxLines: 4, + }, + ), + ).toBe("- step 1\n- step 2"); + }); + + it("appends an overflow marker when the preview truncates remaining content", () => { + expect( + buildCollapsedProposedPlanPreviewMarkdown("# Integrate RPC\n\n- step 1\n- step 2\n- step 3", { + maxLines: 2, + }), + ).toBe("- step 1\n- step 2\n\n..."); + }); +}); + +describe("stripDisplayedPlanMarkdown", () => { + it("drops the leading title heading from displayed plan markdown", () => { + expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n")).toBe( + "- step 1", + ); + }); + + it("preserves non-summary headings after dropping the title heading", () => { + expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe( + "## Scope\n\n- step 1", + ); + }); +}); + describe("resolvePlanFollowUpSubmission", () => { it("switches to default mode when implementing the ready plan without extra text", () => { expect( diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 3bd4f62e602d..48186392e8a3 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -3,6 +3,64 @@ export function proposedPlanTitle(planMarkdown: string): string | null { 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"); +} + function sanitizePlanFileSegment(input: string): string { const sanitized = input .toLowerCase() @@ -16,10 +74,7 @@ export function buildPlanImplementationPrompt(planMarkdown: string): string { return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; } -export function resolvePlanFollowUpSubmission(input: { - draftText: string; - planMarkdown: string; -}): { +export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { text: string; interactionMode: "default" | "plan"; } { diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts new file mode 100644 index 000000000000..60bb7b12f5a5 --- /dev/null +++ b/apps/web/src/pullRequestReference.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { parsePullRequestReference } from "./pullRequestReference"; + +describe("parsePullRequestReference", () => { + it("accepts GitHub pull request URLs", () => { + expect(parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42")).toBe( + "https://github.com/pingdotgg/t3code/pull/42", + ); + }); + + it("accepts raw numbers", () => { + expect(parsePullRequestReference("42")).toBe("42"); + }); + + it("accepts #number references", () => { + expect(parsePullRequestReference("#42")).toBe("#42"); + }); + + it("rejects non-pull-request input", () => { + expect(parsePullRequestReference("feature/my-branch")).toBeNull(); + }); +}); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts new file mode 100644 index 000000000000..ecaf916b71e3 --- /dev/null +++ b/apps/web/src/pullRequestReference.ts @@ -0,0 +1,22 @@ +const GITHUB_PULL_REQUEST_URL_PATTERN = + /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; + +export function parsePullRequestReference(input: string): string | null { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return null; + } + + const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed); + if (urlMatch?.[1]) { + return trimmed; + } + + const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(trimmed); + if (numberMatch?.[1]) { + return trimmed.startsWith("#") ? trimmed : numberMatch[1]; + } + + return null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 472a3922f960..dac88d8c1a3a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -8,6 +8,7 @@ import { } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { useAppSettings } from "../appSettings"; @@ -173,6 +174,7 @@ function EventRouter() { let latestSequence = 0; let syncing = false; let pending = false; + let needsProviderInvalidation = false; const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); @@ -208,7 +210,20 @@ function EventRouter() { syncing = false; }; - void syncSnapshot().catch(() => undefined); + const domainEventFlushThrottler = new Throttler( + () => { + if (needsProviderInvalidation) { + needsProviderInvalidation = false; + void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + } + void syncSnapshot(); + }, + { + wait: 100, + leading: false, + trailing: true, + }, + ); const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { if (event.sequence <= latestSequence) { @@ -224,9 +239,9 @@ function EventRouter() { } } if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { - void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + needsProviderInvalidation = true; } - void syncSnapshot(); + domainEventFlushThrottler.maybeExecute(); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); @@ -311,6 +326,8 @@ function EventRouter() { }); return () => { disposed = true; + needsProviderInvalidation = false; + domainEventFlushThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); unsubWelcome(); diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 2593eb7605a1..57b8ad43b5d3 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -15,6 +15,7 @@ const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; +const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; const DiffPanelSheet = (props: { children: ReactNode; @@ -91,8 +92,23 @@ const DiffPanelInlineSidebar = (props: { composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, ); const formRect = composerForm.getBoundingClientRect(); + const composerFooter = composerForm.querySelector( + "[data-chat-composer-footer='true']", + ); + const composerRightActions = composerForm.querySelector( + "[data-chat-composer-actions='right']", + ); + const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; + const composerFooterGap = composerFooter + ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || + Number.parseFloat(window.getComputedStyle(composerFooter).gap) || + 0 + : 0; + const minimumComposerWidth = + COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; const overflowsViewport = formRect.width > viewportContentWidth + 0.5; + const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; if (previousSidebarWidth.length > 0) { wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); @@ -100,7 +116,7 @@ const DiffPanelInlineSidebar = (props: { wrapper.style.removeProperty("--sidebar-width"); } - return !hasComposerOverflow && !overflowsViewport; + return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; }, [], ); @@ -140,8 +156,8 @@ function ChatThreadRouteView() { }); const search = Route.useSearch(); const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId)); - const draftThreadExists = useComposerDraftStore( - (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), + const draftThreadExists = useComposerDraftStore((store) => + Object.hasOwn(store.draftThreadsByThreadId, threadId), ); const routeThreadExists = threadExists || draftThreadExists; const diffOpen = search.diff === "1"; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 0289e71e1d35..f0c3705e743f 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,12 +3,10 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { ZapIcon } from "lucide-react"; import { - APP_SERVICE_TIER_OPTIONS, + APP_PROVIDER_LOGO_APPEARANCE_OPTIONS, MAX_CUSTOM_MODEL_LENGTH, - shouldShowFastTierIcon, useAppSettings, } from "../appSettings"; import { @@ -23,8 +21,14 @@ import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -206,7 +210,6 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; - const codexServiceTier = settings.codexServiceTier; const accentColor = settings.accentColor; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; @@ -227,54 +230,62 @@ function SettingsRouteView() { }); }, [keybindingsConfigPath]); - const addCustomModel = useCallback((provider: ProviderKind) => { - const customModelInput = customModelInputByProvider[provider]; - const customModels = getCustomModelsForProvider(settings, provider); - const normalized = normalizeModelSlug(customModelInput, provider); - if (!normalized) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "Enter a model slug.", - })); - return; - } - if (getModelOptions(provider).some((option) => option.slug === normalized)) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "That model is already built in.", - })); - return; - } - if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { - setCustomModelErrorByProvider((existing) => ({ + const addCustomModel = useCallback( + (provider: ProviderKind) => { + const customModelInput = customModelInputByProvider[provider]; + const customModels = getCustomModelsForProvider(settings, provider); + const normalized = normalizeModelSlug(customModelInput, provider); + if (!normalized) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "Enter a model slug.", + })); + return; + } + if (getModelOptions(provider).some((option) => option.slug === normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That model is already built in.", + })); + return; + } + if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, + })); + return; + } + if (customModels.includes(normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That custom model is already saved.", + })); + return; + } + + updateSettings(patchCustomModels(provider, [...customModels, normalized])); + setCustomModelInputByProvider((existing) => ({ ...existing, - [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, + [provider]: "", })); - return; - } - if (customModels.includes(normalized)) { setCustomModelErrorByProvider((existing) => ({ ...existing, - [provider]: "That custom model is already saved.", + [provider]: null, })); - return; - } - - updateSettings(patchCustomModels(provider, [...customModels, normalized])); - setCustomModelInputByProvider((existing) => ({ - ...existing, - [provider]: "", - })); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - }, [customModelInputByProvider, settings, updateSettings]); + }, + [customModelInputByProvider, settings, updateSettings], + ); const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { const customModels = getCustomModelsForProvider(settings, provider); - updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug))); + updateSettings( + patchCustomModels( + provider, + customModels.filter((model) => model !== slug), + ), + ); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, @@ -406,32 +417,45 @@ function SettingsRouteView() { ) : null}
    -
    -
    -

    Grayscale provider logos

    -

    - Desaturate provider logos in the thread list while keeping the default layout. -

    -
    - - updateSettings({ - grayscaleProviderLogos: Boolean(checked), - }) - } - aria-label="Use grayscale provider logos" - /> -
    + - {settings.grayscaleProviderLogos !== defaults.grayscaleProviderLogos ? ( + {settings.providerLogoAppearance !== defaults.providerLogoAppearance ? (
    - - {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { const provider = providerSettings.provider; const customModels = getCustomModelsForProvider(settings, provider); @@ -626,10 +613,9 @@ function SettingsRouteView() { variant="outline" onClick={() => updateSettings( - patchCustomModels( - provider, - [...getDefaultCustomModelsForProvider(defaults, provider)], - ), + patchCustomModels(provider, [ + ...getDefaultCustomModelsForProvider(defaults, provider), + ]), ) } > @@ -645,14 +631,9 @@ function SettingsRouteView() { key={`${provider}:${slug}`} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2" > -
    - {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? ( - - ) : null} - - {slug} - -
    + + {slug} +