diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..0c4c30240c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +{ + "name": "T3 Code Dev", + "image": "debian:bookworm", + "features": { + "ghcr.io/devcontainers-extra/features/bun:1": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "24", + "nodeGypDependencies": true + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + } + }, + "postCreateCommand": { + "bun-install": "bun install --backend=copyfile --frozen-lockfile" + }, + "customizations": { + "vscode": { + "extensions": ["oxc.oxc-vscode"] + } + } +} diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md new file mode 100644 index 0000000000..32e35d7caf --- /dev/null +++ b/.docs/remote-architecture.md @@ -0,0 +1,302 @@ +# Remote Architecture + +This document describes the target architecture for first-class remote environments in T3 Code. + +It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. + +## Goals + +- Treat remote environments as first-class product primitives, not special cases. +- Support multiple ways to reach the same environment. +- Keep the T3 server as the execution boundary. +- Let desktop, mobile, and web all share the same conceptual model. +- Avoid introducing a local control plane unless product pressure proves it is necessary. + +## Non-goals + +- Replacing the existing WebSocket server boundary with a custom transport protocol. +- Making SSH the only remote story. +- Syncing provider auth across machines. +- Shipping every access method in the first iteration. + +## High-level architecture + +T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. + +Remote support should preserve that boundary. + +```text +┌──────────────────────────────────────────────┐ +│ Client (desktop / mobile / web) │ +│ │ +│ - known environments │ +│ - connection manager │ +│ - environment-aware routing │ +└───────────────┬──────────────────────────────┘ + │ + │ resolves one access endpoint + │ +┌───────────────▼──────────────────────────────┐ +│ Access method │ +│ │ +│ - direct ws / wss │ +│ - tunneled ws / wss │ +│ - desktop-managed ssh bootstrap + forward │ +└───────────────┬──────────────────────────────┘ + │ + │ connects to one T3 server + │ +┌───────────────▼──────────────────────────────┐ +│ Execution environment = one T3 server │ +│ │ +│ - environment identity │ +│ - provider state │ +│ - projects / threads / terminals │ +│ - git / filesystem / process runtime │ +└──────────────────────────────────────────────┘ +``` + +The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself. + +## Domain model + +### ExecutionEnvironment + +An `ExecutionEnvironment` is one running T3 server instance. + +It is the unit that owns: + +- provider availability and auth state +- model availability +- projects and threads +- terminal processes +- filesystem access +- git operations +- server settings + +It is identified by a stable `environmentId`. + +This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here. + +### KnownEnvironment + +A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. + +It is not server-authored. It is local to a device or client profile. + +Examples: + +- a saved LAN URL +- a saved public `wss://` endpoint +- a desktop-managed SSH host entry +- a saved tunneled environment + +A known environment may or may not know the target `environmentId` before first successful connect. + +### AccessEndpoint + +An `AccessEndpoint` is one concrete way to reach a known environment. + +This is the key abstraction that keeps SSH from taking over the model. + +A single environment may have many endpoints: + +- `wss://t3.example.com` +- `ws://10.0.0.25:3773` +- a tunneled relay URL +- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL + +The environment stays the same. Only the access path changes. + +### RepositoryIdentity + +`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. + +It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. + +### Workspace / Project + +The current `Project` model remains environment-local. + +That means: + +- a local clone and a remote clone are different projects +- they may share a `RepositoryIdentity` +- threads still bind to one project in one environment + +## Access methods + +Access methods answer one question: + +How does the client speak WebSocket to a T3 server? + +They do not answer: + +- how the server got started +- who manages the server process +- whether the environment is local or remote + +### 1. Direct WebSocket access + +Examples: + +- `ws://10.0.0.15:3773` +- `wss://t3.example.com` + +This is the base model and should be the first-class default. + +Benefits: + +- works for desktop, mobile, and web +- no client-specific process management required +- best fit for hosted or self-managed remote T3 deployments + +### 2. Tunneled WebSocket access + +Examples: + +- public relay URLs +- private network relay URLs +- local tunnel products such as pipenet + +This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. + +For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. + +This is especially useful when: + +- the host is behind NAT +- inbound ports are unavailable +- mobile must reach a desktop-hosted environment +- a machine should be reachable without exposing raw LAN or public ports + +### 3. Desktop-managed SSH access + +SSH is an access and launch helper, not a separate environment type. + +The desktop main process can use SSH to: + +- reach a machine +- probe it +- launch or reuse a remote T3 server +- establish a local port forward + +After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. + +This keeps the renderer transport model consistent with every other access method. + +## Launch methods + +Launch methods answer a different question: + +How does a T3 server come to exist on the target machine? + +Launch and access should stay separate in the design. + +### 1. Pre-existing server + +The simplest launch method is no launch at all. + +The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. + +This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. + +### 2. Desktop-managed remote launch over SSH + +This is the main place where Zed is a useful reference. + +Useful ideas to borrow from Zed: + +- remote probing +- platform detection +- session directories with pid/log metadata +- reconnect-friendly launcher behavior +- desktop-owned connection UX + +What should be different in T3: + +- no custom stdio/socket proxy protocol between renderer and remote runtime +- no attempt to make the remote runtime look like an editor transport +- keep the final client-to-server connection as WebSocket + +The recommended T3 flow is: + +1. Desktop connects over SSH. +2. Desktop probes the remote machine and verifies T3 availability. +3. Desktop launches or reuses a remote T3 server. +4. Desktop establishes local port forwarding. +5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. + +### 3. Client-managed local publish + +This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. + +This is useful for: + +- exposing a desktop-hosted environment to mobile +- temporary remote access without changing router or firewall settings + +This is still a launch concern, not a new environment kind. + +## Why access and launch must stay separate + +These concerns are easy to conflate, but separating them prevents architectural drift. + +Examples: + +- A manually hosted T3 server might be reached through direct `wss`. +- The same server might also be reachable through a tunnel. +- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. +- A local desktop server might be published through a tunnel for mobile. + +In all of those cases, the `ExecutionEnvironment` is the same kind of thing. + +Only the launch and access paths differ. + +## Security model + +Remote support must assume that some environments will be reachable over untrusted networks. + +That means: + +- remote-capable environments should require explicit authentication +- tunnel exposure should not rely on obscurity +- client-saved endpoints should carry enough auth metadata to reconnect safely + +T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. + +For publicly reachable environments, authenticated access should be treated as required. + +## Relationship to Zed + +Zed is a useful reference implementation for managed remote launch and reconnect behavior. + +The relevant lessons are: + +- remote bootstrap should be explicit +- reconnect should be first-class +- connection UX belongs in the client shell +- runtime ownership should stay clearly on the remote host + +The important mismatch is transport shape. + +Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. + +T3 should not copy that part. + +T3 already has the right runtime boundary: + +- one T3 server per environment +- ordinary HTTP/WebSocket between client and environment + +So T3 should borrow Zed's launch discipline, not its transport protocol. + +## Recommended rollout + +1. First-class known environments and access endpoints. +2. Direct `ws` / `wss` remote environments. +3. Authenticated tunnel-backed environments. +4. Desktop-managed SSH launch and forwarding. +5. Multi-environment UI improvements after the base runtime path is proven. + +This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67b5e32860..f3ad2d9354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: quality: name: Format, Lint, Typecheck, Test, Browser Test, Build runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 @@ -71,11 +72,12 @@ jobs: - name: Verify preload bundle output run: | test -f apps/desktop/dist-electron/preload.js - grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js release_smoke: name: Release Smoke runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 @@ -90,5 +92,8 @@ jobs: with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 504952e3aa..3d72680d3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,24 @@ -name: Release Desktop +name: Release on: push: tags: - "v*.*.*" + schedule: + - cron: "0 9 * * *" workflow_dispatch: inputs: + channel: + description: "Release channel" + required: false + default: stable + type: choice + options: + - stable + - nightly version: description: "Release version (for example 1.2.3 or v1.2.3)" - required: true + required: false type: string permissions: @@ -19,41 +29,23 @@ jobs: preflight: name: Preflight runs-on: ubuntu-24.04 + timeout-minutes: 10 outputs: + release_channel: ${{ steps.release_meta.outputs.release_channel }} version: ${{ steps.release_meta.outputs.version }} tag: ${{ steps.release_meta.outputs.tag }} + release_name: ${{ steps.release_meta.outputs.name }} + short_sha: ${{ steps.release_meta.outputs.short_sha }} + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} ref: ${{ github.sha }} steps: - name: Checkout uses: actions/checkout@v6 - - - id: release_meta - name: Resolve release version - shell: bash - run: | - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${{ github.event.inputs.version }}" - else - raw="${GITHUB_REF_NAME}" - fi - - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - fi + with: + fetch-depth: 0 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -68,6 +60,60 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - id: release_meta + name: Resolve release version + shell: bash + env: + DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} + DISPATCH_VERSION: ${{ github.event.inputs.version }} + NIGHTLY_DATE: ${{ github.run_started_at }} + NIGHTLY_SHA: ${{ github.sha }} + NIGHTLY_RUN_NUMBER: ${{ github.run_number }} + run: | + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + + node scripts/resolve-nightly-release.ts \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$NIGHTLY_SHA" \ + --github-output + + echo "release_channel=nightly" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + raw="${DISPATCH_VERSION}" + if [[ -z "$raw" ]]; then + echo "workflow_dispatch stable releases require the version input." >&2 + exit 1 + fi + else + raw="${GITHUB_REF_NAME}" + fi + + version="${raw#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release version: $raw" >&2 + exit 1 + fi + + echo "release_channel=stable" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + fi + fi + - name: Lint run: bun run lint @@ -77,10 +123,19 @@ jobs: - name: Test run: bun run test + - id: previous_tag + name: Resolve previous release tag + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.release_channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + build: name: Build ${{ matrix.label }} needs: preflight runs-on: ${{ matrix.runner }} + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -204,16 +259,17 @@ jobs: "release/*.AppImage" \ "release/*.exe" \ "release/*.blockmap" \ - "release/latest*.yml"; do + "release/*.yml"; do for file in $pattern; do cp "$file" release-publish/ done done if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - if [[ -f release-publish/latest-mac.yml ]]; then - mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml" - fi + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" + done fi - name: Upload build artifacts @@ -227,6 +283,7 @@ jobs: name: Publish CLI to npm needs: [preflight, build] runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 @@ -254,12 +311,13 @@ jobs: run: bun run build --filter=@t3tools/web --filter=t3 - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose + run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose release: name: Publish GitHub Release needs: [preflight, build, publish_cli] runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 @@ -280,17 +338,42 @@ jobs: - name: Merge macOS updater manifests run: | - node scripts/merge-mac-update-manifests.ts \ - release-assets/latest-mac.yml \ - release-assets/latest-mac-x64.yml - rm -f release-assets/latest-mac-x64.yml + shopt -s nullglob + for x64_manifest in release-assets/*-mac-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-mac-update-manifests.ts "$arm64_manifest" "$x64_manifest" + rm -f "$x64_manifest" + fi + done - name: Publish release + if: needs.preflight.outputs.previous_tag != '' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.ref }} + name: ${{ needs.preflight.outputs.release_name }} + generate_release_notes: true + previous_tag: ${{ needs.preflight.outputs.previous_tag }} + prerelease: ${{ needs.preflight.outputs.is_prerelease }} + make_latest: ${{ needs.preflight.outputs.make_latest }} + files: | + release-assets/*.dmg + release-assets/*.zip + release-assets/*.AppImage + release-assets/*.exe + release-assets/*.blockmap + release-assets/*.yml + fail_on_unmatched_files: true + + - name: Publish first release + if: needs.preflight.outputs.previous_tag == '' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} - name: T3 Code v${{ needs.preflight.outputs.version }} + name: ${{ needs.preflight.outputs.release_name }} generate_release_notes: true prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} @@ -300,13 +383,15 @@ jobs: release-assets/*.AppImage release-assets/*.exe release-assets/*.blockmap - release-assets/latest*.yml + release-assets/*.yml fail_on_unmatched_files: true finalize: name: Finalize release + if: needs.preflight.outputs.release_channel == 'stable' needs: [preflight, release] runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - id: app_token name: Mint release app token diff --git a/.gitignore b/.gitignore index 6e5f8cc59c..6c48782f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ apps/web/src/components/__screenshots__ .vitest-* __screenshots__/ .tanstack +squashfs-root/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index a3e32c9797..dded6b0acd 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,7 +9,9 @@ "bun.lock", "*.tsbuildinfo", "**/routeTree.gen.ts", - "apps/web/public/mockServiceWorker.js" + "apps/web/public/mockServiceWorker.js", + "apps/web/src/lib/vendor/qrcodegen.ts", + "*.icon/**" ], "sortPackageJson": {} } diff --git a/.plans/18-server-auth-model.md b/.plans/18-server-auth-model.md new file mode 100644 index 0000000000..9f8ba8a05d --- /dev/null +++ b/.plans/18-server-auth-model.md @@ -0,0 +1,823 @@ +# Server Auth Model Plan + +## Purpose + +Define the long-term server auth architecture for T3 Code before first-class remote environments ship. + +This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. + +This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. + +## Primary goals + +- Make auth server-wide, not WebSocket-only. +- Make insecure exposure hard to do accidentally. +- Preserve zero-login local desktop UX for desktop-managed environments. +- Support browser-native pairing and session auth. +- Leave room for native/mobile credentials later without rewriting the server boundary. +- Keep auth separate from transport and launch method. + +## Non-goals + +- Full multi-user authorization and RBAC. +- OAuth / SSO / enterprise identity. +- Passkeys or biometric UX in v1. +- Syncing auth state across environments. +- Designing the full remote environment product in this document. + +## Core decisions + +### 1. Auth is a server concern + +Every privileged surface of the T3 server must go through the same auth policy engine: + +- HTTP routes +- WebSocket upgrades +- RPC methods reached through WebSocket + +The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. + +### 2. Pairing and session are different things + +The system should distinguish: + +- bootstrap credentials +- session credentials + +Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. + +Session credentials are the durable credentials used after pairing. + +Bootstrap should never become the long-lived request credential. + +### 3. Auth and transport are separate + +Auth must not be defined by how the client reached the server. + +Examples: + +- local desktop-managed server +- LAN `ws://` +- public `wss://` +- tunneled `wss://` +- SSH-forwarded `ws://127.0.0.1:` + +All of these should feed into the same auth model. + +### 4. Exposure level changes defaults + +The more exposed an environment is, the narrower the safe default should be. + +Safe default expectations: + +- local desktop-managed: auto-pair allowed +- loopback browser access: explicit bootstrap allowed +- non-loopback bind: auth required +- tunnel/public endpoint: auth required, explicit enablement required + +### 5. Browser and native clients may use different session credentials + +The auth model should support more than one session credential type even if only one ships first. + +Examples: + +- browser session cookie +- native bearer/device token + +This should be represented in the model now, even if browser cookies are the first implementation. + +## Target auth domain + +### Route classes + +Every route or transport entrypoint should be classified as one of: + +1. `public` +2. `bootstrap` +3. `authenticated` + +#### `public` + +Unauthenticated by definition. + +Should be extremely small. Examples: + +- static shell needed to render the pairing/login UI +- favicon/assets required for the pairing screen +- a minimal server health/version endpoint if needed + +#### `bootstrap` + +Used only to exchange a bootstrap credential for a session. + +Examples: + +- Initial bootstrap envelope over file descriptor at startup +- `POST /api/auth/bootstrap` +- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX + +#### `authenticated` + +Everything that reveals machine state or mutates it. + +Examples: + +- WebSocket upgrade +- orchestration snapshot and events +- terminal open/write/close +- project search and file writes +- git routes +- attachments +- project favicon lookup +- server settings + +The default stance should be: if it touches the machine, it is authenticated. + +## Credential model + +### Bootstrap credentials + +Initial credential types to model: + +- `desktop-bootstrap` +- `one-time-token` + +Possible future credential types: + +- `device-code` +- `passkey-assertion` +- `external-identity` + +#### `desktop-bootstrap` + +Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. + +Properties: + +- launcher-provided +- short-lived +- one-time or bounded-use +- never shown to the user as a reusable password + +#### `one-time-token` + +Used for explicit browser/mobile pairing flows. + +Properties: + +- short TTL +- one-time use +- safe to embed in a pairing URL fragment +- exchanged for a session credential + +### Session credentials + +Initial credential types to model: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `browser-session-cookie` + +Primary browser credential. + +Properties: + +- signed +- `HttpOnly` +- bounded lifetime +- revocable by server key rotation or session invalidation + +#### `bearer-session-token` + +Reserved for native/mobile or non-browser clients. + +Properties: + +- opaque token, not a bootstrap secret +- long enough lifetime to survive reconnects +- stored in secure client storage when available + +## Auth policy model + +Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. + +### Policy examples + +#### `DesktopManagedLocalPolicy` + +Default for desktop-managed local server. + +Allowed bootstrap methods: + +- `desktop-bootstrap` + +Allowed session methods: + +- `browser-session-cookie` + +Disabled by default: + +- `one-time-token` +- `bearer-session-token` +- password login +- public pairing + +#### `LoopbackBrowserPolicy` + +Used for browser access on localhost without desktop-managed bootstrap. + +Allowed bootstrap methods: + +- `one-time-token` + +Allowed session methods: + +- `browser-session-cookie` + +#### `RemoteReachablePolicy` + +Used when binding non-loopback or using an explicit remote/tunnel workflow. + +Allowed bootstrap methods: + +- `one-time-token` +- possibly `desktop-bootstrap` when a desktop shell is brokering access + +Allowed session methods: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `UnsafeNoAuthPolicy` + +Should exist only as an explicit escape hatch. + +Requirements: + +- explicit opt-in flag +- loud startup warnings +- never defaulted automatically + +## Effect-native service model + +### `ServerAuth` + +The main auth facade used by HTTP routes and WebSocket upgrade handling. + +Responsibilities: + +- classify requests +- authenticate requests +- authorize bootstrap attempts +- create sessions from bootstrap credentials +- enforce policy by environment mode + +Sketch: + +```ts +export interface ServerAuthShape { + readonly getCapabilities: Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + routeClass: RouteAuthClass, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + input: BootstrapExchangeInput, + ) => Effect.Effect; +} + +export class ServerAuth extends ServiceMap.Service()( + "t3/ServerAuth", +) {} +``` + +### `BootstrapCredentialService` + +Owns issuance, storage, validation, and consumption of bootstrap credentials. + +Responsibilities: + +- issue desktop bootstrap grants +- issue one-time pairing tokens +- validate TTL and single-use semantics +- consume bootstrap grants atomically + +Sketch: + +```ts +export interface BootstrapCredentialServiceShape { + readonly issueDesktopBootstrap: ( + input: IssueDesktopBootstrapInput, + ) => Effect.Effect; + readonly issueOneTimeToken: ( + input: IssueOneTimeTokenInput, + ) => Effect.Effect; + readonly consume: ( + presented: PresentedBootstrapCredential, + ) => Effect.Effect; +} +``` + +### `SessionCredentialService` + +Owns creation and validation of authenticated sessions. + +Responsibilities: + +- mint cookie sessions +- mint bearer sessions +- validate active session credentials +- revoke sessions if needed later + +Sketch: + +```ts +export interface SessionCredentialServiceShape { + readonly createBrowserSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly createBearerSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly authenticateCookie: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateBearer: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; +} +``` + +### `ServerAuthPolicy` + +Pure policy/config service that decides which credential types are allowed. + +Responsibilities: + +- map runtime mode and bind/exposure settings to allowed auth methods +- answer whether a route can be public +- answer whether remote exposure requires auth + +This should stay mostly pure and cheap to test. + +### `ServerSecretStore` + +Owns long-lived server signing keys and secrets. + +Responsibilities: + +- get or create signing key +- rotate signing key +- abstract secure OS-backed storage vs filesystem fallback + +Important: + +- prefer platform secure storage when available +- support hardened filesystem fallback for headless/server-only environments + +### `BrowserSessionCookieCodec` + +Focused utility service for cookie encode/decode/signing behavior. + +This should not own policy. It should only own the cookie format. + +### `AuthRouteGuards` + +Thin helper layer used by routes to enforce auth consistently. + +Responsibilities: + +- require auth for HTTP route handlers +- classify route auth mode +- convert auth failures into `401` / `403` + +This prevents every route from re-implementing the same pattern. + +Integrates with `HttpRouter.middleware` to enforce auth consistently. + +## Suggested layer graph + +```text +ServerSecretStore + ├─> BootstrapCredentialService + ├─> BrowserSessionCookieCodec + └─> SessionCredentialService + +ServerAuthPolicy + ├─> BootstrapCredentialService + ├─> SessionCredentialService + └─> ServerAuth + +ServerAuth + └─> AuthRouteGuards +``` + +Layer naming should follow existing repo style: + +- `ServerSecretStoreLive` +- `BootstrapCredentialServiceLive` +- `SessionCredentialServiceLive` +- `ServerAuthPolicyLive` +- `ServerAuthLive` +- `AuthRouteGuardsLive` + +## High-level implementation examples + +### Example: WebSocket upgrade auth + +Current state: + +- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) + +Target shape: + +```ts +const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateWebSocketUpgrade(request); + return yield* httpApp; + }), +); +``` + +Then the `/ws` route becomes: + +```ts +export const websocketRpcRouteLayer = HttpRouter.add( + "GET", + "/ws", + rpcWebSocketHttpEffect.pipe( + websocketUpgradeAuth, + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +This keeps the route itself declarative and makes auth compose like normal HTTP middleware. + +### Example: authenticated HTTP route + +For routes like attachments or project favicon: + +```ts +const authenticatedRoute = (routeClass: RouteAuthClass) => + HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request, routeClass); + return yield* httpApp; + }), + ); +``` + +Then: + +```ts +export const attachmentsRouteLayer = HttpRouter.add( + "GET", + `${ATTACHMENTS_ROUTE_PREFIX}/*`, + serveAttachment.pipe( + authenticatedRoute(RouteAuthClass.Authenticated), + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +### Example: desktop bootstrap exchange + +The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. + +That grant is then exchanged for a browser cookie session when the renderer loads. + +Sketch: + +```ts +const pairDesktopRenderer = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + const credential = yield* bootstrapService.issueDesktopBootstrap({ + audience: "desktop-renderer", + ttlMs: 30_000, + }); + return credential; +}); +``` + +The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. + +### Example: one-time pairing URL + +For browser-driven pairing: + +```ts +const createPairingToken = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + return yield* bootstrapService.issueOneTimeToken({ + ttlMs: 5 * 60_000, + audience: "browser", + }); +}); +``` + +The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. + +## Sequence diagrams + +These flows are meant to anchor the auth model in concrete user journeys. + +The important invariant across all of them is: + +- access method is not the auth method +- launch method is not the auth method +- bootstrap credential is not the session credential + +### Normal desktop user + +This is the default desktop-managed local flow. + +The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. + +```text +Participants: + DesktopMain = Electron main + SecretStore = secure local secret backend + T3Server = local backend child process + Frontend = desktop renderer + +DesktopMain -> SecretStore : getOrCreate("server-signing-key") +SecretStore --> DesktopMain : signing key available + +DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) +DesktopMain -> T3Server : send desktop bootstrap envelope +note over T3Server : policy = DesktopManagedLocalPolicy +note over T3Server : allowed pairing = desktop-bootstrap only + +Frontend -> DesktopMain : request local bootstrap grant +DesktopMain --> Frontend : short-lived desktop bootstrap grant + +Frontend -> T3Server : POST /api/auth/bootstrap +T3Server -> T3Server : validate desktop bootstrap grant +T3Server -> T3Server : create browser session +T3Server --> Frontend : Set-Cookie: session=... + +Frontend -> T3Server : GET /ws + authenticated cookie +T3Server -> T3Server : validate cookie session +T3Server --> Frontend : websocket accepted +``` + +### `npx t3` user + +This is the standalone local server flow. + +There is no trusted desktop shell here, so pairing should be explicit. + +```text +Participants: + UserShell = npx t3 launcher + T3Server = standalone local server + Browser = browser tab + +UserShell -> T3Server : start server +T3Server -> T3Server : getOrCreate("server-signing-key") +note over T3Server : policy = LoopbackBrowserPolicy + +UserShell -> T3Server : issue one-time pairing token +T3Server --> UserShell : pairing URL or pairing token + +UserShell --> Browser : open /pair?token=... + +Browser -> T3Server : GET /pair?token=... +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create browser session +T3Server --> Browser : Set-Cookie: session=... +T3Server --> Browser : redirect to app + +Browser -> T3Server : GET /ws + authenticated cookie +T3Server --> Browser : websocket accepted +``` + +### Phone user with tunneled host + +This is the explicit remote access flow for a browser on another device. + +The tunnel only provides reachability. It must not imply trust. + +Recommended UX: + +- desktop shows a QR code +- desktop also shows a copyable pairing URL +- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access + +```text +Participants: + DesktopUser = user at the host machine + DesktopMain = desktop app + Tunnel = tunnel provider + T3Server = T3 server + PhoneBrowser = mobile browser + +DesktopUser -> DesktopMain : enable remote access via tunnel +DesktopMain -> T3Server : switch policy to RemoteReachablePolicy +DesktopMain -> Tunnel : publish local T3 endpoint +Tunnel --> DesktopMain : public https/wss URL + +DesktopMain -> T3Server : issue one-time pairing token +T3Server --> DesktopMain : pairing token +DesktopMain -> DesktopUser : show QR code / shareable URL + +DesktopUser -> PhoneBrowser : scan QR / open URL +PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... +Tunnel -> T3Server : forward request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> Tunnel : GET /ws + authenticated cookie +Tunnel -> T3Server : forward websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Phone user with private network + +This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. + +The auth flow should stay the same. + +```text +Participants: + DesktopUser = user at the host machine + T3Server = T3 server + PrivateNet = tailscale / private LAN + PhoneBrowser = mobile browser + +DesktopUser -> T3Server : enable private-network access +T3Server -> T3Server : switch policy to RemoteReachablePolicy +DesktopUser -> T3Server : issue one-time pairing token +T3Server --> DesktopUser : pairing URL / QR + +DesktopUser -> PhoneBrowser : open private-network URL +PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... +PrivateNet -> T3Server : route request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie +PrivateNet -> T3Server : websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Desktop user adding new SSH hosts + +SSH should be treated as launch and reachability plumbing, not as the long-term auth model. + +The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. + +```text +Participants: + DesktopUser = local desktop user + DesktopMain = desktop app + SSH = ssh transport/session + RemoteHost = remote machine + RemoteT3 = remote T3 server + Frontend = desktop renderer + +DesktopUser -> DesktopMain : add SSH host +DesktopMain -> SSH : connect to remote host +SSH -> RemoteHost : probe environment / verify t3 availability +DesktopMain -> SSH : run remote launch command +SSH -> RemoteHost : t3 remote launch --json +RemoteHost -> RemoteT3 : start or reuse server +RemoteT3 --> RemoteHost : port + environment metadata +RemoteHost --> SSH : launch result JSON +SSH --> DesktopMain : remote server details + +DesktopMain -> SSH : establish local port forward +SSH --> DesktopMain : localhost:FORWARDED_PORT ready + +note over RemoteT3 : policy = RemoteReachablePolicy +note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here + +Frontend -> DesktopMain : request bootstrap for selected environment +DesktopMain --> Frontend : short-lived bootstrap grant + +Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port +RemoteT3 -> RemoteT3 : validate bootstrap grant +RemoteT3 -> RemoteT3 : create browser session +RemoteT3 --> Frontend : Set-Cookie: session=... + +Frontend -> RemoteT3 : GET /ws + authenticated cookie +RemoteT3 --> Frontend : websocket accepted +``` + +## Storage decisions + +### Server secrets + +Use a `ServerSecretStore` abstraction. + +Preferred order (use a layer for each, resolve on startup): + +1. OS secure storage if available +2. hardened filesystem fallback if not + +The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. + +### Client credentials + +Client-side credential persistence should prefer secure storage when available: + +- desktop: OS keychain / secure store +- mobile: platform secure storage +- browser: cookie session for browser auth + +This concern should stay in the client shell/runtime layer, not the server auth layer. + +## What to build now + +These are the parts worth building before remote environments ship: + +1. `ServerAuth` service boundary. +2. route classification and route guards. +3. `ServerSecretStore` abstraction. +4. bootstrap vs session credential split. +5. browser session cookie codec as one session method. +6. explicit auth capabilities/config surfaced in contracts. + +Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. + +## What to add as part of first remote-capable auth + +1. Browser pairing flow using one-time bootstrap token and cookie session. +2. Desktop-managed auto-bootstrap for the local desktop-managed environment. +3. Auth-required defaults for any non-loopback or explicitly published server. +4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. + +## What to defer + +- passkeys / WebAuthn +- iCloud Keychain / Face ID-specific UX +- multi-user permissions +- collaboration roles +- OAuth / SSO +- polished session management UI +- complex device approval flows + +These can all sit on top of the same bootstrap/session/service split. + +## Relationship to future remote environments + +Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. + +Keep the design focused on: + +- one T3 server +- one auth policy +- multiple credential types +- multiple future access methods + +That keeps the server auth model stable even as access methods expand later. + +## Recommended implementation order + +### Phase 1 + +- Introduce route auth classes. +- Add `ServerAuth` and `AuthRouteGuards`. +- Move existing `authToken` check behind `ServerAuth`. +- Require auth for all privileged HTTP routes as well as WebSocket. + +### Phase 2 + +- Add `ServerSecretStore` service with platform-specific layer implementations. + - `layerOSXKeychain`, `layer +- Add bootstrap/session split. +- Add browser session cookie support. +- Add one-time bootstrap exchange endpoint. + +### Phase 3 + +- Add desktop bootstrap flow on top of the same services. +- Make desktop-managed local environments default to bootstrap-only pairing. +- Surface auth capabilities in shared contracts and renderer bootstrap. + +### Phase 4 + +- Add non-browser bearer session support if mobile/native needs it. +- Add richer policy modes for remote-reachable environments. + +## Acceptance criteria + +- No privileged HTTP or WebSocket path bypasses auth policy. +- Local desktop-managed flows still avoid a visible login screen. +- Non-loopback or published environments require explicit authenticated pairing by default. +- Bootstrap and session credentials are distinct in code and in behavior. +- Auth logic is centralized in Effect services/layers rather than route-local branching. diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e7..b57c13032c 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, @@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/README.md b/README.md index d3a54a1b90..0e99b4bbf7 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,14 @@ Observability guide: [docs/observability.md](./docs/observability.md) ## If you REALLY want to contribute still.... read this first +Before local development, prepare the environment and install dependencies: + +```bash +# Optional: only needed if you use mise for dev tool management. +mise install +bun install . +``` + Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). diff --git a/REMOTE.md b/REMOTE.md index 9dc15ed1fe..30dc562792 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -1,68 +1,87 @@ -# Remote Access Setup +# Remote Access -Use this when you want to open T3 Code from another device (phone, tablet, another laptop). +Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. -## CLI ↔ Env option map +## Recommended Setup -The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: +Use a trusted private network that meshes your devices together, such as a tailnet. -| CLI flag | Env var | Notes | -| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | -| `--mode ` | `T3CODE_MODE` | Runtime mode. | -| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | -| `--host
` | `T3CODE_HOST` | Bind interface/address. | -| `--base-dir ` | `T3CODE_HOME` | Base directory. | -| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | -| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | -| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. | -| `--bootstrap-fd ` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. | +That gives you: -> TIP: Use the `--help` flag to see all available options and their descriptions. +- a stable address to connect to +- transport security at the network layer +- less exposure than opening the server to the public internet -## Security First +## Enabling Network Access -- Always set `--auth-token` before exposing the server outside localhost. - - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd `. - With `--bootstrap-fd `, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments. -- Treat the token like a password. -- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. +There are two ways to expose your server for remote connections: from the desktop app or from the CLI. -## 1) Build + run server for remote access +### Option 1: Desktop App -Remote access should use the built web app (not local Vite redirect mode). +If you are already running the desktop app and want to make it reachable from other devices: + +1. Open **Settings** → **Connections**. +2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. +3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). +4. Use **Create Link** to generate a pairing link you can share with another device. + +### Option 2: Headless Server (CLI) + +Use this when you want to run the server without a GUI, for example on a remote machine over SSH. + +Run the server with `t3 serve`. ```bash -bun run build -TOKEN="$(openssl rand -hex 24)" -bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser +npx t3 serve --host "$(tailscale ip -4)" ``` -Then open on your phone: +`t3 serve` starts the server without opening a browser and prints: -`http://:3773` +- a connection string +- a pairing token +- a pairing URL +- a QR code for the pairing URL -Example: +From there, connect from another device in either of these ways: -`http://192.168.1.42:3773` +- scan the QR code on your phone +- in the desktop app, enter the full pairing URL +- in the desktop app, enter the host and token separately -Notes: +Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. -- `--host 0.0.0.0` listens on all IPv4 interfaces. -- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. -- Ensure your OS firewall allows inbound TCP on the selected port. +> Note +> The GUIs do not currently support adding projects on remote environments. +> For now, use `t3 project ...` on the server machine instead. +> Full GUI support for remote project management is coming soon. -## 2) Tailnet / Tailscale access +## How Pairing Works -If you use Tailscale, you can bind directly to your Tailnet address. +The remote device does not need a long-lived secret up front. -```bash -TAILNET_IP="$(tailscale ip -4)" -TOKEN="$(openssl rand -hex 24)" -bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser -``` +Instead: + +1. `t3 serve` issues a one-time owner pairing token. +2. The remote device exchanges that token with the server. +3. The server creates an authenticated session for that device. + +After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. + +## Managing Access Later + +Use `t3 auth` to manage access after the initial pairing flow. + +Typical uses: + +- issue additional pairing credentials +- inspect active sessions +- revoke old pairing links or sessions -Open from any device in your tailnet: +Use `t3 auth --help` and the nested subcommand help pages for the full reference. -`http://:3773` +## Security Notes -You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. +- Treat pairing URLs and pairing tokens like passwords. +- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. +- Anyone with a valid pairing credential can create a session until that credential expires or is revoked. +- Use `t3 auth` to revoke credentials or sessions you no longer trust. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dfa3bde2f8..a38ffd2df1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.15", + "version": "0.0.17", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 5244d51dbf..7c0d55ac9a 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -5,8 +5,17 @@ import { join } from "node:path"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; -const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); -const devServerUrl = `http://localhost:${port}`; +const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); +if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required for desktop development."); +} + +const devServer = new URL(devServerUrl); +const port = Number.parseInt(devServer.port, 10); +if (!Number.isInteger(port) || port <= 0) { + throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`); +} + const requiredFiles = [ "dist-electron/main.js", "dist-electron/preload.js", @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; await waitForResources({ baseDir: desktopDir, files: requiredFiles, + tcpHost: devServer.hostname, tcpPort: port, }); @@ -62,10 +72,7 @@ function startApp() { [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], { cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, + env: childEnv, stdio: "inherit", }, ); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 9d7c522781..77d9df3142 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -6,6 +6,7 @@ import { cpSync, existsSync, mkdirSync, + mkdtempSync, readFileSync, readdirSync, rmSync, @@ -18,11 +19,14 @@ import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; -const APP_BUNDLE_ID = "com.t3tools.t3code"; +const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; const LAUNCHER_VERSION = 1; const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); +const repoRoot = resolve(desktopDir, "..", ".."); +const defaultIconPath = join(desktopDir, "resources", "icon.icns"); +const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); function setPlistString(plistPath, key, value) { const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { @@ -43,6 +47,68 @@ function setPlistString(plistPath, key, value) { throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); } +function runChecked(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status === 0) { + return; + } + + const details = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); +} + +function ensureDevelopmentIconIcns(runtimeDir) { + const generatedIconPath = join(runtimeDir, "icon-dev.icns"); + mkdirSync(runtimeDir, { recursive: true }); + + if (!existsSync(developmentMacIconPngPath)) { + return defaultIconPath; + } + + const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; + if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { + return generatedIconPath; + } + + const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); + const iconsetDir = join(iconsetRoot, "icon.iconset"); + mkdirSync(iconsetDir, { recursive: true }); + + try { + for (const size of [16, 32, 128, 256, 512]) { + runChecked("sips", [ + "-z", + String(size), + String(size), + developmentMacIconPngPath, + "--out", + join(iconsetDir, `icon_${size}x${size}.png`), + ]); + + const retinaSize = size * 2; + runChecked("sips", [ + "-z", + String(retinaSize), + String(retinaSize), + developmentMacIconPngPath, + "--out", + join(iconsetDir, `icon_${size}x${size}@2x.png`), + ]); + } + + runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]); + return generatedIconPath; + } catch (error) { + console.warn( + "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.", + error, + ); + return defaultIconPath; + } finally { + rmSync(iconsetRoot, { recursive: true, force: true }); + } +} + function patchMainBundleInfoPlist(appBundlePath, iconPath) { const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); @@ -102,7 +168,7 @@ function buildMacLauncher(electronBinaryPath) { const runtimeDir = join(desktopDir, ".electron-runtime"); const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); - const iconPath = join(desktopDir, "resources", "icon.icns"); + const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = join(runtimeDir, "metadata.json"); mkdirSync(runtimeDir, { recursive: true }); diff --git a/apps/desktop/src/appBranding.test.ts b/apps/desktop/src/appBranding.test.ts new file mode 100644 index 0000000000..93e872fb04 --- /dev/null +++ b/apps/desktop/src/appBranding.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding"; + +describe("resolveDesktopAppStageLabel", () => { + it("uses Dev in desktop development", () => { + expect( + resolveDesktopAppStageLabel({ + isDevelopment: true, + appVersion: "0.0.17-nightly.20260414.1", + }), + ).toBe("Dev"); + }); + + it("uses Nightly for packaged nightly builds", () => { + expect( + resolveDesktopAppStageLabel({ + isDevelopment: false, + appVersion: "0.0.17-nightly.20260414.1", + }), + ).toBe("Nightly"); + }); + + it("uses Alpha for packaged stable builds", () => { + expect( + resolveDesktopAppStageLabel({ + isDevelopment: false, + appVersion: "0.0.17", + }), + ).toBe("Alpha"); + }); +}); + +describe("resolveDesktopAppBranding", () => { + it("returns a complete desktop branding payload", () => { + expect( + resolveDesktopAppBranding({ + isDevelopment: false, + appVersion: "0.0.17-nightly.20260414.1", + }), + ).toEqual({ + baseName: "T3 Code", + stageLabel: "Nightly", + displayName: "T3 Code (Nightly)", + }); + }); +}); diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts new file mode 100644 index 0000000000..49cbcc6780 --- /dev/null +++ b/apps/desktop/src/appBranding.ts @@ -0,0 +1,28 @@ +import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; + +import { isNightlyDesktopVersion } from "./updateChannels"; + +const APP_BASE_NAME = "T3 Code"; + +export function resolveDesktopAppStageLabel(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppStageLabel { + if (input.isDevelopment) { + return "Dev"; + } + + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; +} + +export function resolveDesktopAppBranding(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppBranding { + const stageLabel = resolveDesktopAppStageLabel(input); + return { + baseName: APP_BASE_NAME, + stageLabel, + displayName: `${APP_BASE_NAME} (${stageLabel})`, + }; +} diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts new file mode 100644 index 0000000000..8f586deb70 --- /dev/null +++ b/apps/desktop/src/backendPort.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveDesktopBackendPort } from "./backendPort"; + +describe("resolveDesktopBackendPort", () => { + it("returns the starting port when it is available", async () => { + const canListenOnHost = vi.fn(async (port: number) => port === 3773); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3773); + + expect(canListenOnHost).toHaveBeenCalledTimes(1); + expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); + }); + + it("increments sequentially until it finds an available port", async () => { + const canListenOnHost = vi.fn(async (port: number) => port === 3775); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3775); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3774, "127.0.0.1"], + [3775, "127.0.0.1"], + ]); + }); + + it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { + const canListenOnHost = vi.fn(async (port: number, host: string) => { + if (port === 3773 && host === "127.0.0.1") return true; + if (port === 3773 && host === "0.0.0.0") return false; + return port === 3774; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0"], + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3774); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3774, "127.0.0.1"], + [3774, "0.0.0.0"], + ]); + }); + + it("checks overlapping hosts sequentially to avoid self-interference", async () => { + let inFlightCount = 0; + const canListenOnHost = vi.fn(async (_port: number, _host: string) => { + inFlightCount += 1; + const overlapped = inFlightCount > 1; + await Promise.resolve(); + inFlightCount -= 1; + return !overlapped; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0", "::"], + startPort: 3773, + maxPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3773); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3773, "::"], + ]); + }); + + it("fails when the scan range is exhausted", async () => { + const canListenOnHost = vi.fn(async () => false); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + startPort: 65534, + maxPort: 65535, + canListenOnHost, + }), + ).rejects.toThrow( + "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", + ); + + expect(canListenOnHost.mock.calls).toEqual([ + [65534, "127.0.0.1"], + [65535, "127.0.0.1"], + ]); + }); +}); diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts new file mode 100644 index 0000000000..1ce90a257f --- /dev/null +++ b/apps/desktop/src/backendPort.ts @@ -0,0 +1,83 @@ +import * as Effect from "effect/Effect"; +import { NetService } from "@t3tools/shared/Net"; + +export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; +const MAX_TCP_PORT = 65_535; + +export interface ResolveDesktopBackendPortOptions { + readonly host: string; + readonly startPort?: number; + readonly maxPort?: number; + readonly requiredHosts?: ReadonlyArray; + readonly canListenOnHost?: (port: number, host: string) => Promise; +} + +const defaultCanListenOnHost = async (port: number, host: string): Promise => + Effect.service(NetService).pipe( + Effect.flatMap((net) => net.canListenOnHost(port, host)), + Effect.provide(NetService.layer), + Effect.runPromise, + ); + +const isValidPort = (port: number): boolean => + Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; + +const normalizeHosts = ( + host: string, + requiredHosts: ReadonlyArray, +): ReadonlyArray => + Array.from( + new Set( + [host, ...requiredHosts] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0), + ), + ); + +async function canListenOnAllHosts( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Promise, +): Promise { + for (const candidateHost of hosts) { + if (!(await canListenOnHost(port, candidateHost))) { + return false; + } + } + + return true; +} + +export async function resolveDesktopBackendPort({ + host, + startPort = DEFAULT_DESKTOP_BACKEND_PORT, + maxPort = MAX_TCP_PORT, + requiredHosts = [], + canListenOnHost = defaultCanListenOnHost, +}: ResolveDesktopBackendPortOptions): Promise { + if (!isValidPort(startPort)) { + throw new Error(`Invalid desktop backend start port: ${startPort}`); + } + + if (!isValidPort(maxPort)) { + throw new Error(`Invalid desktop backend max port: ${maxPort}`); + } + + if (maxPort < startPort) { + throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); + } + + const hostsToCheck = normalizeHosts(host, requiredHosts); + + // Keep desktop startup predictable across app restarts by probing upward from + // the same preferred port instead of picking a fresh ephemeral port. + for (let port = startPort; port <= maxPort; port += 1) { + if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { + return port; + } + } + + throw new Error( + `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, + ); +} diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts new file mode 100644 index 0000000000..33a5ef6b71 --- /dev/null +++ b/apps/desktop/src/backendReadiness.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BackendReadinessAbortedError, + isBackendReadinessAborted, + waitForHttpReady, +} from "./backendReadiness"; + +describe("waitForHttpReady", () => { + it("returns once the backend serves the requested readiness path", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + "http://127.0.0.1:3773/", + expect.objectContaining({ redirect: "manual" }), + ); + }); + + it("retries after a readiness request stalls past the per-request timeout", async () => { + const fetchImpl = vi + .fn() + .mockImplementationOnce( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + reject(new Error("request timed out")); + }, + { once: true }, + ); + }) as ReturnType, + ) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 100, + intervalMs: 0, + requestTimeoutMs: 1, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("aborts an in-flight readiness wait", async () => { + const controller = new AbortController(); + const fetchImpl = vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + controller.signal.addEventListener( + "abort", + () => { + reject(new BackendReadinessAbortedError()); + }, + { once: true }, + ); + }) as ReturnType, + ); + + const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + signal: controller.signal, + }); + + controller.abort(); + + await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); + }); + + it("recognizes aborted readiness errors", () => { + expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); + expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); + }); + + it("supports custom readiness predicates", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + path: "/api/healthz", + isReady: (response) => response.status === 204, + }); + + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + "http://127.0.0.1:3773/api/healthz", + expect.objectContaining({ redirect: "manual" }), + ); + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + "http://127.0.0.1:3773/api/healthz", + expect.objectContaining({ redirect: "manual" }), + ); + }); +}); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts new file mode 100644 index 0000000000..71c28929eb --- /dev/null +++ b/apps/desktop/src/backendReadiness.ts @@ -0,0 +1,107 @@ +export interface WaitForHttpReadyOptions { + readonly timeoutMs?: number; + readonly intervalMs?: number; + readonly requestTimeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly signal?: AbortSignal; + readonly path?: string; + readonly isReady?: (response: Response) => boolean; +} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_INTERVAL_MS = 100; +const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; + +export class BackendReadinessAbortedError extends Error { + constructor() { + super("Backend readiness wait was aborted."); + this.name = "BackendReadinessAbortedError"; + } +} + +function delay(ms: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + const onAbort = () => { + cleanup(); + reject(new BackendReadinessAbortedError()); + }; + + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + + if (signal?.aborted) { + cleanup(); + reject(new BackendReadinessAbortedError()); + return; + } + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { + return error instanceof BackendReadinessAbortedError; +} + +export async function waitForHttpReady( + baseUrl: string, + options?: WaitForHttpReadyOptions, +): Promise { + const fetchImpl = options?.fetchImpl ?? fetch; + const signal = options?.signal; + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; + const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const readinessPath = options?.path ?? "/"; + const isReady = options?.isReady ?? ((response: Response) => response.ok); + const deadline = Date.now() + timeoutMs; + + for (;;) { + if (signal?.aborted) { + throw new BackendReadinessAbortedError(); + } + + const requestController = new AbortController(); + const requestTimeout = setTimeout(() => { + requestController.abort(); + }, requestTimeoutMs); + const abortRequest = () => { + requestController.abort(); + }; + signal?.addEventListener("abort", abortRequest, { once: true }); + + try { + const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), { + redirect: "manual", + signal: requestController.signal, + }); + if (isReady(response)) { + return; + } + } catch (error) { + if (isBackendReadinessAborted(error)) { + throw error; + } + if (signal?.aborted) { + throw new BackendReadinessAbortedError(); + } + // Retry until the backend becomes reachable or the deadline expires. + } finally { + clearTimeout(requestTimeout); + signal?.removeEventListener("abort", abortRequest); + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); + } + + await delay(intervalMs, signal); + } +} diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts new file mode 100644 index 0000000000..df2178c0b0 --- /dev/null +++ b/apps/desktop/src/clientPersistence.test.ts @@ -0,0 +1,234 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + EnvironmentId, + type ClientSettings, + type PersistedSavedEnvironmentRecord, +} from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + readClientSettings, + readSavedEnvironmentRegistry, + readSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + writeClientSettings, + writeSavedEnvironmentRegistry, + writeSavedEnvironmentSecret, + type DesktopSecretStorage, +} from "./clientPersistence"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeTempPath(fileName: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-")); + tempDirectories.push(directory); + return path.join(directory, fileName); +} + +function makeSecretStorage(available: boolean): DesktopSecretStorage { + return { + isEncryptionAvailable: () => available, + encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), + decryptString: (value) => { + const decoded = value.toString("utf8"); + if (!decoded.startsWith("enc:")) { + throw new Error("invalid secret"); + } + return decoded.slice("enc:".length); + }, + }; +} + +const clientSettings: ClientSettings = { + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", +}; + +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: "2026-04-09T01:00:00.000Z", +}; + +describe("clientPersistence", () => { + it("persists and reloads client settings", () => { + const settingsPath = makeTempPath("client-settings.json"); + + writeClientSettings(settingsPath, clientSettings); + + expect(readClientSettings(settingsPath)).toEqual(clientSettings); + }); + + it("persists and reloads saved environment metadata", () => { + const registryPath = makeTempPath("saved-environments.json"); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); + }); + + it("persists encrypted saved environment secrets when encryption is available", () => { + const registryPath = makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + expect( + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage, + }), + ).toBe(true); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage, + }), + ).toBe("bearer-token"); + + expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ + records: [ + { + ...savedRegistryRecord, + encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), + }, + ], + }); + }); + + it("preserves existing secrets when encryption is unavailable", () => { + const registryPath = makeTempPath("saved-environments.json"); + const availableSecretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage: availableSecretStorage, + }); + + expect( + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + secretStorage: makeSecretStorage(false), + }), + ).toBe(false); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage: availableSecretStorage, + }), + ).toBe("bearer-token"); + }); + + it("removes saved environment secrets", () => { + const registryPath = makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage, + }); + + removeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + }); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage, + }), + ).toBeNull(); + }); + + it("treats malformed secrets documents as empty", () => { + const registryPath = makeTempPath("saved-environments.json"); + fs.writeFileSync(registryPath, "{}\n", "utf8"); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage: makeSecretStorage(true), + }), + ).toBeNull(); + + expect(() => + removeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + }), + ).not.toThrow(); + }); + + it("returns false when writing a secret without metadata", () => { + const registryPath = makeTempPath("saved-environments.json"); + + expect( + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage: makeSecretStorage(true), + }), + ).toBe(false); + }); + + it("preserves encrypted secrets when metadata is rewritten", () => { + const registryPath = makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage, + }); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage, + }), + ).toBe("bearer-token"); + }); +}); diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts new file mode 100644 index 0000000000..183de1a971 --- /dev/null +++ b/apps/desktop/src/clientPersistence.ts @@ -0,0 +1,216 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +import type { ClientSettings, PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { Predicate } from "effect"; + +interface ClientSettingsDocument { + readonly settings: ClientSettings; +} + +interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { + readonly encryptedBearerToken?: string; +} + +interface SavedEnvironmentRegistryDocument { + readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +export interface DesktopSecretStorage { + readonly isEncryptionAvailable: () => boolean; + readonly encryptString: (value: string) => Buffer; + readonly decryptString: (value: Buffer) => string; +} + +function readJsonFile(filePath: string): T | null { + try { + if (!FS.existsSync(filePath)) { + return null; + } + return JSON.parse(FS.readFileSync(filePath, "utf8")) as T; + } catch { + return null; + } +} + +function writeJsonFile(filePath: string, value: unknown): void { + const directory = Path.dirname(filePath); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + FS.mkdirSync(directory, { recursive: true }); + FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + FS.renameSync(tempPath, filePath); +} + +function isPersistedSavedEnvironmentStorageRecord( + value: unknown, +): value is PersistedSavedEnvironmentStorageRecord { + return ( + Predicate.isObject(value) && + typeof value.environmentId === "string" && + typeof value.label === "string" && + typeof value.httpBaseUrl === "string" && + typeof value.wsBaseUrl === "string" && + typeof value.createdAt === "string" && + (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && + (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") + ); +} + +function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument { + const parsed = readJsonFile(filePath); + if (!Predicate.isObject(parsed)) { + return { records: [] }; + } + + return { + records: Array.isArray(parsed.records) + ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord) + : [], + }; +} + +function toPersistedSavedEnvironmentRecord( + record: PersistedSavedEnvironmentStorageRecord, +): PersistedSavedEnvironmentRecord { + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; +} + +export function readClientSettings(settingsPath: string): ClientSettings | null { + return readJsonFile(settingsPath)?.settings ?? null; +} + +export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { + writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); +} + +export function readSavedEnvironmentRegistry( + registryPath: string, +): readonly PersistedSavedEnvironmentRecord[] { + return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => + toPersistedSavedEnvironmentRecord(record), + ); +} + +export function writeSavedEnvironmentRegistry( + registryPath: string, + records: readonly PersistedSavedEnvironmentRecord[], +): void { + const currentDocument = readSavedEnvironmentRegistryDocument(registryPath); + const encryptedBearerTokenById = new Map( + currentDocument.records.flatMap((record) => + record.encryptedBearerToken + ? [[record.environmentId, record.encryptedBearerToken] as const] + : [], + ), + ); + writeJsonFile(registryPath, { + records: records.map((record) => { + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); + return encryptedBearerToken + ? { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + encryptedBearerToken, + } + : record; + }), + } satisfies SavedEnvironmentRegistryDocument); +} + +export function readSavedEnvironmentSecret(input: { + readonly registryPath: string; + readonly environmentId: string; + readonly secretStorage: DesktopSecretStorage; +}): string | null { + const document = readSavedEnvironmentRegistryDocument(input.registryPath); + const encoded = document.records.find( + (record) => record.environmentId === input.environmentId, + )?.encryptedBearerToken; + if (!encoded) { + return null; + } + + if (!input.secretStorage.isEncryptionAvailable()) { + return null; + } + + try { + return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); + } catch { + return null; + } +} + +export function writeSavedEnvironmentSecret(input: { + readonly registryPath: string; + readonly environmentId: string; + readonly secret: string; + readonly secretStorage: DesktopSecretStorage; +}): boolean { + const document = readSavedEnvironmentRegistryDocument(input.registryPath); + + if (!input.secretStorage.isEncryptionAvailable()) { + return false; + } + + let found = false; + + writeJsonFile(input.registryPath, { + records: document.records.map((record) => { + if (record.environmentId !== input.environmentId) { + return record; + } + + found = true; + const encryptedBearerToken = input.secretStorage + .encryptString(input.secret) + .toString("base64"); + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + encryptedBearerToken, + } satisfies PersistedSavedEnvironmentStorageRecord; + }), + } satisfies SavedEnvironmentRegistryDocument); + return found; +} + +export function removeSavedEnvironmentSecret(input: { + readonly registryPath: string; + readonly environmentId: string; +}): void { + const document = readSavedEnvironmentRegistryDocument(input.registryPath); + if ( + !document.records.some( + (record) => + record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + writeJsonFile(input.registryPath, { + records: document.records.map((record) => { + if (record.environmentId !== input.environmentId) { + return record; + } + + return toPersistedSavedEnvironmentRecord(record); + }), + } satisfies SavedEnvironmentRegistryDocument); +} diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts new file mode 100644 index 0000000000..7c8be53f82 --- /dev/null +++ b/apps/desktop/src/desktopSettings.test.ts @@ -0,0 +1,147 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettings, + resolveDefaultDesktopSettings, + setDesktopServerExposurePreference, + setDesktopUpdateChannelPreference, + writeDesktopSettings, +} from "./desktopSettings"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeSettingsPath() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); + tempDirectories.push(directory); + return path.join(directory, "desktop-settings.json"); +} + +describe("desktopSettings", () => { + it("returns defaults when no settings file exists", () => { + expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("defaults packaged nightly builds to the nightly update channel", () => { + expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }); + + it("persists and reloads the configured server exposure mode", () => { + const settingsPath = makeSettingsPath(); + + writeDesktopSettings(settingsPath, { + serverExposureMode: "network-accessible", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ + serverExposureMode: "network-accessible", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + }); + + it("preserves the requested network-accessible preference across temporary fallback", () => { + expect( + setDesktopServerExposurePreference( + { + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + "network-accessible", + ), + ).toEqual({ + serverExposureMode: "network-accessible", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }); + + it("persists the requested nightly update channel", () => { + expect( + setDesktopUpdateChannelPreference( + { + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + "nightly", + ), + ).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: true, + }); + }); + + it("falls back to defaults when the settings file is malformed", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync(settingsPath, "{not-json", "utf8"); + + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("falls back to the nightly channel for legacy nightly settings without an update track", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8"); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }); + + it("migrates legacy implicit stable settings to nightly when running a nightly build", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "latest", + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }); + + it("preserves an explicit stable choice on nightly builds", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + }); +}); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts new file mode 100644 index 0000000000..cb0829a8b6 --- /dev/null +++ b/apps/desktop/src/desktopSettings.ts @@ -0,0 +1,92 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; +import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; + +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels"; + +export interface DesktopSettings { + readonly serverExposureMode: DesktopServerExposureMode; + readonly updateChannel: DesktopUpdateChannel; + readonly updateChannelConfiguredByUser: boolean; +} + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, +}; + +export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { + return { + ...DEFAULT_DESKTOP_SETTINGS, + updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + }; +} + +export function setDesktopServerExposurePreference( + settings: DesktopSettings, + requestedMode: DesktopServerExposureMode, +): DesktopSettings { + return settings.serverExposureMode === requestedMode + ? settings + : { + ...settings, + serverExposureMode: requestedMode, + }; +} + +export function setDesktopUpdateChannelPreference( + settings: DesktopSettings, + requestedChannel: DesktopUpdateChannel, +): DesktopSettings { + return { + ...settings, + updateChannel: requestedChannel, + updateChannelConfiguredByUser: true, + }; +} + +export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + + try { + if (!FS.existsSync(settingsPath)) { + return defaultSettings; + } + + const raw = FS.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as { + readonly serverExposureMode?: unknown; + readonly updateChannel?: unknown; + readonly updateChannelConfiguredByUser?: unknown; + }; + const parsedUpdateChannel = + parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" + ? parsed.updateChannel + : null; + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; + const updateChannelConfiguredByUser = + parsed.updateChannelConfiguredByUser === true || + (isLegacySettings && parsedUpdateChannel === "nightly"); + + return { + serverExposureMode: + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + updateChannel: + updateChannelConfiguredByUser && parsedUpdateChannel !== null + ? parsedUpdateChannel + : defaultSettings.updateChannel, + updateChannelConfiguredByUser, + }; + } catch { + return defaultSettings; + } +} + +export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { + const directory = Path.dirname(settingsPath); + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; + FS.mkdirSync(directory, { recursive: true }); + FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + FS.renameSync(tempPath, settingsPath); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..4386c340f9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,18 +7,26 @@ import * as Path from "node:path"; import { app, BrowserWindow, + type BrowserWindowConstructorOptions, + clipboard, dialog, ipcMain, Menu, nativeImage, nativeTheme, protocol, + safeStorage, shell, } from "electron"; -import type { MenuItemConstructorOptions } from "electron"; -import * as Effect from "effect/Effect"; +import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; import type { + ClientSettings, DesktopTheme, + DesktopAppBranding, + DesktopServerExposureMode, + DesktopServerExposureState, + DesktopUpdateChannel, + PersistedSavedEnvironmentRecord, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, @@ -26,12 +34,32 @@ import type { import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; -import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; +import { + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettings, + setDesktopServerExposurePreference, + setDesktopUpdateChannelPreference, + writeDesktopSettings, +} from "./desktopSettings"; +import { + readClientSettings, + readSavedEnvironmentRegistry, + readSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + writeClientSettings, + writeSavedEnvironmentRegistry, + writeSavedEnvironmentSecret, +} from "./clientPersistence"; +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { resolveDesktopServerExposure } from "./serverExposure"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; +import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels"; +import { ServerListeningDetector } from "./serverListeningDetector"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -45,6 +73,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { resolveDesktopAppBranding } from "./appBranding"; syncShellEnvironment(); @@ -56,17 +85,35 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; +const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; +const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; +const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; +const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; +const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); +const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); +const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); +const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; -const APP_USER_MODEL_ID = "com.t3tools.t3code"; +const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ + isDevelopment, + appVersion: app.getVersion(), +}); +const APP_DISPLAY_NAME = desktopAppBranding.displayName; +const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; @@ -80,8 +127,43 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; -const DESKTOP_UPDATE_CHANNEL = "latest"; -const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; + +function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { + if (typeof rawOptions !== "object" || rawOptions === null) { + return undefined; + } + + const { initialPath } = rawOptions as { initialPath?: unknown }; + if (typeof initialPath !== "string") { + return undefined; + } + + const trimmedPath = initialPath.trim(); + if (trimmedPath.length === 0) { + return undefined; + } + + if (trimmedPath === "~") { + return OS.homedir(); + } + + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { + return Path.join(OS.homedir(), trimmedPath.slice(2)); + } + + return Path.resolve(trimmedPath); +} +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; +const TITLEBAR_HEIGHT = 40; +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; + +type WindowTitleBarOptions = Pick< + BrowserWindowConstructorOptions, + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" +>; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -91,8 +173,15 @@ type LinuxDesktopNamedApp = Electron.App & { let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; -let backendAuthToken = ""; +let backendBindHost = DESKTOP_LOOPBACK_HOST; +let backendBootstrapToken = ""; +let backendHttpUrl = ""; let backendWsUrl = ""; +let backendEndpointUrl: string | null = null; +let backendAdvertisedHost: string | null = null; +let backendReadinessAbortController: AbortController | null = null; +let backendInitialWindowOpenInFlight: Promise | null = null; +let backendListeningDetector: ServerListeningDetector | null = null; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -102,6 +191,8 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); +let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); +let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -111,7 +202,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ runningUnderArm64Translation: app.runningUnderARM64Translation === true, }); const initialUpdateState = (): DesktopUpdateState => - createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); + createInitialDesktopUpdateState( + app.getVersion(), + desktopRuntimeInfo, + desktopSettings.updateChannel, + ); function logTimestamp(): string { return new Date().toISOString(); @@ -140,17 +235,128 @@ function readPersistedBackendObservabilitySettings(): { } } +function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { + if (!rawPort) { + return undefined; + } + + const parsedPort = Number.parseInt(rawPort, 10); + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { + return undefined; + } + + return parsedPort; +} + +function resolveDesktopDevServerUrl(): string { + const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); + if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); + } + + return devServerUrl; +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; - delete env.T3CODE_AUTH_TOKEN; delete env.T3CODE_MODE; delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; + delete env.T3CODE_DESKTOP_LAN_ACCESS; + delete env.T3CODE_DESKTOP_LAN_HOST; return env; } +function getDesktopServerExposureState(): DesktopServerExposureState { + return { + mode: desktopServerExposureMode, + endpointUrl: backendEndpointUrl, + advertisedHost: backendAdvertisedHost, + }; +} + +function getDesktopSecretStorage() { + return { + isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), + encryptString: (value: string) => safeStorage.encryptString(value), + decryptString: (value: Buffer) => safeStorage.decryptString(value), + } as const; +} + +function resolveAdvertisedHostOverride(): string | undefined { + const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); + return override && override.length > 0 ? override : undefined; +} + +async function applyDesktopServerExposureMode( + mode: DesktopServerExposureMode, + options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, +): Promise { + const advertisedHostOverride = resolveAdvertisedHostOverride(); + const requestedMode = mode; + let exposure = resolveDesktopServerExposure({ + mode, + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + + if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { + if (options?.rejectIfUnavailable) { + throw new Error("No reachable network address is available for this desktop right now."); + } + exposure = resolveDesktopServerExposure({ + mode: "local-only", + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + } + + desktopServerExposureMode = exposure.mode; + desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); + backendBindHost = exposure.bindHost; + backendHttpUrl = exposure.localHttpUrl; + backendWsUrl = exposure.localWsUrl; + backendEndpointUrl = exposure.endpointUrl; + backendAdvertisedHost = exposure.advertisedHost; + + if (options?.persist) { + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + } + + return getDesktopServerExposureState(); +} + +function relaunchDesktopApp(reason: string): void { + writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); + setImmediate(() => { + isQuitting = true; + clearUpdatePollTimer(); + cancelBackendReadinessWait(); + void stopBackendAndWaitForExit() + .catch((error) => { + writeDesktopLogHeader( + `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, + ); + }) + .finally(() => { + restoreStdIoCapture?.(); + if (isDevelopment) { + app.exit(75); + return; + } + app.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + app.exit(0); + }); + }); +} + function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); @@ -198,6 +404,113 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +async function waitForBackendHttpReady( + baseUrl: string, + options?: Parameters[1], +): Promise { + cancelBackendReadinessWait(); + const controller = new AbortController(); + backendReadinessAbortController = controller; + + try { + await waitForHttpReady(baseUrl, { + ...options, + signal: controller.signal, + }); + } finally { + if (backendReadinessAbortController === controller) { + backendReadinessAbortController = null; + } + } +} + +function cancelBackendReadinessWait(): void { + backendReadinessAbortController?.abort(); + backendReadinessAbortController = null; +} + +async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { + const httpReadyPromise = waitForBackendHttpReady(baseUrl, { + timeoutMs: 60_000, + }); + const listeningPromise = backendListeningDetector?.promise; + + if (!listeningPromise) { + await httpReadyPromise; + return "http"; + } + + return await new Promise<"listening" | "http">((resolve, reject) => { + let settled = false; + + const settleResolve = (source: "listening" | "http") => { + if (settled) { + return; + } + settled = true; + if (source === "listening") { + cancelBackendReadinessWait(); + } + resolve(source); + }; + + const settleReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + listeningPromise.then( + () => settleResolve("listening"), + (error) => settleReject(error), + ); + httpReadyPromise.then( + () => settleResolve("http"), + (error) => { + if (settled && isBackendReadinessAborted(error)) { + return; + } + settleReject(error); + }, + ); + }); +} + +function ensureInitialBackendWindowOpen(): void { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) { + return; + } + + const nextOpen = waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`bootstrap backend ready source=${source}`); + if (mainWindow ?? BrowserWindow.getAllWindows()[0]) { + return; + } + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) { + return; + } + writeDesktopLogHeader( + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); + }) + .finally(() => { + if (backendInitialWindowOpenInFlight === nextOpen) { + backendInitialWindowOpenInFlight = null; + } + }); + + backendInitialWindowOpenInFlight = nextOpen; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -275,14 +588,16 @@ function initializePackagedLogging(): void { } function captureBackendOutput(child: ChildProcess.ChildProcess): void { - if (!app.isPackaged || backendLogSink === null) return; - const writeChunk = (chunk: unknown): void => { - if (!backendLogSink) return; - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); - backendLogSink.write(buffer); + const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => { + stream?.on("data", (chunk: unknown) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); + backendLogSink?.write(buffer); + backendListeningDetector?.push(buffer); + }); }; - child.stdout?.on("data", writeChunk); - child.stderr?.on("data", writeChunk); + + attachStream(child.stdout); + attachStream(child.stderr); } initializePackagedLogging(); @@ -542,10 +857,7 @@ function dispatchMenuAction(action: string): void { const send = () => { if (targetWindow.isDestroyed()) return; targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - if (!targetWindow.isVisible()) { - targetWindow.show(); - } - targetWindow.focus(); + revealWindow(targetWindow); }; if (targetWindow.webContents.isLoadingMainFrame()) { @@ -557,12 +869,15 @@ function dispatchMenuAction(action: string): void { } function handleCheckForUpdatesMenuClick(): void { + const hasUpdateFeedConfig = + readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); const disabledReason = getAutoUpdateDisabledReason({ isDevelopment, isPackaged: app.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + hasUpdateFeedConfig, }); if (disabledReason) { console.info("[desktop-updater] Manual update check requested, but updates are disabled."); @@ -699,6 +1014,18 @@ function resolveResourcePath(fileName: string): string | null { } function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { + if (isDevelopment && process.platform === "darwin" && ext === "png") { + const developmentDockIconPath = Path.join( + ROOT_DIR, + "assets", + "dev", + "blueprint-macos-1024.png", + ); + if (FS.existsSync(developmentDockIconPath)) { + return developmentDockIconPath; + } + } + return resolveResourcePath(`icon.${ext}`); } @@ -766,6 +1093,26 @@ function clearUpdatePollTimer(): void { } } +function revealWindow(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + if (process.platform === "darwin") { + app.focus({ steal: true }); + } + + window.focus(); +} + function emitUpdateState(): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue; @@ -778,7 +1125,29 @@ function setUpdateState(patch: Partial): void { emitUpdateState(); } +function createBaseUpdateState( + channel: DesktopUpdateChannel, + enabled: boolean, +): DesktopUpdateState { + return { + ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel), + enabled, + status: enabled ? "idle" : "disabled", + }; +} + +function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { + autoUpdater.channel = channel; + autoUpdater.allowPrerelease = channel === "nightly"; + autoUpdater.allowDowngrade = channel === "nightly"; + console.info( + `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`, + ); +} + function shouldEnableAutoUpdates(): boolean { + const hasUpdateFeedConfig = + readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); return ( getAutoUpdateDisabledReason({ isDevelopment, @@ -786,6 +1155,7 @@ function shouldEnableAutoUpdates(): boolean { platform: process.platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + hasUpdateFeedConfig, }) === null ); } @@ -869,17 +1239,6 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed } function configureAutoUpdater(): void { - const enabled = shouldEnableAutoUpdates(); - setUpdateState({ - ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), - enabled, - status: enabled ? "idle" : "disabled", - }); - if (!enabled) { - return; - } - updaterConfigured = true; - const githubToken = process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; if (githubToken) { @@ -904,12 +1263,16 @@ function configureAutoUpdater(): void { }); } + const enabled = shouldEnableAutoUpdates(); + setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled)); + if (!enabled) { + return; + } + updaterConfigured = true; + autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; - // Keep alpha branding, but force all installs onto the stable update track. - autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; - autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; - autoUpdater.allowDowngrade = false; + applyAutoUpdaterChannel(desktopSettings.updateChannel); autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); let lastLoggedDownloadMilestone = -1; @@ -923,6 +1286,15 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { + if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { + console.info( + `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`, + ); + setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); + lastLoggedDownloadMilestone = -1; + return; + } + setUpdateState( reduceDesktopUpdateStateOnUpdateAvailable( updateState, @@ -1014,7 +1386,7 @@ function startBackend(): void { return; } - const captureBackendLogs = app.isPackaged && backendLogSink !== null; + const captureBackendLogs = !isDevelopment; const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. @@ -1035,7 +1407,8 @@ function startBackend(): void { noBrowser: true, port: backendPort, t3Home: BASE_DIR, - authToken: backendAuthToken, + host: backendBindHost, + desktopBootstrapToken: backendBootstrapToken, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), @@ -1050,6 +1423,8 @@ function startBackend(): void { scheduleBackendRestart("missing desktop bootstrap pipe"); return; } + const listeningDetector = new ServerListeningDetector(); + backendListeningDetector = listeningDetector; backendProcess = child; let backendSessionClosed = false; const closeBackendSession = (details: string) => { @@ -1068,6 +1443,10 @@ function startBackend(): void { }); child.on("error", (error) => { + if (backendListeningDetector === listeningDetector) { + listeningDetector.fail(error); + backendListeningDetector = null; + } const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; @@ -1080,6 +1459,14 @@ function startBackend(): void { }); child.on("exit", (code, signal) => { + if (backendListeningDetector === listeningDetector) { + listeningDetector.fail( + new Error( + `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, + ), + ); + backendListeningDetector = null; + } const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; @@ -1091,9 +1478,13 @@ function startBackend(): void { const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; scheduleBackendRestart(reason); }); + + ensureInitialBackendWindowOpen(); } function stopBackend(): void { + cancelBackendReadinessWait(); + backendListeningDetector = null; if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1115,6 +1506,7 @@ function stopBackend(): void { } async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { + cancelBackendReadinessWait(); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1167,21 +1559,134 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { } function registerIpcHandlers(): void { - ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); - ipcMain.on(GET_WS_URL_CHANNEL, (event) => { - event.returnValue = backendWsUrl; + ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); + ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { + event.returnValue = desktopAppBranding; + }); + + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { + event.returnValue = { + label: "Local environment", + httpBaseUrl: backendHttpUrl || null, + wsBaseUrl: backendWsUrl || null, + bootstrapToken: backendBootstrapToken || undefined, + } as const; + }); + + ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); + ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); + + ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); + ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { + if (typeof rawSettings !== "object" || rawSettings === null) { + throw new Error("Invalid client settings payload."); + } + + writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); + }); + + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); + ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => + readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH), + ); + + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); + ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => { + if (!Array.isArray(rawRecords)) { + throw new Error("Invalid saved environment registry payload."); + } + + writeSavedEnvironmentRegistry( + SAVED_ENVIRONMENT_REGISTRY_PATH, + rawRecords as readonly PersistedSavedEnvironmentRecord[], + ); + }); + + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + return null; + } + + return readSavedEnvironmentSecret({ + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, + environmentId: rawEnvironmentId, + secretStorage: getDesktopSecretStorage(), + }); + }, + ); + + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + throw new Error("Invalid saved environment id."); + } + if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { + throw new Error("Invalid saved environment secret."); + } + + return writeSavedEnvironmentSecret({ + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, + environmentId: rawEnvironmentId, + secret: rawSecret, + secretStorage: getDesktopSecretStorage(), + }); + }, + ); + + ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + return; + } + + removeSavedEnvironmentSecret({ + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, + environmentId: rawEnvironmentId, + }); + }, + ); + + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); + ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); + + ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); + ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { + if (rawMode !== "local-only" && rawMode !== "network-accessible") { + throw new Error("Invalid desktop server exposure input."); + } + + const nextMode = rawMode as DesktopServerExposureMode; + if (nextMode === desktopServerExposureMode) { + return getDesktopServerExposureState(); + } + + const nextState = await applyDesktopServerExposureMode(nextMode, { + persist: true, + rejectIfUnavailable: true, + }); + relaunchDesktopApp(`serverExposureMode=${nextMode}`); + return nextState; }); ipcMain.removeHandler(PICK_FOLDER_CHANNEL); - ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { + ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + const defaultPath = resolvePickFolderDefaultPath(rawOptions); + const openDialogOptions: OpenDialogOptions = { + properties: ["openDirectory", "createDirectory"], + ...(defaultPath ? { defaultPath } : {}), + }; const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openDirectory", "createDirectory"], - }) - : await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); + ? await dialog.showOpenDialog(owner, openDialogOptions) + : await dialog.showOpenDialog(openDialogOptions); if (result.canceled) return null; return result.filePaths[0] ?? null; }); @@ -1287,6 +1792,43 @@ function registerIpcHandlers(): void { ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); + ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); + ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { + if (rawChannel !== "latest" && rawChannel !== "nightly") { + throw new Error("Invalid desktop update channel input."); + } + if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { + throw new Error("Cannot change update tracks while an update action is in progress."); + } + + const nextChannel = rawChannel as DesktopUpdateChannel; + + desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + + if (nextChannel === updateState.channel) { + return updateState; + } + + const enabled = shouldEnableAutoUpdates(); + setUpdateState(createBaseUpdateState(nextChannel, enabled)); + + if (!enabled || !updaterConfigured) { + return updateState; + } + + applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = autoUpdater.allowDowngrade; + // An explicit channel switch should allow the immediate nightly->stable rollback path. + autoUpdater.allowDowngrade = true; + try { + await checkForUpdates("channel-change"); + } finally { + autoUpdater.allowDowngrade = allowDowngrade; + } + return updateState; + }); + ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { const result = await downloadAvailableUpdate(); @@ -1337,6 +1879,50 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function getInitialWindowBackgroundColor(): string { + return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + +function getWindowTitleBarOptions(): WindowTitleBarOptions { + if (process.platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + } + + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: nativeTheme.shouldUseDarkColors + ? TITLEBAR_DARK_SYMBOL_COLOR + : TITLEBAR_LIGHT_SYMBOL_COLOR, + }, + }; +} + +function syncWindowAppearance(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + + window.setBackgroundColor(getInitialWindowBackgroundColor()); + const { titleBarOverlay } = getWindowTitleBarOptions(); + if (typeof titleBarOverlay === "object") { + window.setTitleBarOverlay(titleBarOverlay); + } +} + +function syncAllWindowAppearance(): void { + for (const window of BrowserWindow.getAllWindows()) { + syncWindowAppearance(window); + } +} + +nativeTheme.on("updated", syncAllWindowAppearance); + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -1345,10 +1931,10 @@ function createWindow(): BrowserWindow { minHeight: 620, show: false, autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + ...getWindowTitleBarOptions(), webPreferences: { preload: Path.join(__dirname, "preload.js"), contextIsolation: true, @@ -1375,6 +1961,22 @@ function createWindow(): BrowserWindow { menuTemplate.push({ type: "separator" }); } + const externalUrl = getSafeExternalUrl(params.linkURL); + if (externalUrl) { + menuTemplate.push( + { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) }, + { type: "separator" }, + ); + } + + if (params.mediaType === "image") { + menuTemplate.push({ + label: "Copy Image", + click: () => window.webContents.copyImageAt(params.x, params.y), + }); + menuTemplate.push({ type: "separator" }); + } + menuTemplate.push( { role: "cut", enabled: params.editFlags.canCut }, { role: "copy", enabled: params.editFlags.canCopy }, @@ -1401,15 +2003,23 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); - window.once("ready-to-show", () => { - window.show(); - }); + + let initialRevealScheduled = false; + const revealInitialWindow = () => { + if (initialRevealScheduled) { + return; + } + initialRevealScheduled = true; + revealWindow(window); + }; + + window.once("ready-to-show", revealInitialWindow); if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); + void window.loadURL(resolveDesktopDevServerUrl()); window.webContents.openDevTools({ mode: "detach" }); } else { - void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); + void window.loadURL(backendHttpUrl); } window.on("closed", () => { @@ -1430,23 +2040,71 @@ configureAppIdentity(); async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); - backendPort = await Effect.service(NetService).pipe( - Effect.flatMap((net) => net.reserveLoopbackPort()), - Effect.provide(NetService.layer), - Effect.runPromise, + const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); + if (isDevelopment && configuredBackendPort === undefined) { + throw new Error("T3CODE_PORT is required in desktop development."); + } + + backendPort = + configuredBackendPort ?? + (await resolveDesktopBackendPort({ + host: DESKTOP_LOOPBACK_HOST, + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, + })); + writeDesktopLogHeader( + configuredBackendPort === undefined + ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` + : `using configured backend port port=${backendPort}`, + ); + backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); + if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { + writeDesktopLogHeader( + `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, + ); + } + const serverExposureState = await applyDesktopServerExposureMode( + desktopSettings.serverExposureMode, + { + persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + }, ); - writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); - backendAuthToken = Crypto.randomBytes(24).toString("hex"); - const baseUrl = `ws://127.0.0.1:${backendPort}`; - backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; - writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); + writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); + if (serverExposureState.endpointUrl) { + writeDesktopLogHeader( + `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, + ); + } else if (desktopSettings.serverExposureMode === "network-accessible") { + writeDesktopLogHeader( + "bootstrap fell back to local-only because no advertised network host was available", + ); + } registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); startBackend(); writeDesktopLogHeader("bootstrap backend start requested"); - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); + + if (isDevelopment) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + void waitForBackendHttpReady(backendHttpUrl) + .then(() => { + writeDesktopLogHeader("bootstrap backend ready"); + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) { + return; + } + writeDesktopLogHeader( + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); + }); + return; + } + + ensureInitialBackendWindowOpen(); } app.on("before-quit", () => { @@ -1454,6 +2112,7 @@ app.on("before-quit", () => { updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); + cancelBackendReadinessWait(); stopBackend(); restoreStdIoCapture?.(); }); @@ -1467,13 +2126,23 @@ app registerDesktopProtocol(); configureAutoUpdater(); void bootstrap().catch((error) => { + if (isBackendReadinessAborted(error) && isQuitting) { + return; + } handleFatalStartupError("bootstrap", error); }); app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; + if (existingWindow) { + revealWindow(existingWindow); + return; + } + if (isDevelopment) { mainWindow = createWindow(); + return; } + ensureInitialBackendWindowOpen(); }); }) .catch((error) => { @@ -1492,6 +2161,7 @@ if (process.platform !== "win32") { isQuitting = true; writeDesktopLogHeader("SIGINT received"); clearUpdatePollTimer(); + cancelBackendReadinessWait(); stopBackend(); restoreStdIoCapture?.(); app.quit(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..a675604872 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,17 +9,51 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; +const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; +const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; +const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; +const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; +const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; contextBridge.exposeInMainWorld("desktopBridge", { - getWsUrl: () => { - const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); - return typeof result === "string" ? result : null; + getAppBranding: () => { + const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; }, - pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), + getLocalEnvironmentBootstrap: () => { + const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; + }, + getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), + setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), + getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), + setSavedEnvironmentRegistry: (records) => + ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), + getSavedEnvironmentSecret: (environmentId) => + ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + setSavedEnvironmentSecret: (environmentId, secret) => + ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), + removeSavedEnvironmentSecret: (environmentId) => + ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), + setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), @@ -36,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), + setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts new file mode 100644 index 0000000000..b1ae4bef4f --- /dev/null +++ b/apps/desktop/src/serverExposure.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; + +describe("resolveLanAdvertisedHost", () => { + it("prefers an explicit host override", () => { + expect( + resolveLanAdvertisedHost( + { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + "10.0.0.9", + ), + ).toBe("10.0.0.9"); + }); + + it("returns the first usable non-internal IPv4 address", () => { + expect( + resolveLanAdvertisedHost( + { + lo0: [ + { + address: "127.0.0.1", + family: "IPv4", + internal: true, + netmask: "255.0.0.0", + cidr: "127.0.0.1/8", + mac: "00:00:00:00:00:00", + }, + ], + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + undefined, + ), + ).toBe("192.168.1.44"); + }); + + it("returns null when no usable network address is available", () => { + expect( + resolveLanAdvertisedHost( + { + lo0: [ + { + address: "127.0.0.1", + family: "IPv4", + internal: true, + netmask: "255.0.0.0", + cidr: "127.0.0.1/8", + mac: "00:00:00:00:00:00", + }, + ], + }, + undefined, + ), + ).toBeNull(); + }); +}); + +describe("resolveDesktopServerExposure", () => { + it("keeps the desktop server loopback-only when local-only mode is selected", () => { + expect( + resolveDesktopServerExposure({ + mode: "local-only", + port: 3773, + networkInterfaces: {}, + }), + ).toEqual({ + mode: "local-only", + bindHost: "127.0.0.1", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: null, + advertisedHost: null, + }); + }); + + it("binds to all interfaces in network-accessible mode", () => { + expect( + resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + }), + ).toEqual({ + mode: "network-accessible", + bindHost: "0.0.0.0", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: "http://192.168.1.44:3773", + advertisedHost: "192.168.1.44", + }); + }); + + it("stays network-accessible even when no LAN address is currently detectable", () => { + expect( + resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: {}, + }), + ).toEqual({ + mode: "network-accessible", + bindHost: "0.0.0.0", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: null, + advertisedHost: null, + }); + }); +}); diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts new file mode 100644 index 0000000000..65c99b60e1 --- /dev/null +++ b/apps/desktop/src/serverExposure.ts @@ -0,0 +1,80 @@ +import type { NetworkInterfaceInfo } from "node:os"; +import type { DesktopServerExposureMode } from "@t3tools/contracts"; + +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; + +export interface DesktopServerExposure { + readonly mode: DesktopServerExposureMode; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly endpointUrl: string | null; + readonly advertisedHost: string | null; +} + +const normalizeOptionalHost = (value: string | undefined): string | undefined => { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +}; + +const isUsableLanIpv4Address = (address: string): boolean => + !address.startsWith("127.") && !address.startsWith("169.254."); + +export function resolveLanAdvertisedHost( + networkInterfaces: NodeJS.Dict, + explicitHost: string | undefined, +): string | null { + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); + if (normalizedExplicitHost) { + return normalizedExplicitHost; + } + + for (const interfaceAddresses of Object.values(networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isUsableLanIpv4Address(address.address)) continue; + return address.address; + } + } + + return null; +} + +export function resolveDesktopServerExposure(input: { + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly networkInterfaces: NodeJS.Dict; + readonly advertisedHostOverride?: string; +}): DesktopServerExposure { + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + + if (input.mode === "local-only") { + return { + mode: input.mode, + bindHost: DESKTOP_LOOPBACK_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: null, + advertisedHost: null, + }; + } + + const advertisedHost = resolveLanAdvertisedHost( + input.networkInterfaces, + input.advertisedHostOverride, + ); + + return { + mode: input.mode, + bindHost: DESKTOP_LAN_BIND_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, + advertisedHost, + }; +} diff --git a/apps/desktop/src/serverListeningDetector.test.ts b/apps/desktop/src/serverListeningDetector.test.ts new file mode 100644 index 0000000000..b7c66b6312 --- /dev/null +++ b/apps/desktop/src/serverListeningDetector.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { ServerListeningDetector } from "./serverListeningDetector"; + +describe("ServerListeningDetector", () => { + it("resolves when the server logs the listening line", async () => { + const detector = new ServerListeningDetector(); + + detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n"); + + await expect(detector.promise).resolves.toBeUndefined(); + }); + + it("resolves when the listening line arrives across multiple chunks", async () => { + const detector = new ServerListeningDetector(); + + detector.push("[01:23:30.571] INFO (#148): Listen"); + detector.push("ing on http://0.0.0.0:7011\n"); + + await expect(detector.promise).resolves.toBeUndefined(); + }); + + it("rejects when the server exits before logging readiness", async () => { + const detector = new ServerListeningDetector(); + const error = new Error("server exited"); + + detector.fail(error); + + await expect(detector.promise).rejects.toBe(error); + }); +}); diff --git a/apps/desktop/src/serverListeningDetector.ts b/apps/desktop/src/serverListeningDetector.ts new file mode 100644 index 0000000000..e738aacc38 --- /dev/null +++ b/apps/desktop/src/serverListeningDetector.ts @@ -0,0 +1,56 @@ +const LISTENING_LOG_FRAGMENT = "Listening on http://"; +const MAX_BUFFER_CHARS = 8_192; + +export class ServerListeningDetector { + private buffer = ""; + private settled = false; + private readonly resolvePromise: () => void; + private readonly rejectPromise: (error: unknown) => void; + readonly promise: Promise; + + constructor() { + let resolvePromise: (() => void) | null = null; + let rejectPromise: ((error: unknown) => void) | null = null; + + this.promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + this.resolvePromise = () => { + if (this.settled) { + return; + } + this.settled = true; + resolvePromise?.(); + }; + this.rejectPromise = (error) => { + if (this.settled) { + return; + } + this.settled = true; + rejectPromise?.(error); + }; + } + + push(chunk: unknown): void { + if (this.settled) { + return; + } + + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`; + if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) { + this.resolvePromise(); + return; + } + + if (this.buffer.length > MAX_BUFFER_CHARS) { + this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS); + } + } + + fail(error: unknown): void { + this.rejectPromise(error); + } +} diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index cda78a20b2..7d4578895f 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -6,11 +6,12 @@ describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readEnvironment = vi.fn(() => ({ PATH: "/opt/homebrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", })); syncShellEnvironment(env, { @@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => { readEnvironment, }); - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); }); it("preserves an inherited SSH_AUTH_SOCK value", () => { @@ -77,11 +87,67 @@ describe("syncShellEnvironment", () => { readEnvironment, }); - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); }); + it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readEnvironment = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => ({})); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read login shell environment from /opt/homebrew/bin/nu.", + expect.any(Error), + ); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + }); + it("does nothing outside macOS and linux", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 13036149b8..7e031b1116 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,36 +1,79 @@ import { + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, readEnvironmentFromLoginShell, - resolveLoginShell, ShellEnvironmentReader, } from "@t3tools/shared/shell"; +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; + +function logShellEnvironmentWarning(message: string, error?: unknown): void { + console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} + export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, options: { platform?: NodeJS.Platform; readEnvironment?: ShellEnvironmentReader; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; if (platform !== "darwin" && platform !== "linux") return; - try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; + const logWarning = options.logWarning ?? logShellEnvironmentWarning; + const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; + const shellEnvironment: Partial> = {}; - const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ - "PATH", - "SSH_AUTH_SOCK", - ]); + try { + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); + if (shellEnvironment.PATH) { + break; + } + } catch (error) { + logWarning(`Failed to read login shell environment from ${shell}.`, error); + } + } - if (shellEnvironment.PATH) { - env.PATH = shellEnvironment.PATH; + const launchctlPath = + platform === "darwin" && !shellEnvironment.PATH + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } - } catch { - // Keep inherited environment if shell lookup fails. + + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] as const) { + if (!env[name] && shellEnvironment[name]) { + env[name] = shellEnvironment[name]; + } + } + } catch (error) { + logWarning("Failed to synchronize the desktop shell environment.", error); } } diff --git a/apps/desktop/src/updateChannels.test.ts b/apps/desktop/src/updateChannels.test.ts new file mode 100644 index 0000000000..bd1dcc0c73 --- /dev/null +++ b/apps/desktop/src/updateChannels.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + doesVersionMatchDesktopUpdateChannel, + isNightlyDesktopVersion, + resolveDefaultDesktopUpdateChannel, +} from "./updateChannels"; + +describe("isNightlyDesktopVersion", () => { + it("detects packaged nightly versions", () => { + expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true); + }); + + it("does not flag stable versions as nightly", () => { + expect(isNightlyDesktopVersion("0.0.17")).toBe(false); + }); +}); + +describe("resolveDefaultDesktopUpdateChannel", () => { + it("defaults stable builds to latest", () => { + expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest"); + }); + + it("defaults nightly builds to nightly", () => { + expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly"); + }); +}); + +describe("doesVersionMatchDesktopUpdateChannel", () => { + it("accepts nightly releases on the nightly channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true); + }); + + it("rejects stable releases on the nightly channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false); + }); + + it("rejects nightly releases on the stable channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false); + }); +}); diff --git a/apps/desktop/src/updateChannels.ts b/apps/desktop/src/updateChannels.ts new file mode 100644 index 0000000000..615b8e6db6 --- /dev/null +++ b/apps/desktop/src/updateChannels.ts @@ -0,0 +1,18 @@ +import type { DesktopUpdateChannel } from "@t3tools/contracts"; + +const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; + +export function isNightlyDesktopVersion(version: string): boolean { + return NIGHTLY_VERSION_PATTERN.test(version); +} + +export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { + return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; +} + +export function doesVersionMatchDesktopUpdateChannel( + version: string, + channel: DesktopUpdateChannel, +): boolean { + return resolveDefaultDesktopUpdateChannel(version) === channel; +} diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updateMachine.test.ts index 7fbc982eff..a6fbcfb5d7 100644 --- a/apps/desktop/src/updateMachine.test.ts +++ b/apps/desktop/src/updateMachine.test.ts @@ -23,7 +23,7 @@ describe("updateMachine", () => { it("clears transient errors when a check starts", () => { const state = reduceDesktopUpdateStateOnCheckStart( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "error", message: "network", @@ -42,7 +42,7 @@ describe("updateMachine", () => { it("records a check failure without exposing an action", () => { const state = reduceDesktopUpdateStateOnCheckFailure( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "checking", }, @@ -58,7 +58,7 @@ describe("updateMachine", () => { it("preserves available version on download failure for retry", () => { const state = reduceDesktopUpdateStateOnDownloadFailure( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "downloading", availableVersion: "1.1.0", @@ -76,7 +76,7 @@ describe("updateMachine", () => { it("transitions to downloaded and then preserves install retry state", () => { const downloaded = reduceDesktopUpdateStateOnDownloadComplete( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "downloading", availableVersion: "1.1.0", @@ -98,7 +98,7 @@ describe("updateMachine", () => { it("clears stale download state when no update is available", () => { const state = reduceDesktopUpdateStateOnNoUpdate( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "error", availableVersion: "1.1.0", @@ -120,7 +120,7 @@ describe("updateMachine", () => { it("tracks available, download start, and progress cleanly", () => { const available = reduceDesktopUpdateStateOnUpdateAvailable( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "checking", }, @@ -131,6 +131,7 @@ describe("updateMachine", () => { const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); + expect(available.channel).toBe("latest"); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updateMachine.ts index f13b420281..c767dfd2fe 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updateMachine.ts @@ -1,14 +1,20 @@ -import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts"; +import type { + DesktopRuntimeInfo, + DesktopUpdateChannel, + DesktopUpdateState, +} from "@t3tools/contracts"; import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; export function createInitialDesktopUpdateState( currentVersion: string, runtimeInfo: DesktopRuntimeInfo, + channel: DesktopUpdateChannel, ): DesktopUpdateState { return { enabled: false, status: "disabled", + channel, currentVersion, hostArch: runtimeInfo.hostArch, appArch: runtimeInfo.appArch, diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 43b718bd00..9d7fe5b7ab 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -11,6 +11,7 @@ import { const baseState: DesktopUpdateState = { enabled: true, status: "idle", + channel: "latest", currentVersion: "1.0.0", hostArch: "x64", appArch: "x64", @@ -71,10 +72,37 @@ describe("getAutoUpdateDisabledReason", () => { platform: "darwin", appImage: undefined, disabledByEnv: false, + hasUpdateFeedConfig: true, }), ).toContain("packaged production builds"); }); + it("reports packaged local builds without an update feed as disabled", () => { + expect( + getAutoUpdateDisabledReason({ + isDevelopment: false, + isPackaged: true, + platform: "darwin", + appImage: undefined, + disabledByEnv: false, + hasUpdateFeedConfig: false, + }), + ).toContain("no update feed"); + }); + + it("allows packaged builds with an update feed", () => { + expect( + getAutoUpdateDisabledReason({ + isDevelopment: false, + isPackaged: true, + platform: "darwin", + appImage: undefined, + disabledByEnv: false, + hasUpdateFeedConfig: true, + }), + ).toBeNull(); + }); + it("reports env-disabled auto updates", () => { expect( getAutoUpdateDisabledReason({ @@ -83,6 +111,7 @@ describe("getAutoUpdateDisabledReason", () => { platform: "darwin", appImage: undefined, disabledByEnv: true, + hasUpdateFeedConfig: true, }), ).toContain("T3CODE_DISABLE_AUTO_UPDATE"); }); @@ -95,6 +124,7 @@ describe("getAutoUpdateDisabledReason", () => { platform: "linux", appImage: undefined, disabledByEnv: false, + hasUpdateFeedConfig: true, }), ).toContain("AppImage"); }); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index 8c8ef1ddc9..928bb40886 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -34,7 +34,11 @@ export function getAutoUpdateDisabledReason(args: { platform: NodeJS.Platform; appImage?: string | undefined; disabledByEnv: boolean; + hasUpdateFeedConfig: boolean; }): string | null { + if (!args.hasUpdateFeedConfig) { + return "Automatic updates are not available because no update feed is configured."; + } if (args.isDevelopment || !args.isPackaged) { return "Automatic updates are only available in packaged production builds."; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 87c81f08c8..6daa43ca42 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -26,6 +26,7 @@ import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; +import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts"; import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; @@ -45,6 +46,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; +import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; @@ -320,6 +322,22 @@ export const makeOrchestrationIntegrationHarness = ( ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge( + Layer.succeed(GitStatusBroadcaster, { + getStatus: () => Effect.die("getStatus should not be called in this test"), + refreshLocalStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: false, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), + streamStatus: () => Stream.empty, + }), + ), Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), @@ -338,6 +356,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), Layer.provide(persistenceLayer), + Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), @@ -419,7 +438,7 @@ export const makeOrchestrationIntegrationHarness = ( ) => waitFor( pendingApprovalRepository - .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) + .getByRequestId({ requestId: ApprovalRequestId.make(requestId) }) .pipe( Effect.map((row) => Option.match(row, { diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 9c87d9821a..f497395307 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -289,7 +289,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter state.turnCount += 1; const turnCount = state.turnCount; - const turnId = TurnId.makeUnsafe(`turn-${turnCount}`); + const turnId = TurnId.make(`turn-${turnCount}`); const response = state.queuedResponses.shift(); if (!response) { @@ -307,7 +307,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter ...(fixtureEvent as Record), eventId: randomUUID(), provider, - sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)), + sessionId: RuntimeSessionId.make(String(input.threadId)), createdAt: nowIso(), }; rawEvent.threadId = state.snapshot.threadId; @@ -363,7 +363,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter if (deferredTurnCompletedEvents.length === 0) { yield* emit({ type: "turn.completed", - eventId: EventId.makeUnsafe(randomUUID()), + eventId: EventId.make(randomUUID()), provider, createdAt: nowIso(), threadId: state.snapshot.threadId, diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index 42e7ecd87d..14a45518c3 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -5,14 +5,14 @@ const PROVIDER = "codex" as const; const SESSION_ID = "fixture-session"; const THREAD_ID = "fixture-thread"; const TURN_ID = "fixture-turn"; -const REQUEST_ID = RuntimeRequestId.makeUnsafe("req-1"); +const REQUEST_ID = RuntimeRequestId.make("req-1"); function baseEvent( eventId: string, createdAt: string, ): Pick { return { - eventId: EventId.makeUnsafe(eventId), + eventId: EventId.make(eventId), provider: PROVIDER, sessionId: SESSION_ID, createdAt, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index a5cc8f8cc6..a7f845672c 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -30,14 +30,13 @@ import type { } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); -const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asApprovalRequestId = (value: string): ApprovalRequestId => - ApprovalRequestId.makeUnsafe(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const PROJECT_ID = asProjectId("project-1"); -const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +const THREAD_ID = ThreadId.make("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); type IntegrationProvider = ProviderKind; @@ -112,7 +111,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => yield* harness.engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-create"), + commandId: CommandId.make("cmd-project-create"), projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, @@ -125,7 +124,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => yield* harness.engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create"), + commandId: CommandId.make("cmd-thread-create"), threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", @@ -150,7 +149,7 @@ const startTurn = (input: { }) => input.harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe(input.commandId), + commandId: CommandId.make(input.commandId), threadId: THREAD_ID, message: { messageId: asMessageId(input.messageId), @@ -261,7 +260,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( yield* harness.engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"), + commandId: CommandId.make("cmd-project-create-real-codex"), projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, @@ -274,7 +273,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( yield* harness.engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"), + commandId: CommandId.make("cmd-thread-create-real-codex"), threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", @@ -291,7 +290,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( yield* harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"), + commandId: CommandId.make("cmd-turn-start-real-codex-1"), threadId: THREAD_ID, message: { messageId: asMessageId("msg-real-codex-1"), @@ -318,7 +317,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( yield* harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"), + commandId: CommandId.make("cmd-turn-start-real-codex-2"), threadId: THREAD_ID, message: { messageId: asMessageId("msg-real-codex-2"), @@ -583,7 +582,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons yield* harness.engine.dispatch({ type: "thread.approval.respond", - commandId: CommandId.makeUnsafe("cmd-approval-respond"), + commandId: CommandId.make("cmd-approval-respond"), threadId: THREAD_ID, requestId: APPROVAL_REQUEST_ID, decision: "accept", @@ -815,7 +814,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git yield* harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), + commandId: CommandId.make("cmd-checkpoint-revert"), threadId: THREAD_ID, turnCount: 1, createdAt: nowIso(), @@ -875,7 +874,7 @@ it.live( yield* harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"), + commandId: CommandId.make("cmd-checkpoint-revert-no-session"), threadId: THREAD_ID, turnCount: 0, createdAt: nowIso(), @@ -1119,7 +1118,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => yield* harness.engine.dispatch({ type: "thread.approval.respond", - commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), + commandId: CommandId.make("cmd-claude-approval-respond"), threadId: THREAD_ID, requestId: APPROVAL_REQUEST_ID, decision: "accept", @@ -1192,7 +1191,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => yield* harness.engine.dispatch({ type: "thread.turn.interrupt", - commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), + commandId: CommandId.make("cmd-turn-interrupt-claude"), threadId: THREAD_ID, createdAt: nowIso(), }); @@ -1309,7 +1308,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", yield* harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), + commandId: CommandId.make("cmd-checkpoint-revert-claude"), threadId: THREAD_ID, turnCount: 1, createdAt: nowIso(), diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 1ca6fa1b83..89cf6ac153 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -122,15 +122,12 @@ it.live("replays typed runtime fixture events", () => yield* Effect.gen(function* () { const provider = yield* ProviderService; - const session = yield* provider.startSession( - ThreadId.makeUnsafe("thread-integration-typed"), - { - threadId: ThreadId.makeUnsafe("thread-integration-typed"), - provider: "codex", - cwd: fixture.cwd, - runtimeMode: "full-access", - }, - ); + const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), { + threadId: ThreadId.make("thread-integration-typed"), + provider: "codex", + cwd: fixture.cwd, + runtimeMode: "full-access", + }); assert.equal((session.threadId ?? "").length > 0, true); const observedEvents = yield* runTurn({ @@ -157,15 +154,12 @@ it.live("replays file-changing fixture turn events", () => yield* Effect.gen(function* () { const provider = yield* ProviderService; - const session = yield* provider.startSession( - ThreadId.makeUnsafe("thread-integration-tools"), - { - threadId: ThreadId.makeUnsafe("thread-integration-tools"), - provider: "codex", - cwd: fixture.cwd, - runtimeMode: "full-access", - }, - ); + const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), { + threadId: ThreadId.make("thread-integration-tools"), + provider: "codex", + cwd: fixture.cwd, + runtimeMode: "full-access", + }); assert.equal((session.threadId ?? "").length > 0, true); const observedEvents = yield* runTurn({ @@ -196,15 +190,12 @@ it.live("runs multi-turn tool/approval flow", () => yield* Effect.gen(function* () { const provider = yield* ProviderService; - const session = yield* provider.startSession( - ThreadId.makeUnsafe("thread-integration-multi"), - { - threadId: ThreadId.makeUnsafe("thread-integration-multi"), - provider: "codex", - cwd: fixture.cwd, - runtimeMode: "full-access", - }, - ); + const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), { + threadId: ThreadId.make("thread-integration-multi"), + provider: "codex", + cwd: fixture.cwd, + runtimeMode: "full-access", + }); assert.equal((session.threadId ?? "").length > 0, true); const firstTurnEvents = yield* runTurn({ @@ -250,15 +241,12 @@ it.live("rolls back provider conversation state only", () => yield* Effect.gen(function* () { const provider = yield* ProviderService; - const session = yield* provider.startSession( - ThreadId.makeUnsafe("thread-integration-rollback"), - { - threadId: ThreadId.makeUnsafe("thread-integration-rollback"), - provider: "codex", - cwd: fixture.cwd, - runtimeMode: "full-access", - }, - ); + const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), { + threadId: ThreadId.make("thread-integration-rollback"), + provider: "codex", + cwd: fixture.cwd, + runtimeMode: "full-access", + }); assert.equal((session.threadId ?? "").length > 0, true); yield* runTurn({ diff --git a/apps/server/package.json b/apps/server/package.json index e59c7c208c..af6450a88a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.15", + "version": "0.0.17", "license": "MIT", "repository": { "type": "git", @@ -23,7 +23,7 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@anthropic-ai/claude-agent-sdk": "^0.2.111", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 21bc515aa7..299da67fab 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -14,6 +14,22 @@ import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog import rootPackageJson from "../../../package.json" with { type: "json" }; import serverPackageJson from "../package.json" with { type: "json" }; +interface PackageJson { + name: string; + repository: { + type: string; + url: string; + directory: string; + }; + bin: Record; + type: string; + version: string; + engines: Record; + files: string[]; + dependencies: Record; + overrides: Record; +} + class CliError extends Data.TaggedError("CliError")<{ readonly message: string; readonly cause?: unknown; @@ -177,7 +193,7 @@ const publishCmd = Command.make( const backupPath = `${packageJsonPath}.bak`; // Assert build assets exist - for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) { + for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { return yield* new CliError({ @@ -192,7 +208,7 @@ const publishCmd = Command.make( // Resolve catalog dependencies before any file mutations. If this throws, // acquire fails and no release hook runs, so filesystem must still be untouched. const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); - const pkg = { + const pkg: PackageJson = { name: serverPackageJson.name, repository: serverPackageJson.repository, bin: serverPackageJson.bin, @@ -200,7 +216,8 @@ const publishCmd = Command.make( version, engines: serverPackageJson.engines, files: serverPackageJson.files, - dependencies: serverPackageJson.dependencies as Record, + dependencies: serverPackageJson.dependencies, + overrides: rootPackageJson.overrides, }; pkg.dependencies = resolveCatalogDependencies( @@ -208,6 +225,11 @@ const publishCmd = Command.make( rootPackageJson.workspaces.catalog, "apps/server dependencies", ); + pkg.overrides = resolveCatalogDependencies( + pkg.overrides, + rootPackageJson.workspaces.catalog, + "root overrides", + ); const original = yield* fs.readFileString(packageJsonPath); yield* fs.writeFileString(backupPath, original); diff --git a/apps/server/src/auth/Layers/AuthControlPlane.test.ts b/apps/server/src/auth/Layers/AuthControlPlane.test.ts new file mode 100644 index 0000000000..9fc091124b --- /dev/null +++ b/apps/server/src/auth/Layers/AuthControlPlane.test.ts @@ -0,0 +1,111 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; +import { makeAuthControlPlane } from "./AuthControlPlane.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), + ); + +const makeAuthControlPlaneLayer = ( + overrides?: Partial>, +) => + Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe( + Layer.provideMerge(BootstrapCredentialServiceLive), + Layer.provideMerge(SessionCredentialServiceLive), + Layer.provideMerge(ServerSecretStoreLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("AuthControlPlane", (it) => { + it.effect("creates, lists, and revokes client pairing links", () => + Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + + const created = yield* authControlPlane.createPairingLink({ + role: "client", + subject: "one-time-token", + label: "CI phone", + }); + const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); + const revoked = yield* authControlPlane.revokePairingLink(created.id); + const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); + + expect(created.role).toBe("client"); + expect(created.credential.length).toBeGreaterThan(0); + expect(listedBeforeRevoke).toHaveLength(1); + expect(listedBeforeRevoke[0]?.id).toBe(created.id); + expect(listedBeforeRevoke[0]?.label).toBe("CI phone"); + expect(listedBeforeRevoke[0]?.credential).toBe(created.credential); + expect(revoked).toBe(true); + expect(listedAfterRevoke).toHaveLength(0); + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), + ); + + it.effect("issues bearer sessions and lists them without exposing raw tokens", () => + Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const sessionCredentials = yield* SessionCredentialService; + + const issued = yield* authControlPlane.issueSession({ + label: "deploy-bot", + }); + const verified = yield* sessionCredentials.verify(issued.token); + const listedBeforeRevoke = yield* authControlPlane.listSessions(); + const revoked = yield* authControlPlane.revokeSession(issued.sessionId); + const listedAfterRevoke = yield* authControlPlane.listSessions(); + + expect(issued.method).toBe("bearer-session-token"); + expect(issued.role).toBe("owner"); + expect(issued.client.deviceType).toBe("bot"); + expect(issued.client.label).toBe("deploy-bot"); + expect(verified.sessionId).toBe(issued.sessionId); + expect(verified.role).toBe("owner"); + expect(verified.method).toBe("bearer-session-token"); + expect(listedBeforeRevoke).toHaveLength(1); + expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); + expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false); + expect(revoked).toBe(true); + expect(listedAfterRevoke).toHaveLength(0); + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), + ); + + it.effect("surfaces lastConnectedAt through the listed session view", () => + Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const sessionCredentials = yield* SessionCredentialService; + + const issued = yield* authControlPlane.issueSession({ + label: "remote-ipad", + }); + const beforeConnect = yield* authControlPlane.listSessions(); + yield* sessionCredentials.markConnected(issued.sessionId); + const afterConnect = yield* authControlPlane.listSessions(); + + expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); + expect(afterConnect[0]?.lastConnectedAt).not.toBeNull(); + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts new file mode 100644 index 0000000000..98b2107800 --- /dev/null +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -0,0 +1,176 @@ +import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +import { DateTime, Effect, Layer } from "effect"; + +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts"; +import { + AuthControlPlane, + AuthControlPlaneError, + AuthControlPlaneShape, + DEFAULT_SESSION_SUBJECT, + IssuedBearerSession, + IssuedPairingLink, +} from "../Services/AuthControlPlane.ts"; + +const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { + if (left.role !== right.role) { + return left.role === "owner" ? -1 : 1; + } + if (left.connected !== right.connected) { + return left.connected ? -1 : 1; + } + return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; +}; + +const toAuthControlPlaneError = + (message: string) => + (cause: unknown): AuthControlPlaneError => + new AuthControlPlaneError({ + message, + cause, + }); + +export const makeAuthControlPlane = Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const sessions = yield* SessionCredentialService; + + const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) => + Effect.gen(function* () { + const createdAt = yield* DateTime.now; + const issued = yield* bootstrapCredentials.issueOneTimeToken({ + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + ...(input?.ttl ? { ttl: input.ttl } : {}), + ...(input?.label ? { label: input.label } : {}), + }); + return { + id: issued.id, + credential: issued.credential, + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + ...(issued.label ? { label: issued.label } : {}), + createdAt: DateTime.toUtc(createdAt), + expiresAt: DateTime.toUtc(issued.expiresAt), + } satisfies IssuedPairingLink; + }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link."))); + + const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) => + bootstrapCredentials.listActive().pipe( + Effect.map((pairingLinks) => + pairingLinks + .filter((pairingLink) => (input?.role ? pairingLink.role === input.role : true)) + .filter((pairingLink) => !input?.excludeSubjects?.includes(pairingLink.subject)) + .map((pairingLink) => + pairingLink.label + ? ({ + id: pairingLink.id, + credential: pairingLink.credential, + role: pairingLink.role, + subject: pairingLink.subject, + label: pairingLink.label, + createdAt: pairingLink.createdAt, + expiresAt: pairingLink.expiresAt, + } satisfies AuthPairingLink) + : ({ + id: pairingLink.id, + credential: pairingLink.credential, + role: pairingLink.role, + subject: pairingLink.subject, + createdAt: pairingLink.createdAt, + expiresAt: pairingLink.expiresAt, + } satisfies AuthPairingLink), + ) + .toSorted( + (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, + ), + ), + Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")), + ); + + const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) => + bootstrapCredentials + .revoke(id) + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link."))); + + const issueSession: AuthControlPlaneShape["issueSession"] = (input) => + sessions + .issue({ + subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + method: "bearer-session-token", + role: input?.role ?? "owner", + client: { + ...(input?.label ? { label: input.label } : {}), + deviceType: "bot", + }, + ...(input?.ttl ? { ttl: input.ttl } : {}), + }) + .pipe( + Effect.flatMap((issued) => { + if (issued.method !== "bearer-session-token") { + return Effect.fail( + new AuthControlPlaneError({ + message: "CLI session issuance produced an unexpected session method.", + }), + ); + } + + return Effect.succeed({ + sessionId: issued.sessionId, + token: issued.token, + method: "bearer-session-token" as const, + role: issued.role, + subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + client: issued.client, + expiresAt: DateTime.toUtc(issued.expiresAt), + } satisfies IssuedBearerSession); + }), + Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")), + ); + + const listSessions: AuthControlPlaneShape["listSessions"] = () => + sessions.listActive().pipe( + Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), + Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")), + ); + + const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) => + sessions + .revoke(sessionId) + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session."))); + + const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = ( + sessionId, + ) => + sessions + .revokeAllExcept(sessionId) + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions."))); + + return { + createPairingLink, + listPairingLinks, + revokePairingLink, + issueSession, + listSessions, + revokeSession, + revokeOtherSessionsExcept, + } satisfies AuthControlPlaneShape; +}); + +export const AuthCoreLive = Layer.mergeAll( + BootstrapCredentialServiceLive, + SessionCredentialServiceLive, +); + +export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive); + +export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive)); + +export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane); + +export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe( + Layer.provideMerge(AuthRuntimeLive), +); diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts new file mode 100644 index 0000000000..ec110ee96f --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -0,0 +1,151 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Duration, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), + ); + +const makeBootstrapCredentialLayer = ( + overrides?: Partial>, +) => + BootstrapCredentialServiceLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { + it.effect("issues pairing tokens in a short manual-entry format", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const issued = yield* bootstrapCredentials.issueOneTimeToken(); + + expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("issues one-time bootstrap tokens that can only be consumed once", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); + const first = yield* bootstrapCredentials.consume(issued.credential); + const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); + + expect(first.method).toBe("one-time-token"); + expect(first.role).toBe("client"); + expect(first.subject).toBe("one-time-token"); + expect(first.label).toBe("Julius iPhone"); + expect(issued.label).toBe("Julius iPhone"); + expect(second._tag).toBe("BootstrapCredentialError"); + expect(second.message).toContain("Unknown bootstrap credential"); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("atomically consumes a one-time token when multiple requests race", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const token = yield* bootstrapCredentials.issueOneTimeToken(); + const results = yield* Effect.all( + Array.from({ length: 8 }, () => + Effect.result(bootstrapCredentials.consume(token.credential)), + ), + { + concurrency: "unbounded", + }, + ); + + const successes = results.filter((result) => result._tag === "Success"); + const failures = results.filter((result) => result._tag === "Failure"); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(7); + for (const failure of failures) { + expect(failure.failure._tag).toBe("BootstrapCredentialError"); + expect(failure.failure.message).toContain("Unknown bootstrap credential"); + } + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("seeds the desktop bootstrap credential as a one-time grant", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); + const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); + + expect(first.method).toBe("desktop-bootstrap"); + expect(first.role).toBe("owner"); + expect(first.subject).toBe("desktop-bootstrap"); + expect(second._tag).toBe("BootstrapCredentialError"); + expect(second.status).toBe(401); + }).pipe( + Effect.provide( + makeBootstrapCredentialLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); + + it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + + yield* TestClock.adjust(Duration.minutes(6)); + const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); + + expect(expired._tag).toBe("BootstrapCredentialError"); + expect(expired.status).toBe(401); + expect(expired.message).toContain("Bootstrap credential expired"); + }).pipe( + Effect.provide( + Layer.merge( + makeBootstrapCredentialLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + TestClock.layer(), + ), + ), + ), + ); + + it.effect("lists and revokes active pairing links", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const first = yield* bootstrapCredentials.issueOneTimeToken(); + const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" }); + + const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); + + const revoked = yield* bootstrapCredentials.revoke(first.id); + const activeAfterRevoke = yield* bootstrapCredentials.listActive(); + const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); + + expect(revoked).toBe(true); + expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); + expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); + expect(revokedConsume.message).toContain("no longer available"); + expect(revokedConsume.status).toBe(401); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts new file mode 100644 index 0000000000..5539f62c70 --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -0,0 +1,296 @@ +import type { AuthPairingLink } from "@t3tools/contracts"; +import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect"; +import { Option } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; +import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts"; +import { + BootstrapCredentialError, + BootstrapCredentialService, + type BootstrapCredentialChange, + type BootstrapCredentialServiceShape, + type BootstrapGrant, + type IssuedBootstrapCredential, +} from "../Services/BootstrapCredentialService.ts"; + +interface StoredBootstrapGrant extends BootstrapGrant { + readonly remainingUses: number | "unbounded"; +} + +type ConsumeResult = + | { + readonly _tag: "error"; + readonly reason: "not-found" | "expired"; + readonly error: BootstrapCredentialError; + } + | { + readonly _tag: "success"; + readonly grant: BootstrapGrant; + }; + +const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); +const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; +const PAIRING_TOKEN_LENGTH = 12; + +const generatePairingToken = (): string => { + const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH)); + + return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join(""); +}; + +export const makeBootstrapCredentialService = Effect.gen(function* () { + const config = yield* ServerConfig; + const pairingLinks = yield* AuthPairingLinkRepository; + const seededGrantsRef = yield* Ref.make(new Map()); + const changesPubSub = yield* PubSub.unbounded(); + + const invalidBootstrapCredentialError = (message: string) => + new BootstrapCredentialError({ + message, + status: 401, + }); + + const internalBootstrapCredentialError = (message: string, cause: unknown) => + new BootstrapCredentialError({ + message, + status: 500, + cause, + }); + + const seedGrant = (credential: string, grant: StoredBootstrapGrant) => + Ref.update(seededGrantsRef, (current) => { + const next = new Map(current); + next.set(credential, grant); + return next; + }); + + const emitUpsert = (pairingLink: AuthPairingLink) => + PubSub.publish(changesPubSub, { + type: "pairingLinkUpserted", + pairingLink, + }).pipe(Effect.asVoid); + + const emitRemoved = (id: string) => + PubSub.publish(changesPubSub, { + type: "pairingLinkRemoved", + id, + }).pipe(Effect.asVoid); + + if (config.desktopBootstrapToken) { + const now = yield* DateTime.now; + yield* seedGrant(config.desktopBootstrapToken, { + method: "desktop-bootstrap", + role: "owner", + subject: "desktop-bootstrap", + expiresAt: DateTime.add(now, { + milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), + }), + remainingUses: 1, + }); + } + + const toBootstrapCredentialError = (message: string) => (cause: unknown) => + internalBootstrapCredentialError(message, cause); + + const listActive: BootstrapCredentialServiceShape["listActive"] = () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const rows = yield* pairingLinks.listActive({ now }); + + return rows.map((row) => + row.label + ? ({ + id: row.id, + credential: row.credential, + role: row.role, + subject: row.subject, + label: row.label, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + } satisfies AuthPairingLink) + : ({ + id: row.id, + credential: row.credential, + role: row.role, + subject: row.subject, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + } satisfies AuthPairingLink), + ); + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links."))); + + const revoke: BootstrapCredentialServiceShape["revoke"] = (id) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revoked = yield* pairingLinks.revoke({ + id, + revokedAt, + }); + if (revoked) { + yield* emitRemoved(id); + } + return revoked; + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link."))); + + const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => + Effect.gen(function* () { + const id = crypto.randomUUID(); + const credential = generatePairingToken(); + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); + const issued: IssuedBootstrapCredential = { + id, + credential, + ...(input?.label ? { label: input.label } : {}), + expiresAt, + }; + yield* pairingLinks.create({ + id, + credential, + method: "one-time-token", + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + label: input?.label ?? null, + createdAt: now, + expiresAt: expiresAt, + }); + yield* emitUpsert({ + id, + credential, + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + ...(input?.label ? { label: input.label } : {}), + createdAt: now, + expiresAt, + }); + return issued; + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential."))); + + const consume: BootstrapCredentialServiceShape["consume"] = (credential) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const seededResult: ConsumeResult = yield* Ref.modify( + seededGrantsRef, + (current): readonly [ConsumeResult, Map] => { + const grant = current.get(credential); + if (!grant) { + return [ + { + _tag: "error", + reason: "not-found", + error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + }, + current, + ]; + } + + const next = new Map(current); + if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { + next.delete(credential); + return [ + { + _tag: "error", + reason: "expired", + error: invalidBootstrapCredentialError("Bootstrap credential expired."), + }, + next, + ]; + } + + const remainingUses = grant.remainingUses; + if (typeof remainingUses === "number") { + if (remainingUses <= 1) { + next.delete(credential); + } else { + next.set(credential, { + ...grant, + remainingUses: remainingUses - 1, + }); + } + } + + return [ + { + _tag: "success", + grant: { + method: grant.method, + role: grant.role, + subject: grant.subject, + ...(grant.label ? { label: grant.label } : {}), + expiresAt: grant.expiresAt, + } satisfies BootstrapGrant, + }, + next, + ]; + }, + ); + + if (seededResult._tag === "success") { + return seededResult.grant; + } + if (seededResult.reason !== "not-found") { + return yield* seededResult.error; + } + + const consumed = yield* pairingLinks.consumeAvailable({ + credential, + consumedAt: now, + now, + }); + + if (Option.isSome(consumed)) { + yield* emitRemoved(consumed.value.id); + return { + method: consumed.value.method, + role: consumed.value.role, + subject: consumed.value.subject, + ...(consumed.value.label ? { label: consumed.value.label } : {}), + expiresAt: consumed.value.expiresAt, + } satisfies BootstrapGrant; + } + + const matching = yield* pairingLinks.getByCredential({ credential }); + if (Option.isNone(matching)) { + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + } + + if (matching.value.revokedAt !== null) { + return yield* invalidBootstrapCredentialError( + "Bootstrap credential is no longer available.", + ); + } + + if (matching.value.consumedAt !== null) { + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + } + + if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { + return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); + } + + return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); + }).pipe( + Effect.mapError((cause) => + cause instanceof BootstrapCredentialError + ? cause + : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), + ), + ); + + return { + issueOneTimeToken, + listActive, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + revoke, + consume, + } satisfies BootstrapCredentialServiceShape; +}); + +export const BootstrapCredentialServiceLive = Layer.effect( + BootstrapCredentialService, + makeBootstrapCredentialService, +).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive)); diff --git a/apps/server/src/auth/Layers/ServerAuth.test.ts b/apps/server/src/auth/Layers/ServerAuth.test.ts new file mode 100644 index 0000000000..0c3d71cc9f --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.test.ts @@ -0,0 +1,182 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; +import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts"; +import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; + +const makeServerConfigLayer = (overrides?: Partial) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); + +const makeServerAuthLayer = (overrides?: Partial) => + ServerAuthLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStoreLive), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +const makeCookieRequest = ( + sessionToken: string, +): Parameters[0] => + ({ + cookies: { + t3_session: sessionToken, + }, + headers: {}, + }) as unknown as Parameters[0]; + +const requestMetadata = { + deviceType: "desktop" as const, + os: "macOS", + browser: "Chrome", + ipAddress: "192.168.1.23", +}; + +it.layer(NodeServices.layer)("ServerAuthLive", (it) => { + it.effect("maps invalid bootstrap credential failures to 401", () => + Effect.sync(() => { + const error = toBootstrapExchangeAuthError( + new BootstrapCredentialError({ + message: "Unknown bootstrap credential.", + status: 401, + }), + ); + + expect(error.status).toBe(401); + expect(error.message).toBe("Invalid bootstrap credential."); + }), + ); + + it.effect("maps unexpected bootstrap failures to 500", () => + Effect.sync(() => { + const error = toBootstrapExchangeAuthError( + new BootstrapCredentialError({ + message: "Failed to consume bootstrap credential.", + status: 500, + cause: new Error("sqlite is unavailable"), + }), + ); + + expect(error.status).toBe(500); + expect(error.message).toBe("Failed to validate bootstrap credential."); + }), + ); + + it.effect("issues client pairing credentials by default", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const pairingCredential = yield* serverAuth.issuePairingCredential(); + const exchanged = yield* serverAuth.exchangeBootstrapCredential( + pairingCredential.credential, + requestMetadata, + ); + const verified = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(exchanged.sessionToken), + ); + + expect(verified.sessionId.length).toBeGreaterThan(0); + expect(verified.role).toBe("client"); + expect(verified.subject).toBe("one-time-token"); + }).pipe(Effect.provide(makeServerAuthLayer())), + ); + + it.effect("issues startup pairing URLs that bootstrap owner sessions", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); + const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); + const listedPairingLinks = yield* serverAuth.listPairingLinks(); + expect(token).toBeTruthy(); + expect( + listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"), + ).toBe(false); + + const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata); + const verified = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(exchanged.sessionToken), + ); + + expect(verified.role).toBe("owner"); + expect(verified.subject).toBe("owner-bootstrap"); + }).pipe(Effect.provide(makeServerAuthLayer())), + ); + + it.effect("lists pairing links and revokes other client sessions while keeping the owner", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const ownerExchange = yield* serverAuth.exchangeBootstrapCredential( + "desktop-bootstrap-token", + requestMetadata, + ); + const ownerSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(ownerExchange.sessionToken), + ); + const pairingCredential = yield* serverAuth.issuePairingCredential({ + label: "Julius iPhone", + }); + const listedPairingLinks = yield* serverAuth.listPairingLinks(); + const clientExchange = yield* serverAuth.exchangeBootstrapCredential( + pairingCredential.credential, + { + ...requestMetadata, + deviceType: "mobile", + os: "iOS", + browser: "Safari", + ipAddress: "192.168.1.88", + }, + ); + const clientSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(clientExchange.sessionToken), + ); + const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); + const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId); + const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); + + expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id); + expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe( + "Julius iPhone", + ); + expect(clientsBeforeRevoke).toHaveLength(2); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current, + ).toBe(true); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current, + ).toBe(false); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client + .label, + ).toBe("Julius iPhone"); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client + .deviceType, + ).toBe("mobile"); + expect(revokedCount).toBe(1); + expect(clientsAfterRevoke).toHaveLength(1); + expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId); + }).pipe( + Effect.provide( + makeServerAuthLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts new file mode 100644 index 0000000000..cb1c6fa4c4 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -0,0 +1,395 @@ +import { + type AuthBearerBootstrapResult, + type AuthClientSession, + type AuthBootstrapResult, + type AuthPairingCredentialResult, + type AuthSessionState, + type AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Option } from "effect"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; + +import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; +import { + ServerAuth, + type AuthenticatedSession, + AuthError, + type ServerAuthShape, +} from "../Services/ServerAuth.ts"; +import { + SessionCredentialError, + SessionCredentialService, +} from "../Services/SessionCredentialService.ts"; +import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; + +type BootstrapExchangeResult = { + readonly response: AuthBootstrapResult; + readonly sessionToken: string; +}; + +const AUTHORIZATION_PREFIX = "Bearer "; +const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken"; + +export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { + if (cause.status === 500) { + return new AuthError({ + message: "Failed to validate bootstrap credential.", + status: 500, + cause, + }); + } + + return new AuthError({ + message: "Invalid bootstrap credential.", + status: 401, + cause, + }); +} + +function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { + const header = request.headers["authorization"]; + if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { + return null; + } + const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); + return token.length > 0 ? token : null; +} + +export const makeServerAuth = Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const bootstrapCredentials = yield* BootstrapCredentialService; + const authControlPlane = yield* AuthControlPlane; + const sessions = yield* SessionCredentialService; + const descriptor = yield* policy.getDescriptor(); + + const authenticateToken = (token: string): Effect.Effect => + sessions.verify(token).pipe( + Effect.tapError((cause: SessionCredentialError) => + Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: cause.message, + }), + ), + ), + Effect.map((session) => ({ + sessionId: session.sessionId, + subject: session.subject, + method: session.method, + role: session.role, + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), + })), + Effect.mapError( + (cause) => + new AuthError({ + message: "Unauthorized request.", + status: 401, + cause, + }), + ), + ); + + const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const cookieToken = request.cookies[sessions.cookieName]; + const bearerToken = parseBearerToken(request); + const credential = cookieToken ?? bearerToken; + if (!credential) { + return Effect.fail( + new AuthError({ + message: "Authentication required.", + status: 401, + }), + ); + } + return authenticateToken(credential); + }; + + const getSessionState: ServerAuthShape["getSessionState"] = (request) => + authenticateRequest(request).pipe( + Effect.map( + (session) => + ({ + authenticated: true, + auth: descriptor, + role: session.role, + sessionMethod: session.method, + ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), + }) satisfies AuthSessionState, + ), + Effect.catchTag("AuthError", () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + ), + ); + + const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = ( + credential, + requestMetadata, + ) => + bootstrapCredentials.consume(credential).pipe( + Effect.mapError(toBootstrapExchangeAuthError), + Effect.flatMap((grant) => + sessions + .issue({ + method: "browser-session-cookie", + subject: grant.subject, + role: grant.role, + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue authenticated session.", + cause, + }), + ), + ), + ), + Effect.map( + (session) => + ({ + response: { + authenticated: true, + role: session.role, + sessionMethod: session.method, + expiresAt: DateTime.toUtc(session.expiresAt), + } satisfies AuthBootstrapResult, + sessionToken: session.token, + }) satisfies BootstrapExchangeResult, + ), + ); + + const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] = + (credential, requestMetadata) => + bootstrapCredentials.consume(credential).pipe( + Effect.mapError(toBootstrapExchangeAuthError), + Effect.flatMap((grant) => + sessions + .issue({ + method: "bearer-session-token", + subject: grant.subject, + role: grant.role, + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue authenticated session.", + cause, + }), + ), + ), + ), + Effect.map( + (session) => + ({ + authenticated: true, + role: session.role, + sessionMethod: "bearer-session-token", + expiresAt: DateTime.toUtc(session.expiresAt), + sessionToken: session.token, + }) satisfies AuthBearerBootstrapResult, + ), + ); + + const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) => + authControlPlane + .createPairingLink({ + role: input?.role ?? "client", + subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token", + ...(input?.label ? { label: input.label } : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue pairing credential.", + cause, + }), + ), + Effect.map( + (issued) => + ({ + id: issued.id, + credential: issued.credential, + ...(issued.label ? { label: issued.label } : {}), + expiresAt: issued.expiresAt, + }) satisfies AuthPairingCredentialResult, + ), + ); + + const listPairingLinks: ServerAuthShape["listPairingLinks"] = () => + authControlPlane + .listPairingLinks({ + role: "client", + excludeSubjects: ["owner-bootstrap"], + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to load pairing links.", + cause, + }), + ), + ); + + const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) => + authControlPlane.revokePairingLink(id).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke pairing link.", + cause, + }), + ), + ); + + const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) => + authControlPlane.listSessions().pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to load paired clients.", + cause, + }), + ), + Effect.map((clientSessions) => + clientSessions.map( + (clientSession): AuthClientSession => ({ + ...clientSession, + current: clientSession.sessionId === currentSessionId, + }), + ), + ), + ); + + const revokeClientSession: ServerAuthShape["revokeClientSession"] = ( + currentSessionId, + targetSessionId, + ) => + Effect.gen(function* () { + if (currentSessionId === targetSessionId) { + return yield* new AuthError({ + message: "Use revoke other clients to keep the current owner session active.", + status: 403, + }); + } + return yield* authControlPlane.revokeSession(targetSessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke client session.", + cause, + }), + ), + ); + }); + + const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = ( + currentSessionId, + ) => + authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke other client sessions.", + cause, + }), + ), + ); + + const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => + issuePairingCredential({ role: "owner" }).pipe( + Effect.map((issued) => { + const url = new URL(baseUrl); + url.pathname = "/pair"; + url.searchParams.delete("token"); + url.hash = new URLSearchParams([["token", issued.credential]]).toString(); + return url.toString(); + }), + ); + + const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) => + sessions.issueWebSocketToken(session.sessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue websocket token.", + cause, + }), + ), + Effect.map( + (issued) => + ({ + token: issued.token, + expiresAt: DateTime.toUtc(issued.expiresAt), + }) satisfies AuthWebSocketTokenResult, + ), + ); + + const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) => + Effect.gen(function* () { + const requestUrl = HttpServerRequest.toURL(request); + if (Option.isSome(requestUrl)) { + const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM); + if (websocketToken && websocketToken.trim().length > 0) { + return yield* sessions.verifyWebSocketToken(websocketToken).pipe( + Effect.map((session) => ({ + sessionId: session.sessionId, + subject: session.subject, + method: session.method, + role: session.role, + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), + })), + Effect.mapError( + (cause) => + new AuthError({ + message: "Unauthorized request.", + status: 401, + cause, + }), + ), + ); + } + } + + return yield* authenticateRequest(request); + }); + + return { + getDescriptor: () => Effect.succeed(descriptor), + getSessionState, + exchangeBootstrapCredential, + exchangeBootstrapCredentialForBearerSession, + issuePairingCredential, + listPairingLinks, + revokePairingLink, + listClientSessions, + revokeClientSession, + revokeOtherClientSessions, + authenticateHttpRequest: authenticateRequest, + authenticateWebSocketUpgrade, + issueWebSocketToken, + issueStartupPairingUrl, + } satisfies ServerAuthShape; +}); + +export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe( + Layer.provideMerge(AuthControlPlaneLive), + Layer.provideMerge(AuthCoreLive), + Layer.provideMerge(ServerAuthPolicyLive), +); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts new file mode 100644 index 0000000000..13ca0233ee --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; + +const makeServerAuthPolicyLayer = (overrides?: Partial) => + ServerAuthPolicyLive.pipe( + Layer.provide( + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), + ), + ), + ); + +it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { + it.effect("uses desktop-managed-local policy for desktop mode", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("desktop-managed-local"); + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + expect(descriptor.sessionCookieName).toBe("t3_session_3773"); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "desktop", + port: 3773, + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "desktop", + host: "0.0.0.0", + }), + ), + ), + ); + + it.effect("uses loopback-browser policy for loopback web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("loopback-browser"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.sessionCookieName).toBe("t3_session"); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "127.0.0.1", + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for wildcard web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "0.0.0.0", + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for non-loopback web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "192.168.1.50", + }), + ), + ), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts new file mode 100644 index 0000000000..43735b4761 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -0,0 +1,44 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; +import { resolveSessionCookieName } from "../utils.ts"; +import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; + +export const makeServerAuthPolicy = Effect.gen(function* () { + const config = yield* ServerConfig; + const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); + + const policy = + config.mode === "desktop" + ? isRemoteReachable + ? "remote-reachable" + : "desktop-managed-local" + : isRemoteReachable + ? "remote-reachable" + : "loopback-browser"; + + const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = + policy === "desktop-managed-local" + ? ["desktop-bootstrap"] + : config.mode === "desktop" && policy === "remote-reachable" + ? ["desktop-bootstrap", "one-time-token"] + : ["one-time-token"]; + + const descriptor: ServerAuthDescriptor = { + policy, + bootstrapMethods, + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: resolveSessionCookieName({ + mode: config.mode, + port: config.port, + }), + }; + + return { + getDescriptor: () => Effect.succeed(descriptor), + } satisfies ServerAuthPolicyShape; +}); + +export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts new file mode 100644 index 0000000000..7e6352eec2 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -0,0 +1,263 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; + +const makeServerConfigLayer = () => + ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); + +const makeServerSecretStoreLayer = () => + Layer.provide(ServerSecretStoreLive, makeServerConfigLayer()); + +const PermissionDeniedFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + readFile: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: path, + description: "Permission denied while reading secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makePermissionDeniedSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(PermissionDeniedFileSystemLayer), + ); + +const RenameFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + rename: (from, to) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: `${String(from)} -> ${String(to)}`, + description: "Permission denied while persisting secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRenameFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(RenameFailureFileSystemLayer), + ); + +const RemoveFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + remove: (path, options) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: String(path), + description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRemoveFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(RemoveFailureFileSystemLayer), + ); + +const ConcurrentReadMissFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const readCountRef = yield* Ref.make(0); + const readBarrier = yield* Deferred.make(); + + return { + ...fileSystem, + readFile: (path) => + String(path).endsWith("/session-signing-key.bin") + ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( + Effect.flatMap((count) => { + if (count > 2) { + return fileSystem.readFile(path); + } + return Effect.gen(function* () { + if (count === 2) { + yield* Deferred.succeed(readBarrier, void 0); + } + yield* Deferred.await(readBarrier); + return yield* Effect.failCause( + Cause.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: String(path), + description: "Secret file does not exist yet.", + }), + ), + ); + }); + }), + ) + : fileSystem.readFile(path), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeConcurrentCreateSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(ConcurrentReadMissFileSystemLayer), + ); + +it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { + it.effect("returns null when a secret file does not exist", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const secret = yield* secretStore.get("missing-secret"); + + expect(secret).toBeNull(); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("reuses an existing secret instead of regenerating it", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + + expect(Array.from(second)).toEqual(Array.from(first)); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("returns the persisted secret when concurrent creators race", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const [first, second] = yield* Effect.all( + [ + secretStore.getOrCreateRandom("session-signing-key", 32), + secretStore.getOrCreateRandom("session-signing-key", 32), + ], + { concurrency: "unbounded" }, + ); + const persisted = yield* secretStore.get("session-signing-key"); + + expect(persisted).not.toBeNull(); + expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); + expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); + }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), + ); + + it.effect("uses restrictive permissions for the secret directory and files", () => + Effect.gen(function* () { + const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; + const recordingFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + makeDirectory: () => Effect.void, + writeFile: () => Effect.void, + rename: () => Effect.void, + chmod: (path, mode) => + Effect.sync(() => { + chmodCalls.push({ path: String(path), mode }); + }), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeServices.layer)); + + const secretStore = yield* Effect.service(ServerSecretStore).pipe( + Effect.provide( + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(recordingFileSystemLayer), + ), + ), + ); + + yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); + + expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( + true, + ); + expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("propagates read failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to read secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), + ); + + it.effect("propagates write failures instead of treating them as success", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip( + secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), + ); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to persist secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), + ); + + it.effect("propagates remove failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.remove("session-signing-key")); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to remove secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts new file mode 100644 index 0000000000..c8acf11bab --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -0,0 +1,148 @@ +import * as Crypto from "node:crypto"; + +import { Effect, FileSystem, Layer, Path, Predicate } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { + SecretStoreError, + ServerSecretStore, + type ServerSecretStoreShape, +} from "../Services/ServerSecretStore.ts"; + +export const makeServerSecretStore = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); + yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( + Effect.mapError( + (cause) => + new SecretStoreError({ + message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, + cause, + }), + ), + ); + + const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + + const isPlatformError = (u: unknown): u is PlatformError.PlatformError => + Predicate.isTagged(u, "PlatformError"); + + const get: ServerSecretStoreShape["get"] = (name) => + fileSystem.readFile(resolveSecretPath(name)).pipe( + Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new SecretStoreError({ + message: `Failed to read secret ${name}.`, + cause, + }), + ), + ), + ); + + const set: ServerSecretStoreShape["set"] = (name, value) => { + const secretPath = resolveSecretPath(name); + const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; + return Effect.gen(function* () { + yield* fileSystem.writeFile(tempPath, value); + yield* fileSystem.chmod(tempPath, 0o600); + yield* fileSystem.rename(tempPath, secretPath); + yield* fileSystem.chmod(secretPath, 0o600); + }).pipe( + Effect.catch((cause) => + fileSystem.remove(tempPath).pipe( + Effect.ignore, + Effect.flatMap(() => + Effect.fail( + new SecretStoreError({ + message: `Failed to persist secret ${name}.`, + cause, + }), + ), + ), + ), + ), + ); + }; + + const create: ServerSecretStoreShape["set"] = (name, value) => { + const secretPath = resolveSecretPath(name); + return Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(secretPath, { + flag: "wx", + mode: 0o600, + }); + yield* file.writeAll(value); + yield* file.sync; + yield* fileSystem.chmod(secretPath, 0o600); + }), + ).pipe( + Effect.mapError( + (cause) => + new SecretStoreError({ + message: `Failed to persist secret ${name}.`, + cause, + }), + ), + ); + }; + + const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + get(name).pipe( + Effect.flatMap((existing) => { + if (existing) { + return Effect.succeed(existing); + } + + const generated = Crypto.randomBytes(bytes); + return create(name, generated).pipe( + Effect.as(Uint8Array.from(generated)), + Effect.catchTag("SecretStoreError", (error) => + isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" + ? get(name).pipe( + Effect.flatMap((created) => + created !== null + ? Effect.succeed(created) + : Effect.fail( + new SecretStoreError({ + message: `Failed to read secret ${name} after concurrent creation.`, + }), + ), + ), + ) + : Effect.fail(error), + ), + ); + }), + ); + + const remove: ServerSecretStoreShape["remove"] = (name) => + fileSystem.remove(resolveSecretPath(name)).pipe( + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.void + : Effect.fail( + new SecretStoreError({ + message: `Failed to remove secret ${name}.`, + cause, + }), + ), + ), + ); + + return { + get, + set, + getOrCreateRandom, + remove, + } satisfies ServerSecretStoreShape; +}); + +export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts new file mode 100644 index 0000000000..bafd9c85c2 --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -0,0 +1,191 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Duration, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); + +const makeSessionCredentialLayer = ( + overrides?: Partial>, +) => + SessionCredentialServiceLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStoreLive), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { + it.effect("issues and verifies signed browser session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + subject: "desktop-bootstrap", + role: "owner", + client: { + label: "Desktop app", + deviceType: "desktop", + os: "macOS", + browser: "Electron", + ipAddress: "127.0.0.1", + }, + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("browser-session-cookie"); + expect(verified.subject).toBe("desktop-bootstrap"); + expect(verified.role).toBe("owner"); + expect(verified.client.label).toBe("Desktop app"); + expect(verified.client.browser).toBe("Electron"); + expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + it.effect("rejects malformed session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const error = yield* Effect.flip(sessions.verify("not-a-session-token")); + + expect(error._tag).toBe("SessionCredentialError"); + expect(error.message).toContain("Malformed session token"); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + it.effect("verifies session tokens against the Effect clock", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + method: "bearer-session-token", + subject: "test-clock", + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("bearer-session-token"); + expect(verified.subject).toBe("test-clock"); + expect(verified.role).toBe("client"); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); + + it.effect("rejects websocket tokens once the parent session has expired", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + method: "bearer-session-token", + subject: "short-lived", + ttl: Duration.seconds(1), + }); + const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); + + yield* TestClock.adjust(Duration.seconds(2)); + + const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + expect(error.message).toContain("expired"); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); + + it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const owner = yield* sessions.issue({ + subject: "desktop-bootstrap", + role: "owner", + client: { + label: "Desktop app", + deviceType: "desktop", + os: "macOS", + browser: "Electron", + }, + }); + const client = yield* sessions.issue({ + subject: "one-time-token", + role: "client", + client: { + label: "Julius iPhone", + deviceType: "mobile", + os: "iOS", + browser: "Safari", + ipAddress: "192.168.1.88", + }, + }); + + yield* sessions.markConnected(client.sessionId); + const beforeRevoke = yield* sessions.listActive(); + const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId); + const afterRevoke = yield* sessions.listActive(); + const revokedClient = yield* Effect.flip(sessions.verify(client.token)); + + expect(beforeRevoke).toHaveLength(2); + expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( + true, + ); + expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe( + "Julius iPhone", + ); + expect( + beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType, + ).toBe("desktop"); + expect(revokedCount).toBe(1); + expect(afterRevoke).toHaveLength(1); + expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId); + expect(revokedClient.message).toContain("revoked"); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + + it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + subject: "reconnect-test", + method: "bearer-session-token", + }); + + const beforeConnect = yield* sessions.listActive(); + expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* sessions.markConnected(issued.sessionId); + const firstConnect = yield* sessions.listActive(); + const firstConnectedAt = firstConnect[0]?.lastConnectedAt; + + expect(firstConnect[0]?.connected).toBe(true); + expect(firstConnectedAt).not.toBeNull(); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* sessions.markConnected(issued.sessionId); + const stillConnected = yield* sessions.listActive(); + + expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); + + yield* sessions.markDisconnected(issued.sessionId); + yield* sessions.markDisconnected(issued.sessionId); + const afterDisconnect = yield* sessions.listActive(); + + expect(afterDisconnect[0]?.connected).toBe(false); + expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* sessions.markConnected(issued.sessionId); + const afterReconnect = yield* sessions.listActive(); + + expect(afterReconnect[0]?.connected).toBe(true); + expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); + expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); +}); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts new file mode 100644 index 0000000000..5ff4bbffff --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -0,0 +1,500 @@ +import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts"; +import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; +import { Option } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; +import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; +import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { + SessionCredentialError, + SessionCredentialService, + type IssuedSession, + type SessionCredentialChange, + type SessionCredentialServiceShape, + type VerifiedSession, +} from "../Services/SessionCredentialService.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + resolveSessionCookieName, + signPayload, + timingSafeEqualBase64Url, +} from "../utils.ts"; + +const SIGNING_SECRET_NAME = "server-signing-key"; +const DEFAULT_SESSION_TTL = Duration.days(30); +const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); + +const SessionClaims = Schema.Struct({ + v: Schema.Literal(1), + kind: Schema.Literal("session"), + sid: AuthSessionId, + sub: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + iat: Schema.Number, + exp: Schema.Number, +}); +type SessionClaims = typeof SessionClaims.Type; + +const WebSocketClaims = Schema.Struct({ + v: Schema.Literal(1), + kind: Schema.Literal("websocket"), + sid: AuthSessionId, + iat: Schema.Number, + exp: Schema.Number, +}); +type WebSocketClaims = typeof WebSocketClaims.Type; + +const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims)); +const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); + +function createDefaultClientMetadata(): AuthClientMetadata { + return { + deviceType: "unknown", + }; +} + +function toClientMetadata(record: { + readonly label: string | null; + readonly ipAddress: string | null; + readonly userAgent: string | null; + readonly deviceType: AuthClientMetadata["deviceType"]; + readonly os: string | null; + readonly browser: string | null; +}): AuthClientMetadata { + return { + ...(record.label ? { label: record.label } : {}), + ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), + ...(record.userAgent ? { userAgent: record.userAgent } : {}), + deviceType: record.deviceType, + ...(record.os ? { os: record.os } : {}), + ...(record.browser ? { browser: record.browser } : {}), + }; +} + +function toAuthClientSession(input: Omit): AuthClientSession { + return { + ...input, + current: false, + }; +} + +export const makeSessionCredentialService = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const secretStore = yield* ServerSecretStore; + const authSessions = yield* AuthSessionRepository; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); + const connectedSessionsRef = yield* Ref.make(new Map()); + const changesPubSub = yield* PubSub.unbounded(); + const cookieName = resolveSessionCookieName({ + mode: serverConfig.mode, + port: serverConfig.port, + }); + + const toSessionCredentialError = (message: string) => (cause: unknown) => + new SessionCredentialError({ + message, + cause, + }); + + const emitUpsert = (clientSession: AuthClientSession) => + PubSub.publish(changesPubSub, { + type: "clientUpserted", + clientSession, + }).pipe(Effect.asVoid); + + const emitRemoved = (sessionId: AuthSessionId) => + PubSub.publish(changesPubSub, { + type: "clientRemoved", + sessionId, + }).pipe(Effect.asVoid); + + const loadActiveSession = (sessionId: AuthSessionId) => + Effect.gen(function* () { + const row = yield* authSessions.getById({ sessionId }); + if (Option.isNone(row) || row.value.revokedAt !== null) { + return Option.none(); + } + + const connectedSessions = yield* Ref.get(connectedSessionsRef); + return Option.some( + toAuthClientSession({ + sessionId: row.value.sessionId, + subject: row.value.subject, + role: row.value.role, + method: row.value.method, + client: toClientMetadata(row.value.client), + issuedAt: row.value.issuedAt, + expiresAt: row.value.expiresAt, + lastConnectedAt: row.value.lastConnectedAt, + connected: connectedSessions.has(row.value.sessionId), + }), + ); + }); + + const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) => + Ref.modify(connectedSessionsRef, (current) => { + const next = new Map(current); + const wasDisconnected = !next.has(sessionId); + next.set(sessionId, (next.get(sessionId) ?? 0) + 1); + return [wasDisconnected, next] as const; + }).pipe( + Effect.flatMap((wasDisconnected) => + wasDisconnected + ? DateTime.now.pipe( + Effect.flatMap((lastConnectedAt) => + authSessions.setLastConnectedAt({ + sessionId, + lastConnectedAt, + }), + ), + ) + : Effect.void, + ), + Effect.flatMap(() => loadActiveSession(sessionId)), + Effect.flatMap((session) => + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logError("Failed to publish connected-session auth update.").pipe( + Effect.annotateLogs({ + sessionId, + cause, + }), + ), + ), + ); + + const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) => + Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + const remaining = (next.get(sessionId) ?? 0) - 1; + if (remaining > 0) { + next.set(sessionId, remaining); + } else { + next.delete(sessionId); + } + return next; + }).pipe( + Effect.flatMap(() => loadActiveSession(sessionId)), + Effect.flatMap((session) => + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logError("Failed to publish disconnected-session auth update.").pipe( + Effect.annotateLogs({ + sessionId, + cause, + }), + ), + ), + ); + + const issue: SessionCredentialServiceShape["issue"] = (input) => + Effect.gen(function* () { + const sessionId = AuthSessionId.make(crypto.randomUUID()); + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), + }); + const claims: SessionClaims = { + v: 1, + kind: "session", + sid: sessionId, + sub: input?.subject ?? "browser", + role: input?.role ?? "client", + method: input?.method ?? "browser-session-cookie", + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = signPayload(encodedPayload, signingSecret); + const client = input?.client ?? createDefaultClientMetadata(); + yield* authSessions.create({ + sessionId, + subject: claims.sub, + role: claims.role, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + }); + yield* emitUpsert( + toAuthClientSession({ + sessionId, + subject: claims.sub, + role: claims.role, + method: claims.method, + client, + issuedAt, + expiresAt, + lastConnectedAt: null, + connected: false, + }), + ); + + return { + sessionId, + token: `${encodedPayload}.${signature}`, + method: claims.method, + client, + expiresAt: expiresAt, + role: claims.role, + } satisfies IssuedSession; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential."))); + + const verify: SessionCredentialServiceShape["verify"] = (token) => + Effect.gen(function* () { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new SessionCredentialError({ + message: "Malformed session token.", + }); + } + + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new SessionCredentialError({ + message: "Invalid session token signature.", + }); + } + + const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError( + (cause) => + new SessionCredentialError({ + message: "Invalid session token payload.", + cause, + }), + ), + ); + + const now = yield* Clock.currentTimeMillis; + if (claims.exp <= now) { + return yield* new SessionCredentialError({ + message: "Session token expired.", + }); + } + + const row = yield* authSessions.getById({ sessionId: claims.sid }); + if (Option.isNone(row)) { + return yield* new SessionCredentialError({ + message: "Unknown session token.", + }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionCredentialError({ + message: "Session token revoked.", + }); + } + + return { + sessionId: claims.sid, + token, + method: claims.method, + client: toClientMetadata(row.value.client), + expiresAt: DateTime.makeUnsafe(claims.exp), + subject: claims.sub, + role: claims.role, + } satisfies VerifiedSession; + }).pipe( + Effect.mapError((cause) => + cause instanceof SessionCredentialError + ? cause + : new SessionCredentialError({ + message: "Failed to verify session credential.", + cause, + }), + ), + ); + + const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = ( + sessionId, + input, + ) => + Effect.gen(function* () { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), + }); + const claims: WebSocketClaims = { + v: 1, + kind: "websocket", + sid: sessionId, + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = signPayload(encodedPayload, signingSecret); + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + }; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token."))); + + const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) => + Effect.gen(function* () { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new SessionCredentialError({ + message: "Malformed websocket token.", + }); + } + + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new SessionCredentialError({ + message: "Invalid websocket token signature.", + }); + } + + const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError( + (cause) => + new SessionCredentialError({ + message: "Invalid websocket token payload.", + cause, + }), + ), + ); + + const now = yield* Clock.currentTimeMillis; + if (claims.exp <= now) { + return yield* new SessionCredentialError({ + message: "Websocket token expired.", + }); + } + + const row = yield* authSessions.getById({ sessionId: claims.sid }); + if (Option.isNone(row)) { + return yield* new SessionCredentialError({ + message: "Unknown websocket session.", + }); + } + if (row.value.expiresAt.epochMilliseconds <= now) { + return yield* new SessionCredentialError({ + message: "Websocket session expired.", + }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionCredentialError({ + message: "Websocket session revoked.", + }); + } + + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + role: row.value.role, + } satisfies VerifiedSession; + }).pipe( + Effect.mapError((cause) => + cause instanceof SessionCredentialError + ? cause + : new SessionCredentialError({ + message: "Failed to verify websocket token.", + cause, + }), + ), + ); + + const listActive: SessionCredentialServiceShape["listActive"] = () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const connectedSessions = yield* Ref.get(connectedSessionsRef); + const rows = yield* authSessions.listActive({ now }); + + return rows.map((row) => + toAuthClientSession({ + sessionId: row.sessionId, + subject: row.subject, + role: row.role, + method: row.method, + client: toClientMetadata(row.client), + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + lastConnectedAt: row.lastConnectedAt, + connected: connectedSessions.has(row.sessionId), + }), + ); + }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions."))); + + const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revoked = yield* authSessions.revoke({ + sessionId, + revokedAt, + }); + if (revoked) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + next.delete(sessionId); + return next; + }); + yield* emitRemoved(sessionId); + } + return revoked; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); + + const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions.revokeAllExcept({ + currentSessionId: sessionId, + revokedAt, + }); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; + }); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); + } + return revokedSessionIds.length; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); + + return { + cookieName, + issue, + verify, + issueWebSocketToken, + verifyWebSocketToken, + listActive, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + revoke, + revokeAllExcept, + markConnected, + markDisconnected, + } satisfies SessionCredentialServiceShape; +}); + +export const SessionCredentialServiceLive = Layer.effect( + SessionCredentialService, + makeSessionCredentialService, +).pipe(Layer.provideMerge(AuthSessionRepositoryLive)); diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts new file mode 100644 index 0000000000..b59e330bca --- /dev/null +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -0,0 +1,69 @@ +import type { + AuthClientMetadata, + AuthClientSession, + AuthPairingLink, + AuthSessionId, +} from "@t3tools/contracts"; +import { Data, DateTime, Duration, Effect, Context } from "effect"; +import { SessionRole } from "./SessionCredentialService"; + +export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; + +export interface IssuedPairingLink { + readonly id: string; + readonly credential: string; + readonly role: SessionRole; + readonly subject: string; + readonly label?: string; + readonly createdAt: DateTime.Utc; + readonly expiresAt: DateTime.Utc; +} + +export interface IssuedBearerSession { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly method: "bearer-session-token"; + readonly role: SessionRole; + readonly subject: string; + readonly client: AuthClientMetadata; + readonly expiresAt: DateTime.Utc; +} + +export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface AuthControlPlaneShape { + readonly createPairingLink: (input?: { + readonly ttl?: Duration.Duration; + readonly label?: string; + readonly role?: SessionRole; + readonly subject?: string; + }) => Effect.Effect; + readonly listPairingLinks: (input?: { + readonly role?: SessionRole; + readonly excludeSubjects?: ReadonlyArray; + }) => Effect.Effect, AuthControlPlaneError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly issueSession: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly role?: SessionRole; + readonly label?: string; + }) => Effect.Effect; + readonly listSessions: () => Effect.Effect< + ReadonlyArray, + AuthControlPlaneError + >; + readonly revokeSession: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherSessionsExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; +} + +export class AuthControlPlane extends Context.Service()( + "t3/AuthControlPlane", +) {} diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts new file mode 100644 index 0000000000..bcb6119a22 --- /dev/null +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -0,0 +1,57 @@ +import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts"; +import { Data, DateTime, Duration, Context } from "effect"; +import type { Effect, Stream } from "effect"; + +export type BootstrapCredentialRole = "owner" | "client"; + +export interface BootstrapGrant { + readonly method: ServerAuthBootstrapMethod; + readonly role: BootstrapCredentialRole; + readonly subject: string; + readonly label?: string; + readonly expiresAt: DateTime.DateTime; +} + +export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ + readonly message: string; + readonly status?: 401 | 500; + readonly cause?: unknown; +}> {} + +export interface IssuedBootstrapCredential { + readonly id: string; + readonly credential: string; + readonly label?: string; + readonly expiresAt: DateTime.Utc; +} + +export type BootstrapCredentialChange = + | { + readonly type: "pairingLinkUpserted"; + readonly pairingLink: AuthPairingLink; + } + | { + readonly type: "pairingLinkRemoved"; + readonly id: string; + }; + +export interface BootstrapCredentialServiceShape { + readonly issueOneTimeToken: (input?: { + readonly ttl?: Duration.Duration; + readonly role?: BootstrapCredentialRole; + readonly subject?: string; + readonly label?: string; + }) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + BootstrapCredentialError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (id: string) => Effect.Effect; + readonly consume: (credential: string) => Effect.Effect; +} + +export class BootstrapCredentialService extends Context.Service< + BootstrapCredentialService, + BootstrapCredentialServiceShape +>()("t3/auth/Services/BootstrapCredentialService") {} diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts new file mode 100644 index 0000000000..07be98269b --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -0,0 +1,84 @@ +import type { + AuthBearerBootstrapResult, + AuthBootstrapResult, + AuthClientMetadata, + AuthClientSession, + AuthCreatePairingCredentialInput, + AuthPairingLink, + AuthPairingCredentialResult, + AuthSessionId, + AuthSessionState, + ServerAuthDescriptor, + ServerAuthSessionMethod, + AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import { Data, DateTime, Context } from "effect"; +import type { Effect } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import type { SessionRole } from "./SessionCredentialService.ts"; + +export interface AuthenticatedSession { + readonly sessionId: AuthSessionId; + readonly subject: string; + readonly method: ServerAuthSessionMethod; + readonly role: SessionRole; + readonly expiresAt?: DateTime.DateTime; +} + +export class AuthError extends Data.TaggedError("AuthError")<{ + readonly message: string; + readonly status?: 400 | 401 | 403 | 500; + readonly cause?: unknown; +}> {} + +export interface ServerAuthShape { + readonly getDescriptor: () => Effect.Effect; + readonly getSessionState: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + credential: string, + requestMetadata: AuthClientMetadata, + ) => Effect.Effect< + { + readonly response: AuthBootstrapResult; + readonly sessionToken: string; + }, + AuthError + >; + readonly exchangeBootstrapCredentialForBearerSession: ( + credential: string, + requestMetadata: AuthClientMetadata, + ) => Effect.Effect; + readonly issuePairingCredential: ( + input?: AuthCreatePairingCredentialInput & { + readonly role?: SessionRole; + }, + ) => Effect.Effect; + readonly listPairingLinks: () => Effect.Effect, AuthError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly listClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect, AuthError>; + readonly revokeClientSession: ( + currentSessionId: AuthSessionId, + targetSessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueWebSocketToken: ( + session: AuthenticatedSession, + ) => Effect.Effect; + readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect; +} + +export class ServerAuth extends Context.Service()( + "t3/auth/Services/ServerAuth", +) {} diff --git a/apps/server/src/auth/Services/ServerAuthPolicy.ts b/apps/server/src/auth/Services/ServerAuthPolicy.ts new file mode 100644 index 0000000000..530d776998 --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuthPolicy.ts @@ -0,0 +1,11 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface ServerAuthPolicyShape { + readonly getDescriptor: () => Effect.Effect; +} + +export class ServerAuthPolicy extends Context.Service()( + "t3/auth/Services/ServerAuthPolicy", +) {} diff --git a/apps/server/src/auth/Services/ServerSecretStore.ts b/apps/server/src/auth/Services/ServerSecretStore.ts new file mode 100644 index 0000000000..7d97b4c3a1 --- /dev/null +++ b/apps/server/src/auth/Services/ServerSecretStore.ts @@ -0,0 +1,21 @@ +import { Data, Context } from "effect"; +import type { Effect } from "effect"; + +export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface ServerSecretStoreShape { + readonly get: (name: string) => Effect.Effect; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; +} + +export class ServerSecretStore extends Context.Service()( + "t3/auth/Services/ServerSecretStore", +) {} diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts new file mode 100644 index 0000000000..3d72b5a636 --- /dev/null +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -0,0 +1,87 @@ +import type { + AuthClientMetadata, + AuthClientSession, + AuthSessionId, + ServerAuthSessionMethod, +} from "@t3tools/contracts"; +import { Data, DateTime, Duration, Context } from "effect"; +import type { Effect, Stream } from "effect"; + +export type SessionRole = "owner" | "client"; + +export interface IssuedSession { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly client: AuthClientMetadata; + readonly expiresAt: DateTime.DateTime; + readonly role: SessionRole; +} + +export interface VerifiedSession { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly client: AuthClientMetadata; + readonly expiresAt?: DateTime.DateTime; + readonly subject: string; + readonly role: SessionRole; +} + +export type SessionCredentialChange = + | { + readonly type: "clientUpserted"; + readonly clientSession: AuthClientSession; + } + | { + readonly type: "clientRemoved"; + readonly sessionId: AuthSessionId; + }; + +export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface SessionCredentialServiceShape { + readonly cookieName: string; + readonly issue: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly method?: ServerAuthSessionMethod; + readonly role?: SessionRole; + readonly client?: AuthClientMetadata; + }) => Effect.Effect; + readonly verify: (token: string) => Effect.Effect; + readonly issueWebSocketToken: ( + sessionId: AuthSessionId, + input?: { + readonly ttl?: Duration.Duration; + }, + ) => Effect.Effect< + { + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, + SessionCredentialError + >; + readonly verifyWebSocketToken: ( + token: string, + ) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + SessionCredentialError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (sessionId: AuthSessionId) => Effect.Effect; + readonly revokeAllExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; +} + +export class SessionCredentialService extends Context.Service< + SessionCredentialService, + SessionCredentialServiceShape +>()("t3/auth/Services/SessionCredentialService") {} diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts new file mode 100644 index 0000000000..76c14646e9 --- /dev/null +++ b/apps/server/src/auth/http.ts @@ -0,0 +1,254 @@ +import { + type AuthBearerBootstrapResult, + AuthBootstrapInput, + AuthCreatePairingCredentialInput, + AuthRevokeClientSessionInput, + AuthRevokePairingLinkInput, + type AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import { DateTime, Effect, Schema } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; +import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; +import { deriveAuthClientMetadata } from "./utils.ts"; + +export const respondToAuthError = (error: AuthError) => + Effect.gen(function* () { + if ((error.status ?? 500) >= 500) { + yield* Effect.logError("auth route failed", { + message: error.message, + cause: error.cause, + }); + } + return HttpServerResponse.jsonUnsafe( + { + error: error.message, + }, + { status: error.status ?? 500 }, + ); + }); + +export const authSessionRouteLayer = HttpRouter.add( + "GET", + "/api/auth/session", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.getSessionState(request); + return HttpServerResponse.jsonUnsafe(session, { status: 200 }); + }), +); + +const PairingCredentialRequestHeaders = Schema.Struct({ + "content-length": Schema.optionalKey(Schema.String), + "content-type": Schema.optionalKey(Schema.String), + "transfer-encoding": Schema.optionalKey(Schema.String), +}); + +function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) { + const contentLengthHeader = headers["content-length"]; + if (typeof contentLengthHeader === "string") { + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(contentLength)) { + return contentLength > 0; + } + } + return typeof headers["transfer-encoding"] === "string"; +} + +export const authBootstrapRouteLayer = HttpRouter.add( + "POST", + "/api/auth/bootstrap", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const sessions = yield* SessionCredentialService; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap payload.", + status: 400, + cause, + }), + ), + ); + const result = yield* serverAuth.exchangeBootstrapCredential( + payload.credential, + deriveAuthClientMetadata({ request }), + ); + + return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe( + HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, { + expires: DateTime.toDate(result.response.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authBearerBootstrapRouteLayer = HttpRouter.add( + "POST", + "/api/auth/bootstrap/bearer", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap payload.", + status: 400, + cause, + }), + ), + ); + const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession( + payload.credential, + deriveAuthClientMetadata({ request }), + ); + return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, { + status: 200, + }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authWebSocketTokenRouteLayer = HttpRouter.add( + "POST", + "/api/auth/ws-token", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + const result = yield* serverAuth.issueWebSocketToken(session); + return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, { + status: 200, + }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authPairingCredentialRouteLayer = HttpRouter.add( + "POST", + "/api/auth/pairing-token", + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + const request = yield* HttpServerRequest.HttpServerRequest; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new AuthError({ + message: "Only owner sessions can create pairing credentials.", + status: 403, + }); + } + const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid pairing credential request headers.", + status: 400, + cause, + }), + ), + ); + const payload = hasRequestBody(headers) + ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid pairing credential payload.", + status: 400, + cause, + }), + ), + ) + : {}; + const result = yield* serverAuth.issuePairingCredential(payload); + return HttpServerResponse.jsonUnsafe(result, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +const authenticateOwnerSession = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new AuthError({ + message: "Only owner sessions can manage network access.", + status: 403, + }); + } + return { serverAuth, session } as const; +}); + +export const authPairingLinksRouteLayer = HttpRouter.add( + "GET", + "/api/auth/pairing-links", + Effect.gen(function* () { + const { serverAuth } = yield* authenticateOwnerSession; + const pairingLinks = yield* serverAuth.listPairingLinks(); + return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authPairingLinksRevokeRouteLayer = HttpRouter.add( + "POST", + "/api/auth/pairing-links/revoke", + Effect.gen(function* () { + const { serverAuth } = yield* authenticateOwnerSession; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid revoke pairing link payload.", + status: 400, + cause, + }), + ), + ); + const revoked = yield* serverAuth.revokePairingLink(payload.id); + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRouteLayer = HttpRouter.add( + "GET", + "/api/auth/clients", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const clients = yield* serverAuth.listClientSessions(session.sessionId); + return HttpServerResponse.jsonUnsafe(clients, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRevokeRouteLayer = HttpRouter.add( + "POST", + "/api/auth/clients/revoke", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid revoke client payload.", + status: 400, + cause, + }), + ), + ); + const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId); + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRevokeOthersRouteLayer = HttpRouter.add( + "POST", + "/api/auth/clients/revoke-others", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); + return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts new file mode 100644 index 0000000000..a767b77de1 --- /dev/null +++ b/apps/server/src/auth/utils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { deriveAuthClientMetadata } from "./utils"; + +describe("deriveAuthClientMetadata", () => { + it("labels Electron user agents as Electron instead of Chrome", () => { + const metadata = deriveAuthClientMetadata({ + request: { + headers: { + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) t3code/0.0.15 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36", + }, + source: { + remoteAddress: "::ffff:127.0.0.1", + }, + } as never, + }); + + expect(metadata).toMatchObject({ + browser: "Electron", + deviceType: "desktop", + ipAddress: "127.0.0.1", + os: "macOS", + }); + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts new file mode 100644 index 0000000000..2c76a81f65 --- /dev/null +++ b/apps/server/src/auth/utils.ts @@ -0,0 +1,132 @@ +import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Crypto from "node:crypto"; + +const SESSION_COOKIE_NAME = "t3_session"; + +export function resolveSessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly port: number; +}): string { + if (input.mode !== "desktop") { + return SESSION_COOKIE_NAME; + } + + return `${SESSION_COOKIE_NAME}_${input.port}`; +} + +export function base64UrlEncode(input: string | Uint8Array): string { + const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); + return buffer.toString("base64url"); +} + +export function base64UrlDecodeUtf8(input: string): string { + return Buffer.from(input, "base64url").toString("utf8"); +} + +export function signPayload(payload: string, secret: Uint8Array): string { + return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); +} + +export function timingSafeEqualBase64Url(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "base64url"); + const rightBuffer = Buffer.from(right, "base64url"); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return Crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function normalizeNonEmptyString(value: string | null | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeIpAddress(value: string | null | undefined): string | undefined { + const normalized = normalizeNonEmptyString(value); + if (!normalized) { + return undefined; + } + return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized; +} + +function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType { + if (!userAgent) { + return "unknown"; + } + + const normalized = userAgent.toLowerCase(); + if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) { + return "bot"; + } + if (/ipad|tablet/.test(normalized)) { + return "tablet"; + } + if (/iphone|android.+mobile|mobile/.test(normalized)) { + return "mobile"; + } + return "desktop"; +} + +function inferBrowser(userAgent: string | undefined): string | undefined { + if (!userAgent) { + return undefined; + } + const normalized = userAgent.toLowerCase(); + if (/edg\//.test(normalized)) return "Edge"; + if (/opr\//.test(normalized)) return "Opera"; + if (/firefox\//.test(normalized)) return "Firefox"; + if (/electron\//.test(normalized)) return "Electron"; + if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; + if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; + return undefined; +} + +function inferOs(userAgent: string | undefined): string | undefined { + if (!userAgent) { + return undefined; + } + const normalized = userAgent.toLowerCase(); + if (/iphone|ipad|ipod/.test(normalized)) return "iOS"; + if (/android/.test(normalized)) return "Android"; + if (/mac os x|macintosh/.test(normalized)) return "macOS"; + if (/windows nt/.test(normalized)) return "Windows"; + if (/linux/.test(normalized)) return "Linux"; + return undefined; +} + +function readRemoteAddressFromSource(source: unknown): string | undefined { + if (!source || typeof source !== "object") { + return undefined; + } + + const candidate = source as { + readonly remoteAddress?: string | null; + readonly socket?: { + readonly remoteAddress?: string | null; + }; + }; + + return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress); +} + +export function deriveAuthClientMetadata(input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly label?: string; +}): AuthClientMetadata { + const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); + const ipAddress = readRemoteAddressFromSource(input.request.source); + const os = inferOs(userAgent); + const browser = inferBrowser(userAgent); + return { + ...(input.label ? { label: input.label } : {}), + ...(ipAddress ? { ipAddress } : {}), + ...(userAgent ? { userAgent } : {}), + deviceType: inferDeviceType(userAgent), + ...(os ? { os } : {}), + ...(browser ? { browser } : {}), + }; +} diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index c66c529b9a..e6c53dd06b 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -26,7 +26,7 @@ function makeThreadCheckpointContext(input: { worktreePath: input.worktreePath, checkpoints: [ { - turnId: TurnId.makeUnsafe("turn-1"), + turnId: TurnId.make("turn-1"), checkpointTurnCount: input.checkpointTurnCount, checkpointRef: input.checkpointRef, status: "ready", @@ -40,8 +40,8 @@ function makeThreadCheckpointContext(input: { describe("CheckpointDiffQueryLive", () => { it("computes diffs using canonical turn-0 checkpoint refs", async () => { - const projectId = ProjectId.makeUnsafe("project-1"); - const threadId = ThreadId.makeUnsafe("thread-1"); + const projectId = ProjectId.make("project-1"); + const threadId = ThreadId.make("thread-1"); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); const hasCheckpointRefCalls: Array = []; const diffCheckpointsCalls: Array<{ @@ -82,10 +82,15 @@ describe("CheckpointDiffQueryLive", () => { Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); @@ -119,7 +124,7 @@ describe("CheckpointDiffQueryLive", () => { }); it("fails when the thread is missing from the snapshot", async () => { - const threadId = ThreadId.makeUnsafe("thread-missing"); + const threadId = ThreadId.make("thread-missing"); const checkpointStore: CheckpointStoreShape = { isGitRepository: () => Effect.succeed(true), @@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => { Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 6e7b18277c..fe377eb1ec 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -93,7 +93,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); const checkpointStore = yield* CheckpointStore; - const threadId = ThreadId.makeUnsafe("thread-checkpoint-store"); + const threadId = ThreadId.make("thread-checkpoint-store"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); diff --git a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts index 2204294a43..d865256ac5 100644 --- a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts @@ -12,7 +12,7 @@ import type { OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { CheckpointServiceError } from "../Errors.ts"; @@ -43,7 +43,7 @@ export interface CheckpointDiffQueryShape { /** * CheckpointDiffQuery - Service tag for checkpoint diff queries. */ -export class CheckpointDiffQuery extends ServiceMap.Service< +export class CheckpointDiffQuery extends Context.Service< CheckpointDiffQuery, CheckpointDiffQueryShape >()("t3/checkpointing/Services/CheckpointDiffQuery") {} diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index 284198c145..d9a43fa4e9 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -5,12 +5,12 @@ * workspace thread timeline. It does not store user-facing checkpoint metadata * and does not coordinate provider conversation rollback. * - * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed + * Uses Effect `Context.Service` for dependency injection and exposes typed * domain errors for checkpoint storage operations. * * @module CheckpointStore */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { CheckpointStoreError } from "../Errors.ts"; @@ -95,6 +95,6 @@ export interface CheckpointStoreShape { /** * CheckpointStore - Service tag for checkpoint persistence and restore operations. */ -export class CheckpointStore extends ServiceMap.Service()( +export class CheckpointStore extends Context.Service()( "t3/checkpointing/Services/CheckpointStore", ) {} diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index 3cd92f8510..c709aa3735 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -4,7 +4,7 @@ import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { - return CheckpointRef.makeUnsafe( + return CheckpointRef.make( `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`, ); } diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index ef2f9f55d8..6fa6e0c96b 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "true", - T3CODE_AUTH_TOKEN: "env-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", }, @@ -85,7 +83,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "env-token", + startupPresentation: "browser", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -106,7 +105,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.some(true), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), @@ -125,7 +123,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: join(os.tmpdir(), "ignored-base"), VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "false", - T3CODE_AUTH_TOKEN: "ignored-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "false", }, @@ -148,13 +145,77 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", + startupPresentation: "browser", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); }), ); + it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); + const fd = yield* openBootstrapFd({ + noBrowser: true, + autoBootstrapProjectFromCwd: true, + logWebSocketEvents: true, + }); + const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(8788), + host: Option.some("127.0.0.1"), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:4173")), + noBrowser: Option.some(false), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.some(false), + logWebSocketEvents: Option.some(false), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_BOOTSTRAP_FD: String(fd), + T3CODE_NO_BROWSER: "true", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", + T3CODE_LOG_WS_EVENTS: "true", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved).toEqual({ + logLevel: "Info", + ...defaultObservabilityConfig, + mode: "web", + port: 8788, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + host: "127.0.0.1", + staticDir: undefined, + devUrl: new URL("http://127.0.0.1:4173"), + noBrowser: false, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + }), + ); + it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -166,7 +227,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: baseDir, devUrl: "http://127.0.0.1:5173", noBrowser: true, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -183,7 +243,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -218,7 +277,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "bootstrap-token", + startupPresentation: "browser", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -242,7 +302,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.some(customCwd), devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -285,7 +344,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: "/tmp/t3-bootstrap-home", devUrl: "http://127.0.0.1:5173", noBrowser: false, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); @@ -300,7 +358,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.none(), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -338,7 +395,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", + startupPresentation: "browser", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -371,7 +429,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -402,7 +459,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: resolved.staticDir, devUrl: undefined, noBrowser: true, - authToken: undefined, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + }), + ); + + it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base"); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.none(), + { + startupPresentation: "headless", + }, + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_NO_BROWSER: "false", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved).toEqual({ + logLevel: "Info", + ...defaultObservabilityConfig, + mode: "web", + port: 3773, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + host: undefined, + staticDir: resolved.staticDir, + devUrl: undefined, + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index fbbe26e6cf..7ebde01067 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -1,28 +1,164 @@ +import * as NodeHttp from "node:http"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; import * as CliError from "effect/unstable/cli/CliError"; +import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli } from "./cli.ts"; +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { + orchestrationDispatchRouteLayer, + orchestrationSnapshotRouteLayer, +} from "./orchestration/http.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "./serverRuntimeState.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); +const runCliWithRuntime = (args: ReadonlyArray) => + runCli(args).pipe(Effect.provide(CliRuntimeLayer)); + +const captureStdout = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* effect; + const output = + (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? + ""; + return { result, output }; + }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); + +const makeCliTestServerConfig = (baseDir: string) => + Effect.gen(function* () { + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + return { + logLevel: "Info", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + } satisfies ServerConfigShape; + }); + +const makeProjectPersistenceLayer = (config: ServerConfigShape) => + Layer.mergeAll( + OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceLayerLive), + ), + WorkspacePathsLive, + ).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + ); + +const readPersistedSnapshot = (baseDir: string) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + return yield* Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + }); + +const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Effect) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + const routesLayer = Layer.mergeAll( + orchestrationSnapshotRouteLayer, + orchestrationDispatchRouteLayer, + ); + const appLayer = HttpRouter.serve(routesLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge( + ServerAuthLive.pipe( + Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerSecretStoreLive), + ), + ), + Layer.provideMerge(makeProjectPersistenceLayer(config)), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + ); + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + assert.fail(`Expected TCP address, got ${address}`); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: makePersistedServerRuntimeState({ + config, + port: address.port, + }), + }); + return yield* run(); + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), + ); + }); + it.layer(NodeServices.layer)("cli log-level parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => - Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe( - Effect.provide(CliRuntimeLayer), - ), + runCliWithRuntime(["--log-level", "debug", "--version"]), + ); + + it.effect("accepts canonical --no- boolean negation", () => + runCliWithRuntime(["--no-log-websocket-events", "--version"]), ); it.effect("rejects invalid log-level casing before launching the server", () => Effect.gen(function* () { - const error = yield* Command.runWith(cli, { version: "0.0.0" })([ - "--log-level", - "Debug", - ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip); + const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip); if (!CliError.isCliError(error)) { assert.fail(`Expected CliError, got ${String(error)}`); @@ -34,4 +170,187 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { assert.equal(error.value, "Debug"); }), ); + + it.effect("executes auth pairing subcommands and redacts secrets from list output", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); + + const createdOutput = yield* captureStdout( + runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), + ); + const created = JSON.parse(createdOutput.output) as { + readonly id: string; + readonly credential: string; + }; + const listedOutput = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ + readonly id: string; + readonly credential?: string; + }>; + + assert.equal(typeof created.id, "string"); + assert.equal(typeof created.credential, "string"); + assert.equal(created.credential.length > 0, true); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.id, created.id); + assert.equal("credential" in (listed[0] ?? {}), false); + }), + ); + + it.effect("executes auth session subcommands and redacts secrets from list output", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-")); + + const issuedOutput = yield* captureStdout( + runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), + ); + const issued = JSON.parse(issuedOutput.output) as { + readonly sessionId: string; + readonly token: string; + readonly role: string; + }; + const listedOutput = yield* captureStdout( + runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), + ); + const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ + readonly sessionId: string; + readonly token?: string; + readonly role: string; + }>; + + assert.equal(typeof issued.sessionId, "string"); + assert.equal(typeof issued.token, "string"); + assert.equal(issued.role, "owner"); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.sessionId, issued.sessionId); + assert.equal(listed[0]?.role, "owner"); + assert.equal("token" in (listed[0] ?? {}), false); + }), + ); + + it.effect("rejects invalid ttl values before running auth commands", () => + Effect.gen(function* () { + const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe( + Effect.flip, + ); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + if (error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${error._tag}`); + } + assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]); + const ttlError = error.errors[0] as CliError.CliError | undefined; + if (!ttlError || ttlError._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`); + } + assert.equal(ttlError.option, "ttl"); + assert.equal(ttlError.value, "soon"); + assert.isTrue(ttlError.message.includes("Invalid duration")); + assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes")); + }), + ); + + it.effect("adds, renames, and removes projects offline through the orchestration engine", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-")); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "Alpha", + "--base-dir", + baseDir, + ]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const addedProject = afterAdd.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); + assert.isTrue(addedProject !== undefined); + assert.equal(addedProject?.title, "Alpha"); + + yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]); + const afterRename = yield* readPersistedSnapshot(baseDir); + const renamedProject = afterRename.projects.find( + (project) => project.id === addedProject?.id, + ); + assert.equal(renamedProject?.title, "Beta"); + assert.equal(renamedProject?.deletedAt, null); + + yield* runCliWithRuntime([ + "project", + "remove", + addedProject?.id ?? "", + "--base-dir", + baseDir, + ]); + const afterRemove = yield* readPersistedSnapshot(baseDir); + const removedProject = afterRemove.projects.find( + (project) => project.id === addedProject?.id, + ); + assert.isTrue((removedProject?.deletedAt ?? null) !== null); + }), + ); + + it.effect("routes project commands through a running server when runtime state is present", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-")); + + yield* withLiveProjectCliServer(baseDir, () => + Effect.gen(function* () { + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "Live Project", + "--base-dir", + baseDir, + ]); + const orchestrationEngine = yield* OrchestrationEngineService; + const readModel = yield* orchestrationEngine.getReadModel(); + const addedProject = readModel.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); + assert.isTrue(addedProject !== undefined); + assert.equal(addedProject?.title, "Live Project"); + }), + ); + }), + ); + + it.effect("rejects dev-url on project commands", () => + Effect.gen(function* () { + const workspaceRoot = mkdtempSync( + join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"), + ); + const error = yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--dev-url", + "http://127.0.0.1:5173", + ]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + if (error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${error._tag}`); + } + assert.deepEqual(error.commandPath, ["t3", "project", "add"]); + const optionError = error.errors[0] as CliError.CliError | undefined; + if (!optionError || optionError._tag !== "UnrecognizedOption") { + assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); + } + assert.equal(optionError.option, "--dev-url"); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 9ece02a0d3..5f73750920 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,7 +1,35 @@ import { NetService } from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect"; +import { + AuthSessionId, + CommandId, + OrchestrationReadModel, + ProjectId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import { + Config, + Console, + Duration, + Effect, + Exit, + FileSystem, + Layer, + LogLevel, + Option, + Path, + References, + Schema, + SchemaIssue, + SchemaTransformation, +} from "effect"; import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { DEFAULT_PORT, @@ -11,10 +39,31 @@ import { ServerConfig, RuntimeMode, type ServerConfigShape, + type StartupPresentation, } from "./config"; import { readBootstrapEnvelope } from "./bootstrap"; import { expandHomePath, resolveBaseDir } from "./os-jank"; import { runServer } from "./server"; +import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; +import { + formatIssuedPairingCredential, + formatIssuedSession, + formatPairingCredentialList, + formatSessionList, +} from "./cliAuthFormat"; +import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup"; +import { + clearPersistedServerRuntimeState, + readPersistedServerRuntimeState, +} from "./serverRuntimeState"; +import { WorkspacePaths } from "./workspace/Services/WorkspacePaths"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); @@ -25,7 +74,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ t3Home: Schema.optional(Schema.String), devUrl: Schema.optional(Schema.URLFromString), noBrowser: Schema.optional(Schema.Boolean), - authToken: Schema.optional(Schema.String), + desktopBootstrapToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), otlpTracesUrl: Schema.optional(Schema.String), @@ -58,11 +107,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Disable automatic browser opening."), Flag.optional, ); -const authTokenFlag = Flag.string("auth-token").pipe( - Flag.withDescription("Auth token required for WebSocket connections."), - Flag.withAlias("token"), - Flag.optional, -); const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( Flag.withSchema(Schema.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), @@ -117,10 +161,6 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), - authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -143,14 +183,15 @@ interface CliServerFlags { readonly cwd: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; - readonly authToken: Option.Option; readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; } -const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => - Option.getOrElse(Option.filter(flag, Boolean), () => envValue); +interface CliAuthLocationFlags { + readonly baseDir: Option.Option; + readonly devUrl?: Option.Option; +} const resolveOptionPrecedence = ( ...values: ReadonlyArray> @@ -170,32 +211,49 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, ) => Effect.gen(function* () { const { findAvailablePort } = yield* NetService; const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const env = yield* EnvServerConfig; - const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd; + const normalizedFlags = { + mode: flags.mode ?? Option.none(), + port: flags.port ?? Option.none(), + host: flags.host ?? Option.none(), + baseDir: flags.baseDir ?? Option.none(), + cwd: flags.cwd ?? Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: flags.noBrowser ?? Option.none(), + bootstrapFd: flags.bootstrapFd ?? Option.none(), + autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), + logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), + } satisfies CliServerFlags; + const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = bootstrapFd !== undefined ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) : Option.none(); + const bootstrap = Option.getOrUndefined(bootstrapEnvelope); const mode: RuntimeMode = Option.getOrElse( resolveOptionPrecedence( - flags.mode, + normalizedFlags.mode, Option.fromUndefinedOr(env.mode), - Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)), + Option.fromUndefinedOr(bootstrap?.mode), ), () => "web", ); const port = yield* Option.match( resolveOptionPrecedence( - flags.port, + normalizedFlags.port, Option.fromUndefinedOr(env.port), - Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)), + Option.fromUndefinedOr(bootstrap?.port), ), { onSome: (value) => Effect.succeed(value), @@ -209,24 +267,22 @@ export const resolveServerConfig = ( ); const devUrl = Option.getOrElse( resolveOptionPrecedence( - flags.devUrl, + normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl), - Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)), + Option.fromUndefinedOr(bootstrap?.devUrl), ), () => undefined, ); const baseDir = yield* resolveBaseDir( Option.getOrUndefined( resolveOptionPrecedence( - flags.baseDir, + normalizedFlags.baseDir, Option.fromUndefinedOr(env.t3Home), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.t3Home), - ), + Option.fromUndefinedOr(bootstrap?.t3Home), ), ), ); - const rawCwd = Option.getOrElse(flags.cwd, () => process.cwd()); + const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); yield* fs.makeDirectory(cwd, { recursive: true }); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); @@ -236,57 +292,42 @@ export const resolveServerConfig = ( ); const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); - const noBrowser = resolveBooleanFlag( - flags.noBrowser, - Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(env.noBrowser), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.noBrowser), - ), - ), - () => mode === "desktop", - ), - ); - const authToken = Option.getOrUndefined( + const startupPresentation = options?.startupPresentation ?? "browser"; + const isHeadlessStartup = startupPresentation === "headless"; + const noBrowser = Option.getOrElse( resolveOptionPrecedence( - flags.authToken, - Option.fromUndefinedOr(env.authToken), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.authToken), - ), + isHeadlessStartup ? Option.some(true) : Option.none(), + normalizedFlags.noBrowser, + Option.fromUndefinedOr(env.noBrowser), + Option.fromUndefinedOr(bootstrap?.noBrowser), ), + () => mode === "desktop", ); - const autoBootstrapProjectFromCwd = resolveBooleanFlag( - flags.autoBootstrapProjectFromCwd, - Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), - ), - ), - () => mode === "web", + const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const autoBootstrapProjectFromCwd = Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), + isHeadlessStartup ? Option.some(false) : Option.none(), + normalizedFlags.autoBootstrapProjectFromCwd, + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), ), + () => mode === "web", ); - const logWebSocketEvents = resolveBooleanFlag( - flags.logWebSocketEvents, - Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(env.logWebSocketEvents), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.logWebSocketEvents), - ), - ), - () => Boolean(devUrl), + const logWebSocketEvents = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.logWebSocketEvents, + Option.fromUndefinedOr(env.logWebSocketEvents), + Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), ), + () => Boolean(devUrl), ); const staticDir = devUrl ? undefined : yield* resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( - flags.host, + normalizedFlags.host, Option.fromUndefinedOr(env.host), - Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), + Option.fromUndefinedOr(bootstrap?.host), ), () => (mode === "desktop" ? "127.0.0.1" : undefined), ); @@ -301,19 +342,11 @@ export const resolveServerConfig = ( traceMaxFiles: env.traceMaxFiles, otlpTracesUrl: env.otlpTracesUrl ?? - Option.getOrUndefined( - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.otlpTracesUrl), - ), - ) ?? + bootstrap?.otlpTracesUrl ?? persistedObservabilitySettings.otlpTracesUrl, otlpMetricsUrl: env.otlpMetricsUrl ?? - Option.getOrUndefined( - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.otlpMetricsUrl), - ), - ) ?? + bootstrap?.otlpMetricsUrl ?? persistedObservabilitySettings.otlpMetricsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, @@ -327,7 +360,8 @@ export const resolveServerConfig = ( staticDir, devUrl, noBrowser, - authToken, + startupPresentation, + desktopBootstrapToken, autoBootstrapProjectFromCwd, logWebSocketEvents, }; @@ -335,7 +369,387 @@ export const resolveServerConfig = ( return config; }); -const commandFlags = { +const resolveCliAuthConfig = ( + flags: CliAuthLocationFlags, + cliLogLevel: Option.Option, +) => + resolveServerConfig( + { + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: flags.baseDir, + cwd: Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + cliLogLevel, + ); + +const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; + +const parseDurationInput = (value: string): Duration.Duration | null => { + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + const shorthand = DurationShorthandPattern.exec(trimmed); + const normalizedInput = shorthand?.groups + ? (() => { + const amountText = shorthand.groups.value; + const unitText = shorthand.groups.unit; + if (typeof amountText !== "string" || typeof unitText !== "string") { + return null; + } + + const amount = Number.parseInt(amountText, 10); + if (!Number.isFinite(amount)) return null; + + switch (unitText.toLowerCase()) { + case "ms": + return `${amount} millis`; + case "s": + return `${amount} seconds`; + case "m": + return `${amount} minutes`; + case "h": + return `${amount} hours`; + case "d": + return `${amount} days`; + case "w": + return `${amount} weeks`; + default: + return null; + } + })() + : (trimmed as Duration.Input); + + if (normalizedInput === null) return null; + + const decoded = Duration.fromInput(normalizedInput as Duration.Input); + return Option.isSome(decoded) ? decoded.value : null; +}; + +const DurationFromString = Schema.String.pipe( + Schema.decodeTo( + Schema.Duration, + SchemaTransformation.transformOrFail({ + decode: (value) => { + const duration = parseDurationInput(value); + if (duration !== null) { + return Effect.succeed(duration); + } + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(value), { + message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", + }), + ); + }, + encode: (duration) => Effect.succeed(Duration.format(duration)), + }), + ), +); + +const runWithAuthControlPlane = ( + flags: CliAuthLocationFlags, + run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect, + options?: { + readonly quietLogs?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + return yield* run(authControlPlane); + }).pipe( + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); + }); + +type ProjectMutationTarget = { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +}; + +type ProjectCommandExecutionMode = "live" | "offline"; +type ProjectCliDispatchCommand = Extract< + ClientOrchestrationCommand, + { type: "project.create" | "project.meta.update" | "project.delete" } +>; + +const ProjectCliRuntimeLive = Layer.mergeAll( + WorkspacePathsLive, + OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceLayerLive), + ), +); + +const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); +const OrchestrationHttpErrorResponse = Schema.Struct({ + error: Schema.String, +}); + +const withProjectCliSessionToken = ( + authControlPlane: AuthControlPlaneShape, + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + authControlPlane.issueSession({ + role: "owner", + label: "t3 project cli", + }), + (issued) => run(issued.token), + (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + +const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => + effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); + +const runLiveServerRequest = ( + request: HttpClientRequest.HttpClientRequest, + handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, +) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + return yield* handle(response); + }).pipe(withProjectCliLiveServerTimeout); + +const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); + +const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( + Effect.map((body) => body.error), + Effect.catch(() => Effect.succeed(null)), + Effect.map((body) => { + if (typeof body === "string" && body.trim().length > 0) { + return body; + } + return `Server request failed with status ${response.status}.`; + }), + ); + +const normalizeWorkspaceRootForProjectCommand = Effect.fn( + "normalizeWorkspaceRootForProjectCommand", +)(function* (workspaceRoot: string) { + const workspacePaths = yield* WorkspacePaths; + return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); +}); + +const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( + workspaceRoot: string, + explicitTitle?: string, +) { + if (explicitTitle !== undefined) { + const trimmed = explicitTitle.trim(); + if (trimmed.length > 0) { + return trimmed; + } + return yield* Effect.fail(new Error("Project title cannot be empty.")); + } + + const path = yield* Path.Path; + const basename = path.basename(workspaceRoot).trim(); + return basename.length > 0 ? basename : "project"; +}); + +const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { + readonly snapshot: OrchestrationReadModel; + readonly identifier: string; +}) { + const trimmedIdentifier = input.identifier.trim(); + if (trimmedIdentifier.length === 0) { + return yield* Effect.fail(new Error("Project identifier cannot be empty.")); + } + + const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); + const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); + if (exactIdMatch) { + return { + id: exactIdMatch.id, + title: exactIdMatch.title, + workspaceRoot: exactIdMatch.workspaceRoot, + } satisfies ProjectMutationTarget; + } + + const normalizedWorkspaceRootResult = yield* Effect.exit( + normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), + ); + const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) + ? normalizedWorkspaceRootResult.value + : null; + + const exactWorkspaceMatch = + normalizedWorkspaceRoot === null + ? undefined + : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); + + const resolved = exactWorkspaceMatch; + if (!resolved) { + return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); + } + + return { + id: resolved.id, + title: resolved.title, + workspaceRoot: resolved.workspaceRoot, + } satisfies ProjectMutationTarget; +}); + +const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => + runLiveServerRequest( + HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + ), + HttpClientResponse.matchStatus({ + "2xx": decodeOrchestrationReadModelResponse, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new Error(message))), + ), + }), + ); + +const dispatchLiveOrchestrationCommand = ( + origin: string, + bearerToken: string, + command: ProjectCliDispatchCommand, +) => + HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + HttpClientRequest.bodyJson(command), + Effect.flatMap((request) => + runLiveServerRequest( + request, + HttpClientResponse.matchStatus({ + "2xx": () => Effect.void, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new Error(message))), + ), + }), + ), + ), + ); + +const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); +}); + +const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( + function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return Option.none<{ readonly origin: string }>(); + } + + const attempt = withProjectCliSessionToken(authControlPlane, (token) => + fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( + Effect.as({ + origin: runtimeState.value.origin, + }), + ), + ); + + const attempted = yield* Effect.exit(attempt); + if (Exit.isSuccess(attempted)) { + return Option.some(attempted.value); + } + + yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); + return Option.none<{ readonly origin: string }>(); + }, +); + +const runProjectMutation = Effect.fn("runProjectMutation")(function* ( + flags: CliAuthLocationFlags, + run: (input: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + readonly mode: ProjectCommandExecutionMode; + }) => Effect.Effect< + string, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths + >, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = config.logLevel; + + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); + + if (Option.isSome(liveMode)) { + return yield* withProjectCliSessionToken(authControlPlane, (token) => + Effect.gen(function* () { + const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); + const output = yield* run({ + snapshot, + dispatch: (command) => + dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), + mode: "live", + }); + yield* Console.log(output); + }), + ); + } + + const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + + return yield* Effect.gen(function* () { + const snapshot = yield* getOfflineSnapshot(); + const orchestrationEngine = yield* OrchestrationEngineService; + const output = yield* run({ + snapshot, + dispatch: (command) => orchestrationEngine.dispatch(command), + mode: "offline", + }); + yield* Console.log(output); + }).pipe(Effect.provide(offlineRuntimeLayer)); + }).pipe( + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( + Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); +}); + +const sharedServerLocationFlags = { + baseDir: baseDirFlag, + devUrl: devUrlFlag, +} as const; + +const projectLocationFlags = { + baseDir: baseDirFlag, +} as const; + +const sharedServerCommandFlags = { mode: modeFlag, port: portFlag, host: hostFlag, @@ -348,21 +762,371 @@ const commandFlags = { ), devUrl: devUrlFlag, noBrowser: noBrowserFlag, - authToken: authTokenFlag, bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, } as const; -const rootCommand = Command.make("t3", commandFlags).pipe( +const authLocationFlags = sharedServerLocationFlags; + +const ttlFlag = Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), + Flag.optional, +); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( + Flag.withDescription("Role for the issued bearer session."), + Flag.withDefault("owner"), +); + +const labelFlag = Flag.string("label").pipe( + Flag.withDescription("Optional human-readable label."), + Flag.optional, +); + +const subjectFlag = Flag.string("subject").pipe( + Flag.withDescription("Optional session subject."), + Flag.optional, +); + +const baseUrlFlag = Flag.string("base-url").pipe( + Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), + Flag.optional, +); + +const tokenOnlyFlag = Flag.boolean("token-only").pipe( + Flag.withDescription("Print only the issued bearer token."), + Flag.withDefault(false), +); + +const pairingCreateCommand = Command.make("create", { + ...authLocationFlags, + ttl: ttlFlag, + label: labelFlag, + baseUrl: baseUrlFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a new client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.createPairingLink({ + role: "client", + subject: "one-time-token", + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + }); + const output = formatIssuedPairingCredential(issued, { + json: flags.json, + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + }); + yield* Console.log(output); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active client pairing tokens without revealing their secrets."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); + yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), +}).pipe( + Command.withDescription("Revoke an active client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokePairingLink(flags.id); + yield* Console.log( + revoked + ? `Revoked pairing credential ${flags.id}.\n` + : `No active pairing credential found for ${flags.id}.\n`, + ); + }), + ), + ), +); + +const pairingCommand = Command.make("pairing").pipe( + Command.withDescription("Manage one-time client pairing tokens."), + Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), +); + +const sessionIssueCommand = Command.make("issue", { + ...authLocationFlags, + ttl: ttlFlag, + role: sessionRoleFlag, + label: labelFlag, + subject: subjectFlag, + tokenOnly: tokenOnlyFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a bearer session token for headless or remote clients."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.issueSession({ + role: flags.role, + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), + }); + yield* Console.log( + formatIssuedSession(issued, { + json: flags.json, + tokenOnly: flags.tokenOnly, + }), + ); + }), + { + quietLogs: flags.json || flags.tokenOnly, + }, + ), + ), +); + +const sessionListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active sessions without revealing bearer tokens."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const sessions = yield* authControlPlane.listSessions(); + yield* Console.log(formatSessionList(sessions, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const sessionRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + sessionId: Argument.string("session-id").pipe( + Argument.withDescription("Session id to revoke."), + Argument.withSchema(AuthSessionId), + ), +}).pipe( + Command.withDescription("Revoke an active session."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokeSession(flags.sessionId); + yield* Console.log( + revoked + ? `Revoked session ${flags.sessionId}.\n` + : `No active session found for ${flags.sessionId}.\n`, + ); + }), + ), + ), +); + +const sessionCommand = Command.make("session").pipe( + Command.withDescription("Manage bearer sessions."), + Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), +); + +const authCommand = Command.make("auth").pipe( + Command.withDescription("Manage the local auth control plane for headless deployments."), + Command.withSubcommands([pairingCommand, sessionCommand]), +); + +const projectAddCommand = Command.make("add", { + ...projectLocationFlags, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Workspace root to add as a project."), + ), + title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), +}).pipe( + Command.withDescription("Add a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectAddMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); + const existingProject = snapshot.projects.find( + (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, + ); + if (existingProject) { + return yield* Effect.fail( + new Error(`An active project already exists for '${workspaceRoot}'.`), + ); + } + + const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); + const projectId = ProjectId.make(crypto.randomUUID()); + yield* dispatch({ + type: "project.create", + commandId: CommandId.make(crypto.randomUUID()), + projectId, + title, + workspaceRoot, + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + createdAt: new Date().toISOString(), + }); + return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; + }), + ), + ), +); + +const projectRemoveCommand = Command.make("remove", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to remove."), + ), +}).pipe( + Command.withDescription("Remove a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRemoveMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + yield* dispatch({ + type: "project.delete", + commandId: CommandId.make(crypto.randomUUID()), + projectId: project.id, + }); + return `Removed project ${project.id} (${project.title}).`; + }), + ), + ), +); + +const projectRenameCommand = Command.make("rename", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to rename."), + ), + title: Argument.string("title").pipe(Argument.withDescription("New project title.")), +}).pipe( + Command.withDescription("Rename a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRenameMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); + if (nextTitle === project.title) { + return `Project ${project.id} is already named ${nextTitle}.`; + } + + yield* dispatch({ + type: "project.meta.update", + commandId: CommandId.make(crypto.randomUUID()), + projectId: project.id, + title: nextTitle, + }); + return `Renamed project ${project.id} to ${nextTitle}.`; + }), + ), + ), +); + +const projectCommand = Command.make("project").pipe( + Command.withDescription("Manage projects."), + Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), +); + +const runServerCommand = ( + flags: CliServerFlags, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveServerConfig(flags, logLevel, options); + return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + }); + +const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), +); + +const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( + Command.withDescription( + "Run the T3 Code server without opening a browser and print headless pairing details.", + ), Command.withHandler((flags) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveServerConfig(flags, logLevel); - return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + runServerCommand(flags, { + startupPresentation: "headless", + forceAutoBootstrapProjectFromCwd: false, }), ), ); -export const cli = rootCommand; +export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), + Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), +); diff --git a/apps/server/src/cliAuthFormat.test.ts b/apps/server/src/cliAuthFormat.test.ts new file mode 100644 index 0000000000..017ced97e8 --- /dev/null +++ b/apps/server/src/cliAuthFormat.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from "@effect/vitest"; +import { DateTime } from "effect"; + +import { + formatIssuedPairingCredential, + formatIssuedSession, + formatPairingCredentialList, + formatSessionList, +} from "./cliAuthFormat.ts"; + +it("formats issued pairing credentials with the secret and optional pair URL", () => { + const output = formatIssuedPairingCredential( + { + id: "pairing-1", + credential: "secret-pairing-token", + role: "client", + subject: "one-time-token", + createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + }, + { baseUrl: "https://example.com", json: false }, + ); + + expect(output).toContain("secret-pairing-token"); + expect(output).toContain("https://example.com/pair#token=secret-pairing-token"); +}); + +it("formats pairing listings without exposing the secret token", () => { + const output = formatPairingCredentialList( + [ + { + id: "pairing-1", + credential: "secret-pairing-token", + subject: "one-time-token", + label: "Phone", + role: "client", + createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + }, + ], + { json: false }, + ); + + expect(output).toContain("pairing-1"); + expect(output).not.toContain("secret-pairing-token"); +}); + +it("formats issued sessions with the bearer token but omits tokens from listings", () => { + const issuedOutput = formatIssuedSession( + { + sessionId: "session-1" as never, + token: "secret-session-token", + method: "bearer-session-token", + role: "owner", + subject: "cli-issued-session", + client: { + label: "deploy-bot", + deviceType: "bot", + }, + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + }, + { json: false }, + ); + + const listedOutput = formatSessionList( + [ + { + sessionId: "session-1" as never, + method: "bearer-session-token", + role: "owner", + subject: "cli-issued-session", + client: { + label: "deploy-bot", + deviceType: "bot", + }, + connected: false, + current: false, + issuedAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + lastConnectedAt: null, + }, + ], + { json: false }, + ); + + expect(issuedOutput).toContain("secret-session-token"); + expect(listedOutput).not.toContain("secret-session-token"); +}); diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts new file mode 100644 index 0000000000..44356c5a8a --- /dev/null +++ b/apps/server/src/cliAuthFormat.ts @@ -0,0 +1,190 @@ +import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +import { DateTime } from "effect"; + +import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; + +const newline = "\n"; + +function serializeOptionalFields(values: ReadonlyArray) { + return values.filter((value): value is string => typeof value === "string" && value.length > 0); +} + +function formatClientMetadata(metadata: AuthClientMetadata): string { + const details = serializeOptionalFields([ + metadata.label, + metadata.deviceType !== "unknown" ? metadata.deviceType : undefined, + metadata.os, + metadata.browser, + metadata.ipAddress, + ]); + return details.length > 0 ? details.join(" | ") : "unlabeled client"; +} + +function toIsoString(value: DateTime.DateTime | DateTime.Utc): string { + return DateTime.formatIso(DateTime.toUtc(value)); +} + +export function formatIssuedPairingCredential( + credential: IssuedPairingLink, + options?: { + readonly json?: boolean; + readonly baseUrl?: string; + }, +): string { + const pairUrl = + options?.baseUrl != null && options.baseUrl.length > 0 + ? (() => { + const url = new URL("/pair", options.baseUrl); + url.searchParams.delete("token"); + url.hash = new URLSearchParams([["token", credential.credential]]).toString(); + return url.toString(); + })() + : undefined; + + if (options?.json) { + return `${JSON.stringify( + { + id: credential.id, + credential: credential.credential, + ...(credential.label ? { label: credential.label } : {}), + role: credential.role, + expiresAt: toIsoString(credential.expiresAt), + ...(pairUrl ? { pairUrl } : {}), + }, + null, + 2, + )}${newline}`; + } + + return ( + [ + `Issued client pairing token ${credential.id}.`, + `Token: ${credential.credential}`, + ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []), + `Expires at: ${credential.expiresAt}`, + ].join(newline) + newline + ); +} + +export function formatPairingCredentialList( + credentials: ReadonlyArray, + options?: { + readonly json?: boolean; + }, +): string { + if (options?.json) { + return `${JSON.stringify( + credentials.map((credential) => ({ + id: credential.id, + ...(credential.label ? { label: credential.label } : {}), + role: credential.role, + createdAt: toIsoString(credential.createdAt), + expiresAt: toIsoString(credential.expiresAt), + })), + null, + 2, + )}${newline}`; + } + + if (credentials.length === 0) { + return `No active pairing credentials.${newline}`; + } + + return ( + credentials + .map((credential) => + [ + `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, + ` role: ${credential.role}`, + ` created: ${toIsoString(credential.createdAt)}`, + ` expires: ${toIsoString(credential.expiresAt)}`, + ].join(newline), + ) + .join(`${newline}${newline}`) + newline + ); +} + +export function formatIssuedSession( + session: IssuedBearerSession, + options?: { + readonly json?: boolean; + readonly tokenOnly?: boolean; + }, +): string { + if (options?.tokenOnly) { + return `${session.token}${newline}`; + } + + if (options?.json) { + return `${JSON.stringify( + { + sessionId: session.sessionId, + token: session.token, + method: session.method, + role: session.role, + subject: session.subject, + client: session.client, + expiresAt: toIsoString(session.expiresAt), + }, + null, + 2, + )}${newline}`; + } + + return ( + [ + `Issued ${session.role} bearer session ${session.sessionId}.`, + `Token: ${session.token}`, + `Subject: ${session.subject}`, + `Client: ${formatClientMetadata(session.client)}`, + `Expires at: ${toIsoString(session.expiresAt)}`, + ].join(newline) + newline + ); +} + +export function formatSessionList( + sessions: ReadonlyArray, + options?: { + readonly json?: boolean; + }, +): string { + if (options?.json) { + return `${JSON.stringify( + sessions.map((session) => ({ + sessionId: session.sessionId, + method: session.method, + role: session.role, + subject: session.subject, + client: session.client, + connected: session.connected, + issuedAt: toIsoString(session.issuedAt), + expiresAt: toIsoString(session.expiresAt), + lastConnectedAt: session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : null, + })), + null, + 2, + )}${newline}`; + } + + if (sessions.length === 0) { + return `No active sessions.${newline}`; + } + + return ( + sessions + .map((session) => + [ + `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`, + ` method: ${session.method}`, + ` subject: ${session.subject}`, + ` client: ${formatClientMetadata(session.client)}`, + ` issued: ${toIsoString(session.issuedAt)}`, + ` last connected: ${ + session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : "never" + }`, + ` expires: ${toIsoString(session.expiresAt)}`, + ].join(newline), + ) + .join(`${newline}${newline}`) + newline + ); +} diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 2f740a7407..ab3b7a569d 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -17,7 +17,7 @@ import { resolveCodexModelForAccount, } from "./codexAppServerManager"; -const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); function createSendTurnHarness() { const manager = new CodexAppServerManager(); @@ -111,9 +111,9 @@ function createPendingUserInputHarness() { }, pendingUserInputs: new Map([ [ - ApprovalRequestId.makeUnsafe("req-user-input-1"), + ApprovalRequestId.make("req-user-input-1"), { - requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + requestId: ApprovalRequestId.make("req-user-input-1"), jsonRpcId: 42, threadId: asThreadId("thread_1"), }, @@ -743,7 +743,7 @@ describe("respondToUserInput", () => { await manager.respondToUserInput( asThreadId("thread_1"), - ApprovalRequestId.makeUnsafe("req-user-input-1"), + ApprovalRequestId.make("req-user-input-1"), { scope: "All request methods", compat: "Keep current envelope", @@ -780,7 +780,7 @@ describe("respondToUserInput", () => { await manager.respondToUserInput( asThreadId("thread_1"), - ApprovalRequestId.makeUnsafe("req-user-input-1"), + ApprovalRequestId.make("req-user-input-1"), { scope: [], }, diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..230ba8e364 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -19,7 +19,7 @@ import { ProviderInteractionMode, } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { Effect, ServiceMap } from "effect"; +import { Effect, Context } from "effect"; import { formatCodexCliUpgradeMessage, @@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the `; function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { - readonly approvalPolicy: "on-request" | "never"; - readonly sandbox: "workspace-write" | "danger-full-access"; + readonly approvalPolicy: "untrusted" | "on-request" | "never"; + readonly sandbox: "read-only" | "workspace-write" | "danger-full-access"; } { - if (runtimeMode === "approval-required") { - return { - approvalPolicy: "on-request", - sandbox: "workspace-write", - }; + switch (runtimeMode) { + case "approval-required": + return { + approvalPolicy: "untrusted", + sandbox: "read-only", + }; + case "auto-accept-edits": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + }; + case "full-access": + return { + approvalPolicy: "never", + sandbox: "danger-full-access", + }; } - - return { - approvalPolicy: "never", - sandbox: "danger-full-access", - }; } /** @@ -433,7 +439,7 @@ export class CodexAppServerManager extends EventEmitter(); private runPromise: (effect: Effect.Effect) => Promise; - constructor(services?: ServiceMap.ServiceMap) { + constructor(services?: Context.Context) { super(); this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise; } @@ -634,7 +640,7 @@ export class CodexAppServerManager extends EventEmitter { const turn = this.readObject(turnValue); const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; - const turnId = TurnId.makeUnsafe(turnIdRaw); + const turnId = TurnId.make(turnIdRaw); const items = this.readArray(turn, "items") ?? []; return { id: turnId, @@ -1584,9 +1590,9 @@ function readResumeThreadId(input: { } function toTurnId(value: string | undefined): TurnId | undefined { - return brandIfNonEmpty(value, TurnId.makeUnsafe); + return brandIfNonEmpty(value, TurnId.make); } function toProviderItemId(value: string | undefined): ProviderItemId | undefined { - return brandIfNonEmpty(value, ProviderItemId.makeUnsafe); + return brandIfNonEmpty(value, ProviderItemId.make); } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9ceea4c13c..7840c76115 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,13 +6,16 @@ * * @module ServerConfig */ -import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect"; +import { Effect, FileSystem, Layer, LogLevel, Path, Schema, Context } from "effect"; export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); export type RuntimeMode = typeof RuntimeMode.Type; +export const StartupPresentation = Schema.Literals(["browser", "headless"]); +export type StartupPresentation = typeof StartupPresentation.Type; + /** * ServerDerivedPaths - Derived paths from the base directory. */ @@ -21,6 +24,7 @@ export interface ServerDerivedPaths { readonly dbPath: string; readonly keybindingsConfigPath: string; readonly settingsPath: string; + readonly providerStatusCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; readonly logsDir: string; @@ -30,6 +34,9 @@ export interface ServerDerivedPaths { readonly providerEventLogPath: string; readonly terminalLogsDir: string; readonly anonymousIdPath: string; + readonly environmentIdPath: string; + readonly serverRuntimeStatePath: string; + readonly secretsDir: string; } /** @@ -54,7 +61,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; - readonly authToken: string | undefined; + readonly startupPresentation: StartupPresentation; + readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; } @@ -69,11 +77,13 @@ export const deriveServerPaths = Effect.fn(function* ( const attachmentsDir = join(stateDir, "attachments"); const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); + const providerStatusCacheDir = join(baseDir, "caches"); return { stateDir, dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), + providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, logsDir, @@ -83,6 +93,9 @@ export const deriveServerPaths = Effect.fn(function* ( providerEventLogPath: join(providerLogsDir, "events.log"), terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), + environmentIdPath: join(stateDir, "environment-id"), + serverRuntimeStatePath: join(stateDir, "server-runtime.json"), + secretsDir: join(stateDir, "secrets"), }; }); @@ -100,7 +113,9 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), + fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), + fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), ], { concurrency: "unbounded" }, ); @@ -109,7 +124,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server /** * ServerConfig - Service tag for server runtime configuration. */ -export class ServerConfig extends ServiceMap.Service()( +export class ServerConfig extends Context.Service()( "t3/config/ServerConfig", ) { static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) => @@ -145,10 +160,11 @@ export class ServerConfig extends ServiceMap.Service + ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + +const makeServerConfig = Effect.fn(function* (baseDir: string) { + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + + return { + ...derivedPaths, + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + cwd: process.cwd(), + baseDir, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + port: 0, + host: undefined, + desktopBootstrapToken: undefined, + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + startupPresentation: "browser", + } satisfies ServerConfigShape; +}); + +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("persists the environment id across service restarts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-test-", + }); + + const first = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + const second = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + + expect(first.environmentId).toBe(second.environmentId); + expect(second.capabilities.repositoryIdentity).toBe(true); + }), + ); + + it.effect("fails instead of overwriting a persisted id when reading the file errors", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-read-error-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + const environmentIdPath = serverConfig.environmentIdPath; + yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); + yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); + const writeAttempts: string[] = []; + const failingFileSystemLayer = FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === environmentIdPath), + readFileString: (path) => + path === environmentIdPath + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: path, + }), + ) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + description: "not found", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => { + writeAttempts.push(path); + return Effect.void; + }, + }); + + const exit = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironmentLive.pipe( + Layer.provide( + Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), + ), + ), + ), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(writeAttempts).toEqual([]); + expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( + "persisted-environment-id\n", + ); + }), + ); +}); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts new file mode 100644 index 0000000000..506fc45af7 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -0,0 +1,92 @@ +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Path, Random } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; +import { version } from "../../../package.json" with { type: "json" }; +import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; + +function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (process.platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + return "other"; + } +} + +export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem + .exists(serverConfig.environmentIdPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + const raw = yield* fileSystem + .readFileString(serverConfig.environmentIdPath) + .pipe(Effect.map((value) => value.trim())); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); + + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } + + const generated = yield* Random.nextUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); + + const environmentId = EnvironmentId.make(environmentIdRaw); + const cwdBaseName = path.basename(serverConfig.cwd).trim(); + const label = yield* resolveServerEnvironmentLabel({ + cwdBaseName, + }); + + const descriptor: ExecutionEnvironmentDescriptor = { + environmentId, + label, + platform: { + os: platformOs(), + arch: platformArch(), + }, + serverVersion: version, + capabilities: { + repositoryIdentity: true, + }, + }; + + return { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + } satisfies ServerEnvironmentShape; +}); + +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts new file mode 100644 index 0000000000..3d44713510 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import { vi } from "vitest"; + +vi.mock("../../processRunner.ts", () => ({ + runProcess: vi.fn(), +})); + +import { runProcess } from "../../processRunner.ts"; +import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; + +const mockedRunProcess = vi.mocked(runProcess); +const NoopFileSystemLayer = FileSystem.layerNoop({}); + +afterEach(() => { + mockedRunProcess.mockReset(); +}); + +describe("resolveServerEnvironmentLabel", () => { + it.effect("uses hostname fallback regardless of launch mode", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "win32", + hostname: "macbook-pro", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("macbook-pro"); + }), + ); + + it.effect("prefers the macOS ComputerName", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: " Julius's MacBook Pro \n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "darwin", + hostname: "macbook-pro", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("Julius's MacBook Pro"); + expect(mockedRunProcess).toHaveBeenCalledWith( + "scutil", + ["--get", "ComputerName"], + expect.objectContaining({ allowNonZeroExit: true }), + ); + }), + ); + + it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "linux", + hostname: "buildbox", + }).pipe( + Effect.provide( + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === "/etc/machine-info"), + readFileString: (path) => + path === "/etc/machine-info" + ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') + : Effect.succeed(""), + }), + ), + ); + + expect(result).toBe("Build Agent 01"); + expect(mockedRunProcess).not.toHaveBeenCalled(); + }), + ); + + it.effect("falls back to hostnamectl pretty hostname on Linux", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: "CI Runner\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "linux", + hostname: "runner-01", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("CI Runner"); + expect(mockedRunProcess).toHaveBeenCalledWith( + "hostnamectl", + ["--pretty"], + expect.objectContaining({ allowNonZeroExit: true }), + ); + }), + ); + + it.effect("falls back to the hostname when friendly labels are unavailable", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "win32", + hostname: "JULIUS-LAPTOP", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("JULIUS-LAPTOP"); + }), + ); + + it.effect("falls back to the hostname when the friendly-label command is missing", () => + Effect.gen(function* () { + mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT")); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "darwin", + hostname: "macbook-pro", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("macbook-pro"); + }), + ); + + it.effect("falls back to the cwd basename when the hostname is blank", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: " ", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "linux", + hostname: " ", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("t3code"); + }), + ); +}); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts new file mode 100644 index 0000000000..dc77658326 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -0,0 +1,106 @@ +import * as OS from "node:os"; + +import { Effect, FileSystem } from "effect"; + +import { runProcess } from "../../processRunner.ts"; + +interface ResolveServerEnvironmentLabelInput { + readonly cwdBaseName: string; + readonly platform?: NodeJS.Platform; + readonly hostname?: string | null; +} + +function normalizeLabel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function parseMachineInfoValue(raw: string, key: string): string | null { + for (const line of raw.split(/\r?\n/g)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { + continue; + } + const value = trimmed.slice(key.length + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return normalizeLabel(value.slice(1, -1)); + } + return normalizeLabel(value); + } + return null; +} + +const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const exists = yield* fileSystem + .exists("/etc/machine-info") + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + return yield* fileSystem + .readFileString("/etc/machine-info") + .pipe(Effect.orElseSucceed(() => null)); +}); + +const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( + command: string, + args: readonly string[], +) { + const result = yield* Effect.tryPromise({ + try: () => + runProcess(command, args, { + allowNonZeroExit: true, + }), + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); + + if (!result || result.code !== 0) { + return null; + } + + return normalizeLabel(result.stdout); +}); + +const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( + platform: NodeJS.Platform, +) { + if (platform === "darwin") { + return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); + } + + if (platform === "linux") { + const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); + if (machineInfo) { + const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); + if (prettyHostname) { + return prettyHostname; + } + } + + return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); + } + + return null; +}); + +export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( + input: ResolveServerEnvironmentLabelInput, +) { + const platform = input.platform ?? process.platform; + const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); + if (friendlyHostLabel) { + return friendlyHostLabel; + } + + const hostname = normalizeLabel(input.hostname ?? OS.hostname()); + if (hostname) { + return hostname; + } + + return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; +}); diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts new file mode 100644 index 0000000000..4858680333 --- /dev/null +++ b/apps/server/src/environment/Services/ServerEnvironment.ts @@ -0,0 +1,12 @@ +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface ServerEnvironmentShape { + readonly getEnvironmentId: Effect.Effect; + readonly getDescriptor: Effect.Effect; +} + +export class ServerEnvironment extends Context.Service()( + "t3/environment/Services/ServerEnvironment", +) {} diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 52ddf55453..be1c6798c9 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -166,6 +166,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { [ "exec", "--ephemeral", + "--skip-git-repo-check", "-s", "read-only", "--model", diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5e4416d8b9..665c4b138f 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -40,6 +40,24 @@ function writeTextFile( }); } +function removePath( + targetPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); + }); +} + +function makeDirectory( + dirPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); + }); +} + /** Run a raw git command for test setup (not under test). */ function git( cwd: string, @@ -219,7 +237,10 @@ it.layer(TestLayer)("git integration", (it) => { const seenChunks: string[][] = []; const core = yield* makeIsolatedGitCore((input) => { - if (input.args.join(" ") !== "check-ignore --no-index -z --stdin") { + if ( + input.args.join(" ") !== + "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin" + ) { return Effect.fail( new GitCommandError({ operation: input.operation, @@ -252,6 +273,35 @@ it.layer(TestLayer)("git integration", (it) => { expect(result).toEqual(expectedPaths); }), ); + + it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => + Effect.gen(function* () { + const core = yield* makeIsolatedGitCore((input) => { + expect(input.args).toEqual([ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ]); + return Effect.succeed({ + code: 0, + stdout: "src/index.ts\0README.md\0", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }); + + const result = yield* core.listWorkspaceFiles("/virtual/repo"); + expect(result.paths).toEqual(["src/index.ts", "README.md"]); + expect(result.truncated).toBe(false); + }), + ); }); // ── listGitBranches ── @@ -267,6 +317,21 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("returns isRepo: false for deleted directories", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const deletedDir = path.join(tmp, "deleted-repo"); + yield* makeDirectory(deletedDir); + yield* removePath(deletedDir); + + const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); + + expect(result.isRepo).toBe(false); + expect(result.hasOriginRemote).toBe(false); + expect(result.branches).toEqual([]); + }), + ); + it.effect("returns the current branch with current: true", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -617,8 +682,8 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => Effect.gen(function* () { - const services = yield* Effect.services(); - const runPromise = Effect.runPromiseWith(services); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -761,7 +826,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("shares upstream refreshes across worktrees that use the same git common dir", () => + it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => Effect.gen(function* () { const ok = (stdout = "") => Effect.succeed({ @@ -780,7 +845,9 @@ it.layer(TestLayer)("git integration", (it) => { input.args[2] === "--symbolic-full-name" && input.args[3] === "@{upstream}" ) { - return ok("origin/main\n"); + return ok( + input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", + ); } if (input.args[0] === "remote") { return ok("origin\n"); @@ -791,10 +858,22 @@ it.layer(TestLayer)("git integration", (it) => { if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { fetchCount += 1; expect(input.cwd).toBe("/repo"); + expect(input.args).toEqual([ + "--git-dir", + "/repo/.git", + "fetch", + "--quiet", + "--no-tags", + "origin", + ]); return ok(); } if (input.operation === "GitCore.statusDetails.status") { - return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); + return ok( + input.cwd === "/repo/worktrees/pr-123" + ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" + : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", + ); } if ( input.operation === "GitCore.statusDetails.unstagedNumstat" || @@ -821,70 +900,80 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); + it.effect( + "briefly backs off failed upstream refreshes across sibling worktrees on one remote", + () => + Effect.gen(function* () { + const ok = (stdout = "") => + Effect.succeed({ + code: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok("origin/main\n"); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; + let fetchCount = 0; + const core = yield* makeIsolatedGitCore((input) => { + if ( + input.args[0] === "rev-parse" && + input.args[1] === "--abbrev-ref" && + input.args[2] === "--symbolic-full-name" && + input.args[3] === "@{upstream}" + ) { + return ok( + input.cwd === "/repo/worktrees/pr-123" + ? "origin/feature/pr-123\n" + : "origin/main\n", + ); + } + if (input.args[0] === "remote") { + return ok("origin\n"); + } + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { + return ok("/repo/.git\n"); + } + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchCount += 1; + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "simulated fetch timeout", + }), + ); + } + if (input.operation === "GitCore.statusDetails.status") { + return ok( + input.cwd === "/repo/worktrees/pr-123" + ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" + : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", + ); + } + if ( + input.operation === "GitCore.statusDetails.unstagedNumstat" || + input.operation === "GitCore.statusDetails.stagedNumstat" + ) { + return ok(); + } + if (input.operation === "GitCore.statusDetails.defaultRef") { + return ok("refs/remotes/origin/main\n"); + } return Effect.fail( new GitCommandError({ operation: input.operation, command: `git ${input.args.join(" ")}`, cwd: input.cwd, - detail: "simulated fetch timeout", + detail: "Unexpected git command in refresh failure cooldown test.", }), ); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in refresh failure cooldown test.", - }), - ); - }); + }); - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), + yield* core.statusDetails("/repo/worktrees/main"); + yield* core.statusDetails("/repo/worktrees/pr-123"); + expect(fetchCount).toBe(1); + }), ); it.effect("throws when branch does not exist", () => @@ -949,11 +1038,12 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ + const checkoutResult = yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: `${remoteName}/${featureBranch}`, }); + expect(checkoutResult.branch).toBe("upstream/feature"); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); const realGitCore = yield* GitCore; let fetchArgs: readonly string[] | null = null; @@ -981,7 +1071,6 @@ it.layer(TestLayer)("git integration", (it) => { "--quiet", "--no-tags", remoteName, - `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`, ]); }), ); @@ -1593,6 +1682,37 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("returns a non-repo status for deleted directories", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const deletedDir = path.join(tmp, "deleted-repo"); + yield* makeDirectory(deletedDir); + yield* removePath(deletedDir); + const core = yield* GitCore; + + const status = yield* core.statusDetails(deletedDir); + const localStatus = yield* core.statusDetailsLocal(deletedDir); + + expect(status).toEqual({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + }); + expect(localStatus).toEqual(status); + }), + ); + it.effect("computes ahead count against base branch when no upstream is configured", () => 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 1178a4b67e..3e9df316f1 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -27,6 +27,7 @@ import { type ExecuteGitProgress, type GitCommitOptions, type GitCoreShape, + type GitStatusDetails, type ExecuteGitInput, type ExecuteGitResult, } from "../Services/GitCore.ts"; @@ -47,23 +48,39 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; +const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", +] as const; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, +}); type TraceTailState = { processedChars: number; remainder: string; }; -class StatusUpstreamRefreshCacheKey extends Data.Class<{ +class StatusRemoteRefreshCacheKey extends Data.Class<{ gitCommonDir: string; - upstreamRef: string; remoteName: string; - upstreamBranch: string; }> {} interface ExecuteGitOptions { @@ -353,6 +370,16 @@ function quoteGitCommand(args: ReadonlyArray): string { return `git ${args.join(" ")}`; } +function isMissingGitCwdError(error: GitCommandError): boolean { + const normalized = `${error.detail}\n${error.message}`.toLowerCase(); + return ( + normalized.includes("no such file or directory") || + normalized.includes("notfound: filesystem.access") || + normalized.includes("enoent") || + normalized.includes("not a directory") + ); +} + function toGitCommandError( input: Pick, detail: string, @@ -890,17 +917,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); }); - const fetchUpstreamRefForStatus = ( + const fetchRemoteForStatus = ( gitCommonDir: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + remoteName: string, ): Effect.Effect => { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return executeGit( - "GitCore.fetchUpstreamRefForStatus", + "GitCore.fetchRemoteForStatus", fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { allowNonZeroExit: true, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), @@ -916,20 +942,15 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); }); - const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* ( - cacheKey: StatusUpstreamRefreshCacheKey, + const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( + cacheKey: StatusRemoteRefreshCacheKey, ) { - yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, { - upstreamRef: cacheKey.upstreamRef, - remoteName: cacheKey.remoteName, - upstreamBranch: cacheKey.upstreamBranch, - }); + yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); return true as const; }); - const statusUpstreamRefreshCache = yield* Cache.makeWith({ + const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - lookup: refreshStatusUpstreamCacheEntry, // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. timeToLive: (exit) => Exit.isSuccess(exit) @@ -944,12 +965,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { if (!upstream) return; const gitCommonDir = yield* resolveGitCommonDir(cwd); yield* Cache.get( - statusUpstreamRefreshCache, - new StatusUpstreamRefreshCacheKey({ + statusRemoteRefreshCache, + new StatusRemoteRefreshCacheKey({ gitCommonDir, - upstreamRef: upstream.upstreamRef, remoteName: upstream.remoteName, - upstreamBranch: upstream.upstreamBranch, }), ); }); @@ -1177,9 +1196,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return branchLastCommit; }); - const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); - + const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGit( "GitCore.statusDetails.status", cwd, @@ -1187,7 +1204,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { { allowNonZeroExit: true, }, - ); + ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + + if (statusResult === null) { + return NON_REPOSITORY_STATUS_DETAILS; + } if (statusResult.code !== 0) { const stderr = statusResult.stderr.trim(); @@ -1312,6 +1333,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; }); + const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( + function* (cwd) { + return yield* readStatusDetailsLocal(cwd); + }, + ); + + const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); + return yield* readStatusDetailsLocal(cwd); + }); + const status: GitCoreShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ @@ -1610,7 +1645,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { executeGit( "GitCore.listWorkspaceFiles", cwd, - ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + [ + ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ], { allowNonZeroExit: true, timeoutMs: 20_000, @@ -1628,7 +1670,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { createGitCommandError( "GitCore.listWorkspaceFiles", cwd, - ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + [ + ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ], result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed", ), ), @@ -1648,7 +1697,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const result = yield* executeGit( "GitCore.filterIgnoredPaths", cwd, - ["check-ignore", "--no-index", "-z", "--stdin"], + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], { stdin: `${chunk.join("\0")}\0`, allowNonZeroExit: true, @@ -1662,7 +1711,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return yield* createGitCommandError( "GitCore.filterIgnoredPaths", cwd, - ["check-ignore", "--no-index", "-z", "--stdin"], + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed", ); } @@ -1691,6 +1740,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, allowNonZeroExit: true, }, + ).pipe( + Effect.catchIf(isMissingGitCwdError, () => + Effect.succeed({ + code: 128, + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), ); if (localBranchResult.code !== 0) { @@ -1973,7 +2032,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { "GitCore.removeWorktree", input.cwd, args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, error, ), ), @@ -2000,12 +2059,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return { branch: targetBranch }; }); - const createBranch: GitCoreShape["createBranch"] = (input) => - executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }).pipe(Effect.asVoid); - const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( function* (input) { const [localInputExists, remoteExists] = yield* Effect.all( @@ -2078,9 +2131,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); + + const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + + return { branch }; }, ); + const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { + yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }); + if (input.checkout) { + yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); + } + + return { branch: input.branch }; + }); + const initRepo: GitCoreShape["initRepo"] = (input) => executeGit("GitCore.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, @@ -2106,6 +2178,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { execute, status, statusDetails, + statusDetailsLocal, prepareCommitContext, commit, pushCurrentBranch, diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts index aafc796db3..0ee4b3f09a 100644 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => { }), ); + it.effect("trims pull request fields decoded from gh json", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: " Add PR thread creation \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", + baseRefName: " main ", + headRefName: "\tfeature/pr-threads\t", + 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", + }); + }), + ); + + it.effect("skips invalid entries when parsing pr lists", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 43, + title: " Valid PR ", + url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", + baseRefName: " main ", + headRefName: " feature/pr-list ", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/pr-list", + }); + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "main", + headRefName: "feature/pr-list", + state: "open", + }, + ]); + }), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockedRunProcess.mockResolvedValueOnce({ diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 280679e337..1a687b0e8d 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,5 +1,5 @@ -import { Effect, Layer, Schema } from "effect"; -import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { runProcess } from "../../processRunner"; import { GitHubCliError } from "@t3tools/contracts"; @@ -7,8 +7,12 @@ import { GitHubCli, type GitHubRepositoryCloneUrls, type GitHubCliShape, - type GitHubPullRequestSummary, } from "../Services/GitHubCli.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, + formatGitHubJsonDecodeError, +} from "../githubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown }); } -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"; - } - 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, - }), - ), - ), -}); - 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 { @@ -154,7 +94,7 @@ function decodeGitHubJson( (error) => new GitHubCliError({ operation, - detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, cause: error, }), ), @@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : decodeGitHubJson( - raw, - Schema.Array(RawGitHubPullRequestSchema), - "listOpenPullRequests", - "GitHub CLI returned invalid PR list JSON.", + : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "listOpenPullRequests", + detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), + ); + }), ), ), - Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), ), getPullRequest: (input) => execute({ @@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubPullRequestSchema, - "getPullRequest", - "GitHub CLI returned invalid pull request JSON.", + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "getPullRequest", + detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), + ); + }), ), ), - Effect.map(normalizePullRequestSummary), ), getRepositoryCloneUrls: (input) => execute({ diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 005bdb5bc6..fd991273d1 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -186,6 +186,24 @@ function makeTempDir( }); } +function removePath( + targetPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); + }); +} + +function makeDirectory( + dirPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); + }); +} + function runGit( cwd: string, args: readonly string[], @@ -694,6 +712,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status trims PR metadata returned by gh before publishing it", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 14, + title: " Existing PR title \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", + baseRefName: " main ", + headRefName: "\tfeature/status-trimmed-pr\t", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 14, + title: "Existing PR title", + url: "https://github.com/pingdotgg/codething-mvp/pull/14", + baseBranch: "main", + headBranch: "feature/status-trimmed-pr", + state: "open", + }); + }), + ); + + it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 15, + title: " Valid PR title ", + url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", + baseRefName: " main ", + headRefName: "\tfeature/status-valid-pr-entry\t", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 15, + title: "Valid PR title", + url: "https://github.com/pingdotgg/codething-mvp/pull/15", + baseBranch: "main", + headBranch: "feature/status-valid-pr-entry", + state: "open", + }); + }), + ); + + it.effect("status preserves lowercase merged and closed PR states from gh json", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 16, + title: "Closed PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/16", + baseRefName: "main", + headRefName: "feature/status-lowercase-state", + state: "closed", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + number: 17, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/17", + baseRefName: "main", + headRefName: "feature/status-lowercase-state", + state: "merged", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 17, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/17", + baseBranch: "main", + headBranch: "feature/status-lowercase-state", + state: "merged", + }); + }), + ); + it.effect("status returns an explicit non-repo result for non-git directories", () => Effect.gen(function* () { const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); @@ -720,6 +876,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status returns an explicit non-repo result for deleted directories", () => + Effect.gen(function* () { + const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); + const cwd = path.join(rootDir, "deleted-repo"); + yield* makeDirectory(cwd); + yield* removePath(cwd); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }), + ); + it.effect("status briefly caches repeated lookups for the same cwd", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -854,7 +1039,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), - 12_000, + 20_000, ); it.effect( @@ -962,7 +1147,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ), ).toBe(false); }), - 12_000, + 20_000, ); it.effect("status returns merged PR state when latest PR was merged", () => @@ -1685,7 +1870,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { false, ); }), - 12_000, + 20_000, ); it.effect( diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 7fedb15714..a84427a194 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,16 +1,31 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; -import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; +import { + Cache, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Ref, + Result, +} from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, GitRunStackedActionResult, GitStackedAction, + type GitStatusLocalResult, + type GitStatusRemoteResult, ModelSelection, } from "@t3tools/contracts"; import { + detectGitHostingProviderFromRemoteUrl, + mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -30,6 +45,10 @@ import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScr import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; +import { + decodeGitHubPullRequestListJson, + formatGitHubJsonDecodeError, +} from "../githubPullRequests.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -236,85 +255,6 @@ function matchesBranchHeadContext( return true; } -function parsePullRequestList(raw: unknown): PullRequestInfo[] { - if (!Array.isArray(raw)) return []; - - const parsed: PullRequestInfo[] = []; - for (const entry of raw) { - 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; - const state = record.state; - const mergedAt = record.mergedAt; - const updatedAt = record.updatedAt; - const isCrossRepository = record.isCrossRepository; - const headRepositoryRecord = - typeof record.headRepository === "object" && record.headRepository !== null - ? (record.headRepository as Record) - : null; - const headRepositoryOwnerRecord = - typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null - ? (record.headRepositoryOwner as Record) - : null; - const headRepositoryNameWithOwner = - typeof record.headRepositoryNameWithOwner === "string" - ? record.headRepositoryNameWithOwner - : typeof headRepositoryRecord?.nameWithOwner === "string" - ? headRepositoryRecord.nameWithOwner - : null; - const headRepositoryOwnerLogin = - typeof record.headRepositoryOwnerLogin === "string" - ? record.headRepositoryOwnerLogin - : typeof headRepositoryOwnerRecord?.login === "string" - ? headRepositoryOwnerRecord.login - : null; - if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { - continue; - } - if ( - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - continue; - } - - let normalizedState: "open" | "closed" | "merged"; - if ( - (typeof mergedAt === "string" && mergedAt.trim().length > 0) || - state === "MERGED" || - state === "merged" - ) { - normalizedState = "merged"; - } else if (state === "OPEN" || state === "open" || state === undefined || state === null) { - normalizedState = "open"; - } else if (state === "CLOSED" || state === "closed") { - normalizedState = "closed"; - } else { - continue; - } - - parsed.push({ - number, - title, - url, - baseRefName, - headRefName, - state: normalizedState, - updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, - ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }); - } - return parsed; -} - function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { return { number: summary.number, @@ -695,26 +635,54 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); - const readStatus = Effect.fn("readStatus")(function* (cwd: string) { - const details = yield* gitCore.statusDetails(cwd).pipe( - Effect.catchIf(isNotGitRepositoryError, () => - Effect.succeed({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - } satisfies GitStatusDetails), - ), - ); + const nonRepositoryStatusDetails = { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails; + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetailsLocal(cwd) + .pipe( + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), + ); + const hostingProvider = details.isRepo + ? yield* resolveHostingProvider(cwd, details.branch) + : null; + + return { + isRepo: details.isRepo, + ...(hostingProvider ? { hostingProvider } : {}), + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + } satisfies GitStatusLocalResult; + }); + const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { + capacity: STATUS_RESULT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateLocalStatusResultCache = (cwd: string) => + Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetails(cwd) + .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); + if (details === null || !details.isRepo) { + return null; + } const pr = - details.isRepo && details.branch !== null + details.branch !== null ? yield* findLatestPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, @@ -725,29 +693,37 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { : null; return { - isRepo: details.isRepo, - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, pr, - }; + } satisfies GitStatusRemoteResult; }); - const statusResultCache = yield* Cache.makeWith({ + const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, - lookup: readStatus, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); - const invalidateStatusResultCache = (cwd: string) => - Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); + const invalidateRemoteStatusResultCache = (cwd: string) => + Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( + cwd: string, + branch: string | null, + ) { + const preferredRemoteName = + branch === null + ? "origin" + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + const remoteUrl = + (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? + (yield* readConfigValueNullable(cwd, "remote.origin.url")); + + return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; + }); + const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( cwd: string, remoteName: string | null, @@ -907,13 +883,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { continue; } - const parsedJson = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => - gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), - }); + const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + gitManagerError( + "findLatestPr", + `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + decoded.failure, + ), + ); + } - for (const pr of parsePullRequestList(parsedJson)) { + return Effect.succeed(decoded.success); + }), + ); + + for (const pr of pullRequests) { if (!matchesBranchHeadContext(pr, headContext)) { continue; } @@ -1311,9 +1297,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); + const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { + return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }); + const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input) { + return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }, + ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); + return mergeGitStatusParts(local, remote); + }); + const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + "invalidateLocalStatus", + )(function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); }); + const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + "invalidateRemoteStatus", + )(function* (cwd) { + yield* invalidateRemoteStatusResultCache(cwd); + }); + const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + yield* invalidateRemoteStatusResultCache(cwd); + }, + ); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { @@ -1488,7 +1499,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { branch: worktree.worktree.branch, worktreePath: worktree.worktree.path, }; - }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -1692,7 +1703,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( - Effect.ensuring(invalidateStatusResultCache(input.cwd)), + Effect.ensuring(invalidateStatus(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ @@ -1707,7 +1718,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); return { + localStatus, + remoteStatus, status, + invalidateLocalStatus, + invalidateRemoteStatus, + invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts new file mode 100644 index 0000000000..72a0c24e27 --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -0,0 +1,312 @@ +import { assert, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { describe } from "vitest"; + +import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; +import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; +import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; + +const baseLocalStatus: GitStatusLocalResult = { + isRepo: true, + hostingProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/status-broadcast", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const baseStatus: GitStatusResult = { + ...baseLocalStatus, + ...baseRemoteStatus, +}; + +function makeTestLayer(state: { + currentLocalStatus: GitStatusLocalResult; + currentRemoteStatus: GitStatusRemoteResult | null; + localStatusCalls: number; + remoteStatusCalls: number; + localInvalidationCalls: number; + remoteInvalidationCalls: number; +}) { + const gitManager: GitManagerShape = { + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), + resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + }; + + return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); +} + +describe("GitStatusBroadcasterLive", () => { + it.effect("reuses the cached git status across repeated reads", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(first, baseStatus); + assert.deepStrictEqual(second, baseStatus); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes the cached snapshot after explicit invalidation", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + branch: "feature/updated-status", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 2, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshed, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes only the cached local snapshot when requested", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + branch: "feature/local-only-refresh", + hasWorkingTreeChanges: true, + }; + + const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...baseRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("streams a local snapshot first and remote updates later", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred); + yield* broadcaster.refreshStatus("/repo"); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies GitStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies GitStatusStreamEvent); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let remoteInterruptedDeferred: Deferred.Deferred | null = null; + let remoteStartedDeferred: Deferred.Deferred | null = null; + const testLayer = GitStatusBroadcasterLive.pipe( + Layer.provide( + Layer.succeed(GitManager, { + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + }).pipe( + Effect.andThen( + remoteStartedDeferred + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Effect.never as Effect.Effect), + Effect.onInterrupt(() => + remoteInterruptedDeferred + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), + resolvePullRequest: () => + Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + } satisfies GitManagerShape), + ), + ); + + return Effect.gen(function* () { + const remoteInterrupted = yield* Deferred.make(); + const remoteStarted = yield* Deferred.make(); + remoteInterruptedDeferred = remoteInterrupted; + remoteStartedDeferred = remoteStarted; + + const broadcaster = yield* GitStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(firstScope)); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(secondScope)); + + yield* Deferred.await(firstSnapshot); + yield* Deferred.await(secondSnapshot); + yield* Deferred.await(remoteStarted); + + assert.equal(state.remoteStatusCalls, 1); + + yield* Scope.close(firstScope, Exit.void); + assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); + + yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); + yield* Deferred.await(remoteInterrupted); + assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); + }).pipe(Effect.provide(testLayer)); + }); +}); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..3ad7d095d8 --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -0,0 +1,311 @@ +import { realpathSync } from "node:fs"; + +import { + Duration, + Effect, + Exit, + Fiber, + Layer, + PubSub, + Ref, + Scope, + Stream, + SynchronizedRef, +} from "effect"; +import type { + GitStatusInput, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; + +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "../Services/GitStatusBroadcaster.ts"; +import { GitManager } from "../Services/GitManager.ts"; + +const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); + +interface GitStatusChange { + readonly cwd: string; + readonly event: GitStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedGitStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + +function normalizeCwd(cwd: string): string { + try { + return realpathSync.native(cwd); + } catch { + return cwd; + } +} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +export const GitStatusBroadcasterLive = Layer.effect( + GitStatusBroadcaster, + Effect.gen(function* () { + const gitManager = yield* GitManager; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); + + const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( + cwd: string, + local: GitStatusLocalResult, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, + }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } + + return local; + }); + + const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( + cwd: string, + remote: GitStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }); + + const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { + const local = yield* gitManager.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote); + }); + + const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(cwd); + }); + + const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( + input: GitStatusInput, + ) { + const normalizedCwd = normalizeCwd(input.cwd); + const [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(normalizedCwd), + getOrLoadRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( + "refreshLocalStatus", + )(function* (cwd) { + const normalizedCwd = normalizeCwd(cwd); + yield* gitManager.invalidateLocalStatus(normalizedCwd); + const local = yield* gitManager.localStatus({ cwd: normalizedCwd }); + return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true }); + }); + + const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { + yield* gitManager.invalidateRemoteStatus(cwd); + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( + function* (cwd) { + const normalizedCwd = normalizeCwd(cwd); + const [local, remote] = yield* Effect.all([ + refreshLocalStatus(normalizedCwd), + refreshRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }, + ); + + const makeRemoteRefreshLoop = (cwd: string) => { + const logRefreshFailure = (error: Error) => + Effect.logWarning("git remote status refresh failed", { + cwd, + detail: error.message, + }); + + return refreshRemoteStatus(cwd).pipe( + Effect.catch(logRefreshFailure), + Effect.andThen( + Effect.forever( + Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( + Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), + ), + ), + ), + ); + }; + + const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } + + return makeRemoteRefreshLoop(cwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextPollers] as const; + }), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } + + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; + } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const normalizedCwd = normalizeCwd(input.cwd); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); + const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; + yield* retainRemotePoller(normalizedCwd); + + const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === normalizedCwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return { + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + } satisfies GitStatusBroadcasterShape; + }), +); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index dee12a3e0e..5372bc1349 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -9,7 +9,7 @@ * * @module RoutingTextGeneration */ -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, Context } from "effect"; import { TextGeneration, @@ -23,11 +23,11 @@ import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; // Internal service tags so both concrete layers can coexist. // --------------------------------------------------------------------------- -class CodexTextGen extends ServiceMap.Service()( +class CodexTextGen extends Context.Service()( "t3/git/Layers/RoutingTextGeneration/CodexTextGen", ) {} -class ClaudeTextGen extends ServiceMap.Service()( +class ClaudeTextGen extends Context.Service()( "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", ) {} diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d7a28d1763..9f3bc0b9b9 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -6,11 +6,13 @@ * * @module GitCore */ -import { ServiceMap } from "effect"; -import type { Effect, Scope } from "effect"; +import { Context } from "effect"; +import type { Effect } from "effect"; import type { GitCheckoutInput, + GitCheckoutResult, GitCreateBranchInput, + GitCreateBranchResult, GitCreateWorktreeInput, GitCreateWorktreeResult, GitInitInput, @@ -156,6 +158,11 @@ export interface GitCoreShape { */ readonly statusDetails: (cwd: string) => Effect.Effect; + /** + * Read detailed working tree / branch status without refreshing remote tracking refs. + */ + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + /** * Build staged change context for commit generation. */ @@ -278,14 +285,16 @@ export interface GitCoreShape { /** * Create a local branch. */ - readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect; + readonly createBranch: ( + input: GitCreateBranchInput, + ) => Effect.Effect; /** * Checkout an existing branch and refresh its upstream metadata in background. */ readonly checkoutBranch: ( input: GitCheckoutInput, - ) => Effect.Effect; + ) => Effect.Effect; /** * Initialize a repository in the provided directory. @@ -301,6 +310,4 @@ export interface GitCoreShape { /** * GitCore - Service tag for low-level Git repository operations. */ -export class GitCore extends ServiceMap.Service()( - "t3/git/Services/GitCore", -) {} +export class GitCore extends Context.Service()("t3/git/Services/GitCore") {} diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 38afdd5f92..216c24bf7c 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -5,7 +5,7 @@ * * @module GitHubCli */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { ProcessRunResult } from "../../processRunner"; @@ -98,6 +98,6 @@ export interface GitHubCliShape { /** * GitHubCli - Service tag for GitHub CLI process execution. */ -export class GitHubCli extends ServiceMap.Service()( +export class GitHubCli extends Context.Service()( "t3/git/Services/GitHubCli", ) {} diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 86842257b4..29c762195e 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -14,10 +14,12 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, + GitStatusLocalResult, + GitStatusRemoteResult, GitStatusInput, GitStatusResult, } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { GitManagerServiceError } from "@t3tools/contracts"; @@ -41,6 +43,35 @@ export interface GitManagerShape { input: GitStatusInput, ) => Effect.Effect; + /** + * Read local repository status without remote hosting enrichment. + */ + readonly localStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Read remote tracking / PR status for a repository. + */ + readonly remoteStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Clear any cached local status snapshot for a repository. + */ + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + + /** + * Clear any cached remote status snapshot for a repository. + */ + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + + /** + * Clear any cached status snapshot for a repository so the next read is fresh. + */ + readonly invalidateStatus: (cwd: string) => Effect.Effect; + /** * Resolve a pull request by URL/number against the current repository. */ @@ -68,6 +99,6 @@ export interface GitManagerShape { /** * GitManager - Service tag for stacked Git workflow orchestration. */ -export class GitManager extends ServiceMap.Service()( +export class GitManager extends Context.Service()( "t3/git/Services/GitManager", ) {} diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..647f840824 --- /dev/null +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -0,0 +1,27 @@ +import { Context } from "effect"; +import type { Effect, Stream } from "effect"; +import type { + GitManagerServiceError, + GitStatusInput, + GitStatusLocalResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; + +export interface GitStatusBroadcasterShape { + readonly getStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: GitStatusInput, + ) => Stream.Stream; +} + +export class GitStatusBroadcaster extends Context.Service< + GitStatusBroadcaster, + GitStatusBroadcasterShape +>()("t3/git/Services/GitStatusBroadcaster") {} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index f4354c7a99..6062d552d9 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -6,7 +6,7 @@ * * @module TextGeneration */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; @@ -118,6 +118,6 @@ export interface TextGenerationShape { /** * TextGeneration - Service tag for commit and PR text generation. */ -export class TextGeneration extends ServiceMap.Service()( +export class TextGeneration extends Context.Service()( "t3/git/Services/TextGeneration", ) {} diff --git a/apps/server/src/git/githubPullRequests.ts b/apps/server/src/git/githubPullRequests.ts new file mode 100644 index 0000000000..d137a46d6f --- /dev/null +++ b/apps/server/src/git/githubPullRequests.ts @@ -0,0 +1,128 @@ +import { Cause, Exit, Result, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitHubPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const GitHubPullRequestSchema = 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)), + updatedAt: 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, + }), + ), + ), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeGitHubPullRequestState(input: { + state?: string | null | undefined; + mergedAt?: string | null | undefined; +}): "open" | "closed" | "merged" { + const normalizedState = input.state?.trim().toUpperCase(); + if ( + (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || + normalizedState === "MERGED" + ) { + return "merged"; + } + if (normalizedState === "CLOSED") { + return "closed"; + } + return "open"; +} + +function normalizeGitHubPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedGitHubPullRequestRecord { + const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const headRepositoryOwnerLogin = + trimOptionalString(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: normalizeGitHubPullRequestState(raw), + updatedAt: + typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null, + ...(typeof raw.isCrossRepository === "boolean" + ? { isCrossRepository: raw.isCrossRepository } + : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); +const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); + +export const formatGitHubJsonDecodeError = formatSchemaError; + +export function decodeGitHubPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeGitHubPullRequestList(raw); + if (Result.isSuccess(result)) { + const pullRequests: NormalizedGitHubPullRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeGitHubPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); + } + return Result.fail(result.failure); +} + +export function decodeGitHubPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeGitHubPullRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts new file mode 100644 index 0000000000..de861cc664 --- /dev/null +++ b/apps/server/src/http.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; + +describe("http dev routing", () => { + it("treats localhost and loopback addresses as local", () => { + expect(isLoopbackHostname("127.0.0.1")).toBe(true); + expect(isLoopbackHostname("localhost")).toBe(true); + expect(isLoopbackHostname("::1")).toBe(true); + expect(isLoopbackHostname("[::1]")).toBe(true); + }); + + it("does not treat LAN addresses as local", () => { + expect(isLoopbackHostname("192.168.86.35")).toBe(false); + expect(isLoopbackHostname("10.0.0.24")).toBe(false); + expect(isLoopbackHostname("example.local")).toBe(false); + }); + + it("preserves path and query when redirecting to the dev server", () => { + const devUrl = new URL("http://127.0.0.1:5173/"); + const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token"); + + expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe( + "http://127.0.0.1:5173/pair?token=test-token", + ); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ca4a2c22ef..7420156b2e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,5 @@ import Mime from "@effect/platform-node/Mime"; -import { Data, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Data, Effect, FileSystem, Option, Path } from "effect"; import { cast } from "effect/Function"; import { HttpBody, @@ -17,14 +17,57 @@ import { resolveAttachmentRelativePath, } from "./attachmentPaths"; import { resolveAttachmentPathById } from "./attachmentStore"; -import { ServerConfig } from "./config"; +import { resolveStaticDir, ServerConfig } from "./config"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import { respondToAuthError } from "./auth/http.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); + +export const browserApiCorsLayer = HttpRouter.cors({ + allowedMethods: ["GET", "POST", "OPTIONS"], + allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], + maxAge: 600, +}); + +export function isLoopbackHostname(hostname: string): boolean { + const normalizedHostname = hostname + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); + return LOOPBACK_HOSTNAMES.has(normalizedHostname); +} + +export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { + const redirectUrl = new URL(devUrl.toString()); + redirectUrl.pathname = requestUrl.pathname; + redirectUrl.search = requestUrl.search; + redirectUrl.hash = requestUrl.hash; + return redirectUrl.toString(); +} + +const requireAuthenticatedRequest = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request); +}); + +export const serverEnvironmentRouteLayer = HttpRouter.add( + "GET", + "/.well-known/t3/environment", + Effect.gen(function* () { + const descriptor = yield* Effect.service(ServerEnvironment).pipe( + Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), + ); + return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 }); + }), +); class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ readonly cause: unknown; @@ -35,6 +78,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; @@ -76,21 +120,14 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), ), ); - }), -).pipe( - Layer.provide( - HttpRouter.cors({ - allowedMethods: ["POST", "OPTIONS"], - allowedHeaders: ["content-type"], - maxAge: 600, - }), - ), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const attachmentsRouteLayer = HttpRouter.add( "GET", `${ATTACHMENTS_ROUTE_PREFIX}/*`, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -139,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const projectFaviconRouteLayer = HttpRouter.add( "GET", "/api/project-favicon", Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -179,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const staticAndDevRouteLayer = HttpRouter.add( @@ -188,16 +226,20 @@ export const staticAndDevRouteLayer = HttpRouter.add( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } const config = yield* ServerConfig; - if (config.devUrl) { - return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { + return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { + status: 302, + }); } - if (!config.staticDir) { + const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); + if (!staticDir) { return HttpServerResponse.text("No static directory configured and no dev URL set.", { status: 503, }); @@ -205,7 +247,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticRoot = path.resolve(config.staticDir); + const staticRoot = path.resolve(staticDir); const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 8eda0ca85d..e3f190ff06 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -2,7 +2,7 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; +import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; import { ServerConfig } from "./config"; import { @@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }), ); + it.effect("formats invalid resolved keybinding rules with the custom message", () => + Effect.sync(() => { + const result = Schema.decodeUnknownExit(ResolvedKeybindingFromConfig)({ + key: "mod+shift+d+o", + command: "terminal.new", + }); + + if (result._tag !== "Failure") { + assert.fail("Expected invalid keybinding decode to fail"); + } + + const detail = Cause.pretty(result.cause); + assert.isTrue(detail.includes("Invalid keybinding rule")); + assert.isFalse(detail.includes("Invalid data")); + }), + ); + it.effect("bootstraps default keybindings when config file is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 086d795c0c..b473f77ca1 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -39,7 +39,7 @@ import { SchemaIssue, SchemaTransformation, Ref, - ServiceMap, + Context, Scope, Stream, } from "effect"; @@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, @@ -322,7 +323,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( Predicate.isNotNull, () => new SchemaIssue.InvalidValue(Option.some(rule), { - title: "Invalid keybinding rule", + message: "Invalid keybinding rule", }), ), Effect.map((resolved) => resolved), @@ -334,7 +335,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( if (!key) { return yield* Effect.fail( new SchemaIssue.InvalidValue(Option.some(resolved), { - title: "Resolved shortcut cannot be encoded to key string", + message: "Resolved shortcut cannot be encoded to key string", }), ); } @@ -521,7 +522,7 @@ export interface KeybindingsShape { /** * Keybindings - Service tag for keybinding configuration operations. */ -export class Keybindings extends ServiceMap.Service()( +export class Keybindings extends Context.Service()( "t3/keybindings", ) {} diff --git a/apps/server/src/observability/Services/BrowserTraceCollector.ts b/apps/server/src/observability/Services/BrowserTraceCollector.ts index 60e8f5fa13..e27a53c9c3 100644 --- a/apps/server/src/observability/Services/BrowserTraceCollector.ts +++ b/apps/server/src/observability/Services/BrowserTraceCollector.ts @@ -1,4 +1,4 @@ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { TraceRecord } from "../TraceRecord.ts"; @@ -7,7 +7,7 @@ export interface BrowserTraceCollectorShape { readonly record: (records: ReadonlyArray) => Effect.Effect; } -export class BrowserTraceCollector extends ServiceMap.Service< +export class BrowserTraceCollector extends Context.Service< BrowserTraceCollector, BrowserTraceCollectorShape >()("t3/observability/Services/BrowserTraceCollector") {} diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 76b14c8597..382daab2d0 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -16,6 +16,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const antigravityLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "antigravity" }, "darwin", + { PATH: "" }, ); assert.deepEqual(antigravityLaunch, { command: "agy", @@ -25,6 +26,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const cursorLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "cursor" }, "darwin", + { PATH: "" }, ); assert.deepEqual(cursorLaunch, { command: "cursor", @@ -40,9 +42,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const kiroLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLaunch, { + command: "kiro", + args: ["ide", "/tmp/workspace"], + }); + const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, "darwin", + { PATH: "" }, ); assert.deepEqual(vscodeLaunch, { command: "code", @@ -70,6 +83,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const zedLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "zed" }, "darwin", + { PATH: "" }, ); assert.deepEqual(zedLaunch, { command: "zed", @@ -92,6 +106,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const lineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, "darwin", + { PATH: "" }, ); assert.deepEqual(lineOnly, { command: "cursor", @@ -101,6 +116,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const lineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, "darwin", + { PATH: "" }, ); assert.deepEqual(lineAndColumn, { command: "cursor", @@ -116,9 +132,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const kiroLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLineAndColumn, { + command: "kiro", + args: ["ide", "--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const vscodeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, "darwin", + { PATH: "" }, ); assert.deepEqual(vscodeLineAndColumn, { command: "code", @@ -146,6 +173,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const zedLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, "darwin", + { PATH: "" }, ); assert.deepEqual(zedLineAndColumn, { command: "zed", @@ -155,6 +183,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const zedLineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, "darwin", + { PATH: "" }, ); assert.deepEqual(zedLineOnly, { command: "zed", @@ -181,11 +210,43 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }), ); + it.effect("falls back to zeditor when zed is not installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); + + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { + PATH: dir, + }); + + assert.deepEqual(result, { + command: "zeditor", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("falls back to the primary command when no alias is installed", () => + Effect.gen(function* () { + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { + PATH: "", + }); + assert.deepEqual(result, { + command: "zed", + args: ["/tmp/workspace"], + }); + }), + ); + it.effect("maps file-manager editor to OS open commands", () => Effect.gen(function* () { const launch1 = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "darwin", + { PATH: "" }, ); assert.deepEqual(launch1, { command: "open", @@ -195,6 +256,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const launch2 = yield* resolveEditorLaunch( { cwd: "C:\\workspace", editor: "file-manager" }, "win32", + { PATH: "" }, ); assert.deepEqual(launch2, { command: "explorer", @@ -204,6 +266,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const launch3 = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "linux", + { PATH: "" }, ); assert.deepEqual(launch3, { command: "xdg-open", @@ -311,6 +374,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); @@ -318,7 +382,32 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]); + assert.deepEqual(editors, ["trae", "kiro", "vscode-insiders", "vscodium", "file-manager"]); }), ); + + it.effect("includes zed when only the zeditor command is installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); + yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); + yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); + + const editors = resolveAvailableEditors("linux", { + PATH: dir, + }); + assert.deepEqual(editors, ["zed", "file-manager"]); + }), + ); + + it("omits file-manager when the platform opener is unavailable", () => { + const editors = resolveAvailableEditors("linux", { + PATH: "", + }); + assert.deepEqual(editors, []); + }); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 58074ceef2..698cc0080b 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -11,7 +11,7 @@ import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; -import { ServiceMap, Effect, Layer } from "effect"; +import { Context, Effect, Layer } from "effect"; // ============================== // Definitions @@ -75,6 +75,26 @@ function resolveCommandEditorArgs( } } +function resolveEditorArgs( + editor: (typeof EDITORS)[number], + target: string, +): ReadonlyArray { + const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; + return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; +} + +function resolveAvailableCommand( + commands: ReadonlyArray, + options: CommandAvailabilityOptions = {}, +): string | null { + for (const command of commands) { + if (isCommandAvailable(command, options)) { + return command; + } + } + return null; +} + function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { switch (platform) { case "darwin": @@ -198,8 +218,16 @@ export function resolveAvailableEditors( const available: EditorId[] = []; for (const editor of EDITORS) { - const command = editor.command ?? fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { + if (editor.commands === null) { + const command = fileManagerCommandForPlatform(platform); + if (isCommandAvailable(command, { platform, env })) { + available.push(editor.id); + } + continue; + } + + const command = resolveAvailableCommand(editor.commands, { platform, env }); + if (command !== null) { available.push(editor.id); } } @@ -227,7 +255,7 @@ export interface OpenShape { /** * Open - Service tag for browser/editor launch operations. */ -export class Open extends ServiceMap.Service()("t3/open") {} +export class Open extends Context.Service()("t3/open") {} // ============================== // Implementations @@ -236,6 +264,7 @@ export class Open extends ServiceMap.Service()("t3/open") {} export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "open.editor": input.editor, @@ -247,10 +276,12 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); } - if (editorDef.command) { + if (editorDef.commands) { + const command = + resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; return { - command: editorDef.command, - args: resolveCommandEditorArgs(editorDef, input.cwd), + command, + args: resolveEditorArgs(editorDef, input.cwd), }; } @@ -270,11 +301,16 @@ export const launchDetached = (launch: EditorLaunch) => yield* Effect.callback((resume) => { let child; try { - child = spawn(launch.command, [...launch.args], { - detached: true, - stdio: "ignore", - shell: process.platform === "win32", - }); + const isWin32 = process.platform === "win32"; + child = spawn( + launch.command, + isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args], + { + detached: true, + stdio: "ignore", + shell: isWin32, + }, + ); } catch (error) { return resume( 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 72adb175f9..12e11450dd 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -20,6 +20,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -42,8 +44,8 @@ import { ServerConfig } from "../../config.ts"; import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; -const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); type LegacyProviderRuntimeEvent = { readonly type: string; @@ -79,7 +81,7 @@ function createProviderServiceHarness( provider: providerName, status: "ready", runtimeMode: "full-access", - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), cwd: sessionCwd, createdAt: now, updatedAt: now, @@ -128,7 +130,7 @@ async function waitForThread( activities: ReadonlyArray<{ kind: string }>; }> => { const readModel = await Effect.runPromise(engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); if (thread && predicate(thread)) { return thread; } @@ -242,6 +244,7 @@ describe("CheckpointReactor", () => { readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; readonly providerName?: ProviderKind; + readonly gitStatusRefreshCalls?: Array; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -256,17 +259,37 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-reactor-test-", }); + const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, { + getStatus: () => Effect.die("getStatus should not be called in this test"), + refreshLocalStatus: (cwd: string) => + Effect.sync(() => { + options?.gitStatusRefreshCalls?.push(cwd); + }).pipe( + Effect.as({ + isRepo: true, + hasOriginRemote: false, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + ), + refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), + streamStatus: () => Stream.empty, + }); const layer = CheckpointReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provideMerge(CheckpointStoreLive), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), @@ -287,7 +310,7 @@ describe("CheckpointReactor", () => { await Effect.runPromise( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-create"), + commandId: CommandId.make("cmd-project-create"), projectId: asProjectId("project-1"), title: "Test Project", workspaceRoot: options?.projectWorkspaceRoot ?? cwd, @@ -301,8 +324,8 @@ describe("CheckpointReactor", () => { await Effect.runPromise( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread", modelSelection: { @@ -321,21 +344,21 @@ describe("CheckpointReactor", () => { await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), }), ); fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), }), ); fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), }), ); } @@ -355,10 +378,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-capture"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-capture"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -372,26 +395,26 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-1"), + eventId: EventId.make("evt-turn-started-1"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), }); await waitForGitRefExists( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-1"), + eventId: EventId.make("evt-turn-completed-1"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), payload: { state: "completed" }, }); @@ -403,27 +426,49 @@ describe("CheckpointReactor", () => { ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), ).toBe(true); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), ).toBe(true); expect( gitShowFileAtRef( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), "README.md", ), ).toBe("v1\n"); expect( gitShowFileAtRef( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), "README.md", ), ).toBe("v2\n"); }); + it("refreshes local git status state on turn completion using the session cwd", async () => { + const gitStatusRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + gitStatusRefreshCalls, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-refresh-local-status"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-refresh-local-status"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + expect(gitStatusRefreshCalls).toEqual([harness.cwd]); + }); + it("ignores auxiliary thread turn completion while primary turn is active", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = new Date().toISOString(); @@ -431,10 +476,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-primary-running"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", runtimeMode: "approval-required", @@ -448,45 +493,43 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-main"), + eventId: EventId.make("evt-turn-started-main"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-main"), }); await waitForGitRefExists( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-aux"), + eventId: EventId.make("evt-turn-completed-aux"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-aux"), payload: { state: "completed" }, }); await harness.drain(); 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.make("thread-1")); expect(midThread?.checkpoints).toHaveLength(0); harness.provider.emit({ type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-main"), + eventId: EventId.make("evt-turn-completed-main"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-main"), payload: { state: "completed" }, }); @@ -508,10 +551,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-capture-claude"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "claudeAgent", runtimeMode: "approval-required", @@ -525,24 +568,24 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), + eventId: EventId.make("evt-turn-started-claude-1"), provider: "claudeAgent", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-1"), }); await waitForGitRefExists( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), + eventId: EventId.make("evt-turn-completed-claude-1"), provider: "claudeAgent", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-1"), payload: { state: "completed" }, }); @@ -555,7 +598,7 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), ).toBe(true); }); @@ -566,10 +609,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -583,11 +626,11 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), + eventId: EventId.make("evt-turn-completed-missing-baseline"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-missing-baseline"), payload: { state: "completed" }, }); @@ -616,10 +659,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-for-baseline"), + threadId: ThreadId.make("thread-1"), message: { - messageId: MessageId.makeUnsafe("message-user-1"), + messageId: MessageId.make("message-user-1"), role: "user", text: "start turn", attachments: [], @@ -632,12 +675,12 @@ describe("CheckpointReactor", () => { await waitForGitRefExists( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); expect( gitShowFileAtRef( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), "README.md", ), ).toBe("v1\n"); @@ -654,10 +697,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-missing-provider-cwd"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", runtimeMode: "approval-required", @@ -672,23 +715,23 @@ describe("CheckpointReactor", () => { fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), + eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-missing-cwd"), payload: { state: "completed" }, }); await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), ).toBe(true); expect( gitShowFileAtRef( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), "README.md", ), ).toBe("v2\n"); @@ -701,10 +744,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-checkpoint-captured"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -718,11 +761,11 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "checkpoint.captured", - eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), + eventId: EventId.make("evt-checkpoint-captured-3"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-3"), turnCount: 3, status: "completed", @@ -730,7 +773,7 @@ describe("CheckpointReactor", () => { await harness.drain(); 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.make("thread-1")); expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( false, ); @@ -751,10 +794,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-non-repo-runtime"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -768,31 +811,31 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", - eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), + eventId: EventId.make("evt-runtime-capture-failure"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-runtime-failure"), payload: { state: "completed" }, }); harness.provider.emit({ type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), + eventId: EventId.make("evt-turn-started-after-runtime-failure"), provider: "codex", createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-after-runtime-failure"), }); await waitForGitRefExists( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), ).toBe(true); }); @@ -803,10 +846,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -821,11 +864,11 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-diff-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), status: "ready", files: [], checkpointTurnCount: 1, @@ -835,11 +878,11 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-diff-2"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-2"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), status: "ready", files: [], checkpointTurnCount: 2, @@ -850,8 +893,8 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-revert-request"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-revert-request"), + threadId: ThreadId.make("thread-1"), turnCount: 1, createdAt, }), @@ -865,12 +908,12 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), numTurns: 1, }); expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)), + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), ).toBe(false); }); @@ -881,10 +924,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-claude"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-claude"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "claudeAgent", runtimeMode: "approval-required", @@ -899,11 +942,11 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-diff-claude-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-1"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), status: "ready", files: [], checkpointTurnCount: 1, @@ -913,11 +956,11 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-diff-claude-2"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-2"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), status: "ready", files: [], checkpointTurnCount: 2, @@ -928,8 +971,8 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-revert-request-claude"), + threadId: ThreadId.make("thread-1"), turnCount: 1, createdAt, }), @@ -938,7 +981,7 @@ describe("CheckpointReactor", () => { await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), numTurns: 1, }); }); @@ -950,10 +993,10 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-inline-revert"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -968,11 +1011,11 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-inline-revert-diff-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), status: "ready", files: [], checkpointTurnCount: 1, @@ -982,11 +1025,11 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-inline-revert-diff-2"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-2"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), status: "ready", files: [], checkpointTurnCount: 2, @@ -997,8 +1040,8 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-sequenced-revert-request-1"), + threadId: ThreadId.make("thread-1"), turnCount: 1, createdAt, }), @@ -1006,8 +1049,8 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-sequenced-revert-request-0"), + threadId: ThreadId.make("thread-1"), turnCount: 0, createdAt, }), @@ -1017,11 +1060,11 @@ describe("CheckpointReactor", () => { expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), numTurns: 1, }); expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), numTurns: 1, }); }); @@ -1033,8 +1076,8 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-revert-no-session"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-revert-no-session"), + threadId: ThreadId.make("thread-1"), turnCount: 1, createdAt, }), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 03abebaf3a..0b1b203ba2 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -24,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; +import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; type ReactorInput = @@ -37,7 +38,7 @@ type ReactorInput = }; function toTurnId(value: string | undefined): TurnId | null { - return value === undefined ? null : TurnId.makeUnsafe(String(value)); + return value === undefined ? null : TurnId.make(String(value)); } function sameId(left: string | null | undefined, right: string | null | undefined): boolean { @@ -61,7 +62,7 @@ function checkpointStatusFromRuntime(status: string | undefined): "ready" | "mis } const serverCommandId = (tag: string): CommandId => - CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + CommandId.make(`server:${tag}:${crypto.randomUUID()}`); const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; @@ -69,6 +70,7 @@ const make = Effect.gen(function* () { const checkpointStore = yield* CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; const appendRevertFailureActivity = (input: { readonly threadId: ThreadId; @@ -81,7 +83,7 @@ const make = Effect.gen(function* () { commandId: serverCommandId("checkpoint-revert-failure"), threadId: input.threadId, activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), + id: EventId.make(crypto.randomUUID()), tone: "error", kind: "checkpoint.revert.failed", summary: "Checkpoint revert failed", @@ -106,7 +108,7 @@ const make = Effect.gen(function* () { commandId: serverCommandId("checkpoint-capture-failure"), threadId: input.threadId, activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), + id: EventId.make(crypto.randomUUID()), tone: "error", kind: "checkpoint.capture.failed", summary: "Checkpoint capture failed", @@ -268,7 +270,7 @@ const make = Effect.gen(function* () { input.thread.messages .toReversed() .find((entry) => entry.role === "assistant" && entry.turnId === input.turnId)?.id ?? - MessageId.makeUnsafe(`assistant:${input.turnId}`); + MessageId.make(`assistant:${input.turnId}`); yield* orchestrationEngine.dispatch({ type: "thread.turn.diff.complete", @@ -305,7 +307,7 @@ const make = Effect.gen(function* () { commandId: serverCommandId("checkpoint-captured-activity"), threadId: input.threadId, activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), + id: EventId.make(crypto.randomUUID()), tone: "info", kind: "checkpoint.captured", summary: "Checkpoint captured", @@ -498,6 +500,26 @@ const make = Effect.gen(function* () { }, ); + const refreshLocalGitStatusFromTurnCompletion = Effect.fn( + "refreshLocalGitStatusFromTurnCompletion", + )(function* (event: Extract) { + const sessionRuntime = yield* resolveSessionRuntimeForThread(event.threadId); + if (Option.isNone(sessionRuntime)) { + return; + } + + yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( + Effect.catch((error) => + Effect.logWarning("failed to refresh local git status after turn completion", { + threadId: event.threadId, + turnId: event.turnId ?? null, + cwd: sessionRuntime.value.cwd, + detail: error.message, + }), + ), + ); + }); + const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn( "ensurePreTurnBaselineFromDomainTurnStart", )(function* ( @@ -736,6 +758,7 @@ const make = Effect.gen(function* () { if (event.type === "turn.completed") { const turnId = toTurnId(event.turnId); + yield* refreshLocalGitStatusFromTurnCompletion(event); yield* captureCheckpointFromTurnCompletion(event).pipe( Effect.catch((error) => appendCaptureFailureActivity({ diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 5a0a6113f0..b61664f161 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -19,6 +19,7 @@ import { OrchestrationEventStore, type OrchestrationEventStoreShape, } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -31,10 +32,10 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" import { ServerConfig } from "../../config.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); +const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); async function createOrchestrationSystem() { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -113,7 +115,7 @@ describe("OrchestrationEngine", () => { ], threads: [ { - id: ThreadId.makeUnsafe("thread-bootstrap"), + id: ThreadId.make("thread-bootstrap"), projectId: asProjectId("project-bootstrap"), title: "Bootstrap Thread", modelSelection: { @@ -142,10 +144,20 @@ describe("OrchestrationEngine", () => { Layer.provide( Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.succeed(projectionSnapshot), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: projectionSnapshot.snapshotSequence, + projects: [], + threads: [], + updatedAt: projectionSnapshot.updatedAt, + }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), Layer.provide( @@ -181,7 +193,7 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-1-create"), + commandId: CommandId.make("cmd-project-1-create"), projectId: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", @@ -195,8 +207,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-1-create"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-1-create"), + threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread", modelSelection: { @@ -213,8 +225,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("msg-1"), role: "user", @@ -241,7 +253,7 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-archive-create"), + commandId: CommandId.make("cmd-project-archive-create"), projectId: asProjectId("project-archive"), title: "Project Archive", workspaceRoot: "/tmp/project-archive", @@ -255,8 +267,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-archive-create"), - threadId: ThreadId.makeUnsafe("thread-archive"), + commandId: CommandId.make("cmd-thread-archive-create"), + threadId: ThreadId.make("thread-archive"), projectId: asProjectId("project-archive"), title: "Archive me", modelSelection: { @@ -274,8 +286,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.archive", - commandId: CommandId.makeUnsafe("cmd-thread-archive"), - threadId: ThreadId.makeUnsafe("thread-archive"), + commandId: CommandId.make("cmd-thread-archive"), + threadId: ThreadId.make("thread-archive"), }), ); expect( @@ -287,8 +299,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.unarchive", - commandId: CommandId.makeUnsafe("cmd-thread-unarchive"), - threadId: ThreadId.makeUnsafe("thread-archive"), + commandId: CommandId.make("cmd-thread-unarchive"), + threadId: ThreadId.make("thread-archive"), }), ); expect( @@ -308,7 +320,7 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-replay-create"), + commandId: CommandId.make("cmd-project-replay-create"), projectId: asProjectId("project-replay"), title: "Replay Project", workspaceRoot: "/tmp/project-replay", @@ -322,8 +334,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-replay-create"), - threadId: ThreadId.makeUnsafe("thread-replay"), + commandId: CommandId.make("cmd-thread-replay-create"), + threadId: ThreadId.make("thread-replay"), projectId: asProjectId("project-replay"), title: "replay", modelSelection: { @@ -340,8 +352,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.delete", - commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"), - threadId: ThreadId.makeUnsafe("thread-replay"), + commandId: CommandId.make("cmd-thread-replay-delete"), + threadId: ThreadId.make("thread-replay"), }), ); @@ -366,7 +378,7 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-stream-create"), + commandId: CommandId.make("cmd-project-stream-create"), projectId: asProjectId("project-stream"), title: "Stream Project", workspaceRoot: "/tmp/project-stream", @@ -390,8 +402,8 @@ describe("OrchestrationEngine", () => { yield* Effect.sleep("10 millis"); yield* engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-stream-thread-create"), - threadId: ThreadId.makeUnsafe("thread-stream"), + commandId: CommandId.make("cmd-stream-thread-create"), + threadId: ThreadId.make("thread-stream"), projectId: asProjectId("project-stream"), title: "domain-stream", modelSelection: { @@ -406,8 +418,8 @@ describe("OrchestrationEngine", () => { }); yield* engine.dispatch({ type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-stream-thread-update"), - threadId: ThreadId.makeUnsafe("thread-stream"), + commandId: CommandId.make("cmd-stream-thread-update"), + threadId: ThreadId.make("thread-stream"), title: "domain-stream-updated", }); eventTypes.push((yield* Queue.take(eventQueue)).type); @@ -427,7 +439,7 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-ack-create"), + commandId: CommandId.make("cmd-project-ack-create"), projectId: asProjectId("project-ack"), title: "Ack Project", workspaceRoot: "/tmp/project-ack", @@ -442,8 +454,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-ack-create"), - threadId: ThreadId.makeUnsafe("thread-ack"), + commandId: CommandId.make("cmd-thread-ack-create"), + threadId: ThreadId.make("thread-ack"), projectId: asProjectId("project-ack"), title: "Ack Thread", modelSelection: { @@ -479,8 +491,8 @@ describe("OrchestrationEngine", () => { system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-missing-project"), - threadId: ThreadId.makeUnsafe("thread-missing-project"), + commandId: CommandId.make("cmd-thread-missing-project"), + threadId: ThreadId.make("thread-missing-project"), projectId: asProjectId("project-missing"), title: "Missing Project Thread", modelSelection: { @@ -516,7 +528,7 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"), + commandId: CommandId.make("cmd-project-turn-diff-create"), projectId: asProjectId("project-turn-diff"), title: "Turn Diff Project", workspaceRoot: "/tmp/project-turn-diff", @@ -530,8 +542,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"), - threadId: ThreadId.makeUnsafe("thread-turn-diff"), + commandId: CommandId.make("cmd-thread-turn-diff-create"), + threadId: ThreadId.make("thread-turn-diff"), projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", modelSelection: { @@ -548,8 +560,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"), - threadId: ThreadId.makeUnsafe("thread-turn-diff"), + commandId: CommandId.make("cmd-turn-diff-complete"), + threadId: ThreadId.make("thread-turn-diff"), turnId: asTurnId("turn-1"), completedAt: createdAt, checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), @@ -588,7 +600,7 @@ describe("OrchestrationEngine", () => { const flakyStore: OrchestrationEventStoreShape = { append(event) { - if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) { + if (shouldFailFirstAppend && event.commandId === CommandId.make("cmd-flaky-1")) { shouldFailFirstAppend = false; return Effect.fail( new PersistenceSqlError({ @@ -623,6 +635,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -634,7 +647,7 @@ describe("OrchestrationEngine", () => { await runtime.runPromise( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-flaky-create"), + commandId: CommandId.make("cmd-project-flaky-create"), projectId: asProjectId("project-flaky"), title: "Flaky Project", workspaceRoot: "/tmp/project-flaky", @@ -650,8 +663,8 @@ describe("OrchestrationEngine", () => { runtime.runPromise( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-flaky-1"), - threadId: ThreadId.makeUnsafe("thread-flaky-fail"), + commandId: CommandId.make("cmd-flaky-1"), + threadId: ThreadId.make("thread-flaky-fail"), projectId: asProjectId("project-flaky"), title: "flaky-fail", modelSelection: { @@ -670,8 +683,8 @@ describe("OrchestrationEngine", () => { const result = await runtime.runPromise( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-flaky-2"), - threadId: ThreadId.makeUnsafe("thread-flaky-ok"), + commandId: CommandId.make("cmd-flaky-2"), + threadId: ThreadId.make("thread-flaky-ok"), projectId: asProjectId("project-flaky"), title: "flaky-ok", modelSelection: { @@ -698,7 +711,7 @@ describe("OrchestrationEngine", () => { projectEvent: (event) => { if ( shouldFailRequestedProjection && - event.commandId === CommandId.makeUnsafe("cmd-turn-start-atomic") && + event.commandId === CommandId.make("cmd-turn-start-atomic") && event.type === "thread.turn-start-requested" ) { shouldFailRequestedProjection = false; @@ -719,6 +732,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); @@ -728,7 +742,7 @@ describe("OrchestrationEngine", () => { await runtime.runPromise( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-atomic-create"), + commandId: CommandId.make("cmd-project-atomic-create"), projectId: asProjectId("project-atomic"), title: "Atomic Project", workspaceRoot: "/tmp/project-atomic", @@ -742,8 +756,8 @@ describe("OrchestrationEngine", () => { await runtime.runPromise( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-atomic-create"), - threadId: ThreadId.makeUnsafe("thread-atomic"), + commandId: CommandId.make("cmd-thread-atomic-create"), + threadId: ThreadId.make("thread-atomic"), projectId: asProjectId("project-atomic"), title: "atomic", modelSelection: { @@ -760,8 +774,8 @@ describe("OrchestrationEngine", () => { const turnStartCommand = { type: "thread.turn.start" as const, - commandId: CommandId.makeUnsafe("cmd-turn-start-atomic"), - threadId: ThreadId.makeUnsafe("thread-atomic"), + commandId: CommandId.make("cmd-turn-start-atomic"), + threadId: ThreadId.make("thread-atomic"), message: { messageId: asMessageId("msg-atomic-1"), role: "user" as const, @@ -841,7 +855,7 @@ describe("OrchestrationEngine", () => { projectEvent: (event) => { if ( shouldFailProjection && - event.commandId === CommandId.makeUnsafe("cmd-thread-meta-sync-fail") + event.commandId === CommandId.make("cmd-thread-meta-sync-fail") ) { shouldFailProjection = false; return Effect.fail( @@ -861,6 +875,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); @@ -870,7 +885,7 @@ describe("OrchestrationEngine", () => { await runtime.runPromise( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-sync-create"), + commandId: CommandId.make("cmd-project-sync-create"), projectId: asProjectId("project-sync"), title: "Sync Project", workspaceRoot: "/tmp/project-sync", @@ -884,8 +899,8 @@ describe("OrchestrationEngine", () => { await runtime.runPromise( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-sync-create"), - threadId: ThreadId.makeUnsafe("thread-sync"), + commandId: CommandId.make("cmd-thread-sync-create"), + threadId: ThreadId.make("thread-sync"), projectId: asProjectId("project-sync"), title: "sync-before", modelSelection: { @@ -904,8 +919,8 @@ describe("OrchestrationEngine", () => { runtime.runPromise( engine.dispatch({ type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-meta-sync-fail"), - threadId: ThreadId.makeUnsafe("thread-sync"), + commandId: CommandId.make("cmd-thread-meta-sync-fail"), + threadId: ThreadId.make("thread-sync"), title: "sync-after-failed-projection", }), ), @@ -929,8 +944,8 @@ describe("OrchestrationEngine", () => { system.run( engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"), - threadId: ThreadId.makeUnsafe("thread-missing"), + commandId: CommandId.make("cmd-invariant-missing-thread"), + threadId: ThreadId.make("thread-missing"), message: { messageId: asMessageId("msg-missing"), role: "user", @@ -955,7 +970,7 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"), + commandId: CommandId.make("cmd-project-duplicate-create"), projectId: asProjectId("project-duplicate"), title: "Duplicate Project", workspaceRoot: "/tmp/project-duplicate", @@ -970,8 +985,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"), - threadId: ThreadId.makeUnsafe("thread-duplicate"), + commandId: CommandId.make("cmd-thread-duplicate-1"), + threadId: ThreadId.make("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", modelSelection: { @@ -990,8 +1005,8 @@ describe("OrchestrationEngine", () => { system.run( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"), - threadId: ThreadId.makeUnsafe("thread-duplicate"), + commandId: CommandId.make("cmd-thread-duplicate-2"), + threadId: ThreadId.make("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", modelSelection: { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1850745469..7a08765647 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -20,6 +20,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, @@ -57,16 +58,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "project.created", - eventId: EventId.makeUnsafe("evt-1"), + eventId: EventId.make("evt-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-1"), + aggregateId: ProjectId.make("project-1"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-1"), + commandId: CommandId.make("cmd-1"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-1"), + correlationId: CommandId.make("cmd-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-1"), + projectId: ProjectId.make("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", defaultModelSelection: null, @@ -78,17 +79,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-2"), + eventId: EventId.make("evt-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-1"), + aggregateId: ThreadId.make("thread-1"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-2"), + commandId: CommandId.make("cmd-2"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-2"), + correlationId: CommandId.make("cmd-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-1"), - projectId: ProjectId.makeUnsafe("project-1"), + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), title: "Thread 1", modelSelection: { provider: "codex", @@ -104,17 +105,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-3"), + eventId: EventId.make("evt-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-1"), + aggregateId: ThreadId.make("thread-1"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-3"), + commandId: CommandId.make("cmd-3"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-3"), + correlationId: CommandId.make("cmd-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-1"), - messageId: MessageId.makeUnsafe("message-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), role: "assistant", text: "hello", turnId: null, @@ -182,17 +183,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments"), + eventId: EventId.make("evt-attachments"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments"), + aggregateId: ThreadId.make("thread-attachments"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments"), + commandId: CommandId.make("cmd-attachments"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments"), + correlationId: CommandId.make("cmd-attachments"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-attachments"), - messageId: MessageId.makeUnsafe("message-attachments"), + threadId: ThreadId.make("thread-attachments"), + messageId: MessageId.make("message-attachments"), role: "user", text: "Inspect this", attachments: [ @@ -248,17 +249,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments-safe"), + eventId: EventId.make("evt-attachments-safe"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), + aggregateId: ThreadId.make("thread-attachments-safe"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments-safe"), + commandId: CommandId.make("cmd-attachments-safe"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), + correlationId: CommandId.make("cmd-attachments-safe"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-attachments-safe"), - messageId: MessageId.makeUnsafe("message-attachments-safe"), + threadId: ThreadId.make("thread-attachments-safe"), + messageId: MessageId.make("message-attachments-safe"), role: "user", text: "Inspect this", attachments: [ @@ -329,16 +330,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "project.created", - eventId: EventId.makeUnsafe("evt-clear-attachments-1"), + eventId: EventId.make("evt-clear-attachments-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-clear-attachments"), + aggregateId: ProjectId.make("project-clear-attachments"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"), + commandId: CommandId.make("cmd-clear-attachments-1"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"), + correlationId: CommandId.make("cmd-clear-attachments-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-clear-attachments"), + projectId: ProjectId.make("project-clear-attachments"), title: "Project Clear Attachments", workspaceRoot: "/tmp/project-clear-attachments", defaultModelSelection: null, @@ -350,17 +351,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-clear-attachments-2"), + eventId: EventId.make("evt-clear-attachments-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + aggregateId: ThreadId.make("thread-clear-attachments"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"), + commandId: CommandId.make("cmd-clear-attachments-2"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"), + correlationId: CommandId.make("cmd-clear-attachments-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-clear-attachments"), - projectId: ProjectId.makeUnsafe("project-clear-attachments"), + threadId: ThreadId.make("thread-clear-attachments"), + projectId: ProjectId.make("project-clear-attachments"), title: "Thread Clear Attachments", modelSelection: { provider: "codex", @@ -376,17 +377,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-clear-attachments-3"), + eventId: EventId.make("evt-clear-attachments-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + aggregateId: ThreadId.make("thread-clear-attachments"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"), + commandId: CommandId.make("cmd-clear-attachments-3"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"), + correlationId: CommandId.make("cmd-clear-attachments-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-clear-attachments"), - messageId: MessageId.makeUnsafe("message-clear-attachments"), + threadId: ThreadId.make("thread-clear-attachments"), + messageId: MessageId.make("message-clear-attachments"), role: "user", text: "Has attachments", attachments: [ @@ -407,17 +408,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-clear-attachments-4"), + eventId: EventId.make("evt-clear-attachments-4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + aggregateId: ThreadId.make("thread-clear-attachments"), occurredAt: later, - commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"), + commandId: CommandId.make("cmd-clear-attachments-4"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"), + correlationId: CommandId.make("cmd-clear-attachments-4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-clear-attachments"), - messageId: MessageId.makeUnsafe("message-clear-attachments"), + threadId: ThreadId.make("thread-clear-attachments"), + messageId: MessageId.make("message-clear-attachments"), role: "user", text: "", attachments: [], @@ -457,16 +458,16 @@ it.layer( yield* eventStore.append({ type: "project.created", - eventId: EventId.makeUnsafe("evt-overwrite-1"), + eventId: EventId.make("evt-overwrite-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-overwrite"), + aggregateId: ProjectId.make("project-overwrite"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-1"), + commandId: CommandId.make("cmd-overwrite-1"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), + correlationId: CommandId.make("cmd-overwrite-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-overwrite"), + projectId: ProjectId.make("project-overwrite"), title: "Project Overwrite", workspaceRoot: "/tmp/project-overwrite", defaultModelSelection: null, @@ -478,17 +479,17 @@ it.layer( yield* eventStore.append({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-overwrite-2"), + eventId: EventId.make("evt-overwrite-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + aggregateId: ThreadId.make("thread-overwrite"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-2"), + commandId: CommandId.make("cmd-overwrite-2"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), + correlationId: CommandId.make("cmd-overwrite-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - projectId: ProjectId.makeUnsafe("project-overwrite"), + threadId: ThreadId.make("thread-overwrite"), + projectId: ProjectId.make("project-overwrite"), title: "Thread Overwrite", modelSelection: { provider: "codex", @@ -504,17 +505,17 @@ it.layer( yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-3"), + eventId: EventId.make("evt-overwrite-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + aggregateId: ThreadId.make("thread-overwrite"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-3"), + commandId: CommandId.make("cmd-overwrite-3"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), + correlationId: CommandId.make("cmd-overwrite-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), + threadId: ThreadId.make("thread-overwrite"), + messageId: MessageId.make("message-overwrite"), role: "user", text: "first image", attachments: [ @@ -535,17 +536,17 @@ it.layer( yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-4"), + eventId: EventId.make("evt-overwrite-4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + aggregateId: ThreadId.make("thread-overwrite"), occurredAt: later, - commandId: CommandId.makeUnsafe("cmd-overwrite-4"), + commandId: CommandId.make("cmd-overwrite-4"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), + correlationId: CommandId.make("cmd-overwrite-4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), + threadId: ThreadId.make("thread-overwrite"), + messageId: MessageId.make("message-overwrite"), role: "user", text: "", attachments: [ @@ -605,16 +606,16 @@ it.layer( yield* appendAndProject({ type: "project.created", - eventId: EventId.makeUnsafe("evt-rollback-1"), + eventId: EventId.make("evt-rollback-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-rollback"), + aggregateId: ProjectId.make("project-rollback"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-1"), + commandId: CommandId.make("cmd-rollback-1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), + correlationId: CorrelationId.make("cmd-rollback-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-rollback"), + projectId: ProjectId.make("project-rollback"), title: "Project Rollback", workspaceRoot: "/tmp/project-rollback", defaultModelSelection: null, @@ -626,17 +627,17 @@ it.layer( yield* appendAndProject({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-rollback-2"), + eventId: EventId.make("evt-rollback-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), + aggregateId: ThreadId.make("thread-rollback"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-2"), + commandId: CommandId.make("cmd-rollback-2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), + correlationId: CorrelationId.make("cmd-rollback-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - projectId: ProjectId.makeUnsafe("project-rollback"), + threadId: ThreadId.make("thread-rollback"), + projectId: ProjectId.make("project-rollback"), title: "Thread Rollback", modelSelection: { provider: "codex", @@ -662,17 +663,17 @@ it.layer( const result = yield* Effect.result( appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-rollback-3"), + eventId: EventId.make("evt-rollback-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), + aggregateId: ThreadId.make("thread-rollback"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-3"), + commandId: CommandId.make("cmd-rollback-3"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), + correlationId: CorrelationId.make("cmd-rollback-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - messageId: MessageId.makeUnsafe("message-rollback"), + threadId: ThreadId.make("thread-rollback"), + messageId: MessageId.make("message-rollback"), role: "user", text: "Rollback me", attachments: [ @@ -721,7 +722,7 @@ it.layer( const eventStore = yield* OrchestrationEventStore; const { attachmentsDir } = yield* ServerConfig; const now = new Date().toISOString(); - const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); + const threadId = ThreadId.make("Thread Revert.Files"); const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; const otherThreadAttachmentId = @@ -734,16 +735,16 @@ it.layer( yield* appendAndProject({ type: "project.created", - eventId: EventId.makeUnsafe("evt-revert-files-1"), + eventId: EventId.make("evt-revert-files-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-revert-files"), + aggregateId: ProjectId.make("project-revert-files"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-1"), + commandId: CommandId.make("cmd-revert-files-1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), + correlationId: CorrelationId.make("cmd-revert-files-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-revert-files"), + projectId: ProjectId.make("project-revert-files"), title: "Project Revert Files", workspaceRoot: "/tmp/project-revert-files", defaultModelSelection: null, @@ -755,17 +756,17 @@ it.layer( yield* appendAndProject({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-revert-files-2"), + eventId: EventId.make("evt-revert-files-2"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-2"), + commandId: CommandId.make("cmd-revert-files-2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), + correlationId: CorrelationId.make("cmd-revert-files-2"), metadata: {}, payload: { threadId, - projectId: ProjectId.makeUnsafe("project-revert-files"), + projectId: ProjectId.make("project-revert-files"), title: "Thread Revert Files", modelSelection: { provider: "codex", @@ -781,39 +782,39 @@ it.layer( yield* appendAndProject({ type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-3"), + eventId: EventId.make("evt-revert-files-3"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-3"), + commandId: CommandId.make("cmd-revert-files-3"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), + correlationId: CorrelationId.make("cmd-revert-files-3"), metadata: {}, payload: { threadId, - turnId: TurnId.makeUnsafe("turn-keep"), + turnId: TurnId.make("turn-keep"), checkpointTurnCount: 1, - checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/1"), status: "ready", files: [], - assistantMessageId: MessageId.makeUnsafe("message-keep"), + assistantMessageId: MessageId.make("message-keep"), completedAt: now, }, }); yield* appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-4"), + eventId: EventId.make("evt-revert-files-4"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-4"), + commandId: CommandId.make("cmd-revert-files-4"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), + correlationId: CorrelationId.make("cmd-revert-files-4"), metadata: {}, payload: { threadId, - messageId: MessageId.makeUnsafe("message-keep"), + messageId: MessageId.make("message-keep"), role: "assistant", text: "Keep", attachments: [ @@ -825,7 +826,7 @@ it.layer( sizeBytes: 5, }, ], - turnId: TurnId.makeUnsafe("turn-keep"), + turnId: TurnId.make("turn-keep"), streaming: false, createdAt: now, updatedAt: now, @@ -834,39 +835,39 @@ it.layer( yield* appendAndProject({ type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-5"), + eventId: EventId.make("evt-revert-files-5"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-5"), + commandId: CommandId.make("cmd-revert-files-5"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), + correlationId: CorrelationId.make("cmd-revert-files-5"), metadata: {}, payload: { threadId, - turnId: TurnId.makeUnsafe("turn-remove"), + turnId: TurnId.make("turn-remove"), checkpointTurnCount: 2, - checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/2"), status: "ready", files: [], - assistantMessageId: MessageId.makeUnsafe("message-remove"), + assistantMessageId: MessageId.make("message-remove"), completedAt: now, }, }); yield* appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-6"), + eventId: EventId.make("evt-revert-files-6"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-6"), + commandId: CommandId.make("cmd-revert-files-6"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), + correlationId: CorrelationId.make("cmd-revert-files-6"), metadata: {}, payload: { threadId, - messageId: MessageId.makeUnsafe("message-remove"), + messageId: MessageId.make("message-remove"), role: "assistant", text: "Remove", attachments: [ @@ -878,7 +879,7 @@ it.layer( sizeBytes: 5, }, ], - turnId: TurnId.makeUnsafe("turn-remove"), + turnId: TurnId.make("turn-remove"), streaming: false, createdAt: now, updatedAt: now, @@ -898,13 +899,13 @@ it.layer( yield* appendAndProject({ type: "thread.reverted", - eventId: EventId.makeUnsafe("evt-revert-files-7"), + eventId: EventId.make("evt-revert-files-7"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-7"), + commandId: CommandId.make("cmd-revert-files-7"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), + correlationId: CorrelationId.make("cmd-revert-files-7"), metadata: {}, payload: { threadId, @@ -930,7 +931,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const eventStore = yield* OrchestrationEventStore; const { attachmentsDir } = yield* ServerConfig; const now = new Date().toISOString(); - const threadId = ThreadId.makeUnsafe("Thread Delete.Files"); + const threadId = ThreadId.make("Thread Delete.Files"); const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; const otherThreadAttachmentId = "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; @@ -942,16 +943,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* appendAndProject({ type: "project.created", - eventId: EventId.makeUnsafe("evt-delete-files-1"), + eventId: EventId.make("evt-delete-files-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-delete-files"), + aggregateId: ProjectId.make("project-delete-files"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-1"), + commandId: CommandId.make("cmd-delete-files-1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), + correlationId: CorrelationId.make("cmd-delete-files-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-delete-files"), + projectId: ProjectId.make("project-delete-files"), title: "Project Delete Files", workspaceRoot: "/tmp/project-delete-files", defaultModelSelection: null, @@ -963,17 +964,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* appendAndProject({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-delete-files-2"), + eventId: EventId.make("evt-delete-files-2"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-2"), + commandId: CommandId.make("cmd-delete-files-2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), + correlationId: CorrelationId.make("cmd-delete-files-2"), metadata: {}, payload: { threadId, - projectId: ProjectId.makeUnsafe("project-delete-files"), + projectId: ProjectId.make("project-delete-files"), title: "Thread Delete Files", modelSelection: { provider: "codex", @@ -989,17 +990,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-delete-files-3"), + eventId: EventId.make("evt-delete-files-3"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-3"), + commandId: CommandId.make("cmd-delete-files-3"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), + correlationId: CorrelationId.make("cmd-delete-files-3"), metadata: {}, payload: { threadId, - messageId: MessageId.makeUnsafe("message-delete-files"), + messageId: MessageId.make("message-delete-files"), role: "user", text: "Delete", attachments: [ @@ -1031,13 +1032,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* appendAndProject({ type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-delete-files-4"), + eventId: EventId.make("evt-delete-files-4"), aggregateKind: "thread", aggregateId: threadId, occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-4"), + commandId: CommandId.make("cmd-delete-files-4"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), + correlationId: CorrelationId.make("cmd-delete-files-4"), metadata: {}, payload: { threadId, @@ -1071,16 +1072,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* eventStore.append({ type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), + eventId: EventId.make("evt-unsafe-thread-delete"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe(".."), + aggregateId: ThreadId.make(".."), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), + commandId: CommandId.make("cmd-unsafe-thread-delete"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), + correlationId: CorrelationId.make("cmd-unsafe-thread-delete"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe(".."), + threadId: ThreadId.make(".."), deletedAt: now, }, }); @@ -1105,16 +1106,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "project.created", - eventId: EventId.makeUnsafe("evt-a1"), + eventId: EventId.make("evt-a1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-a"), + aggregateId: ProjectId.make("project-a"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-a1"), + commandId: CommandId.make("cmd-a1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-a1"), + correlationId: CorrelationId.make("cmd-a1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-a"), + projectId: ProjectId.make("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", defaultModelSelection: null, @@ -1126,17 +1127,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-a2"), + eventId: EventId.make("evt-a2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-a"), + aggregateId: ThreadId.make("thread-a"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-a2"), + commandId: CommandId.make("cmd-a2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-a2"), + correlationId: CorrelationId.make("cmd-a2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-a"), - projectId: ProjectId.makeUnsafe("project-a"), + threadId: ThreadId.make("thread-a"), + projectId: ProjectId.make("project-a"), title: "Thread A", modelSelection: { provider: "codex", @@ -1152,17 +1153,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-a3"), + eventId: EventId.make("evt-a3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-a"), + aggregateId: ThreadId.make("thread-a"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-a3"), + commandId: CommandId.make("cmd-a3"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-a3"), + correlationId: CorrelationId.make("cmd-a3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-a"), - messageId: MessageId.makeUnsafe("message-a"), + threadId: ThreadId.make("thread-a"), + messageId: MessageId.make("message-a"), role: "assistant", text: "hello", turnId: null, @@ -1176,17 +1177,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-a4"), + eventId: EventId.make("evt-a4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-a"), + aggregateId: ThreadId.make("thread-a"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-a4"), + commandId: CommandId.make("cmd-a4"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-a4"), + correlationId: CorrelationId.make("cmd-a4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-a"), - messageId: MessageId.makeUnsafe("message-a"), + threadId: ThreadId.make("thread-a"), + messageId: MessageId.make("message-a"), role: "assistant", text: " world", turnId: null, @@ -1232,16 +1233,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "project.created", - eventId: EventId.makeUnsafe("evt-empty-1"), + eventId: EventId.make("evt-empty-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-empty"), + aggregateId: ProjectId.make("project-empty"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-empty-1"), + commandId: CommandId.make("cmd-empty-1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-empty-1"), + correlationId: CorrelationId.make("cmd-empty-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-empty"), + projectId: ProjectId.make("project-empty"), title: "Project Empty", workspaceRoot: "/tmp/project-empty", defaultModelSelection: null, @@ -1253,17 +1254,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-empty-2"), + eventId: EventId.make("evt-empty-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-empty"), + aggregateId: ThreadId.make("thread-empty"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-empty-2"), + commandId: CommandId.make("cmd-empty-2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-empty-2"), + correlationId: CorrelationId.make("cmd-empty-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-empty"), - projectId: ProjectId.makeUnsafe("project-empty"), + threadId: ThreadId.make("thread-empty"), + projectId: ProjectId.make("project-empty"), title: "Thread Empty", modelSelection: { provider: "codex", @@ -1279,17 +1280,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-empty-3"), + eventId: EventId.make("evt-empty-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-empty"), + aggregateId: ThreadId.make("thread-empty"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-empty-3"), + commandId: CommandId.make("cmd-empty-3"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-empty-3"), + correlationId: CorrelationId.make("cmd-empty-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-empty"), - messageId: MessageId.makeUnsafe("assistant-empty"), + threadId: ThreadId.make("thread-empty"), + messageId: MessageId.make("assistant-empty"), role: "assistant", text: "Hello", turnId: null, @@ -1301,17 +1302,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-empty-4"), + eventId: EventId.make("evt-empty-4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-empty"), + aggregateId: ThreadId.make("thread-empty"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-empty-4"), + commandId: CommandId.make("cmd-empty-4"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-empty-4"), + correlationId: CorrelationId.make("cmd-empty-4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-empty"), - messageId: MessageId.makeUnsafe("assistant-empty"), + threadId: ThreadId.make("thread-empty"), + messageId: MessageId.make("assistant-empty"), role: "assistant", text: " world", turnId: null, @@ -1323,17 +1324,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* eventStore.append({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-empty-5"), + eventId: EventId.make("evt-empty-5"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-empty"), + aggregateId: ThreadId.make("thread-empty"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-empty-5"), + commandId: CommandId.make("cmd-empty-5"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-empty-5"), + correlationId: CorrelationId.make("cmd-empty-5"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-empty"), - messageId: MessageId.makeUnsafe("assistant-empty"), + threadId: ThreadId.make("thread-empty"), + messageId: MessageId.make("assistant-empty"), role: "assistant", text: "", turnId: null, @@ -1372,16 +1373,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "project.created", - eventId: EventId.makeUnsafe("evt-conflict-1"), + eventId: EventId.make("evt-conflict-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-conflict"), + aggregateId: ProjectId.make("project-conflict"), occurredAt: "2026-02-26T13:00:00.000Z", - commandId: CommandId.makeUnsafe("cmd-conflict-1"), + commandId: CommandId.make("cmd-conflict-1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-conflict-1"), + correlationId: CorrelationId.make("cmd-conflict-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-conflict"), + projectId: ProjectId.make("project-conflict"), title: "Project Conflict", workspaceRoot: "/tmp/project-conflict", defaultModelSelection: null, @@ -1393,17 +1394,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-conflict-2"), + eventId: EventId.make("evt-conflict-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-conflict"), + aggregateId: ThreadId.make("thread-conflict"), occurredAt: "2026-02-26T13:00:01.000Z", - commandId: CommandId.makeUnsafe("cmd-conflict-2"), + commandId: CommandId.make("cmd-conflict-2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-conflict-2"), + correlationId: CorrelationId.make("cmd-conflict-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-conflict"), - projectId: ProjectId.makeUnsafe("project-conflict"), + threadId: ThreadId.make("thread-conflict"), + projectId: ProjectId.make("project-conflict"), title: "Thread Conflict", modelSelection: { provider: "codex", @@ -1419,37 +1420,37 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.turn-interrupt-requested", - eventId: EventId.makeUnsafe("evt-conflict-3"), + eventId: EventId.make("evt-conflict-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-conflict"), + aggregateId: ThreadId.make("thread-conflict"), occurredAt: "2026-02-26T13:00:02.000Z", - commandId: CommandId.makeUnsafe("cmd-conflict-3"), + commandId: CommandId.make("cmd-conflict-3"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-conflict-3"), + correlationId: CorrelationId.make("cmd-conflict-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-conflict"), - turnId: TurnId.makeUnsafe("turn-interrupted"), + threadId: ThreadId.make("thread-conflict"), + turnId: TurnId.make("turn-interrupted"), createdAt: "2026-02-26T13:00:02.000Z", }, }); yield* appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-conflict-4"), + eventId: EventId.make("evt-conflict-4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-conflict"), + aggregateId: ThreadId.make("thread-conflict"), occurredAt: "2026-02-26T13:00:03.000Z", - commandId: CommandId.makeUnsafe("cmd-conflict-4"), + commandId: CommandId.make("cmd-conflict-4"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-conflict-4"), + correlationId: CorrelationId.make("cmd-conflict-4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-conflict"), - messageId: MessageId.makeUnsafe("assistant-conflict"), + threadId: ThreadId.make("thread-conflict"), + messageId: MessageId.make("assistant-conflict"), role: "assistant", text: "done", - turnId: TurnId.makeUnsafe("turn-completed"), + turnId: TurnId.make("turn-completed"), streaming: false, createdAt: "2026-02-26T13:00:03.000Z", updatedAt: "2026-02-26T13:00:03.000Z", @@ -1458,22 +1459,22 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-conflict-5"), + eventId: EventId.make("evt-conflict-5"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-conflict"), + aggregateId: ThreadId.make("thread-conflict"), occurredAt: "2026-02-26T13:00:04.000Z", - commandId: CommandId.makeUnsafe("cmd-conflict-5"), + commandId: CommandId.make("cmd-conflict-5"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-conflict-5"), + correlationId: CorrelationId.make("cmd-conflict-5"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-conflict"), - turnId: TurnId.makeUnsafe("turn-completed"), + threadId: ThreadId.make("thread-conflict"), + turnId: TurnId.make("turn-completed"), checkpointTurnCount: 1, - checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-conflict/turn/1"), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-conflict/turn/1"), status: "ready", files: [], - assistantMessageId: MessageId.makeUnsafe("assistant-conflict"), + assistantMessageId: MessageId.make("assistant-conflict"), completedAt: "2026-02-26T13:00:04.000Z", }, }); @@ -1504,6 +1505,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("clears stale pending approvals from projected shell summaries", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-stale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-stale-approval"), + occurredAt: "2026-02-26T12:30:00.000Z", + commandId: CommandId.make("cmd-stale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-stale-approval"), + title: "Project Stale Approval", + workspaceRoot: "/tmp/project-stale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:30:00.000Z", + updatedAt: "2026-02-26T12:30:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-stale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:01.000Z", + commandId: CommandId.make("cmd-stale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + projectId: ProjectId.make("project-stale-approval"), + title: "Thread Stale Approval", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:30:01.000Z", + updatedAt: "2026-02-26T12:30:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:02.000Z", + commandId: CommandId.make("cmd-stale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("activity-stale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-stale-1", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:30:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:03.000Z", + commandId: CommandId.make("cmd-stale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("activity-stale-approval-failed"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-stale-1", + detail: "Unknown pending permission request: approval-request-stale-1", + }, + turnId: null, + createdAt: "2026-02-26T12:30:03.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-request-stale-1' + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-stale-1", + status: "resolved", + resolvedAt: "2026-02-26T12:30:03.000Z", + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-stale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]); + }), + ); + + it.effect("ignores non-stale provider approval response failures", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-nonstale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-nonstale-approval"), + occurredAt: "2026-02-26T12:45:00.000Z", + commandId: CommandId.make("cmd-nonstale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-nonstale-approval"), + title: "Project Non-Stale Approval", + workspaceRoot: "/tmp/project-nonstale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:45:00.000Z", + updatedAt: "2026-02-26T12:45:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-nonstale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:01.000Z", + commandId: CommandId.make("cmd-nonstale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + projectId: ProjectId.make("project-nonstale-approval"), + title: "Thread Non-Stale Approval", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:45:01.000Z", + updatedAt: "2026-02-26T12:45:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:02.000Z", + commandId: CommandId.make("cmd-nonstale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-nonstale-existing", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:03.000Z", + commandId: CommandId.make("cmd-nonstale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-failed-existing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-existing", + detail: "Provider timed out while responding to approval request", + }, + turnId: TurnId.make("turn-nonstale-failure"), + createdAt: "2026-02-26T12:45:03.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:04.000Z", + commandId: CommandId.make("cmd-nonstale-approval-5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-failed-missing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-missing", + detail: "Provider timed out while responding to approval request", + }, + turnId: null, + createdAt: "2026-02-26T12:45:04.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly turnId: string | null; + readonly createdAt: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + turn_id AS "turnId", + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id IN ( + 'approval-request-nonstale-existing', + 'approval-request-nonstale-missing' + ) + ORDER BY request_id + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-nonstale-existing", + status: "pending", + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + resolvedAt: null, + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-nonstale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]); + }), + ); + it.effect("does not fallback-retain messages whose turnId is removed by revert", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -1516,16 +1840,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "project.created", - eventId: EventId.makeUnsafe("evt-revert-1"), + eventId: EventId.make("evt-revert-1"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-revert"), + aggregateId: ProjectId.make("project-revert"), occurredAt: "2026-02-26T12:00:00.000Z", - commandId: CommandId.makeUnsafe("cmd-revert-1"), + commandId: CommandId.make("cmd-revert-1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-1"), + correlationId: CorrelationId.make("cmd-revert-1"), metadata: {}, payload: { - projectId: ProjectId.makeUnsafe("project-revert"), + projectId: ProjectId.make("project-revert"), title: "Project Revert", workspaceRoot: "/tmp/project-revert", defaultModelSelection: null, @@ -1537,17 +1861,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.created", - eventId: EventId.makeUnsafe("evt-revert-2"), + eventId: EventId.make("evt-revert-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert"), + aggregateId: ThreadId.make("thread-revert"), occurredAt: "2026-02-26T12:00:01.000Z", - commandId: CommandId.makeUnsafe("cmd-revert-2"), + commandId: CommandId.make("cmd-revert-2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-2"), + correlationId: CorrelationId.make("cmd-revert-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert"), - projectId: ProjectId.makeUnsafe("project-revert"), + threadId: ThreadId.make("thread-revert"), + projectId: ProjectId.make("project-revert"), title: "Thread Revert", modelSelection: { provider: "codex", @@ -1563,42 +1887,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-3"), + eventId: EventId.make("evt-revert-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert"), + aggregateId: ThreadId.make("thread-revert"), occurredAt: "2026-02-26T12:00:02.000Z", - commandId: CommandId.makeUnsafe("cmd-revert-3"), + commandId: CommandId.make("cmd-revert-3"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-3"), + correlationId: CorrelationId.make("cmd-revert-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert"), - turnId: TurnId.makeUnsafe("turn-1"), + threadId: ThreadId.make("thread-revert"), + turnId: TurnId.make("turn-1"), checkpointTurnCount: 1, - checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/1"), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/1"), status: "ready", files: [], - assistantMessageId: MessageId.makeUnsafe("assistant-keep"), + assistantMessageId: MessageId.make("assistant-keep"), completedAt: "2026-02-26T12:00:02.000Z", }, }); yield* appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-4"), + eventId: EventId.make("evt-revert-4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert"), + aggregateId: ThreadId.make("thread-revert"), occurredAt: "2026-02-26T12:00:02.100Z", - commandId: CommandId.makeUnsafe("cmd-revert-4"), + commandId: CommandId.make("cmd-revert-4"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-4"), + correlationId: CorrelationId.make("cmd-revert-4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert"), - messageId: MessageId.makeUnsafe("assistant-keep"), + threadId: ThreadId.make("thread-revert"), + messageId: MessageId.make("assistant-keep"), role: "assistant", text: "kept", - turnId: TurnId.makeUnsafe("turn-1"), + turnId: TurnId.make("turn-1"), streaming: false, createdAt: "2026-02-26T12:00:02.100Z", updatedAt: "2026-02-26T12:00:02.100Z", @@ -1607,42 +1931,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-5"), + eventId: EventId.make("evt-revert-5"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert"), + aggregateId: ThreadId.make("thread-revert"), occurredAt: "2026-02-26T12:00:03.000Z", - commandId: CommandId.makeUnsafe("cmd-revert-5"), + commandId: CommandId.make("cmd-revert-5"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-5"), + correlationId: CorrelationId.make("cmd-revert-5"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert"), - turnId: TurnId.makeUnsafe("turn-2"), + threadId: ThreadId.make("thread-revert"), + turnId: TurnId.make("turn-2"), checkpointTurnCount: 2, - checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/2"), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/2"), status: "ready", files: [], - assistantMessageId: MessageId.makeUnsafe("assistant-remove"), + assistantMessageId: MessageId.make("assistant-remove"), completedAt: "2026-02-26T12:00:03.000Z", }, }); yield* appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-6"), + eventId: EventId.make("evt-revert-6"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert"), + aggregateId: ThreadId.make("thread-revert"), occurredAt: "2026-02-26T12:00:03.050Z", - commandId: CommandId.makeUnsafe("cmd-revert-6"), + commandId: CommandId.make("cmd-revert-6"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-6"), + correlationId: CorrelationId.make("cmd-revert-6"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert"), - messageId: MessageId.makeUnsafe("user-remove"), + threadId: ThreadId.make("thread-revert"), + messageId: MessageId.make("user-remove"), role: "user", text: "removed", - turnId: TurnId.makeUnsafe("turn-2"), + turnId: TurnId.make("turn-2"), streaming: false, createdAt: "2026-02-26T12:00:03.050Z", updatedAt: "2026-02-26T12:00:03.050Z", @@ -1651,20 +1975,20 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-7"), + eventId: EventId.make("evt-revert-7"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert"), + aggregateId: ThreadId.make("thread-revert"), occurredAt: "2026-02-26T12:00:03.100Z", - commandId: CommandId.makeUnsafe("cmd-revert-7"), + commandId: CommandId.make("cmd-revert-7"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-7"), + correlationId: CorrelationId.make("cmd-revert-7"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert"), - messageId: MessageId.makeUnsafe("assistant-remove"), + threadId: ThreadId.make("thread-revert"), + messageId: MessageId.make("assistant-remove"), role: "assistant", text: "removed", - turnId: TurnId.makeUnsafe("turn-2"), + turnId: TurnId.make("turn-2"), streaming: false, createdAt: "2026-02-26T12:00:03.100Z", updatedAt: "2026-02-26T12:00:03.100Z", @@ -1673,16 +1997,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.reverted", - eventId: EventId.makeUnsafe("evt-revert-8"), + eventId: EventId.make("evt-revert-8"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert"), + aggregateId: ThreadId.make("thread-revert"), occurredAt: "2026-02-26T12:00:04.000Z", - commandId: CommandId.makeUnsafe("cmd-revert-8"), + commandId: CommandId.make("cmd-revert-8"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-8"), + correlationId: CorrelationId.make("cmd-revert-8"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert"), + threadId: ThreadId.make("thread-revert"), turnCount: 1, }, }); @@ -1724,10 +2048,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta Layer.provideMerge(persistenceLayer), ); - const threadId = ThreadId.makeUnsafe("thread-restart"); - const turnId = TurnId.makeUnsafe("turn-restart"); - const messageId = MessageId.makeUnsafe("message-restart"); - const sourcePlanThreadId = ThreadId.makeUnsafe("thread-plan-source"); + const threadId = ThreadId.make("thread-restart"); + const turnId = TurnId.make("turn-restart"); + const messageId = MessageId.make("message-restart"); + const sourcePlanThreadId = ThreadId.make("thread-plan-source"); const sourcePlanId = "plan-source"; const turnStartedAt = "2026-02-26T14:00:00.000Z"; const sessionSetAt = "2026-02-26T14:00:05.000Z"; @@ -1738,13 +2062,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta yield* eventStore.append({ type: "thread.turn-start-requested", - eventId: EventId.makeUnsafe("evt-restart-1"), + eventId: EventId.make("evt-restart-1"), aggregateKind: "thread", aggregateId: threadId, occurredAt: turnStartedAt, - commandId: CommandId.makeUnsafe("cmd-restart-1"), + commandId: CommandId.make("cmd-restart-1"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-restart-1"), + correlationId: CorrelationId.make("cmd-restart-1"), metadata: {}, payload: { threadId, @@ -1768,13 +2092,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta yield* eventStore.append({ type: "thread.session-set", - eventId: EventId.makeUnsafe("evt-restart-2"), + eventId: EventId.make("evt-restart-2"), aggregateKind: "thread", aggregateId: threadId, occurredAt: sessionSetAt, - commandId: CommandId.makeUnsafe("cmd-restart-2"), + commandId: CommandId.make("cmd-restart-2"), causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-restart-2"), + correlationId: CorrelationId.make("cmd-restart-2"), metadata: {}, payload: { threadId, @@ -1846,6 +2170,7 @@ const engineLayer = it.layer( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1865,8 +2190,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { yield* engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-live-project"), - projectId: ProjectId.makeUnsafe("project-live"), + commandId: CommandId.make("cmd-live-project"), + projectId: ProjectId.make("project-live"), title: "Live Project", workspaceRoot: "/tmp/project-live", defaultModelSelection: { @@ -1903,8 +2228,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { yield* engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-scripts-project-create"), - projectId: ProjectId.makeUnsafe("project-scripts"), + commandId: CommandId.make("cmd-scripts-project-create"), + projectId: ProjectId.make("project-scripts"), title: "Scripts Project", workspaceRoot: "/tmp/project-scripts", defaultModelSelection: { @@ -1916,8 +2241,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { yield* engine.dispatch({ type: "project.meta.update", - commandId: CommandId.makeUnsafe("cmd-scripts-project-update"), - projectId: ProjectId.makeUnsafe("project-scripts"), + commandId: CommandId.make("cmd-scripts-project-update"), + projectId: ProjectId.make("project-scripts"), scripts: [ { id: "script-1", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0844cf8bb0..d981ae0da6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, type ChatAttachment, type OrchestrationEvent, + ThreadId, } from "@t3tools/contracts"; import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -86,7 +87,89 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { return null; } const requestId = (payload as Record).requestId; - return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null; + return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; +} + +function isStalePendingApprovalFailureDetail(detail: string | null): boolean { + if (detail === null) { + return false; + } + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") + ); +} + +function derivePendingUserInputCountFromActivities( + activities: ReadonlyArray, +): number { + const openRequestIds = new Set(); + const ordered = [...activities].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + + for (const activity of ordered) { + const requestId = extractActivityRequestId(activity.payload); + if (requestId === null) { + continue; + } + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + + if (activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + continue; + } + + if (activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.user-input.respond.failed" && + detail !== null && + (detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request")) + ) { + openRequestIds.delete(requestId); + } + } + + return openRequestIds.size; +} + +function deriveHasActionableProposedPlan(input: { + readonly latestTurnId: string | null; + readonly proposedPlans: ReadonlyArray; +}): boolean { + const sorted = [...input.proposedPlans].toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), + ); + + let latestForTurn: ProjectionThreadProposedPlan | null = null; + if (input.latestTurnId !== null) { + for (let index = sorted.length - 1; index >= 0; index -= 1) { + const plan = sorted[index]; + if (plan?.turnId === input.latestTurnId) { + latestForTurn = plan; + break; + } + } + } + if (latestForTurn !== null) { + return latestForTurn.implementedAt === null; + } + + const latestPlan = sorted.at(-1) ?? null; + return latestPlan !== null && latestPlan.implementedAt === null; } function retainProjectionMessagesAfterRevert( @@ -432,6 +515,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* ( + threadId: ThreadId, + ) { + const existingRow = yield* projectionThreadRepository.getById({ + threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + + const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ + projectionThreadMessageRepository.listByThreadId({ threadId }), + projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionPendingApprovalRepository.listByThreadId({ threadId }), + ]); + + const latestUserMessageAt = + messages + .filter((message) => message.role === "user") + .map((message) => message.createdAt) + .toSorted() + .at(-1) ?? null; + + const pendingApprovalCount = pendingApprovals.filter( + (approval) => approval.status === "pending", + ).length; + const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); + const hasActionableProposedPlan = deriveHasActionableProposedPlan({ + latestTurnId: existingRow.value.latestTurnId, + proposedPlans, + }); + + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestUserMessageAt, + pendingApprovalCount, + pendingUserInputCount, + hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, + }); + }); + const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadsProjection", )(function* (event, attachmentSideEffects) { @@ -450,6 +575,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); return; @@ -554,7 +683,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": - case "thread.activity-appended": { + case "thread.activity-appended": + case "thread.approval-response-requested": + case "thread.user-input-response-requested": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -565,6 +696,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -580,6 +712,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.session.activeTurnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -595,6 +728,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.turnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -610,6 +744,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: null, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -1121,6 +1256,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; } + if (event.payload.activity.kind === "provider.approval.respond.failed") { + const payload = + typeof event.payload.activity.payload === "object" && + event.payload.activity.payload !== null + ? (event.payload.activity.payload as Record) + : null; + const detail = + typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (isStalePendingApprovalFailureDetail(detail)) { + if (Option.isNone(existingRow)) { + return; + } + if (existingRow.value.status === "resolved") { + return; + } + yield* projectionPendingApprovalRepository.upsert({ + requestId, + threadId: existingRow.value.threadId, + turnId: existingRow.value.turnId, + status: "resolved", + decision: null, + createdAt: existingRow.value.createdAt, + resolvedAt: event.payload.activity.createdAt, + }); + return; + } + return; + } + // Only approval-requested activities should create pending-approval + // rows. Other activity kinds that happen to carry a requestId + // (e.g. user-input.requested / user-input.resolved) must not + // pollute this projection — they have their own accounting via + // derivePendingUserInputCountFromActivities. + if (event.payload.activity.kind !== "approval.requested") { + return; + } if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..9f0d63545f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -4,18 +4,22 @@ import { Effect, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; -const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); -const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); const projectionSnapshotLayer = it.layer( - OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), ); projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { @@ -58,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { project_id, title, model_selection_json, + runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, deleted_at @@ -70,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'project-1', 'Thread 1', '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', NULL, NULL, 'turn-1', + '2026-02-24T00:00:04.000Z', + 1, + 0, + 0, '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -234,6 +250,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -254,7 +271,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ]); assert.deepEqual(snapshot.threads, [ { - id: ThreadId.makeUnsafe("thread-1"), + id: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", modelSelection: { @@ -273,7 +290,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { completedAt: "2026-02-24T00:00:08.000Z", assistantMessageId: asMessageId("message-1"), sourceProposedPlan: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), planId: "plan-1", }, }, @@ -298,7 +315,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { turnId: asTurnId("turn-1"), planMarkdown: "# Ship it", implementedAt: "2026-02-24T00:00:05.500Z", - implementationThreadId: ThreadId.makeUnsafe("thread-2"), + implementationThreadId: ThreadId.make("thread-2"), createdAt: "2026-02-24T00:00:05.000Z", updatedAt: "2026-02-24T00:00:05.500Z", }, @@ -326,7 +343,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, ], session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", runtimeMode: "approval-required", @@ -336,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, }, ]); + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.equal(shellSnapshot.snapshotSequence, 5); + assert.deepEqual(shellSnapshot.projects, [ + { + id: asProjectId("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + scripts: [ + { + id: "script-1", + name: "Build", + command: "bun run build", + icon: "build", + runOnWorktreeCreate: false, + }, + ], + createdAt: "2026-02-24T00:00:00.000Z", + updatedAt: "2026-02-24T00:00:01.000Z", + }, + ]); + assert.deepEqual(shellSnapshot.threads, [ + { + id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread 1", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: { + turnId: asTurnId("turn-1"), + state: "completed", + requestedAt: "2026-02-24T00:00:08.000Z", + startedAt: "2026-02-24T00:00:08.000Z", + completedAt: "2026-02-24T00:00:08.000Z", + assistantMessageId: asMessageId("message-1"), + sourceProposedPlan: { + threadId: ThreadId.make("thread-1"), + planId: "plan-1", + }, + }, + createdAt: "2026-02-24T00:00:02.000Z", + updatedAt: "2026-02-24T00:00:03.000Z", + archivedAt: null, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: "2026-02-24T00:00:07.000Z", + }, + latestUserMessageAt: "2026-02-24T00:00:04.000Z", + hasPendingApprovals: true, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ]); + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value, snapshot.threads[0]); + } }), ); @@ -468,7 +560,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ); assert.equal(firstThreadId._tag, "Some"); if (firstThreadId._tag === "Some") { - assert.equal(firstThreadId.value, ThreadId.makeUnsafe("thread-first")); + assert.equal(firstThreadId.value, ThreadId.make("thread-first")); } }), ); @@ -591,12 +683,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { `; const context = yield* snapshotQuery.getThreadCheckpointContext( - ThreadId.makeUnsafe("thread-context"), + ThreadId.make("thread-context"), ); assert.equal(context._tag, "Some"); if (context._tag === "Some") { assert.deepEqual(context.value, { - threadId: ThreadId.makeUnsafe("thread-context"), + threadId: ThreadId.make("thread-context"), projectId: asProjectId("project-context"), workspaceRoot: "/tmp/context-workspace", worktreePath: "/tmp/context-worktree", @@ -624,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { } }), ); + + it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-01T00:00:00.000Z', + '2026-04-01T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-01T00:00:02.000Z', + '2026-04-01T00:00:03.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-unsequenced', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'unsequenced first', + '{"source":"unsequenced"}', + NULL, + '2026-04-01T00:00:06.000Z' + ), + ( + 'activity-sequence-2', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence two', + '{"source":"sequence-2"}', + 2, + '2026-04-01T00:00:04.000Z' + ), + ( + 'activity-sequence-1', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence one', + '{"source":"sequence-1"}', + 1, + '2026-04-01T00:00:05.000Z' + ) + `; + + const snapshot = yield* snapshotQuery.getSnapshot(); + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); + } + + assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ + { + id: asEventId("activity-unsequenced"), + tone: "info", + kind: "runtime.note", + summary: "unsequenced first", + payload: { source: "unsequenced" }, + turnId: null, + createdAt: "2026-04-01T00:00:06.000Z", + }, + { + id: asEventId("activity-sequence-1"), + tone: "info", + kind: "runtime.note", + summary: "sequence one", + payload: { source: "sequence-1" }, + turnId: null, + sequence: 1, + createdAt: "2026-04-01T00:00:05.000Z", + }, + { + id: asEventId("activity-sequence-2"), + tone: "info", + kind: "runtime.note", + summary: "sequence two", + payload: { source: "sequence-2" }, + turnId: null, + sequence: 2, + createdAt: "2026-04-01T00:00:04.000Z", + }, + ]); + }), + ); + + it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-02T00:00:00.000Z', + '2026-04-02T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + 'turn-running', + '2026-04-02T00:00:04.000Z', + 0, + 0, + 0, + '2026-04-02T00:00:02.000Z', + '2026-04-02T00:00:03.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + 'thread-1', + 'turn-completed', + 'message-user-1', + NULL, + NULL, + 'message-assistant-1', + 'completed', + '2026-04-02T00:00:05.000Z', + '2026-04-02T00:00:06.000Z', + '2026-04-02T00:00:20.000Z', + 5, + 'checkpoint-5', + 'ready', + '[]' + ), + ( + 'thread-1', + 'turn-running', + 'message-user-2', + NULL, + NULL, + NULL, + 'running', + '2026-04-02T00:00:30.000Z', + '2026-04-02T00:00:30.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(threadShell._tag, "Some"); + if (threadShell._tag === "Some") { + assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadShell.value.latestTurn?.state, "running"); + assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadDetail.value.latestTurn?.state, "running"); + assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c695674..07645571ba 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -6,16 +6,19 @@ import { OrchestrationCheckpointFile, OrchestrationProposedPlanId, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThread, ProjectScript, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, type OrchestrationMessage, + type OrchestrationProjectShell, type OrchestrationProposedPlan, type OrchestrationProject, type OrchestrationSession, - type OrchestrationThread, type OrchestrationThreadActivity, + type OrchestrationThreadShell, ModelSelection, ProjectId, ThreadId, @@ -38,6 +41,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -47,6 +51,8 @@ import { } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); +const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); +const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -154,6 +160,64 @@ function computeSnapshotSequence( return Number.isFinite(minSequence) ? minSequence : 0; } +function mapLatestTurn( + row: Schema.Schema.Type, +): OrchestrationLatestTurn { + return { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }; +} + +function mapSessionRow( + row: Schema.Schema.Type, +): OrchestrationSession { + return { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }; +} + +function mapProjectShellRow( + row: Schema.Schema.Type, + repositoryIdentity: OrchestrationProject["repositoryIdentity"], +): OrchestrationProjectShell { + return { + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + repositoryIdentity, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -163,6 +227,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const repositoryIdentityResolutionConcurrency = 4; const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, @@ -201,6 +267,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads ORDER BY created_at ASC, thread_id ASC @@ -378,6 +448,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getActiveProjectRowById = SqlSchema.findOneOption({ + Request: ProjectIdLookupInput, + Result: ProjectionProjectLookupRowSchema, + execute: ({ projectId }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE project_id = ${projectId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ Request: ProjectIdLookupInput, Result: ProjectionThreadIdLookupRowSchema, @@ -412,6 +503,146 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getActiveThreadRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + + const listThreadMessageRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const listThreadProposedPlanRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadProposedPlanDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const listThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const getThreadSessionRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadSessionDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + + const getLatestTurnRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionLatestTurnDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + LIMIT 1 + `, + }); + const listCheckpointRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionCheckpointDbRowSchema, @@ -436,274 +667,427 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( - Effect.gen(function* () { - const [ - projectRows, - threadRows, - messageRows, - proposedPlanRows, - activityRows, - sessionRows, - checkpointRows, - latestTurnRows, - stateRows, - ] = yield* Effect.all([ - listProjectRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listProjects:query", - "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", - ), + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", ), ), - listThreadRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreads:query", - "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", - ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", ), ), - listThreadMessageRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", - ), + ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", ), ), - listThreadProposedPlanRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", - ), + ), + listThreadProposedPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", ), ), - listThreadActivityRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", - ), + ), + listThreadActivityRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", ), ), - listThreadSessionRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", - ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", ), ), - listCheckpointRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", - ), + ), + listCheckpointRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", ), ), - listLatestTurnRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", - ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", ), ), - listProjectionStateRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", - ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", ), ), - ]); + ), + ]), + ) + .pipe( + Effect.flatMap( + ([ + projectRows, + threadRows, + messageRows, + proposedPlanRows, + activityRows, + sessionRows, + checkpointRows, + latestTurnRows, + stateRows, + ]) => + Effect.gen(function* () { + const messagesByThread = new Map>(); + const proposedPlansByThread = new Map>(); + const activitiesByThread = new Map>(); + const checkpointsByThread = new Map>(); + const sessionsByThread = new Map(); + const latestTurnByThread = new Map(); - const messagesByThread = new Map>(); - const proposedPlansByThread = new Map>(); - const activitiesByThread = new Map>(); - const checkpointsByThread = new Map>(); - const sessionsByThread = new Map(); - const latestTurnByThread = new Map(); + let updatedAt: string | null = null; - let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } + for (const row of messageRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } - for (const row of messageRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadMessages = messagesByThread.get(row.threadId) ?? []; - threadMessages.push({ - id: row.messageId, - role: row.role, - text: row.text, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - turnId: row.turnId, - streaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - messagesByThread.set(row.threadId, threadMessages); - } + for (const row of proposedPlanRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; + threadProposedPlans.push({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + proposedPlansByThread.set(row.threadId, threadProposedPlans); + } - for (const row of proposedPlanRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; - threadProposedPlans.push({ - id: row.planId, - turnId: row.turnId, - planMarkdown: row.planMarkdown, - implementedAt: row.implementedAt, - implementationThreadId: row.implementationThreadId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - proposedPlansByThread.set(row.threadId, threadProposedPlans); - } + for (const row of activityRows) { + updatedAt = maxIso(updatedAt, row.createdAt); + const threadActivities = activitiesByThread.get(row.threadId) ?? []; + threadActivities.push({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, threadActivities); + } - for (const row of activityRows) { - updatedAt = maxIso(updatedAt, row.createdAt); - const threadActivities = activitiesByThread.get(row.threadId) ?? []; - threadActivities.push({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - }); - activitiesByThread.set(row.threadId, threadActivities); - } + for (const row of checkpointRows) { + updatedAt = maxIso(updatedAt, row.completedAt); + const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; + threadCheckpoints.push({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }); + checkpointsByThread.set(row.threadId, threadCheckpoints); + } - for (const row of checkpointRows) { - updatedAt = maxIso(updatedAt, row.completedAt); - const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; - threadCheckpoints.push({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }); - checkpointsByThread.set(row.threadId, threadCheckpoints); - } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + if (latestTurnByThread.has(row.threadId)) { + continue; + } + latestTurnByThread.set(row.threadId, { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }); + } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); - } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); - } - if (latestTurnByThread.has(row.threadId)) { - continue; - } - latestTurnByThread.set(row.threadId, { - turnId: row.turnId, - state: - row.state === "error" - ? "error" - : row.state === "interrupted" - ? "interrupted" - : row.state === "completed" - ? "completed" - : "running", - requestedAt: row.requestedAt, - startedAt: row.startedAt, - completedAt: row.completedAt, - assistantMessageId: row.assistantMessageId, - ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null - ? { - sourceProposedPlan: { - threadId: row.sourceProposedPlanThreadId, - planId: row.sourceProposedPlanId, - }, - } - : {}), - }); - } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + sessionsByThread.set(row.threadId, { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }); + } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - sessionsByThread.set(row.threadId, { - threadId: row.threadId, - status: row.status, - providerName: row.providerName, - runtimeMode: row.runtimeMode, - activeTurnId: row.activeTurnId, - lastError: row.lastError, - updatedAt: row.updatedAt, - }); - } + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); - const projects: ReadonlyArray = projectRows.map((row) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })); - - const threads: ReadonlyArray = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects, - threads, - updatedAt: updatedAt ?? new Date(0).toISOString(), - }; + const projects: ReadonlyArray = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); + + const threads: ReadonlyArray = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeReadModel(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + ), + ); + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getSnapshot:query")(error); + }), + ); - return yield* decodeReadModel(snapshot).pipe( + const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( Effect.mapError( - toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeRows", + ), ), - ); - }), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeRows", + ), + ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]), ) .pipe( + Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => row.deletedAt === null) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows + .filter((row) => row.deletedAt === null) + .map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), + ), Effect.mapError((error) => { if (isPersistenceError(error)) { return error; } - return toPersistenceSqlError("ProjectionSnapshotQuery.getSnapshot:query")(error); + return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error); }), ); @@ -732,22 +1116,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", ), ), - Effect.map( - Option.map( - (row): OrchestrationProject => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - ), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => + Option.some({ + id: option.value.projectId, + title: option.value.title, + workspaceRoot: option.value.workspaceRoot, + repositoryIdentity, + defaultModelSelection: option.value.defaultModelSelection, + scripts: option.value.scripts, + createdAt: option.value.createdAt, + updatedAt: option.value.updatedAt, + deletedAt: option.value.deletedAt, + } satisfies OrchestrationProject), + ), + ), ), ); + const getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"] = (projectId) => + getActiveProjectRowById({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectShellById:query", + "ProjectionSnapshotQuery.getProjectShellById:decodeRow", + ), + ), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver + .resolve(option.value.workspaceRoot) + .pipe( + Effect.map((repositoryIdentity) => + Option.some(mapProjectShellRow(option.value, repositoryIdentity)), + ), + ), + ), + ); + const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = (projectId) => getFirstActiveThreadIdByProject({ projectId }).pipe( @@ -804,12 +1214,217 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => + Effect.gen(function* () { + const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getThread:query", + "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getSession:query", + "ProjectionSnapshotQuery.getThreadShellById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + return Option.some({ + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + latestUserMessageAt: threadRow.value.latestUserMessageAt, + hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, + hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, + hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, + } satisfies OrchestrationThreadShell); + }); + + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + Effect.gen(function* () { + const [ + threadRow, + messageRows, + proposedPlanRows, + activityRows, + checkpointRows, + latestTurnRow, + sessionRow, + ] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", + "ProjectionSnapshotQuery.getThreadDetailById:getThread:decodeRow", + ), + ), + ), + listThreadMessageRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:decodeRows", + ), + ), + ), + listThreadProposedPlanRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:decodeRows", + ), + ), + ), + listThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + listCheckpointRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query", + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:decodeRows", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getSession:query", + "ProjectionSnapshotQuery.getThreadDetailById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + const thread = { + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + deletedAt: null, + messages: messageRows.map((row) => { + const message = { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (row.attachments !== null) { + return Object.assign(message, { attachments: row.attachments }); + } + return message; + }), + proposedPlans: proposedPlanRows.map((row) => ({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })), + activities: activityRows.map((row) => { + const activity = { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }; + if (row.sequence !== null) { + return Object.assign(activity, { sequence: row.sequence }); + } + return activity; + }), + checkpoints: checkpointRows.map((row) => ({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + })), + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + }; + + return Option.some( + yield* decodeThread(thread).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getThreadDetailById:decodeThread"), + ), + ), + ); + }); + return { getSnapshot, + getShellSnapshot, getCounts, getActiveProjectByWorkspaceRoot, + getProjectShellById, getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, + getThreadShellById, + getThreadDetailById, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index fe6cb9caf5..7a4913ca32 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -27,7 +27,12 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "../../git/Services/GitStatusBroadcaster.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -37,11 +42,10 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsService } from "../../serverSettings.ts"; -const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asApprovalRequestId = (value: string): ApprovalRequestId => - ApprovalRequestId.makeUnsafe(value); -const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer))); @@ -121,8 +125,8 @@ describe("ProviderCommandReactor", () => { input !== null && "threadId" in input && typeof input.threadId === "string" - ? ThreadId.makeUnsafe(input.threadId) - : ThreadId.makeUnsafe(`thread-${sessionIndex}`); + ? ThreadId.make(input.threadId) + : ThreadId.make(`thread-${sessionIndex}`); const session: ProviderSession = { provider: modelSelection.provider, status: "ready" as const, @@ -144,7 +148,7 @@ describe("ProviderCommandReactor", () => { }); const sendTurn = vi.fn((_: unknown) => Effect.succeed({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), }), ); @@ -177,6 +181,24 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "renamed-branch", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ @@ -218,12 +240,22 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), + Layer.provideMerge( + Layer.succeed(GitStatusBroadcaster, { + getStatus: () => Effect.die("getStatus should not be called in this test"), + refreshLocalStatus: () => + Effect.die("refreshLocalStatus should not be called in this test"), + refreshStatus, + streamStatus: () => Stream.die("streamStatus should not be called in this test"), + } satisfies GitStatusBroadcasterShape), + ), Layer.provideMerge( Layer.mock(TextGeneration, { generateBranchName, @@ -245,7 +277,7 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-create"), + commandId: CommandId.make("cmd-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot: "/tmp/provider-project", @@ -256,8 +288,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread", modelSelection: modelSelection, @@ -278,6 +310,7 @@ describe("ProviderCommandReactor", () => { respondToUserInput, stopSession, renameBranch, + refreshStatus, generateBranchName, generateThreadTitle, stateDir, @@ -292,8 +325,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-1"), role: "user", @@ -308,7 +341,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); + expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", modelSelection: { @@ -319,7 +352,7 @@ describe("ProviderCommandReactor", () => { }); 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.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -333,8 +366,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-title-seed"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-title-seed"), + threadId: ThreadId.make("thread-1"), title: seededTitle, }), ); @@ -342,8 +375,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-title"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-title"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-title"), role: "user", @@ -365,12 +398,12 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); return ( - readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === "Generated title" ); }); 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.make("thread-1")); expect(thread?.title).toBe("Generated title"); }); @@ -382,8 +415,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-title-custom"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-title-custom"), + threadId: ThreadId.make("thread-1"), title: "Keep this custom title", }), ); @@ -391,8 +424,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-title-preserve"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-title-preserve"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-title-preserve"), role: "user", @@ -410,7 +443,7 @@ describe("ProviderCommandReactor", () => { expect(harness.generateThreadTitle).not.toHaveBeenCalled(); 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.make("thread-1")); expect(thread?.title).toBe("Keep this custom title"); }); @@ -427,8 +460,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-title-formatted-seed"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-title-formatted-seed"), + threadId: ThreadId.make("thread-1"), title: seededTitle, }), ); @@ -436,8 +469,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-title-formatted"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-title-formatted"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-title-formatted"), role: "user", @@ -455,13 +488,13 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); return ( - readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === "Reconnect spinner resume bug" ); }); 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.make("thread-1")); expect(thread?.title).toBe("Reconnect spinner resume bug"); }); @@ -472,8 +505,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-branch"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-branch"), + threadId: ThreadId.make("thread-1"), branch: "t3code/1234abcd", worktreePath: "/tmp/provider-project-worktree", }), @@ -497,8 +530,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-branch-model"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-branch-model"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-branch-model"), role: "user", @@ -512,9 +545,11 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.refreshStatus.mock.calls.length === 1); expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ message: "Add a safer reconnect backoff.", }); + expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); it("forwards codex model options through session start and turn send", async () => { @@ -524,8 +559,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-fast"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-fast"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-fast"), role: "user", @@ -559,7 +594,7 @@ describe("ProviderCommandReactor", () => { }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), modelSelection: { provider: "codex", model: "gpt-5.3-codex", @@ -580,8 +615,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-claude-effort"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-claude-effort"), role: "user", @@ -613,7 +648,7 @@ describe("ProviderCommandReactor", () => { }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", @@ -633,8 +668,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-claude-fast-mode"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-claude-fast-mode"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-claude-fast-mode"), role: "user", @@ -666,7 +701,7 @@ describe("ProviderCommandReactor", () => { }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", @@ -684,8 +719,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.interaction-mode.set", - commandId: CommandId.makeUnsafe("cmd-interaction-mode-set-plan"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-interaction-mode-set-plan"), + threadId: ThreadId.make("thread-1"), interactionMode: "plan", createdAt: now, }), @@ -694,8 +729,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-plan"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-plan"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-plan"), role: "user", @@ -710,7 +745,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), interactionMode: "plan", }); }); @@ -724,8 +759,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-provider-first"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-provider-first"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-provider-first"), role: "user", @@ -744,9 +779,7 @@ 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.make("thread-1")); return ( thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? false @@ -757,7 +790,7 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn).not.toHaveBeenCalled(); 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.make("thread-1")); expect(thread?.session).toBeNull(); expect( thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), @@ -776,8 +809,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-unsupported-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-unsupported-1"), role: "user", @@ -795,8 +828,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-unsupported-2"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-unsupported-2"), role: "user", @@ -812,7 +845,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -827,8 +860,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-unchanged-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-unchanged-1"), role: "user", @@ -847,8 +880,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-unchanged-2"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-unchanged-2"), role: "user", @@ -875,8 +908,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-claude-effort-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-claude-effort-1"), role: "user", @@ -902,8 +935,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-claude-effort-2"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-claude-effort-2"), role: "user", @@ -944,8 +977,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access"), + threadId: ThreadId.make("thread-1"), runtimeMode: "full-access", createdAt: now, }), @@ -954,8 +987,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-runtime-mode-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-runtime-mode-1"), role: "user", @@ -974,8 +1007,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-runtime-mode-set-1"), + threadId: ThreadId.make("thread-1"), runtimeMode: "approval-required", createdAt: now, }), @@ -983,17 +1016,15 @@ 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.make("thread-1")); return thread?.runtimeMode === "approval-required"; }); await waitFor(() => harness.startSession.mock.calls.length === 2); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-runtime-mode-2"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-runtime-mode-2"), role: "user", @@ -1010,16 +1041,16 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), resumeCursor: { opaque: "resume-1" }, runtimeMode: "approval-required", }); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), }); 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.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -1033,10 +1064,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-runtime-mode-claude"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-runtime-mode-claude"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "claudeAgent", runtimeMode: "full-access", @@ -1051,8 +1082,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-claude-no-options"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-runtime-mode-set-claude-no-options"), + threadId: ThreadId.make("thread-1"), runtimeMode: "approval-required", createdAt: now, }), @@ -1076,8 +1107,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-provider-switch-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-provider-switch-1"), role: "user", @@ -1096,8 +1127,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-provider-switch-2"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-provider-switch-2"), role: "user", @@ -1116,9 +1147,7 @@ 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.make("thread-1")); return ( thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? false @@ -1130,7 +1159,7 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); 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.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.providerName).toBe("codex"); expect(thread?.session?.runtimeMode).toBe("approval-required"); @@ -1150,8 +1179,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access-2"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), + threadId: ThreadId.make("thread-1"), runtimeMode: "full-access", createdAt: now, }), @@ -1160,8 +1189,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-restart-failure-1"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("user-message-restart-failure-1"), role: "user", @@ -1184,8 +1213,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-restart-failure"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), + threadId: ThreadId.make("thread-1"), runtimeMode: "approval-required", createdAt: now, }), @@ -1193,9 +1222,7 @@ 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.make("thread-1")); return thread?.runtimeMode === "approval-required"; }); await waitFor(() => harness.startSession.mock.calls.length === 2); @@ -1205,7 +1232,7 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn.mock.calls.length).toBe(1); 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.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("full-access"); }); @@ -1217,10 +1244,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", runtimeMode: "approval-required", @@ -1235,8 +1262,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.interrupt", - commandId: CommandId.makeUnsafe("cmd-turn-interrupt"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-interrupt"), + threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), createdAt: now, }), @@ -1248,6 +1275,61 @@ describe("ProviderCommandReactor", () => { }); }); + it("starts a fresh session when only projected session state exists", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stale"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-stale"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-stale"), + role: "user", + text: "resume codex", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + }); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1255,10 +1337,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-for-approval"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-for-approval"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", runtimeMode: "approval-required", @@ -1273,8 +1355,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", - commandId: CommandId.makeUnsafe("cmd-approval-respond"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-approval-respond"), + threadId: ThreadId.make("thread-1"), requestId: asApprovalRequestId("approval-request-1"), decision: "accept", createdAt: now, @@ -1296,10 +1378,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-for-user-input"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", runtimeMode: "approval-required", @@ -1314,8 +1396,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", - commandId: CommandId.makeUnsafe("cmd-user-input-respond"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-user-input-respond"), + threadId: ThreadId.make("thread-1"), requestId: asApprovalRequestId("user-input-request-1"), answers: { sandbox_mode: "workspace-write", @@ -1350,10 +1432,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-for-approval-error"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-for-approval-error"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", runtimeMode: "approval-required", @@ -1368,10 +1450,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.activity.append", - commandId: CommandId.makeUnsafe("cmd-approval-requested"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-approval-requested"), + threadId: ThreadId.make("thread-1"), activity: { - id: EventId.makeUnsafe("activity-approval-requested"), + id: EventId.make("activity-approval-requested"), tone: "approval", kind: "approval.requested", summary: "Command approval requested", @@ -1389,8 +1471,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", - commandId: CommandId.makeUnsafe("cmd-approval-respond-stale"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-approval-respond-stale"), + threadId: ThreadId.make("thread-1"), requestId: asApprovalRequestId("approval-request-1"), decision: "acceptForSession", createdAt: now, @@ -1399,9 +1481,7 @@ 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.make("thread-1")); if (!thread) return false; return thread.activities.some( (activity) => activity.kind === "provider.approval.respond.failed", @@ -1409,7 +1489,7 @@ describe("ProviderCommandReactor", () => { }); 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.make("thread-1")); expect(thread).toBeDefined(); const failureActivity = thread?.activities.find( @@ -1447,10 +1527,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input-error"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-for-user-input-error"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "running", providerName: "claudeAgent", runtimeMode: "approval-required", @@ -1465,10 +1545,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.activity.append", - commandId: CommandId.makeUnsafe("cmd-user-input-requested"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-user-input-requested"), + threadId: ThreadId.make("thread-1"), activity: { - id: EventId.makeUnsafe("activity-user-input-requested"), + id: EventId.make("activity-user-input-requested"), tone: "info", kind: "user-input.requested", summary: "User input requested", @@ -1498,8 +1578,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", - commandId: CommandId.makeUnsafe("cmd-user-input-respond-stale"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-user-input-respond-stale"), + threadId: ThreadId.make("thread-1"), requestId: asApprovalRequestId("user-input-request-1"), answers: { sandbox_mode: "workspace-write", @@ -1510,9 +1590,7 @@ 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.make("thread-1")); if (!thread) return false; return thread.activities.some( (activity) => activity.kind === "provider.user-input.respond.failed", @@ -1520,7 +1598,7 @@ describe("ProviderCommandReactor", () => { }); 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.make("thread-1")); expect(thread).toBeDefined(); const failureActivity = thread?.activities.find( @@ -1549,10 +1627,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-for-stop"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-set-for-stop"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -1567,15 +1645,15 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.stop", - commandId: CommandId.makeUnsafe("cmd-session-stop"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-stop"), + threadId: ThreadId.make("thread-1"), createdAt: now, }), ); await waitFor(() => harness.stopSession.mock.calls.length === 1); 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.make("thread-1")); expect(thread?.session).not.toBeNull(); expect(thread?.session?.status).toBe("stopped"); expect(thread?.session?.threadId).toBe("thread-1"); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 419e3f3bf2..e4e772dc75 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,11 +11,13 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; +import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; @@ -67,13 +69,11 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; const serverCommandId = (tag: string): CommandId => - CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + CommandId.make(`server:${tag}:${crypto.randomUUID()}`); const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); const DEFAULT_THREAD_TITLE = "New thread"; function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { @@ -119,10 +119,6 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function isTemporaryWorktreeBranch(branch: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); -} - function buildGeneratedWorktreeBranchName(raw: string): string { const normalized = raw .trim() @@ -150,6 +146,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ @@ -186,7 +183,7 @@ const make = Effect.gen(function* () { commandId: serverCommandId("provider-failure-activity"), threadId: input.threadId, activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), + id: EventId.make(crypto.randomUUID()), tone: "error", kind: input.kind, summary: input.summary, @@ -290,14 +287,14 @@ const make = Effect.gen(function* () { createdAt, }); + const activeSession = yield* resolveActiveSession(threadId); const existingSessionThreadId = - thread.session && thread.session.status !== "stopped" ? thread.id : null; + thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const providerChanged = requestedModelSelection !== undefined && requestedModelSelection.provider !== currentProvider; - const activeSession = yield* resolveActiveSession(existingSessionThreadId); const sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -452,6 +449,7 @@ const make = Effect.gen(function* () { branch: renamed.branch, worktreePath: cwd, }); + yield* gitStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( Effect.catchCause((cause) => Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 85f4d966e3..2a330a36b5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -29,6 +29,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -46,12 +47,12 @@ function makeTestServerSettingsLayer(overrides: Partial = {}) { return ServerSettingsService.layerTest(overrides); } -const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); -const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); -const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asItemId = (value: string): ProviderItemId => ProviderItemId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); type LegacyProviderRuntimeEvent = { readonly type: string; @@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderRuntimeIngestionLive.pipe( @@ -225,7 +227,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( engine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe("cmd-provider-project-create"), + commandId: CommandId.make("cmd-provider-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot, @@ -239,8 +241,8 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread", modelSelection: { @@ -257,10 +259,10 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-seed"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-seed"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -275,7 +277,7 @@ describe("ProviderRuntimeIngestion", () => { provider: "codex", status: "ready", runtimeMode: "approval-required", - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), createdAt, updatedAt: createdAt, }); @@ -455,9 +457,7 @@ describe("ProviderRuntimeIngestion", () => { await harness.drain(); 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.make("thread-1")); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle"); @@ -484,10 +484,10 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-session-seed-claude-placeholder"), + threadId: ThreadId.make("thread-1"), session: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), status: "ready", providerName: "claudeAgent", runtimeMode: "approval-required", @@ -562,9 +562,7 @@ describe("ProviderRuntimeIngestion", () => { await harness.drain(); 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.make("thread-1")); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-primary"); @@ -616,9 +614,7 @@ describe("ProviderRuntimeIngestion", () => { await harness.drain(); 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.make("thread-1")); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main"); @@ -768,7 +764,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source"), + commandId: CommandId.make("cmd-thread-create-plan-source"), threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", @@ -786,7 +782,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-plan-source"), + commandId: CommandId.make("cmd-session-set-plan-source"), threadId: sourceThreadId, session: { threadId: sourceThreadId, @@ -803,7 +799,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create-plan-target"), + commandId: CommandId.make("cmd-thread-create-plan-target"), threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", @@ -821,7 +817,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-plan-target"), + commandId: CommandId.make("cmd-session-set-plan-target"), threadId: targetThreadId, session: { threadId: targetThreadId, @@ -880,7 +876,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target"), + commandId: CommandId.make("cmd-turn-start-plan-target"), threadId: targetThreadId, message: { messageId: asMessageId("msg-plan-target"), @@ -955,7 +951,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source-guarded"), + commandId: CommandId.make("cmd-thread-create-plan-source-guarded"), threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", @@ -973,7 +969,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-plan-source-guarded"), + commandId: CommandId.make("cmd-session-set-plan-source-guarded"), threadId: sourceThreadId, session: { threadId: sourceThreadId, @@ -1049,7 +1045,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target-guarded"), + commandId: CommandId.make("cmd-turn-start-plan-target-guarded"), threadId: targetThreadId, message: { messageId: asMessageId("msg-plan-target-guarded"), @@ -1108,7 +1104,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source-unrelated"), + commandId: CommandId.make("cmd-thread-create-plan-source-unrelated"), threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", @@ -1126,7 +1122,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-plan-source-unrelated"), + commandId: CommandId.make("cmd-session-set-plan-source-unrelated"), threadId: sourceThreadId, session: { threadId: sourceThreadId, @@ -1143,7 +1139,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create-plan-target-unrelated"), + commandId: CommandId.make("cmd-thread-create-plan-target-unrelated"), threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", @@ -1161,7 +1157,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-plan-target-unrelated"), + commandId: CommandId.make("cmd-session-set-plan-target-unrelated"), threadId: targetThreadId, session: { threadId: targetThreadId, @@ -1211,7 +1207,7 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target-unrelated"), + commandId: CommandId.make("cmd-turn-start-plan-target-unrelated"), threadId: targetThreadId, message: { messageId: asMessageId("msg-plan-target-unrelated"), @@ -1362,9 +1358,7 @@ describe("ProviderRuntimeIngestion", () => { await harness.drain(); 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.make("thread-1")); expect( midThread?.messages.some( (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-buffered", @@ -1405,8 +1399,8 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-streaming-mode"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start-streaming-mode"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("message-streaming-mode"), role: "user", @@ -1647,7 +1641,7 @@ describe("ProviderRuntimeIngestion", () => { provider: "codex", createdAt: now, threadId: asThreadId("thread-1"), - requestId: ApprovalRequestId.makeUnsafe("req-open"), + requestId: ApprovalRequestId.make("req-open"), payload: { requestType: "command_execution_approval", detail: "pwd", @@ -1660,7 +1654,7 @@ describe("ProviderRuntimeIngestion", () => { provider: "codex", createdAt: now, threadId: asThreadId("thread-1"), - requestId: ApprovalRequestId.makeUnsafe("req-open"), + requestId: ApprovalRequestId.make("req-open"), payload: { requestType: "command_execution_approval", decision: "accept", @@ -1679,7 +1673,7 @@ describe("ProviderRuntimeIngestion", () => { ); 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.make("thread-1")); expect(thread).toBeDefined(); const requested = thread?.activities.find( @@ -2284,7 +2278,7 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-user-input"), - requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + requestId: ApprovalRequestId.make("req-user-input-1"), payload: { questions: [ { @@ -2309,7 +2303,7 @@ describe("ProviderRuntimeIngestion", () => { createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-user-input"), - requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + requestId: ApprovalRequestId.make("req-user-input-1"), payload: { answers: { sandbox_mode: "workspace-write", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d3fb5d752..f2c84ea40c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -30,7 +30,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => - CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); + CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); @@ -57,11 +57,11 @@ type RuntimeIngestionInput = }; function toTurnId(value: TurnId | string | undefined): TurnId | undefined { - return value === undefined ? undefined : TurnId.makeUnsafe(String(value)); + return value === undefined ? undefined : TurnId.make(String(value)); } function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined { - return value === undefined ? undefined : ApprovalRequestId.makeUnsafe(value); + return value === undefined ? undefined : ApprovalRequestId.make(value); } function sameId(left: string | null | undefined, right: string | null | undefined): boolean { @@ -465,6 +465,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -857,7 +858,7 @@ const make = Effect.fn("make")(function* () { yield* orchestrationEngine.dispatch({ type: "thread.proposed-plan.upsert", - commandId: CommandId.makeUnsafe( + commandId: CommandId.make( `provider:source-proposed-plan-implemented:${implementationThreadId}:${crypto.randomUUID()}`, ), threadId: sourceThread.id, @@ -1003,7 +1004,7 @@ const make = Effect.fn("make")(function* () { event.type === "turn.proposed.delta" ? event.payload.delta : undefined; if (assistantDelta && assistantDelta.length > 0) { - const assistantMessageId = MessageId.makeUnsafe( + const assistantMessageId = MessageId.make( `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); const turnId = toTurnId(event.turnId); @@ -1049,9 +1050,7 @@ const make = Effect.fn("make")(function* () { const assistantCompletion = event.type === "item.completed" && event.payload.itemType === "assistant_message" ? { - messageId: MessageId.makeUnsafe( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ), + messageId: MessageId.make(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), fallbackText: event.payload.detail, } : undefined; @@ -1186,7 +1185,7 @@ const make = Effect.fn("make")(function* () { if (thread.checkpoints.some((c) => c.turnId === turnId)) { // Already tracked; no-op. } else { - const assistantMessageId = MessageId.makeUnsafe( + const assistantMessageId = MessageId.make( `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); const maxTurnCount = thread.checkpoints.reduce( @@ -1199,7 +1198,7 @@ const make = Effect.fn("make")(function* () { threadId: thread.id, turnId, completedAt: now, - checkpointRef: CheckpointRef.makeUnsafe(`provider-diff:${event.eventId}`), + checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`), status: "missing", files: [], assistantMessageId, diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 69863b7f0a..177a23ec00 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -28,10 +28,31 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ), ); + const normalizeProjectWorkspaceRootForCreate = ( + workspaceRoot: string, + createIfMissing: boolean | undefined, + ) => + workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { + createIfMissing: createIfMissing === true, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: cause.message, + }), + ), + ); + if (command.type === "project.create") { return { ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate( + command.workspaceRoot, + command.createWorkspaceRootIfMissing, + ), + createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true, } satisfies OrchestrationCommand; } diff --git a/apps/server/src/orchestration/Services/CheckpointReactor.ts b/apps/server/src/orchestration/Services/CheckpointReactor.ts index 539ac784cd..9e9c83beb4 100644 --- a/apps/server/src/orchestration/Services/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Services/CheckpointReactor.ts @@ -6,7 +6,7 @@ * * @module CheckpointReactor */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Scope } from "effect"; /** @@ -34,7 +34,6 @@ export interface CheckpointReactorShape { /** * CheckpointReactor - Service tag for checkpoint reactor workers. */ -export class CheckpointReactor extends ServiceMap.Service< - CheckpointReactor, - CheckpointReactorShape ->()("t3/orchestration/Services/CheckpointReactor") {} +export class CheckpointReactor extends Context.Service()( + "t3/orchestration/Services/CheckpointReactor", +) {} diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index 33ac75049f..376b87d30a 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -5,7 +5,7 @@ * `OrchestrationEventStore` persistence. It does not own provider process * management or transport concerns (e.g. websocket request parsing). * - * Uses Effect `ServiceMap.Service` for dependency injection. Command dispatch, + * Uses Effect `Context.Service` for dependency injection. Command dispatch, * replay, and unknown-input decoding all return typed domain errors. * * @module OrchestrationEngineService @@ -15,7 +15,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Stream } from "effect"; import type { OrchestrationDispatchError } from "../Errors.ts"; @@ -74,7 +74,7 @@ export interface OrchestrationEngineShape { * }) * ``` */ -export class OrchestrationEngineService extends ServiceMap.Service< +export class OrchestrationEngineService extends Context.Service< OrchestrationEngineService, OrchestrationEngineShape >()("t3/orchestration/Services/OrchestrationEngine/OrchestrationEngineService") {} diff --git a/apps/server/src/orchestration/Services/OrchestrationReactor.ts b/apps/server/src/orchestration/Services/OrchestrationReactor.ts index 9b628eebda..a3edeaac61 100644 --- a/apps/server/src/orchestration/Services/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Services/OrchestrationReactor.ts @@ -6,7 +6,7 @@ * * @module OrchestrationReactor */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Scope } from "effect"; /** @@ -25,7 +25,7 @@ export interface OrchestrationReactorShape { /** * OrchestrationReactor - Service tag for orchestration reactor coordination. */ -export class OrchestrationReactor extends ServiceMap.Service< +export class OrchestrationReactor extends Context.Service< OrchestrationReactor, OrchestrationReactorShape >()("t3/orchestration/Services/OrchestrationReactor") {} diff --git a/apps/server/src/orchestration/Services/ProjectionPipeline.ts b/apps/server/src/orchestration/Services/ProjectionPipeline.ts index f8c86bf09b..349f3430ad 100644 --- a/apps/server/src/orchestration/Services/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Services/ProjectionPipeline.ts @@ -7,7 +7,7 @@ * @module OrchestrationProjectionPipeline */ import type { OrchestrationEvent } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; @@ -36,7 +36,7 @@ export interface OrchestrationProjectionPipelineShape { /** * OrchestrationProjectionPipeline - Service tag for orchestration projections. */ -export class OrchestrationProjectionPipeline extends ServiceMap.Service< +export class OrchestrationProjectionPipeline extends Context.Service< OrchestrationProjectionPipeline, OrchestrationProjectionPipelineShape >()("t3/orchestration/Services/ProjectionPipeline/OrchestrationProjectionPipeline") {} diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index a7673dc32e..be81dcbb37 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -9,11 +9,15 @@ import type { OrchestrationCheckpointSummary, OrchestrationProject, + OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThread, + OrchestrationThreadShell, ProjectId, ThreadId, } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Option } from "effect"; import type { Effect } from "effect"; @@ -44,6 +48,17 @@ export interface ProjectionSnapshotQueryShape { */ readonly getSnapshot: () => Effect.Effect; + /** + * Read the latest orchestration shell snapshot. + * + * Returns only projects and thread shell summaries so clients can bootstrap + * lightweight navigation state without hydrating every thread body. + */ + readonly getShellSnapshot: () => Effect.Effect< + OrchestrationShellSnapshot, + ProjectionRepositoryError + >; + /** * Read aggregate projection counts without hydrating the full read model. */ @@ -56,6 +71,13 @@ export interface ProjectionSnapshotQueryShape { workspaceRoot: string, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read a single active project shell row by id. + */ + readonly getProjectShellById: ( + projectId: ProjectId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the earliest active thread for a project. */ @@ -69,12 +91,26 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadCheckpointContext: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread shell row by id. + */ + readonly getThreadShellById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread detail snapshot by id. + */ + readonly getThreadDetailById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** * ProjectionSnapshotQuery - Service tag for projection snapshot queries. */ -export class ProjectionSnapshotQuery extends ServiceMap.Service< +export class ProjectionSnapshotQuery extends Context.Service< ProjectionSnapshotQuery, ProjectionSnapshotQueryShape >()("t3/orchestration/Services/ProjectionSnapshotQuery") {} diff --git a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts index 8f96d8fb99..c8b8580682 100644 --- a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts @@ -6,7 +6,7 @@ * * @module ProviderCommandReactor */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Scope } from "effect"; /** @@ -34,7 +34,7 @@ export interface ProviderCommandReactorShape { /** * ProviderCommandReactor - Service tag for provider command reaction workers. */ -export class ProviderCommandReactor extends ServiceMap.Service< +export class ProviderCommandReactor extends Context.Service< ProviderCommandReactor, ProviderCommandReactorShape >()("t3/orchestration/Services/ProviderCommandReactor") {} diff --git a/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts index f1b95762a2..831ce3a6f0 100644 --- a/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts @@ -6,7 +6,7 @@ * * @module ProviderRuntimeIngestionService */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Scope } from "effect"; /** @@ -34,7 +34,7 @@ export interface ProviderRuntimeIngestionShape { /** * ProviderRuntimeIngestionService - Service tag for runtime ingestion workers. */ -export class ProviderRuntimeIngestionService extends ServiceMap.Service< +export class ProviderRuntimeIngestionService extends Context.Service< ProviderRuntimeIngestionService, ProviderRuntimeIngestionShape >()("t3/orchestration/Services/ProviderRuntimeIngestion/ProviderRuntimeIngestionService") {} diff --git a/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts b/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts index bf5c94f3a0..53038006de 100644 --- a/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts +++ b/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts @@ -15,7 +15,7 @@ * @module RuntimeReceiptBus */ import { CheckpointRef, IsoDateTime, NonNegativeInt, ThreadId, TurnId } from "@t3tools/contracts"; -import { Schema, ServiceMap } from "effect"; +import { Schema, Context } from "effect"; import type { Effect, Stream } from "effect"; export const CheckpointBaselineCapturedReceipt = Schema.Struct({ @@ -59,7 +59,6 @@ export interface RuntimeReceiptBusShape { readonly streamEventsForTest: Stream.Stream; } -export class RuntimeReceiptBus extends ServiceMap.Service< - RuntimeReceiptBus, - RuntimeReceiptBusShape ->()("t3/orchestration/Services/RuntimeReceiptBus") {} +export class RuntimeReceiptBus extends Context.Service()( + "t3/orchestration/Services/RuntimeReceiptBus", +) {} diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9..a678bcea16 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -25,7 +25,7 @@ const readModel: OrchestrationReadModel = { updatedAt: now, projects: [ { - id: ProjectId.makeUnsafe("project-a"), + id: ProjectId.make("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", defaultModelSelection: { @@ -38,7 +38,7 @@ const readModel: OrchestrationReadModel = { deletedAt: null, }, { - id: ProjectId.makeUnsafe("project-b"), + id: ProjectId.make("project-b"), title: "Project B", workspaceRoot: "/tmp/project-b", defaultModelSelection: { @@ -53,8 +53,8 @@ const readModel: OrchestrationReadModel = { ], threads: [ { - id: ThreadId.makeUnsafe("thread-1"), - projectId: ProjectId.makeUnsafe("project-a"), + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), title: "Thread A", modelSelection: { provider: "codex", @@ -76,8 +76,8 @@ const readModel: OrchestrationReadModel = { deletedAt: null, }, { - id: ThreadId.makeUnsafe("thread-2"), - projectId: ProjectId.makeUnsafe("project-b"), + id: ThreadId.make("thread-2"), + projectId: ProjectId.make("project-b"), title: "Thread B", modelSelection: { provider: "codex", @@ -103,10 +103,10 @@ const readModel: OrchestrationReadModel = { const messageSendCommand: OrchestrationCommand = { type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-1"), + threadId: ThreadId.make("thread-1"), message: { - messageId: MessageId.makeUnsafe("msg-1"), + messageId: MessageId.make("msg-1"), role: "user", text: "hello", attachments: [], @@ -118,13 +118,11 @@ const messageSendCommand: OrchestrationCommand = { describe("commandInvariants", () => { it("finds threads by id and project", () => { - expect(findThreadById(readModel, ThreadId.makeUnsafe("thread-1"))?.projectId).toBe("project-a"); - expect(findThreadById(readModel, ThreadId.makeUnsafe("missing"))).toBeUndefined(); + expect(findThreadById(readModel, ThreadId.make("thread-1"))?.projectId).toBe("project-a"); + expect(findThreadById(readModel, ThreadId.make("missing"))).toBeUndefined(); expect( - listThreadsByProjectId(readModel, ProjectId.makeUnsafe("project-b")).map( - (thread) => thread.id, - ), - ).toEqual([ThreadId.makeUnsafe("thread-2")]); + listThreadsByProjectId(readModel, ProjectId.make("project-b")).map((thread) => thread.id), + ).toEqual([ThreadId.make("thread-2")]); }); it("requires existing thread", async () => { @@ -132,17 +130,17 @@ describe("commandInvariants", () => { requireThread({ readModel, command: messageSendCommand, - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), }), ); - expect(thread.id).toBe(ThreadId.makeUnsafe("thread-1")); + expect(thread.id).toBe(ThreadId.make("thread-1")); await expect( Effect.runPromise( requireThread({ readModel, command: messageSendCommand, - threadId: ThreadId.makeUnsafe("missing"), + threadId: ThreadId.make("missing"), }), ), ).rejects.toThrow("does not exist"); @@ -154,9 +152,9 @@ describe("commandInvariants", () => { readModel, command: { type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-2"), - threadId: ThreadId.makeUnsafe("thread-3"), - projectId: ProjectId.makeUnsafe("project-a"), + commandId: CommandId.make("cmd-2"), + threadId: ThreadId.make("thread-3"), + projectId: ProjectId.make("project-a"), title: "new", modelSelection: { provider: "codex", @@ -168,7 +166,7 @@ describe("commandInvariants", () => { worktreePath: null, createdAt: now, }, - threadId: ThreadId.makeUnsafe("thread-3"), + threadId: ThreadId.make("thread-3"), }), ); @@ -178,9 +176,9 @@ describe("commandInvariants", () => { readModel, command: { type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-3"), - threadId: ThreadId.makeUnsafe("thread-1"), - projectId: ProjectId.makeUnsafe("project-a"), + commandId: CommandId.make("cmd-3"), + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), title: "dup", modelSelection: { provider: "codex", @@ -192,7 +190,7 @@ describe("commandInvariants", () => { worktreePath: null, createdAt: now, }, - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), }), ), ).rejects.toThrow("already exists"); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 465865549b..a85e21c53f 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -12,9 +12,9 @@ import { Effect } from "effect"; import { decideOrchestrationCommand } from "./decider.ts"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; -const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); describe("decider project scripts", () => { it("emits empty scripts on project.create", async () => { @@ -25,7 +25,7 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), + commandId: CommandId.make("cmd-project-create-scripts"), projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", @@ -51,9 +51,9 @@ describe("decider project scripts", () => { aggregateId: asProjectId("project-scripts"), type: "project.created", occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), + commandId: CommandId.make("cmd-project-create-scripts"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-project-create-scripts"), + correlationId: CommandId.make("cmd-project-create-scripts"), metadata: {}, payload: { projectId: asProjectId("project-scripts"), @@ -81,7 +81,7 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "project.meta.update", - commandId: CommandId.makeUnsafe("cmd-project-update-scripts"), + commandId: CommandId.make("cmd-project-update-scripts"), projectId: asProjectId("project-scripts"), scripts: Array.from(scripts), }, @@ -105,9 +105,9 @@ describe("decider project scripts", () => { aggregateId: asProjectId("project-1"), type: "project.created", occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-project-create"), + commandId: CommandId.make("cmd-project-create"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-project-create"), + correlationId: CommandId.make("cmd-project-create"), metadata: {}, payload: { projectId: asProjectId("project-1"), @@ -125,15 +125,15 @@ describe("decider project scripts", () => { sequence: 2, eventId: asEventId("evt-thread-create"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-1"), + aggregateId: ThreadId.make("thread-1"), type: "thread.created", occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-thread-create"), + commandId: CommandId.make("cmd-thread-create"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-thread-create"), + correlationId: CommandId.make("cmd-thread-create"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread", modelSelection: { @@ -154,8 +154,8 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), message: { messageId: asMessageId("message-user-1"), role: "user", @@ -189,7 +189,7 @@ describe("decider project scripts", () => { return; } expect(turnStartEvent.payload).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), messageId: asMessageId("message-user-1"), modelSelection: { provider: "codex", @@ -214,9 +214,9 @@ describe("decider project scripts", () => { aggregateId: asProjectId("project-1"), type: "project.created", occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-project-create"), + commandId: CommandId.make("cmd-project-create"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-project-create"), + correlationId: CommandId.make("cmd-project-create"), metadata: {}, payload: { projectId: asProjectId("project-1"), @@ -234,15 +234,15 @@ describe("decider project scripts", () => { sequence: 2, eventId: asEventId("evt-thread-create"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-1"), + aggregateId: ThreadId.make("thread-1"), type: "thread.created", occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-thread-create"), + commandId: CommandId.make("cmd-thread-create"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-thread-create"), + correlationId: CommandId.make("cmd-thread-create"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread", modelSelection: { @@ -263,8 +263,8 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-runtime-mode-set"), + threadId: ThreadId.make("thread-1"), runtimeMode: "approval-required", createdAt: now, }, @@ -279,7 +279,7 @@ describe("decider project scripts", () => { expect(singleResult).toMatchObject({ type: "thread.runtime-mode-set", payload: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), runtimeMode: "approval-required", }, }); @@ -296,9 +296,9 @@ describe("decider project scripts", () => { aggregateId: asProjectId("project-1"), type: "project.created", occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-project-create"), + commandId: CommandId.make("cmd-project-create"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-project-create"), + correlationId: CommandId.make("cmd-project-create"), metadata: {}, payload: { projectId: asProjectId("project-1"), @@ -316,15 +316,15 @@ describe("decider project scripts", () => { sequence: 2, eventId: asEventId("evt-thread-create"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-1"), + aggregateId: ThreadId.make("thread-1"), type: "thread.created", occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-thread-create"), + commandId: CommandId.make("cmd-thread-create"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-thread-create"), + correlationId: CommandId.make("cmd-thread-create"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread", modelSelection: { @@ -345,8 +345,8 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "thread.interaction-mode.set", - commandId: CommandId.makeUnsafe("cmd-interaction-mode-set"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-interaction-mode-set"), + threadId: ThreadId.make("thread-1"), interactionMode: "plan", createdAt: now, }, @@ -361,7 +361,7 @@ describe("decider project scripts", () => { expect(singleResult).toMatchObject({ type: "thread.interaction-mode-set", payload: { - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), interactionMode: "plan", }, }); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts new file mode 100644 index 0000000000..959d841f67 --- /dev/null +++ b/apps/server/src/orchestration/http.ts @@ -0,0 +1,93 @@ +import { + ClientOrchestrationCommand, + OrchestrationDispatchCommandError, + OrchestrationGetSnapshotError, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Effect } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { ServerAuth } from "../auth/Services/ServerAuth.ts"; +import { normalizeDispatchCommand } from "./Normalizer.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; + +const respondToOrchestrationHttpError = ( + error: OrchestrationDispatchCommandError | OrchestrationGetSnapshotError, +) => + Effect.gen(function* () { + if (error._tag === "OrchestrationGetSnapshotError") { + yield* Effect.logError("orchestration http route failed", { + message: error.message, + cause: error.cause, + }); + return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 }); + } + + return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 }); + }); + +const authenticateOwnerSession = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new OrchestrationDispatchCommandError({ + message: "Only owner sessions can manage projects.", + }); + } + return session; +}); + +export const orchestrationSnapshotRouteLayer = HttpRouter.add( + "GET", + "/api/orchestration/snapshot", + Effect.gen(function* () { + yield* authenticateOwnerSession; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const snapshot = yield* projectionSnapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot.", + cause, + }), + ), + ); + return HttpServerResponse.jsonUnsafe(snapshot satisfies OrchestrationReadModel, { + status: 200, + }); + }).pipe( + Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError), + Effect.catchTag("OrchestrationGetSnapshotError", respondToOrchestrationHttpError), + ), +); + +export const orchestrationDispatchRouteLayer = HttpRouter.add( + "POST", + "/api/orchestration/dispatch", + Effect.gen(function* () { + yield* authenticateOwnerSession; + const orchestrationEngine = yield* OrchestrationEngineService; + const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Invalid orchestration command payload.", + cause, + }), + ), + ); + const normalizedCommand = yield* normalizeDispatchCommand(command); + const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command.", + cause, + }), + ), + ); + return HttpServerResponse.jsonUnsafe(result, { status: 200 }); + }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 3dcdd19250..a61153bb52 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -21,15 +21,15 @@ function makeEvent(input: { }): OrchestrationEvent { return { sequence: input.sequence, - eventId: EventId.makeUnsafe(`event-${input.sequence}`), + eventId: EventId.make(`event-${input.sequence}`), type: input.type, aggregateKind: input.aggregateKind, aggregateId: input.aggregateKind === "project" - ? ProjectId.makeUnsafe(input.aggregateId) - : ThreadId.makeUnsafe(input.aggregateId), + ? ProjectId.make(input.aggregateId) + : ThreadId.make(input.aggregateId), occurredAt: input.occurredAt, - commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId), + commandId: input.commandId === null ? null : CommandId.make(input.commandId), causationEventId: null, correlationId: null, metadata: {}, diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts new file mode 100644 index 0000000000..1b964709ae --- /dev/null +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; + +export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( + OrchestrationEventStoreLive, + OrchestrationCommandReceiptRepositoryLive, +); + +export const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( + Layer.provide(OrchestrationEventStoreLive), +); + +export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( + OrchestrationProjectionSnapshotQueryLive, + OrchestrationEventInfrastructureLayerLive, + OrchestrationProjectionPipelineLayerLive, +); + +export const OrchestrationLayerLive = Layer.mergeAll( + OrchestrationInfrastructureLayerLive, + OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)), +); diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index ca03ab5868..89eba62d2a 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -6,7 +6,7 @@ describe("fixPath", () => { it("hydrates PATH on linux using the resolved login shell", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); @@ -17,6 +17,39 @@ describe("fixPath", () => { }); expect(readPath).toHaveBeenCalledWith("/bin/zsh"); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + }); + + it("falls back to launchctl PATH on macOS when shell probing fails", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readPath = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => undefined); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + fixPath({ + env, + platform: "darwin", + readPath, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readPath).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu"); + expect(readPath).toHaveBeenNthCalledWith(2, "/bin/zsh"); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read PATH from login shell /opt/homebrew/bin/nu.", + expect.any(Error), + ); expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index c3629e8fde..33b6712809 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,28 +1,57 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; +import { + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, + readPathFromLoginShell, +} from "@t3tools/shared/shell"; + +function logPathHydrationWarning(message: string, error?: unknown): void { + console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} export function fixPath( options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; if (platform !== "darwin" && platform !== "linux") return; const env = options.env ?? process.env; + const logWarning = options.logWarning ?? logPathHydrationWarning; + const readPath = options.readPath ?? readPathFromLoginShell; try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; - const result = (options.readPath ?? readPathFromLoginShell)(shell); - if (result) { - env.PATH = result; + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + shellPath = readPath(shell); + } catch (error) { + logWarning(`Failed to read PATH from login shell ${shell}.`, error); + } + + if (shellPath) { + break; + } + } + + const launchctlPath = + platform === "darwin" && !shellPath + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } - } catch { - // Silently ignore — keep default PATH + } catch (error) { + logWarning("Failed to hydrate PATH from the user environment.", error); } } diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index cb1cb2f3f8..eb05bf5ae9 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -101,5 +101,7 @@ export type OrchestrationCommandReceiptRepositoryError = | PersistenceDecodeError; export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts new file mode 100644 index 0000000000..9767f24993 --- /dev/null +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -0,0 +1,209 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type AuthPairingLinkRepositoryError, +} from "../Errors.ts"; +import { + AuthPairingLinkRecord, + AuthPairingLinkRepository, + type AuthPairingLinkRepositoryShape, + ConsumeAuthPairingLinkInput, + CreateAuthPairingLinkInput, + GetAuthPairingLinkByCredentialInput, + ListActiveAuthPairingLinksInput, + RevokeAuthPairingLinkInput, +} from "../Services/AuthPairingLinks.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): AuthPairingLinkRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeAuthPairingLinkRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createPairingLinkRow = SqlSchema.void({ + Request: CreateAuthPairingLinkInput, + execute: (input) => + sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + role, + subject, + label, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${input.id}, + ${input.credential}, + ${input.method}, + ${input.role}, + ${input.subject}, + ${input.label}, + ${input.createdAt}, + ${input.expiresAt}, + NULL, + NULL + ) + `, + }); + + const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ + Request: ConsumeAuthPairingLinkInput, + Result: AuthPairingLinkRecord, + execute: ({ credential, consumedAt, now }) => + sql` + UPDATE auth_pairing_links + SET consumed_at = ${consumedAt} + WHERE credential = ${credential} + AND revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + RETURNING + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + label AS "label", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + `, + }); + + const listActivePairingLinkRows = SqlSchema.findAll({ + Request: ListActiveAuthPairingLinksInput, + Result: AuthPairingLinkRecord, + execute: ({ now }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + label AS "label", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + ORDER BY created_at DESC, id DESC + `, + }); + + const revokePairingLinkRow = SqlSchema.findAll({ + Request: RevokeAuthPairingLinkInput, + Result: Schema.Struct({ id: Schema.String }), + execute: ({ id, revokedAt }) => + sql` + UPDATE auth_pairing_links + SET revoked_at = ${revokedAt} + WHERE id = ${id} + AND revoked_at IS NULL + AND consumed_at IS NULL + RETURNING id AS "id" + `, + }); + + const getPairingLinkRowByCredential = SqlSchema.findOneOption({ + Request: GetAuthPairingLinkByCredentialInput, + Result: AuthPairingLinkRecord, + execute: ({ credential }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + label AS "label", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE credential = ${credential} + `, + }); + + const create: AuthPairingLinkRepositoryShape["create"] = (input) => + createPairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.create:query", + "AuthPairingLinkRepository.create:encodeRequest", + ), + ), + ); + + const consumeAvailable: AuthPairingLinkRepositoryShape["consumeAvailable"] = (input) => + consumeAvailablePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.consumeAvailable:query", + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + ), + ), + ); + + const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => + listActivePairingLinkRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.listActive:query", + "AuthPairingLinkRepository.listActive:decodeRows", + ), + ), + ); + + const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => + revokePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.revoke:query", + "AuthPairingLinkRepository.revoke:decodeRows", + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const getByCredential: AuthPairingLinkRepositoryShape["getByCredential"] = (input) => + getPairingLinkRowByCredential(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.getByCredential:query", + "AuthPairingLinkRepository.getByCredential:decodeRow", + ), + ), + ); + + return { + create, + consumeAvailable, + listActive, + revoke, + getByCredential, + } satisfies AuthPairingLinkRepositoryShape; +}); + +export const AuthPairingLinkRepositoryLive = Layer.effect( + AuthPairingLinkRepository, + makeAuthPairingLinkRepository, +); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts new file mode 100644 index 0000000000..66e02ed2a7 --- /dev/null +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -0,0 +1,279 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type AuthSessionRepositoryError, +} from "../Errors.ts"; +import { + AuthSessionRecord, + AuthSessionRepository, + type AuthSessionRepositoryShape, + CreateAuthSessionInput, + GetAuthSessionByIdInput, + ListActiveAuthSessionsInput, + RevokeAuthSessionInput, + RevokeOtherAuthSessionsInput, + SetAuthSessionLastConnectedAtInput, +} from "../Services/AuthSessions.ts"; + +const AuthSessionDbRow = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + clientLabel: Schema.NullOr(Schema.String), + clientIpAddress: Schema.NullOr(Schema.String), + clientUserAgent: Schema.NullOr(Schema.String), + clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]), + clientOs: Schema.NullOr(Schema.String), + clientBrowser: Schema.NullOr(Schema.String), + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); + +function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): typeof AuthSessionRecord.Type { + return { + sessionId: row.sessionId, + subject: row.subject, + role: row.role, + method: row.method, + client: { + label: row.clientLabel, + ipAddress: row.clientIpAddress, + userAgent: row.clientUserAgent, + deviceType: row.clientDeviceType, + os: row.clientOs, + browser: row.clientBrowser, + }, + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + lastConnectedAt: row.lastConnectedAt, + revokedAt: row.revokedAt, + }; +} + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): AuthSessionRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeAuthSessionRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createSessionRow = SqlSchema.void({ + Request: CreateAuthSessionInput, + execute: (input) => + sql` + INSERT INTO auth_sessions ( + session_id, + subject, + role, + method, + client_label, + client_ip_address, + client_user_agent, + client_device_type, + client_os, + client_browser, + issued_at, + expires_at, + revoked_at + ) + VALUES ( + ${input.sessionId}, + ${input.subject}, + ${input.role}, + ${input.method}, + ${input.client.label}, + ${input.client.ipAddress}, + ${input.client.userAgent}, + ${input.client.deviceType}, + ${input.client.os}, + ${input.client.browser}, + ${input.issuedAt}, + ${input.expiresAt}, + NULL + ) + `, + }); + + const getSessionRowById = SqlSchema.findOneOption({ + Request: GetAuthSessionByIdInput, + Result: AuthSessionDbRow, + execute: ({ sessionId }) => + sql` + SELECT + session_id AS "sessionId", + subject AS "subject", + role AS "role", + method AS "method", + client_label AS "clientLabel", + client_ip_address AS "clientIpAddress", + client_user_agent AS "clientUserAgent", + client_device_type AS "clientDeviceType", + client_os AS "clientOs", + client_browser AS "clientBrowser", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + last_connected_at AS "lastConnectedAt", + revoked_at AS "revokedAt" + FROM auth_sessions + WHERE session_id = ${sessionId} + `, + }); + + const listActiveSessionRows = SqlSchema.findAll({ + Request: ListActiveAuthSessionsInput, + Result: AuthSessionDbRow, + execute: ({ now }) => + sql` + SELECT + session_id AS "sessionId", + subject AS "subject", + role AS "role", + method AS "method", + client_label AS "clientLabel", + client_ip_address AS "clientIpAddress", + client_user_agent AS "clientUserAgent", + client_device_type AS "clientDeviceType", + client_os AS "clientOs", + client_browser AS "clientBrowser", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + last_connected_at AS "lastConnectedAt", + revoked_at AS "revokedAt" + FROM auth_sessions + WHERE revoked_at IS NULL + AND expires_at > ${now} + ORDER BY issued_at DESC, session_id DESC + `, + }); + + const setLastConnectedAtRow = SqlSchema.void({ + Request: SetAuthSessionLastConnectedAtInput, + execute: ({ sessionId, lastConnectedAt }) => + sql` + UPDATE auth_sessions + SET last_connected_at = ${lastConnectedAt} + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + `, + }); + + const revokeSessionRows = SqlSchema.findAll({ + Request: RevokeAuthSessionInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ sessionId, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + + const revokeOtherSessionRows = SqlSchema.findAll({ + Request: RevokeOtherAuthSessionsInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ currentSessionId, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE session_id <> ${currentSessionId} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + + const create: AuthSessionRepositoryShape["create"] = (input) => + createSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.create:query", + "AuthSessionRepository.create:encodeRequest", + ), + ), + ); + + const getById: AuthSessionRepositoryShape["getById"] = (input) => + getSessionRowById(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.getById:query", + "AuthSessionRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), + }), + ), + ); + + const listActive: AuthSessionRepositoryShape["listActive"] = (input) => + listActiveSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.listActive:query", + "AuthSessionRepository.listActive:decodeRows", + ), + ), + Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), + ); + + const revoke: AuthSessionRepositoryShape["revoke"] = (input) => + revokeSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revoke:query", + "AuthSessionRepository.revoke:decodeRows", + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => + revokeOtherSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revokeAllExcept:query", + "AuthSessionRepository.revokeAllExcept:decodeRows", + ), + ), + Effect.map((rows) => rows.map((row) => row.sessionId)), + ); + + const setLastConnectedAt: AuthSessionRepositoryShape["setLastConnectedAt"] = (input) => + setLastConnectedAtRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.setLastConnectedAt:query", + "AuthSessionRepository.setLastConnectedAt:encodeRequest", + ), + ), + ); + + return { + create, + getById, + listActive, + revoke, + revokeAllExcept, + setLastConnectedAt, + } satisfies AuthSessionRepositoryShape; +}); + +export const AuthSessionRepositoryLive = Layer.effect( + AuthSessionRepository, + makeAuthSessionRepository, +); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 249e9d1e36..80526986a7 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -21,18 +21,18 @@ layer("OrchestrationEventStore", (it) => { const appended = yield* eventStore.append({ type: "project.created", - eventId: EventId.makeUnsafe("evt-store-roundtrip"), + eventId: EventId.make("evt-store-roundtrip"), aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-roundtrip"), + aggregateId: ProjectId.make("project-roundtrip"), occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-store-roundtrip"), + commandId: CommandId.make("cmd-store-roundtrip"), causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-store-roundtrip"), + correlationId: CommandId.make("cmd-store-roundtrip"), metadata: { adapterKey: "codex", }, payload: { - projectId: ProjectId.makeUnsafe("project-roundtrip"), + projectId: ProjectId.make("project-roundtrip"), title: "Roundtrip Project", workspaceRoot: "/tmp/project-roundtrip", defaultModelSelection: null, @@ -87,13 +87,13 @@ layer("OrchestrationEventStore", (it) => { metadata_json ) VALUES ( - ${EventId.makeUnsafe("evt-store-invalid-json")}, + ${EventId.make("evt-store-invalid-json")}, ${"project"}, - ${ProjectId.makeUnsafe("project-invalid-json")}, + ${ProjectId.make("project-invalid-json")}, ${0}, ${"project.created"}, ${now}, - ${CommandId.makeUnsafe("cmd-store-invalid-json")}, + ${CommandId.make("cmd-store-invalid-json")}, ${null}, ${null}, ${"server"}, diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index b0e1774837..d42be69945 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -24,7 +24,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const sql = yield* SqlClient.SqlClient; yield* projects.upsert({ - projectId: ProjectId.makeUnsafe("project-null-options"), + projectId: ProjectId.make("project-null-options"), title: "Null options project", workspaceRoot: "/tmp/project-null-options", defaultModelSelection: { @@ -58,7 +58,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ); const persisted = yield* projects.getById({ - projectId: ProjectId.makeUnsafe("project-null-options"), + projectId: ProjectId.make("project-null-options"), }); assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { provider: "codex", @@ -73,8 +73,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const sql = yield* SqlClient.SqlClient; yield* threads.upsert({ - threadId: ThreadId.makeUnsafe("thread-null-options"), - projectId: ProjectId.makeUnsafe("project-null-options"), + threadId: ThreadId.make("thread-null-options"), + projectId: ProjectId.make("project-null-options"), title: "Null options thread", modelSelection: { provider: "claudeAgent", @@ -88,6 +88,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); @@ -112,7 +116,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ); const persisted = yield* threads.getById({ - threadId: ThreadId.makeUnsafe("thread-null-options"), + threadId: ThreadId.make("thread-null-options"), }); assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { provider: "claudeAgent", diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 5993ad6c20..8bbe723bf3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -14,8 +14,8 @@ layer("ProjectionThreadMessageRepository", (it) => { it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; - const threadId = ThreadId.makeUnsafe("thread-preserve-attachments"); - const messageId = MessageId.makeUnsafe("message-preserve-attachments"); + const threadId = ThreadId.make("thread-preserve-attachments"); + const messageId = MessageId.make("message-preserve-attachments"); const createdAt = "2026-02-28T19:00:00.000Z"; const updatedAt = "2026-02-28T19:00:01.000Z"; const persistedAttachments = [ @@ -68,8 +68,8 @@ layer("ProjectionThreadMessageRepository", (it) => { it.effect("allows explicit attachment clearing with an empty array", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; - const threadId = ThreadId.makeUnsafe("thread-clear-attachments"); - const messageId = MessageId.makeUnsafe("message-clear-attachments"); + const threadId = ThreadId.make("thread-clear-attachments"); + const messageId = MessageId.make("message-clear-attachments"); const createdAt = "2026-02-28T19:10:00.000Z"; yield* repository.upsert({ diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 48dd51fdca..57fb88c371 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -40,6 +40,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, deleted_at ) VALUES ( @@ -55,6 +59,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.latestUserMessageAt}, + ${row.pendingApprovalCount}, + ${row.pendingUserInputCount}, + ${row.hasActionableProposedPlan}, ${row.deletedAt} ) ON CONFLICT (thread_id) @@ -70,6 +78,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + latest_user_message_at = excluded.latest_user_message_at, + pending_approval_count = excluded.pending_approval_count, + pending_user_input_count = excluded.pending_user_input_count, + has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, deleted_at = excluded.deleted_at `, }); @@ -92,6 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} @@ -116,6 +132,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE project_id = ${projectId} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d18..01c649f7e9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,11 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; +import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; +import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; +import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; +import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; +import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; /** * Migration loader with all migrations defined inline. @@ -63,6 +68,11 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionSnapshotLookupIndexes", Migration0019], + [20, "AuthAccessManagement", Migration0020], + [21, "AuthSessionClientMetadata", Migration0021], + [22, "AuthSessionLastConnectedAt", Migration0022], + [23, "ProjectionThreadShellSummary", Migration0023], + [24, "BackfillProjectionThreadShellSummary", Migration0024], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts b/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts new file mode 100644 index 0000000000..1be7fa80ff --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_pairing_links ( + id TEXT PRIMARY KEY, + credential TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + role TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT, + revoked_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active + ON auth_pairing_links(revoked_at, consumed_at, expires_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + role TEXT NOT NULL, + method TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_sessions_active + ON auth_sessions(revoked_at, expires_at, issued_at) + `; +}); diff --git a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts new file mode 100644 index 0000000000..3b387fdcfd --- /dev/null +++ b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts @@ -0,0 +1,62 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN label TEXT + `; + } + + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!sessionColumns.some((column) => column.name === "client_label")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_label TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_ip_address")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_ip_address TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_user_agent")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_user_agent TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_device_type")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' + `; + } + + if (!sessionColumns.some((column) => column.name === "client_os")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_os TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_browser")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_browser TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts new file mode 100644 index 0000000000..e806a073a5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!sessionColumns.some((column) => column.name === "last_connected_at")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN last_connected_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts new file mode 100644 index 0000000000..759f87e8ad --- /dev/null +++ b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts @@ -0,0 +1,26 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN latest_user_message_at TEXT + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_approval_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_user_input_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); +}); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts new file mode 100644 index 0000000000..cc911d2469 --- /dev/null +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts @@ -0,0 +1,218 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("024_BackfillProjectionThreadShellSummary", (it) => { + it.effect("backfills thread shell summary fields and clears stale projected approvals", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 23 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'plan', + NULL, + NULL, + 'turn-1', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z', + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'message-user-1', + 'thread-1', + 'turn-1', + 'user', + 'Need help', + NULL, + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-1', + 'turn-1', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-1","requestKind":"command"}', + NULL, + '2026-02-24T00:02:00.000Z' + ), + ( + 'activity-approval-stale', + 'thread-1', + 'turn-1', + 'error', + 'provider.approval.respond.failed', + 'Provider approval response failed', + '{"requestId":"approval-1","detail":"Unknown pending permission request: approval-1"}', + NULL, + '2026-02-24T00:03:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-1', + 'turn-1', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-1","questions":[{"id":"area","header":"Area","question":"Which repo area should I inspect next?","options":[{"label":"Server","description":"Server orchestration."}]}]}', + NULL, + '2026-02-24T00:04:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES ( + 'plan-1', + 'thread-1', + 'turn-1', + '# Do the thing', + NULL, + NULL, + '2026-02-24T00:05:00.000Z', + '2026-02-24T00:05:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + 'approval-1', + 'thread-1', + 'turn-1', + 'pending', + NULL, + '2026-02-24T00:02:00.000Z', + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 24 }); + + const threadRows = yield* sql<{ + readonly latestUserMessageAt: string | null; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly hasActionableProposedPlan: number; + }>` + SELECT + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepStrictEqual(threadRows, [ + { + latestUserMessageAt: "2026-02-24T00:01:00.000Z", + pendingApprovalCount: 0, + pendingUserInputCount: 1, + hasActionableProposedPlan: 1, + }, + ]); + + const approvalRows = yield* sql<{ + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-1' + `; + assert.deepStrictEqual(approvalRows, [ + { + status: "resolved", + resolvedAt: "2026-02-24T00:03:00.000Z", + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts new file mode 100644 index 0000000000..549906dfb0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts @@ -0,0 +1,277 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT OR IGNORE INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + SELECT + requested.request_id, + requested.thread_id, + requested.turn_id, + 'pending', + NULL, + requested.created_at, + NULL + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + thread_id, + turn_id, + created_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at ASC, activity_id ASC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS requested + WHERE requested.row_number = 1 + `; + + yield* sql` + WITH latest_resolutions AS ( + SELECT + resolved.request_id, + resolved.resolved_at, + resolved.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.resolved' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS resolved + WHERE resolved.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_resolutions.decision + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_resolutions.resolved_at + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_response_events AS ( + SELECT + response.request_id, + response.resolved_at, + response.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + occurred_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY occurred_at DESC, sequence DESC + ) AS row_number + FROM orchestration_events + WHERE event_type = 'thread.approval-response-requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS response + WHERE response.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_response_events.decision + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_response_events.resolved_at + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_stale_failures AS ( + SELECT + failure.request_id, + failure.resolved_at + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'provider.approval.respond.failed' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + AND ( + lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%stale pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending permission request%' + ) + ) AS failure + WHERE failure.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = NULL, + resolved_at = ( + SELECT latest_stale_failures.resolved_at + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + WHERE status = 'pending' + AND EXISTS ( + SELECT 1 + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET + latest_user_message_at = ( + SELECT MAX(message.created_at) + FROM projection_thread_messages AS message + WHERE message.thread_id = projection_threads.thread_id + AND message.role = 'user' + ), + pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0), + pending_user_input_count = COALESCE(( + WITH latest_user_input_states AS ( + SELECT + latest.request_id, + latest.kind, + latest.detail + FROM ( + SELECT + json_extract(activity.payload_json, '$.requestId') AS request_id, + activity.kind, + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS row_number + FROM projection_thread_activities AS activity + WHERE activity.thread_id = projection_threads.thread_id + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + AND activity.kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ) AS latest + WHERE latest.row_number = 1 + ) + SELECT COUNT(*) + FROM latest_user_input_states + WHERE latest_user_input_states.kind = 'user-input.requested' + OR ( + latest_user_input_states.kind = 'provider.user-input.respond.failed' + AND latest_user_input_states.detail NOT LIKE '%stale pending user-input request%' + AND latest_user_input_states.detail NOT LIKE '%unknown pending user-input request%' + ) + ), 0), + has_actionable_proposed_plan = COALESCE(( + SELECT CASE + WHEN projection_threads.latest_turn_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS latest_turn_plan_exists + WHERE latest_turn_plan_exists.thread_id = projection_threads.thread_id + AND latest_turn_plan_exists.turn_id = projection_threads.latest_turn_id + ) + THEN CASE + WHEN ( + SELECT latest_turn_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_turn_plan + WHERE latest_turn_plan.thread_id = projection_threads.thread_id + AND latest_turn_plan.turn_id = projection_threads.latest_turn_id + ORDER BY latest_turn_plan.updated_at DESC, latest_turn_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + WHEN EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS any_plan + WHERE any_plan.thread_id = projection_threads.thread_id + ) + THEN CASE + WHEN ( + SELECT latest_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_plan + WHERE latest_plan.thread_id = projection_threads.thread_id + ORDER BY latest_plan.updated_at DESC, latest_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + ELSE 0 + END + ), 0) + `; +}); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 77c316e8ca..631df9c8b9 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -15,7 +15,7 @@ import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; -import * as ServiceMap from "effect/ServiceMap"; +import * as Context from "effect/Context"; import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; @@ -32,7 +32,7 @@ export type TypeId = "~local/sqlite-node/SqliteClient"; /** * SqliteClient - Effect service tag for the sqlite SQL client. */ -export const SqliteClient = ServiceMap.Service("t3/persistence/NodeSqliteClient"); +export const SqliteClient = Context.Service("t3/persistence/NodeSqliteClient"); export interface SqliteClientConfig { readonly filename: string; @@ -120,7 +120,7 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const runStatement = (statement: StatementSync, params: ReadonlyArray, raw: boolean) => Effect.withFiber, SqlError>((fiber) => { - statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); + statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); try { if (hasRows(statement)) { return Effect.succeed(statement.all(...(params as any))); @@ -200,7 +200,7 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); const transactionAcquirer = Effect.uninterruptibleMask((restore) => { const fiber = Fiber.getCurrent()!; - const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope); + const scope = Context.getUnsafe(fiber.context, Scope.Scope); return Effect.as( Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection, @@ -251,27 +251,27 @@ const makeMemory = ( export const layerConfig = ( config: Config.Wrap, ): Layer.Layer => - Layer.effectServices( + Layer.effectContext( Config.unwrap(config) .asEffect() .pipe( Effect.flatMap(make), Effect.map((client) => - ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), ), ), ).pipe(Layer.provide(Reactivity.layer)); export const layer = (config: SqliteClientConfig): Layer.Layer => - Layer.effectServices( + Layer.effectContext( Effect.map(make(config), (client) => - ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), ), ).pipe(Layer.provide(Reactivity.layer)); export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => - Layer.effectServices( + Layer.effectContext( Effect.map(makeMemory(config), (client) => - ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), ), ).pipe(Layer.provide(Reactivity.layer)); diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts new file mode 100644 index 0000000000..d03e67cbb5 --- /dev/null +++ b/apps/server/src/persistence/Services/AuthPairingLinks.ts @@ -0,0 +1,76 @@ +import { Option, Schema, Context } from "effect"; +import type { Effect } from "effect"; + +import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; + +export const AuthPairingLinkRecord = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + role: Schema.Literals(["owner", "client"]), + subject: Schema.String, + label: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; + +export const CreateAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + role: Schema.Literals(["owner", "client"]), + subject: Schema.String, + label: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; + +export const ConsumeAuthPairingLinkInput = Schema.Struct({ + credential: Schema.String, + consumedAt: Schema.DateTimeUtcFromString, + now: Schema.DateTimeUtcFromString, +}); +export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; + +export const ListActiveAuthPairingLinksInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; + +export const RevokeAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; + +export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ + credential: Schema.String, +}); +export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; + +export interface AuthPairingLinkRepositoryShape { + readonly create: ( + input: CreateAuthPairingLinkInput, + ) => Effect.Effect; + readonly consumeAvailable: ( + input: ConsumeAuthPairingLinkInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly listActive: ( + input: ListActiveAuthPairingLinksInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly revoke: ( + input: RevokeAuthPairingLinkInput, + ) => Effect.Effect; + readonly getByCredential: ( + input: GetAuthPairingLinkByCredentialInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; +} + +export class AuthPairingLinkRepository extends Context.Service< + AuthPairingLinkRepository, + AuthPairingLinkRepositoryShape +>()("t3/persistence/Services/AuthPairingLinks/AuthPairingLinkRepository") {} diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts new file mode 100644 index 0000000000..567d3cca5c --- /dev/null +++ b/apps/server/src/persistence/Services/AuthSessions.ts @@ -0,0 +1,93 @@ +import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts"; +import { Option, Schema, Context } from "effect"; +import type { Effect } from "effect"; + +import type { AuthSessionRepositoryError } from "../Errors.ts"; + +export const AuthSessionClientMetadataRecord = Schema.Struct({ + label: Schema.NullOr(Schema.String), + ipAddress: Schema.NullOr(Schema.String), + userAgent: Schema.NullOr(Schema.String), + deviceType: AuthClientMetadataDeviceType, + os: Schema.NullOr(Schema.String), + browser: Schema.NullOr(Schema.String), +}); +export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; + +export const AuthSessionRecord = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthSessionRecord = typeof AuthSessionRecord.Type; + +export const CreateAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; + +export const GetAuthSessionByIdInput = Schema.Struct({ + sessionId: AuthSessionId, +}); +export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; + +export const ListActiveAuthSessionsInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; + +export const RevokeAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; + +export const RevokeOtherAuthSessionsInput = Schema.Struct({ + currentSessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; + +export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ + sessionId: AuthSessionId, + lastConnectedAt: Schema.DateTimeUtcFromString, +}); +export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; + +export interface AuthSessionRepositoryShape { + readonly create: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; + readonly getById: ( + input: GetAuthSessionByIdInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly listActive: ( + input: ListActiveAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly revoke: ( + input: RevokeAuthSessionInput, + ) => Effect.Effect; + readonly revokeAllExcept: ( + input: RevokeOtherAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly setLastConnectedAt: ( + input: SetAuthSessionLastConnectedAtInput, + ) => Effect.Effect; +} + +export class AuthSessionRepository extends Context.Service< + AuthSessionRepository, + AuthSessionRepositoryShape +>()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index 0b8f8e9b52..da8755ae71 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -15,7 +15,7 @@ import { ProjectId, ThreadId, } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; @@ -63,7 +63,7 @@ export interface OrchestrationCommandReceiptRepositoryShape { /** * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. */ -export class OrchestrationCommandReceiptRepository extends ServiceMap.Service< +export class OrchestrationCommandReceiptRepository extends Context.Service< OrchestrationCommandReceiptRepository, OrchestrationCommandReceiptRepositoryShape >()("t3/persistence/Services/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 24febd8fde..beecdcdc65 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -4,13 +4,13 @@ * Owns durable append/replay access to the orchestration event stream. It does * not reduce events into read models or apply command validation rules. * - * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed + * Uses Effect `Context.Service` for dependency injection and exposes typed * persistence/decode errors for event append and replay operations. * * @module OrchestrationEventStore */ import { OrchestrationEvent } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Stream } from "effect"; import type { OrchestrationEventStoreError } from "../Errors.ts"; @@ -64,7 +64,7 @@ export interface OrchestrationEventStoreShape { * }) * ``` */ -export class OrchestrationEventStore extends ServiceMap.Service< +export class OrchestrationEventStore extends Context.Service< OrchestrationEventStore, OrchestrationEventStoreShape >()("t3/persistence/Services/OrchestrationEventStore") {} diff --git a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts index 191d0af968..617f964f33 100644 --- a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts @@ -16,7 +16,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, ServiceMap, Schema } from "effect"; +import { Option, Context, Schema } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -87,7 +87,7 @@ export interface ProjectionCheckpointRepositoryShape { /** * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence. */ -export class ProjectionCheckpointRepository extends ServiceMap.Service< +export class ProjectionCheckpointRepository extends Context.Service< ProjectionCheckpointRepository, ProjectionCheckpointRepositoryShape >()("t3/persistence/Services/ProjectionCheckpoints/ProjectionCheckpointRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 8f4ec5eb4b..82c43c0bb0 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -14,7 +14,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -85,7 +85,7 @@ export interface ProjectionPendingApprovalRepositoryShape { /** * ProjectionPendingApprovalRepository - Service tag for pending approval persistence. */ -export class ProjectionPendingApprovalRepository extends ServiceMap.Service< +export class ProjectionPendingApprovalRepository extends Context.Service< ProjectionPendingApprovalRepository, ProjectionPendingApprovalRepositoryShape >()("t3/persistence/Services/ProjectionPendingApprovals/ProjectionPendingApprovalRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 996ffe6e7b..0970bb2ead 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -7,7 +7,7 @@ * @module ProjectionProjectRepository */ import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -73,7 +73,7 @@ export interface ProjectionProjectRepositoryShape { /** * ProjectionProjectRepository - Service tag for project projection persistence. */ -export class ProjectionProjectRepository extends ServiceMap.Service< +export class ProjectionProjectRepository extends Context.Service< ProjectionProjectRepository, ProjectionProjectRepositoryShape >()("t3/persistence/Services/ProjectionProjects/ProjectionProjectRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionState.ts b/apps/server/src/persistence/Services/ProjectionState.ts index e8ef9ceeb0..0b75b817d2 100644 --- a/apps/server/src/persistence/Services/ProjectionState.ts +++ b/apps/server/src/persistence/Services/ProjectionState.ts @@ -7,7 +7,7 @@ * @module ProjectionStateRepository */ import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -58,7 +58,7 @@ export interface ProjectionStateRepositoryShape { /** * ProjectionStateRepository - Service tag for projection cursor persistence. */ -export class ProjectionStateRepository extends ServiceMap.Service< +export class ProjectionStateRepository extends Context.Service< ProjectionStateRepository, ProjectionStateRepositoryShape >()("t3/persistence/Services/ProjectionState/ProjectionStateRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 586ae3eb4a..87daa3c636 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -14,7 +14,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Schema, ServiceMap } from "effect"; +import { Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -77,7 +77,7 @@ export interface ProjectionThreadActivityRepositoryShape { /** * ProjectionThreadActivityRepository - Service tag for thread activity persistence. */ -export class ProjectionThreadActivityRepository extends ServiceMap.Service< +export class ProjectionThreadActivityRepository extends Context.Service< ProjectionThreadActivityRepository, ProjectionThreadActivityRepositoryShape >()("t3/persistence/Services/ProjectionThreadActivities/ProjectionThreadActivityRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index b1a769cd91..56f8f92dbf 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -14,7 +14,7 @@ import { TurnId, IsoDateTime, } from "@t3tools/contracts"; -import { Schema, ServiceMap } from "effect"; +import { Schema, Context } from "effect"; import type { Option } from "effect"; import type { Effect } from "effect"; @@ -88,7 +88,7 @@ export interface ProjectionThreadMessageRepositoryShape { /** * ProjectionThreadMessageRepository - Service tag for message projection persistence. */ -export class ProjectionThreadMessageRepository extends ServiceMap.Service< +export class ProjectionThreadMessageRepository extends Context.Service< ProjectionThreadMessageRepository, ProjectionThreadMessageRepositoryShape >()("t3/persistence/Services/ProjectionThreadMessages/ProjectionThreadMessageRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index d141a11bb9..a68bedb8c3 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -5,7 +5,7 @@ import { TrimmedNonEmptyString, TurnId, } from "@t3tools/contracts"; -import { Schema, ServiceMap } from "effect"; +import { Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -46,7 +46,7 @@ export interface ProjectionThreadProposedPlanRepositoryShape { ) => Effect.Effect; } -export class ProjectionThreadProposedPlanRepository extends ServiceMap.Service< +export class ProjectionThreadProposedPlanRepository extends Context.Service< ProjectionThreadProposedPlanRepository, ProjectionThreadProposedPlanRepositoryShape >()( diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 537ee10bee..fcd13f068d 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -13,7 +13,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -68,7 +68,7 @@ export interface ProjectionThreadSessionRepositoryShape { /** * ProjectionThreadSessionRepository - Service tag for thread-session persistence. */ -export class ProjectionThreadSessionRepository extends ServiceMap.Service< +export class ProjectionThreadSessionRepository extends Context.Service< ProjectionThreadSessionRepository, ProjectionThreadSessionRepositoryShape >()("t3/persistence/Services/ProjectionThreadSessions/ProjectionThreadSessionRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 59505c1253..7afdab2d58 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,13 +9,14 @@ import { IsoDateTime, ModelSelection, + NonNegativeInt, ProjectId, ProviderInteractionMode, RuntimeMode, ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -33,6 +34,10 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + pendingApprovalCount: NonNegativeInt, + pendingUserInputCount: NonNegativeInt, + hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionThread = typeof ProjectionThread.Type; @@ -90,7 +95,7 @@ export interface ProjectionThreadRepositoryShape { /** * ProjectionThreadRepository - Service tag for thread projection persistence. */ -export class ProjectionThreadRepository extends ServiceMap.Service< +export class ProjectionThreadRepository extends Context.Service< ProjectionThreadRepository, ProjectionThreadRepositoryShape >()("t3/persistence/Services/ProjectionThreads/ProjectionThreadRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index 95dab450bf..8e37ae09bd 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -17,7 +17,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -162,7 +162,7 @@ export interface ProjectionTurnRepositoryShape { ) => Effect.Effect; } -export class ProjectionTurnRepository extends ServiceMap.Service< +export class ProjectionTurnRepository extends Context.Service< ProjectionTurnRepository, ProjectionTurnRepositoryShape >()("t3/persistence/Services/ProjectionTurns/ProjectionTurnRepository") {} diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts index 885a9dd5f1..bf8e658e8a 100644 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -11,7 +11,7 @@ import { RuntimeMode, ThreadId, } from "@t3tools/contracts"; -import { Option, Schema, ServiceMap } from "effect"; +import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; @@ -75,7 +75,7 @@ export interface ProviderSessionRuntimeRepositoryShape { /** * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. */ -export class ProviderSessionRuntimeRepository extends ServiceMap.Service< +export class ProviderSessionRuntimeRepository extends Context.Service< ProviderSessionRuntimeRepository, ProviderSessionRuntimeRepositoryShape >()("t3/persistence/Services/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {} diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts new file mode 100644 index 0000000000..57f4464804 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -0,0 +1,193 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Duration, Effect, FileSystem, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import { runProcess } from "../../processRunner.ts"; +import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; +import { + makeRepositoryIdentityResolver, + RepositoryIdentityResolverLive, +} from "./RepositoryIdentityResolver.ts"; + +const git = (cwd: string, args: ReadonlyArray) => + Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); + +const makeRepositoryIdentityResolverTestLayer = (options: { + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +}) => + Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver({ + cacheCapacity: 16, + ...options, + }), + ); + +it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + expect(identity?.provider).toBe("github"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("returns null for non-git folders and repos without remotes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-non-git-", + }); + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-no-remote-", + }); + + yield* git(gitDir, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const nonGitIdentity = yield* resolver.resolve(nonGitDir); + const noRemoteIdentity = yield* resolver.resolve(gitDir); + + expect(nonGitIdentity).toBeNull(); + expect(noRemoteIdentity).toBeNull(); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("prefers upstream over origin when both remotes are configured", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-upstream-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.locator.remoteName).toBe("upstream"); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("uses the last remote path segment as the repository name for nested groups", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-group-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); + expect(identity?.displayName).toBe("t3tools/platform/t3code"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect( + "refreshes cached null identities after the negative TTL when a remote is configured later", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-late-remote-test-", + }); + + yield* git(cwd, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).toBeNull(); + + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + + yield* TestClock.adjust(Duration.millis(120)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(refreshedIdentity?.name).toBe("t3code"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.seconds(1), + }), + ), + ), + ), + ); + + it.effect("refreshes cached identities after the positive TTL when a remote changes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-remote-change-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).not.toBeNull(); + expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).not.toBeNull(); + expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* TestClock.adjust(Duration.millis(180)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); + expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); + expect(refreshedIdentity?.name).toBe("t3code-next"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.millis(100), + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..531737ec66 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -0,0 +1,149 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { Cache, Duration, Effect, Exit, Layer } from "effect"; +import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; + +import { runProcess } from "../../processRunner.ts"; +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "../Services/RepositoryIdentityResolver.ts"; + +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 pickPrimaryRemote( + remotes: ReadonlyMap, +): { readonly remoteName: string; readonly remoteUrl: string } | null { + for (const preferredRemoteName of ["upstream", "origin"] as const) { + const remoteUrl = remotes.get(preferredRemoteName); + if (remoteUrl) { + return { remoteName: preferredRemoteName, remoteUrl }; + } + } + + const [remoteName, remoteUrl] = + [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; + return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; +} + +function buildRepositoryIdentity(input: { + readonly remoteName: string; + readonly remoteUrl: string; +}): RepositoryIdentity { + const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); + const repositoryPath = canonicalKey.split("/").slice(1).join("/"); + const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); + const [owner] = repositoryPathSegments; + const repositoryName = repositoryPathSegments.at(-1); + + return { + canonicalKey, + locator: { + source: "git-remote", + remoteName: input.remoteName, + remoteUrl: input.remoteUrl, + }, + ...(repositoryPath ? { displayName: repositoryPath } : {}), + ...(hostingProvider ? { provider: hostingProvider.kind } : {}), + ...(owner ? { owner } : {}), + ...(repositoryName ? { name: repositoryName } : {}), + }; +} + +const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); + +interface RepositoryIdentityResolverOptions { + readonly cacheCapacity?: number; + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +} + +async function resolveRepositoryIdentityCacheKey(cwd: string): Promise { + let cacheKey = cwd; + + try { + const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + allowNonZeroExit: true, + }); + if (topLevelResult.code !== 0) { + return cacheKey; + } + + const candidate = topLevelResult.stdout.trim(); + if (candidate.length > 0) { + cacheKey = candidate; + } + } catch { + return cacheKey; + } + + return cacheKey; +} + +async function resolveRepositoryIdentityFromCacheKey( + cacheKey: string, +): Promise { + try { + const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { + allowNonZeroExit: true, + }); + if (remoteResult.code !== 0) { + return null; + } + + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); + return remote ? buildRepositoryIdentity(remote) : null; + } catch { + return null; + } +} + +export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( + function* (options: RepositoryIdentityResolverOptions = {}) { + const repositoryIdentityCache = yield* Cache.makeWith( + (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), + { + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); + + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }); + + return { + resolve, + } satisfies RepositoryIdentityResolverShape; + }, +); + +export const RepositoryIdentityResolverLive = Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver(), +); diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts index f6a8e22c0d..c05dfe8f1f 100644 --- a/apps/server/src/project/Services/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Services/ProjectFaviconResolver.ts @@ -6,7 +6,7 @@ * * @module ProjectFaviconResolver */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; /** @@ -24,7 +24,7 @@ export interface ProjectFaviconResolverShape { /** * ProjectFaviconResolver - Service tag for project favicon resolution. */ -export class ProjectFaviconResolver extends ServiceMap.Service< +export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, ProjectFaviconResolverShape >()("t3/project/Services/ProjectFaviconResolver") {} diff --git a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts index 3828096f85..acba335a6e 100644 --- a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts @@ -1,4 +1,4 @@ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; export interface ProjectSetupScriptRunnerResultNoScript { @@ -31,7 +31,7 @@ export interface ProjectSetupScriptRunnerShape { ) => Effect.Effect; } -export class ProjectSetupScriptRunner extends ServiceMap.Service< +export class ProjectSetupScriptRunner extends Context.Service< ProjectSetupScriptRunner, ProjectSetupScriptRunnerShape >()("t3/project/ProjectSetupScriptRunner") {} diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..d3eea84619 --- /dev/null +++ b/apps/server/src/project/Services/RepositoryIdentityResolver.ts @@ -0,0 +1,12 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface RepositoryIdentityResolverShape { + readonly resolve: (cwd: string) => Effect.Effect; +} + +export class RepositoryIdentityResolver extends Context.Service< + RepositoryIdentityResolver, + RepositoryIdentityResolverShape +>()("t3/project/Services/RepositoryIdentityResolver") {} diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5a09d8b6ba..6f0d4a352f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -14,6 +14,7 @@ import { ApprovalRequestId, ProviderItemId, ProviderRuntimeEvent, + type RuntimeMode, ThreadId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; @@ -236,8 +237,8 @@ async function readFirstPromptMessage( return next.value; } -const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); -const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); +const THREAD_ID = ThreadId.make("thread-claude-1"); +const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { @@ -350,6 +351,53 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("defaults Claude Opus 4.7 sessions to xhigh effort", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards xhigh effort for Claude Opus 4.7", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + options: { + effort: "xhigh", + }, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -965,6 +1013,97 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("falls back to a default plan step label for blank TodoWrite content", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-todo-1", + name: "TodoWrite", + input: {}, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-input", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 1, + delta: { + type: "input_json_delta", + partial_json: + '{"todos":[{"content":" ","status":"in_progress"},{"content":"Ship it","status":"completed"}]}', + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-todo-plan", + uuid: "result-todo-plan", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + assert.equal(planUpdated?.type, "turn.plan.updated"); + if (planUpdated?.type === "turn.plan.updated") { + assert.equal(String(planUpdated.turnId), String(turn.turnId)); + assert.deepEqual(planUpdated.payload.plan, [ + { step: "Task", status: "inProgress" }, + { step: "Ship it", status: "completed" }, + ]); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1103,8 +1242,8 @@ describe("ClaudeAdapterLive", () => { it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); const adapter = yield* ClaudeAdapter; const runtimeEvents: Array = []; @@ -1203,8 +1342,8 @@ describe("ClaudeAdapterLive", () => { ); return Effect.gen(function* () { - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); const adapter = yield* ClaudeAdapter; @@ -1407,6 +1546,150 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("clamps oversized Claude usage to the reported context window", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-clamped", + usage: { + total_tokens: 535000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.equal(usageEvent?.type, "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(usageEvent.payload, { + usage: { + usedTokens: 200000, + lastUsedTokens: 200000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "preserves oversized Claude result totals after task progress snapshots are recorded", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-clamped", + description: "Thinking through the patch", + usage: { + total_tokens: 190000, + }, + session_id: "sdk-session-task-usage-clamped", + uuid: "task-usage-progress-clamped", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-clamped-after-progress", + usage: { + total_tokens: 535000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const finalUsageEvent = usageEvents.at(-1); + assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); + if (finalUsageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(finalUsageEvent.payload, { + usage: { + usedTokens: 190000, + lastUsedTokens: 190000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect( "emits completion only after turn result when assistant frames arrive before deltas", () => { @@ -2107,7 +2390,7 @@ describe("ClaudeAdapterLive", () => { return; } assert.deepEqual(requested.value.providerRefs, { - providerItemId: ProviderItemId.makeUnsafe("tool-use-1"), + providerItemId: ProviderItemId.make("tool-use-1"), }); const runtimeRequestId = requested.value.requestId; assert.equal(typeof runtimeRequestId, "string"); @@ -2117,7 +2400,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.respondToRequest( session.threadId, - ApprovalRequestId.makeUnsafe(runtimeRequestId), + ApprovalRequestId.make(runtimeRequestId), "accept", ); @@ -2133,7 +2416,7 @@ describe("ClaudeAdapterLive", () => { assert.equal(resolved.value.requestId, requested.value.requestId); assert.equal(resolved.value.payload.decision, "accept"); assert.deepEqual(resolved.value.providerRefs, { - providerItemId: ProviderItemId.makeUnsafe("tool-use-1"), + providerItemId: ProviderItemId.make("tool-use-1"), }); const permissionResult = yield* Effect.promise(() => permissionPromise); @@ -2182,7 +2465,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.respondToRequest( session.threadId, - ApprovalRequestId.makeUnsafe(String(agentRequested.value.requestId)), + ApprovalRequestId.make(String(agentRequested.value.requestId)), "accept", ); yield* Stream.runHead(adapter.streamEvents); @@ -2206,7 +2489,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.respondToRequest( session.threadId, - ApprovalRequestId.makeUnsafe(String(grepRequested.value.requestId)), + ApprovalRequestId.make(String(grepRequested.value.requestId)), "accept", ); yield* Stream.runHead(adapter.streamEvents); @@ -2496,57 +2779,63 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("restores base permission mode on sendTurn when interactionMode is default", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; + it.effect.each<{ runtimeMode: RuntimeMode; expectedBase: PermissionMode }>([ + { runtimeMode: "full-access", expectedBase: "bypassPermissions" }, + { runtimeMode: "approval-required", expectedBase: "default" }, + { runtimeMode: "auto-accept-edits", expectedBase: "acceptEdits" }, + ])( + "restores $expectedBase permission mode after plan turn ($runtimeMode)", + ({ runtimeMode, expectedBase }) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode, + }); - // First turn in plan mode - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "plan this", - interactionMode: "plan", - attachments: [], - }); + // First turn in plan mode + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); - // Complete the turn so we can send another - const turnCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + // Complete the turn so we can send another + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-plan-restore", - uuid: "result-plan", - } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: `sdk-session-${runtimeMode}`, + uuid: `result-${runtimeMode}`, + } as unknown as SDKMessage); - yield* Fiber.join(turnCompletedFiber); + yield* Fiber.join(turnCompletedFiber); - // Second turn back to default - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "now do it", - interactionMode: "default", - attachments: [], - }); + // Second turn back to default + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "now do it", + interactionMode: "default", + attachments: [], + }); - // First call sets "plan", second call restores "bypassPermissions" (the base for full-access) - assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", expectedBase]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); it.effect("does not call setPermissionMode when interactionMode is absent", () => { const harness = makeHarness(); @@ -2622,7 +2911,7 @@ describe("ClaudeAdapterLive", () => { } assert.equal(proposedEvent.value.payload.planMarkdown, "# Ship it\n\n- one\n- two"); assert.deepEqual(proposedEvent.value.providerRefs, { - providerItemId: ProviderItemId.makeUnsafe("tool-exit-1"), + providerItemId: ProviderItemId.make("tool-exit-1"), }); const permissionResult = yield* Effect.promise(() => permissionPromise); @@ -2700,7 +2989,7 @@ describe("ClaudeAdapterLive", () => { } assert.equal(proposedEvent.value.payload.planMarkdown, "# Final plan\n\n- capture it"); assert.deepEqual(proposedEvent.value.providerRefs, { - providerItemId: ProviderItemId.makeUnsafe("tool-exit-2"), + providerItemId: ProviderItemId.make("tool-exit-2"), }); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -2791,15 +3080,13 @@ describe("ClaudeAdapterLive", () => { assert.equal(requestedEvent.value.payload.questions.length, 1); assert.equal(requestedEvent.value.payload.questions[0]?.question, "Which framework?"); assert.deepEqual(requestedEvent.value.providerRefs, { - providerItemId: ProviderItemId.makeUnsafe("tool-ask-1"), + providerItemId: ProviderItemId.make("tool-ask-1"), }); // Respond with the user's answers. - yield* adapter.respondToUserInput( - session.threadId, - ApprovalRequestId.makeUnsafe(requestId!), - { "Which framework?": "React" }, - ); + yield* adapter.respondToUserInput(session.threadId, ApprovalRequestId.make(requestId!), { + "Which framework?": "React", + }); // The adapter should emit a user-input.resolved event. const resolvedEvent = yield* Stream.runHead(adapter.streamEvents); @@ -2815,7 +3102,7 @@ describe("ClaudeAdapterLive", () => { "Which framework?": "React", }); assert.deepEqual(resolvedEvent.value.providerRefs, { - providerItemId: ProviderItemId.makeUnsafe("tool-ask-1"), + providerItemId: ProviderItemId.make("tool-ask-1"), }); // The canUseTool promise should resolve with the answers in SDK format. @@ -2882,11 +3169,9 @@ describe("ClaudeAdapterLive", () => { } const requestId = requestedEvent.value.requestId; - yield* adapter.respondToUserInput( - session.threadId, - ApprovalRequestId.makeUnsafe(requestId!), - { "Deploy to which env?": "Staging" }, - ); + yield* adapter.respondToUserInput(session.threadId, ApprovalRequestId.make(requestId!), { + "Deploy to which env?": "Staging", + }); // Drain the resolved event. yield* Stream.runHead(adapter.streamEvents); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9f2eeb014e..feacfa99ea 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,7 +17,9 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, + ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; +import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { ApprovalRequestId, type CanonicalItemType, @@ -38,7 +40,7 @@ import { ThreadId, TurnId, type UserInputQuestion, - ClaudeCodeEffort, + ClaudeAgentEffort, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -213,9 +215,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeCodeEffort( - effort: ClaudeCodeEffort | null | undefined, -): Exclude | null { +function getEffectiveClaudeAgentEffort( + effort: ClaudeAgentEffort | null | undefined, +): Exclude | null { if (!effort) { return null; } @@ -269,27 +271,17 @@ function isInterruptedResult(result: SDKResultMessage): boolean { } function asRuntimeItemId(value: string): RuntimeItemId { - return RuntimeItemId.makeUnsafe(value); + return RuntimeItemId.make(value); } -function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | undefined { - if (!modelUsage || typeof modelUsage !== "object") { - return undefined; - } +function maxClaudeContextWindowFromModelUsage( + modelUsage: Record | undefined, +): number | undefined { + if (!modelUsage) return undefined; let maxContextWindow: number | undefined; - for (const value of Object.values(modelUsage as Record)) { - if (!value || typeof value !== "object") { - continue; - } - const contextWindow = (value as { contextWindow?: unknown }).contextWindow; - if ( - typeof contextWindow !== "number" || - !Number.isFinite(contextWindow) || - contextWindow <= 0 - ) { - continue; - } + for (const value of Object.values(modelUsage)) { + const contextWindow = value.contextWindow; maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow); } @@ -297,53 +289,58 @@ function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | und } function normalizeClaudeTokenUsage( - usage: unknown, + value: unknown, contextWindow?: number, ): ThreadTokenUsageSnapshot | undefined { - if (!usage || typeof usage !== "object") { + if (!value || typeof value !== "object") { return undefined; } - const record = usage as Record; - const directUsedTokens = - typeof record.total_tokens === "number" && Number.isFinite(record.total_tokens) - ? record.total_tokens - : undefined; + const usage = value as Record; const inputTokens = - (typeof record.input_tokens === "number" && Number.isFinite(record.input_tokens) - ? record.input_tokens + (typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) + ? usage.input_tokens : 0) + - (typeof record.cache_creation_input_tokens === "number" && - Number.isFinite(record.cache_creation_input_tokens) - ? record.cache_creation_input_tokens + (typeof usage.cache_creation_input_tokens === "number" && + Number.isFinite(usage.cache_creation_input_tokens) + ? usage.cache_creation_input_tokens : 0) + - (typeof record.cache_read_input_tokens === "number" && - Number.isFinite(record.cache_read_input_tokens) - ? record.cache_read_input_tokens + (typeof usage.cache_read_input_tokens === "number" && + Number.isFinite(usage.cache_read_input_tokens) + ? usage.cache_read_input_tokens : 0); const outputTokens = - typeof record.output_tokens === "number" && Number.isFinite(record.output_tokens) - ? record.output_tokens + typeof usage.output_tokens === "number" && Number.isFinite(usage.output_tokens) + ? usage.output_tokens : 0; - const derivedUsedTokens = inputTokens + outputTokens; - const usedTokens = directUsedTokens ?? (derivedUsedTokens > 0 ? derivedUsedTokens : undefined); - if (usedTokens === undefined || usedTokens <= 0) { + const derivedTotalProcessedTokens = inputTokens + outputTokens; + const totalProcessedTokens = + (typeof usage.total_tokens === "number" && Number.isFinite(usage.total_tokens) + ? usage.total_tokens + : undefined) ?? (derivedTotalProcessedTokens > 0 ? derivedTotalProcessedTokens : undefined); + if (totalProcessedTokens === undefined || totalProcessedTokens <= 0) { return undefined; } + const maxTokens = + typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? contextWindow + : undefined; + const usedTokens = + maxTokens !== undefined ? Math.min(totalProcessedTokens, maxTokens) : totalProcessedTokens; + return { usedTokens, lastUsedTokens: usedTokens, + ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}), ...(inputTokens > 0 ? { inputTokens } : {}), ...(outputTokens > 0 ? { outputTokens } : {}), - ...(typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 - ? { maxTokens: contextWindow } + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(typeof usage.tool_uses === "number" && Number.isFinite(usage.tool_uses) + ? { toolUses: usage.tool_uses } : {}), - ...(typeof record.tool_uses === "number" && Number.isFinite(record.tool_uses) - ? { toolUses: record.tool_uses } - : {}), - ...(typeof record.duration_ms === "number" && Number.isFinite(record.duration_ms) - ? { durationMs: record.duration_ms } + ...(typeof usage.duration_ms === "number" && Number.isFinite(usage.duration_ms) + ? { durationMs: usage.duration_ms } : {}), }; } @@ -353,7 +350,7 @@ function asCanonicalTurnId(value: TurnId): TurnId { } function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { - return RuntimeRequestId.makeUnsafe(value); + return RuntimeRequestId.make(value); } function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { @@ -371,7 +368,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; const threadId = threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) - ? ThreadId.makeUnsafe(threadIdCandidate) + ? ThreadId.make(threadIdCandidate) : undefined; const resumeCandidate = typeof cursor.resume === "string" @@ -462,6 +459,34 @@ function classifyRequestType(toolName: string): CanonicalRequestType { : "dynamic_tool_call"; } +function isTodoTool(toolName: string): boolean { + return toolName.toLowerCase().includes("todowrite"); +} + +type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; + +function extractPlanStepsFromTodoInput(input: Record): PlanStep[] | null { + // TodoWrite format: { todos: [{ content, status, activeForm? }] } + const todos = input.todos; + if (!Array.isArray(todos) || todos.length === 0) { + return null; + } + return todos + .filter((t): t is Record => t !== null && typeof t === "object") + .map((todo) => ({ + step: + typeof todo.content === "string" && todo.content.trim().length > 0 + ? todo.content.trim() + : "Task", + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "inProgress" + : "pending", + })); +} + function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; @@ -469,6 +494,20 @@ function summarizeToolRequest(toolName: string, input: Record): return `${toolName}: ${command.trim().slice(0, 400)}`; } + // For agent/subagent tools, prefer human-readable description or prompt over raw JSON + const itemType = classifyToolItemType(toolName); + if (itemType === "collab_agent_tool_call") { + const description = + typeof input.description === "string" ? input.description.trim() : undefined; + const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; + const subagentType = + typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; + const label = description || (prompt ? prompt.slice(0, 200) : undefined); + if (label) { + return subagentType ? `${subagentType}: ${label}` : label; + } + } + const serialized = JSON.stringify(input); if (serialized.length <= 400) { return `${toolName}: ${serialized}`; @@ -641,7 +680,7 @@ function nativeProviderRefs( ): NonNullable { if (options?.providerItemId) { return { - providerItemId: ProviderItemId.makeUnsafe(options.providerItemId), + providerItemId: ProviderItemId.make(options.providerItemId), }; } return {}; @@ -934,7 +973,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const serverSettingsService = yield* ServerSettingsService; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => @@ -967,7 +1006,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? { providerThreadId: message.session_id } : {}), ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), + ...(itemId ? { itemId: ProviderItemId.make(itemId) } : {}), payload: message, }, }, @@ -1328,8 +1367,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( errorMessage?: string, result?: SDKResultMessage, ) { - const resultUsage = - result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); if (resultContextWindow !== undefined) { context.lastKnownContextWindow = resultContextWindow; @@ -1341,9 +1378,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Instead, use the last known context-window-accurate usage from task_progress // events and treat the accumulated total as totalProcessedTokens. const accumulatedSnapshot = normalizeClaudeTokenUsage( - resultUsage, + result?.usage, resultContextWindow ?? context.lastKnownContextWindow, ); + const accumulatedTotalProcessedTokens = + accumulatedSnapshot?.totalProcessedTokens ?? accumulatedSnapshot?.usedTokens; const lastGoodUsage = context.lastKnownTokenUsage; const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage @@ -1352,8 +1391,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? { maxTokens } : {}), - ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens - ? { totalProcessedTokens: accumulatedSnapshot.usedTokens } + ...(typeof accumulatedTotalProcessedTokens === "number" && + Number.isFinite(accumulatedTotalProcessedTokens) && + accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens + ? { totalProcessedTokens: accumulatedTotalProcessedTokens } : {}), } : accumulatedSnapshot; @@ -1617,6 +1658,26 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( payload: message, }, }); + + // Emit plan update when TodoWrite input is parsed + if (parsedInput && isTodoTool(nextTool.toolName)) { + const planSteps = extractPlanStepsFromTodoInput(parsedInput); + if (planSteps && planSteps.length > 0) { + const planStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.plan.updated", + eventId: planStamp.eventId, + provider: PROVIDER, + createdAt: planStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + payload: { + plan: planSteps, + }, + providerRefs: nativeProviderRefs(context), + }); + } + } } return; } @@ -1822,7 +1883,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Auto-start a synthetic turn for assistant messages that arrive without // an active turn (e.g., background agent/subagent responses between user prompts). if (!context.turnState) { - const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnId = TurnId.make(yield* Random.nextUUIDv4); const startedAt = yield* nowIso; context.turnState = { turnId, @@ -2013,7 +2074,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, type: "task.started", payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), + taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), }, @@ -2043,7 +2104,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, type: "task.progress", payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), + taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), @@ -2075,7 +2136,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, type: "task.completed", payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), + taskId: RuntimeTaskId.make(message.task_id), status: message.status, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), @@ -2380,9 +2441,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined; const sessionId = existingResumeSessionId ?? newSessionId; - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); - const runPromise = Effect.runPromiseWith(services); + const runtimeContext = yield* Effect.context(); + const runFork = Effect.runForkWith(runtimeContext); + const runPromise = Effect.runPromiseWith(runtimeContext); const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( @@ -2409,7 +2470,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( toolInput: Record, callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, ) { - const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); // Parse questions from the SDK's AskUserQuestion input. const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; @@ -2562,7 +2623,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; } - const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); const requestType = classifyRequestType(toolName); const detail = summarizeToolRequest(toolName, toolInput); const decisionDeferred = yield* Deferred.make(); @@ -2681,19 +2742,24 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; + const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeCodeEffort | null; + null) as ClaudeAgentEffort | null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle ? modelSelection.options.thinking : undefined; - const effectiveEffort = getEffectiveClaudeCodeEffort(effort); - const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; + const effectiveEffort = getEffectiveClaudeAgentEffort(effort); + const runtimeModeToPermission: Record = { + "auto-accept-edits": "acceptEdits", + "full-access": "bypassPermissions", + }; + const permissionMode = runtimeModeToPermission[input.runtimeMode]; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -2716,6 +2782,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; const queryRuntime = yield* Effect.try({ @@ -2881,13 +2948,12 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ - try: () => - context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), + try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); } - const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnId = TurnId.make(yield* Random.nextUUIDv4); const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 761b795fe5..f76c4250eb 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,12 +4,16 @@ import type { ServerProvider, ServerProviderModel, ServerProviderAuth, + ServerProviderSlashCommand, ServerProviderState, } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; +import { + query as claudeQuery, + type SlashCommand as ClaudeSlashCommand, +} from "@anthropic-ai/claude-agent-sdk"; import { buildServerProvider, @@ -22,13 +26,45 @@ import { spawnAndCollect, type CommandResult, } from "../providerSnapshot"; +import { compareCliVersions } from "../cliVersion"; import { makeManagedServerProvider } from "../makeManagedServerProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; +const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + const PROVIDER = "claudeAgent" as const; +const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, { slug: "claude-opus-4-6", name: "Claude Opus 4.6", @@ -84,16 +120,29 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +function supportsClaudeOpus47(version: string | null | undefined): boolean { + return version ? compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; +} + +function getBuiltInClaudeModelsForVersion( + version: string | null | undefined, +): ReadonlyArray { + if (supportsClaudeOpus47(version)) { + return BUILT_IN_MODELS; + } + return BUILT_IN_MODELS.filter((model) => model.slug !== "claude-opus-4-7"); +} + +function formatClaudeOpus47UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; +} + export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? + DEFAULT_CLAUDE_MODEL_CAPABILITIES ); } @@ -273,18 +322,6 @@ function extractClaudeAuthMethodFromOutput(result: CommandResult): string | unde return Option.getOrUndefined(findAuthMethod(parsed.success)); } -// ── Dynamic model capability adjustment ───────────────────────────── - -/** Subscription types where the 1M context window is included in the plan. */ -const PREMIUM_SUBSCRIPTION_TYPES = new Set([ - "max", - "maxplan", - "max5", - "max20", - "enterprise", - "team", -]); - function toTitleCaseWords(value: string): string { return value .split(/[\s_-]+/g) @@ -345,44 +382,77 @@ function claudeAuthMetadata(input: { return undefined; } -/** - * Adjust the built-in model list based on the user's detected subscription. - * - * - Premium tiers (Max, Enterprise, Team): 1M context becomes the default. - * - Other tiers (Pro, free, unknown): 200k context stays the default; - * 1M remains available as a manual option so users can still enable it. - */ -export function adjustModelsForSubscription( - baseModels: ReadonlyArray, - subscriptionType: string | undefined, -): ReadonlyArray { - const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); - if (!normalized || !PREMIUM_SUBSCRIPTION_TYPES.has(normalized)) { - return baseModels; - } +// ── SDK capability probe ──────────────────────────────────────────── - // Flip 1M to be the default for premium users - return baseModels.map((model) => { - const caps = model.capabilities; - if (!caps || caps.contextWindowOptions.length === 0) return model; +const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; - return { - ...model, - capabilities: { - ...caps, - contextWindowOptions: caps.contextWindowOptions.map((opt) => - opt.value === "1m" - ? { value: opt.value, label: opt.label, isDefault: true as const } - : { value: opt.value, label: opt.label }, - ), - }, - }; - }); +function nonEmptyProbeString(value: string): string | undefined { + const candidate = value.trim(); + return candidate ? candidate : undefined; } -// ── SDK capability probe ──────────────────────────────────────────── +function parseClaudeInitializationCommands( + commands: ReadonlyArray | undefined, +): ReadonlyArray { + return dedupeSlashCommands( + (commands ?? []).flatMap((command) => { + const name = nonEmptyProbeString(command.name); + if (!name) { + return []; + } + + const description = nonEmptyProbeString(command.description); + const argumentHint = nonEmptyProbeString(command.argumentHint); + + return [ + { + name, + ...(description ? { description } : {}), + ...(argumentHint ? { input: { hint: argumentHint } } : {}), + } satisfies ServerProviderSlashCommand, + ]; + }), + ); +} -const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +function dedupeSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const commandsByName = new Map(); + + for (const command of commands) { + const name = nonEmptyProbeString(command.name); + if (!name) { + continue; + } + + const key = name.toLowerCase(); + const existing = commandsByName.get(key); + if (!existing) { + commandsByName.set(key, { + ...command, + name, + }); + continue; + } + + commandsByName.set(key, { + ...existing, + ...(existing.description + ? {} + : command.description + ? { description: command.description } + : {}), + ...(existing.input?.hint + ? {} + : command.input?.hint + ? { input: { hint: command.input.hint } } + : {}), + }); + } + + return [...commandsByName.values()]; +} /** * Probe account information by spawning a lightweight Claude Agent SDK @@ -405,13 +475,16 @@ const probeClaudeCapabilities = (binaryPath: string) => { pathToClaudeCodeExecutable: binaryPath, abortController: abort, maxTurns: 0, - settingSources: [], + settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, }, }); const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; + return { + subscriptionType: init.account?.subscriptionType, + slashCommands: parseClaudeInitializationCommands(init.commands), + }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -440,6 +513,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( resolveSubscriptionType?: (binaryPath: string) => Effect.Effect, + resolveSlashCommands?: ( + binaryPath: string, + ) => Effect.Effect | undefined>, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -450,14 +526,19 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( Effect.map((settings) => settings.providers.claudeAgent), ); const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels); + const allModels = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); if (!claudeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, enabled: false, checkedAt, - models, + models: allModels, probe: { installed: false, version: null, @@ -479,7 +560,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: !isCommandMissingCause(error), version: null, @@ -497,7 +578,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: null, @@ -517,7 +598,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: parsedVersion, @@ -530,6 +611,24 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const models = providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(parsedVersion), + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + const opus47UpgradeMessage = supportsClaudeOpus47(parsedVersion) + ? undefined + : formatClaudeOpus47UpgradeMessage(parsedVersion); + + const slashCommands = + (resolveSlashCommands + ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( + Effect.orElseSucceed(() => undefined), + ) + : undefined) ?? []; + const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + // ── Auth check + subscription detection ──────────────────────────── const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( @@ -555,8 +654,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); } - const resolvedModels = adjustModelsForSubscription(models, subscriptionType); - // ── Handle auth results (same logic as before, adjusted models) ── if (Result.isFailure(authProbe)) { @@ -565,7 +662,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -584,7 +682,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -601,7 +700,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -610,11 +710,55 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...parsed.auth, ...(authMetadata ? authMetadata : {}), }, - ...(parsed.message ? { message: parsed.message } : {}), + ...(parsed.message + ? { message: parsed.message } + : opus47UpgradeMessage + ? { message: opus47UpgradeMessage } + : {}), }, }); }); +const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + + if (!claudeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude provider status has not been checked in this session yet.", + }, + }); +}; + export const ClaudeProviderLive = Layer.effect( ClaudeProvider, Effect.gen(function* () { @@ -624,12 +768,18 @@ export const ClaudeProviderLive = Layer.effect( const subscriptionProbeCache = yield* Cache.make({ capacity: 1, timeToLive: Duration.minutes(5), - lookup: (binaryPath: string) => - probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)), + lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), }); - const checkProvider = checkClaudeProviderStatus((binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath), + const checkProvider = checkClaudeProviderStatus( + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.subscriptionType), + ), + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.slashCommands), + ), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -644,6 +794,7 @@ export const ClaudeProviderLive = Layer.effect( Stream.map((settings) => settings.providers.claudeAgent), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingClaudeProvider, checkProvider, }); }), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index b5eb873e85..c4ee33b776 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -28,10 +28,10 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; -const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asItemId = (value: string): ProviderItemId => ProviderItemId.make(value); class FakeCodexManager extends CodexAppServerManager { public startSessionImpl = vi.fn( @@ -238,14 +238,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { .pipe(Effect.result); assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } - assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { - return; - } assert.equal(result.failure.provider, "codex"); assert.equal(result.failure.threadId, "sess-missing"); assert.equal(result.failure.cause instanceof Error, true); @@ -556,7 +549,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "serverRequest/resolved", - requestId: ApprovalRequestId.makeUnsafe("req-1"), + requestId: ApprovalRequestId.make("req-1"), payload: { request: { method: "item/commandExecution/requestApproval", @@ -592,7 +585,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "serverRequest/resolved", - requestId: ApprovalRequestId.makeUnsafe("req-file-read-1"), + requestId: ApprovalRequestId.make("req-file-read-1"), payload: { request: { method: "item/fileRead/requestApproval", @@ -710,7 +703,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "item/tool/requestUserInput", - requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + requestId: ApprovalRequestId.make("req-user-input-1"), payload: { questions: [ { @@ -723,6 +716,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { description: "Allow workspace writes only", }, ], + multiSelect: true, }, ], }, @@ -734,7 +728,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "item/tool/requestUserInput/answered", - requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + requestId: ApprovalRequestId.make("req-user-input-1"), payload: { answers: { sandbox_mode: { @@ -749,6 +743,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (events[0]?.type === "user-input.requested") { assert.equal(events[0].requestId, "req-user-input-1"); assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); + assert.equal(events[0].payload.questions[0]?.multiSelect, true); } assert.equal(events[1]?.type, "user-input.resolved"); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8b9f3b59e7..60de91b79a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -22,7 +22,7 @@ import { TurnId, ProviderSendTurnInput, } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; +import { Effect, FileSystem, Layer, Queue, Schema, Context, Stream } from "effect"; import { ProviderAdapterProcessError, @@ -46,23 +46,16 @@ const PROVIDER = "codex" as const; export interface CodexAdapterLiveOptions { readonly manager?: CodexAppServerManager; - readonly makeManager?: (services?: ServiceMap.ServiceMap) => CodexAppServerManager; + readonly makeManager?: (services?: Context.Context) => CodexAppServerManager; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - function toSessionError( threadId: ThreadId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { - const normalized = toMessage(cause, "").toLowerCase(); + const normalized = cause instanceof Error ? cause.message.toLowerCase() : ""; if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, @@ -88,7 +81,7 @@ function toRequestError(threadId: ThreadId, method: string, cause: unknown): Pro return new ProviderAdapterRequestError({ provider: PROVIDER, method, - detail: toMessage(cause, `${method} failed`), + detail: cause instanceof Error ? `${method} failed: ${cause.message}` : `${method} failed`, cause, }); } @@ -162,11 +155,11 @@ function normalizeCodexTokenUsage(value: unknown): ThreadTokenUsageSnapshot | un } function toTurnId(value: string | undefined): TurnId | undefined { - return value?.trim() ? TurnId.makeUnsafe(value) : undefined; + return value?.trim() ? TurnId.make(value) : undefined; } function toProviderItemId(value: string | undefined): ProviderItemId | undefined { - return value?.trim() ? ProviderItemId.makeUnsafe(value) : undefined; + return value?.trim() ? ProviderItemId.make(value) : undefined; } function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "interrupted" { @@ -382,6 +375,7 @@ function toUserInputQuestions(payload: Record | undefined) { header, question: prompt, options, + multiSelect: question.multiSelect === true, }; }) .filter( @@ -392,6 +386,7 @@ function toUserInputQuestions(payload: Record | undefined) { header: string; question: string; options: Array<{ label: string; description: string }>; + multiSelect: boolean; } => question !== undefined, ); @@ -452,15 +447,15 @@ function extractProposedPlanMarkdown(text: string | undefined): string | undefin } function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId { - return RuntimeItemId.makeUnsafe(itemId); + return RuntimeItemId.make(itemId); } function asRuntimeRequestId(requestId: string): RuntimeRequestId { - return RuntimeRequestId.makeUnsafe(requestId); + return RuntimeRequestId.make(requestId); } function asRuntimeTaskId(taskId: string): RuntimeTaskId { - return RuntimeTaskId.makeUnsafe(taskId); + return RuntimeTaskId.make(taskId); } function codexEventMessage( @@ -1364,7 +1359,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( if (options?.manager) { return options.manager; } - const services = yield* Effect.services(); + const services = yield* Effect.context(); return options?.makeManager?.(services) ?? new CodexAppServerManager(services); }); @@ -1425,7 +1420,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: toMessage(cause, "Failed to start Codex adapter session."), + detail: `Failed to start Codex adapter session: ${cause instanceof Error ? cause.message : String(cause)}.`, cause, }), }); @@ -1453,7 +1448,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( new ProviderAdapterRequestError({ provider: PROVIDER, method: "turn/start", - detail: toMessage(cause, "Failed to read attachment file."), + detail: `Failed to read attachment file: ${cause.message}.`, cause, }), ), @@ -1583,7 +1578,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); const registerListener = Effect.fn("registerListener")(function* () { - const services = yield* Effect.services(); + const services = yield* Effect.context(); const listenerEffect = Effect.fn("listener")(function* (event: ProviderEvent) { yield* writeNativeEvent(event); const runtimeEvents = mapToRuntimeEvents(event, event.threadId); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 667bdf048b..d3f8c742ef 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -5,6 +5,7 @@ import type { ServerProvider, ServerProviderModel, ServerProviderAuth, + ServerProviderSkill, ServerProviderState, } from "@t3tools/contracts"; import { @@ -44,11 +45,24 @@ import { codexAuthSubType, type CodexAccountSnapshot, } from "../codexAccount"; -import { probeCodexAccount } from "../codexAppServer"; +import { probeCodexDiscovery } from "../codexAppServer"; import { CodexProvider } from "../Services/CodexProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; +const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + const PROVIDER = "codex" as const; const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); const BUILT_IN_MODELS: ReadonlyArray = [ @@ -159,13 +173,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ export function getCodexModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? + DEFAULT_CODEX_MODEL_CAPABILITIES ); } @@ -296,8 +305,9 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeCodexCapabilities = (input: { readonly binaryPath: string; readonly homePath?: string; + readonly cwd: string; }) => - Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe( + Effect.tryPromise((signal) => probeCodexDiscovery({ ...input, signal })).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -326,6 +336,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly binaryPath: string; readonly homePath?: string; }) => Effect.Effect, + resolveSkills?: (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + }) => Effect.Effect | undefined>, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -339,7 +354,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu Effect.map((settings) => settings.providers.codex), ); const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, codexSettings.customModels); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + codexSettings.customModels, + DEFAULT_CODEX_MODEL_CAPABILITIES, + ); if (!codexSettings.enabled) { return buildServerProvider({ @@ -376,7 +396,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Codex CLI (`codex`) is not installed or not on PATH." - : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : `Failed to execute Codex CLI health check: ${error.message}.`, }, }); } @@ -436,12 +456,22 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } + const skills = + (resolveSkills + ? yield* resolveSkills({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + cwd: process.cwd(), + }).pipe(Effect.orElseSucceed(() => undefined)) + : undefined) ?? []; + if (yield* hasCustomModelProvider) { return buildServerProvider({ provider: PROVIDER, enabled: codexSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version: parsedVersion, @@ -471,15 +501,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, status: "warning", auth: { status: "unknown" }, - message: - error instanceof Error - ? `Could not verify Codex authentication status: ${error.message}.` - : "Could not verify Codex authentication status.", + message: `Could not verify Codex authentication status: ${error.message}.`, }, }); } @@ -490,6 +518,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -508,6 +537,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -522,6 +552,46 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); }); +const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider => { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + codexSettings.customModels, + DEFAULT_CODEX_MODEL_CAPABILITIES, + ); + + if (!codexSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex provider status has not been checked in this session yet.", + }, + }); +}; + export const CodexProviderLive = Layer.effect( CodexProvider, Effect.gen(function* () { @@ -533,16 +603,29 @@ export const CodexProviderLive = Layer.effect( capacity: 4, timeToLive: Duration.minutes(5), lookup: (key: string) => { - const [binaryPath, homePath] = JSON.parse(key) as [string, string | undefined]; + const [binaryPath, homePath, cwd] = JSON.parse(key) as [string, string | undefined, string]; return probeCodexCapabilities({ binaryPath, + cwd, ...(homePath ? { homePath } : {}), }); }, }); - const checkProvider = checkCodexProviderStatus((input) => - Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])), + const getDiscovery = (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + }) => + Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath, input.cwd])); + + const checkProvider = checkCodexProviderStatus( + (input) => + getDiscovery({ + ...input, + cwd: process.cwd(), + }).pipe(Effect.map((discovery) => discovery?.account)), + (input) => getDiscovery(input).pipe(Effect.map((discovery) => discovery?.skills)), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -559,6 +642,7 @@ export const CodexProviderLive = Layer.effect( Stream.map((settings) => settings.providers.codex), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingCodexProvider, checkProvider, }); }), diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 9284ffac45..aa7e5a2692 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -42,11 +42,11 @@ describe("EventNdjsonLogger", () => { yield* logger.write( { threadId: "provider-thread-1", id: "evt-1" }, - ThreadId.makeUnsafe("thread-1"), + ThreadId.make("thread-1"), ); yield* logger.write( { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, - ThreadId.makeUnsafe("thread-2"), + ThreadId.make("thread-2"), ); yield* logger.close(); @@ -178,7 +178,7 @@ describe("EventNdjsonLogger", () => { id: `evt-${index}`, payload: "x".repeat(40), }, - ThreadId.makeUnsafe("thread-rotate"), + ThreadId.make("thread-rotate"), ); } yield* logger.close(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ca27371b61..d03fffe82f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,6 +31,7 @@ import { } from "./CodexProvider"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; +import { ServerConfig } from "../../config"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; import { ProviderRegistry } from "../Services/ProviderRegistry"; @@ -44,6 +45,7 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), isRunning: Effect.succeed(false), kill: () => Effect.void, + unref: Effect.succeed(Effect.void), stdin: Sink.drain, stdout: Stream.make(encoder.encode(result.stdout)), stderr: Stream.make(encoder.encode(result.stderr)), @@ -220,6 +222,49 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes probed codex skills in the provider snapshot", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus( + () => + Effect.succeed({ + type: "chatgpt" as const, + planType: "pro" as const, + sparkEnabled: true, + }), + () => + Effect.succeed([ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]), + ); + + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + }).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: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides spark from codex models for unsupported chatgpt plans", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -498,6 +543,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], + slashCommands: [], + skills: [], }, { provider: "claudeAgent", @@ -508,12 +555,69 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], + slashCommands: [], + skills: [], }, ] as const satisfies ReadonlyArray; assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); + it.effect("does not probe provider health during registry startup", () => + Effect.gen(function* () { + let spawnCount = 0; + const serverSettings = yield* makeMutableServerSettingsService(); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + spawnCount += 1; + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, []); + assert.strictEqual(spawnCount, 0); + + const refreshed = yield* registry.refresh("codex"); + assert.strictEqual(spawnCount > 0, true); + assert.strictEqual( + refreshed.find((provider) => provider.provider === "codex")?.status, + "ready", + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("reruns codex health when codex provider settings change", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService(); @@ -521,6 +625,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { const joined = args.join(" "); @@ -548,8 +657,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const registry = yield* ProviderRegistry; const initial = yield* registry.getProviders; + assert.deepStrictEqual(initial, []); + + const refreshed = yield* registry.refresh("codex"); assert.strictEqual( - initial.find((status) => status.provider === "codex")?.status, + refreshed.find((status) => status.provider === "codex")?.status, "ready", ); @@ -861,6 +973,69 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect( + "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", + () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); + if (!opus47) { + assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); + } + if (!opus47.capabilities) { + assert.fail( + "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", + ); + } + assert.deepStrictEqual( + opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), + { value: "xhigh", label: "Extra High", isDefault: true }, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); @@ -886,6 +1061,85 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes probed claude slash commands in the provider snapshot", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + () => + Effect.succeed([ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("deduplicates probed claude slash commands by name", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + () => + Effect.succeed([ + { + name: "ui", + description: "Explore and refine UI", + }, + { + name: "ui", + input: { hint: "component-or-screen" }, + }, + ]), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "ui", + description: "Explore and refine UI", + input: { hint: "component-or-screen" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fb2f33c293..41bb81e74f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -4,8 +4,9 @@ * @module ProviderRegistryLive */ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; -import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; +import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; +import { ServerConfig } from "../../config"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; @@ -13,6 +14,14 @@ import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; +import { + hydrateCachedProvider, + PROVIDER_CACHE_IDS, + orderProviderSnapshots, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "../providerStatusCache"; const loadProviders = ( codexProvider: CodexProviderShape, @@ -32,61 +41,152 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); - const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider); + const cachePathByProvider = new Map( + PROVIDER_CACHE_IDS.map( + (provider) => + [ + provider, + resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + provider, + }), + ] as const, + ), + ); + const fallbackByProvider = new Map( + fallbackProviders.map((provider) => [provider.provider, provider] as const), ); + const cachedProviders = yield* Effect.forEach( + PROVIDER_CACHE_IDS, + (provider) => { + const filePath = cachePathByProvider.get(provider)!; + const fallbackProvider = fallbackByProvider.get(provider)!; + return readProviderStatusCache(filePath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.map((cachedProvider) => + cachedProvider === undefined + ? undefined + : hydrateCachedProvider({ + cachedProvider, + fallbackProvider, + }), + ), + ); + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map((providers) => + orderProviderSnapshots( + providers.filter((provider): provider is ServerProvider => provider !== undefined), + ), + ), + ); + const providersRef = yield* Ref.make>(cachedProviders); + + const persistProvider = (provider: ServerProvider) => + writeProviderStatusCache({ + filePath: cachePathByProvider.get(provider.provider)!, + provider, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ); + + const upsertProviders = Effect.fn("upsertProviders")(function* ( + nextProviders: ReadonlyArray, + options?: { + readonly publish?: boolean; + }, + ) { + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const mergedProviders = new Map( + previousProviders.map((provider) => [provider.provider, provider] as const), + ); + + for (const provider of nextProviders) { + mergedProviders.set(provider.provider, provider); + } - const syncProviders = Effect.fn("syncProviders")(function* (options?: { - readonly publish?: boolean; - }) { - const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); - yield* Ref.set(providersRef, providers); + const providers = orderProviderSnapshots([...mergedProviders.values()]); + return [[previousProviders, providers] as const, providers]; + }, + ); - if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { - yield* PubSub.publish(changesPubSub, providers); + if (haveProvidersChanged(previousProviders, providers)) { + yield* Effect.forEach(nextProviders, persistProvider, { + concurrency: "unbounded", + discard: true, + }); + if (options?.publish !== false) { + yield* PubSub.publish(changesPubSub, providers); + } } return providers; }); - yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); - yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); + const syncProvider = Effect.fn("syncProvider")(function* ( + provider: ServerProvider, + options?: { + readonly publish?: boolean; + }, + ) { + return yield* upsertProviders([provider], options); + }); const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { switch (provider) { case "codex": - yield* codexProvider.refresh; - break; + return yield* codexProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ); case "claudeAgent": - yield* claudeProvider.refresh; - break; + return yield* claudeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ); default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); - break; + return yield* Effect.all( + [ + codexProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ), + claudeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ), + ], + { + concurrency: "unbounded", + discard: true, + }, + ).pipe(Effect.andThen(Ref.get(providersRef))); } - return yield* syncProviders(); }); + yield* Stream.runForEach(codexProvider.streamChanges, (provider) => + syncProvider(provider), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => + syncProvider(provider), + ).pipe(Effect.forkScoped); + return { - getProviders: syncProviders({ publish: false }).pipe( - Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => []), - ), + getProviders: Ref.get(providersRef), refresh: (provider?: ProviderKind) => refresh(provider).pipe( Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => []), + Effect.orElseSucceed(() => [] as ReadonlyArray), ), get streamChanges() { return Stream.fromPubSub(changesPubSub); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index fc3c9cf25c..56f9f8d65c 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -47,10 +47,10 @@ import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; const defaultServerSettingsLayer = ServerSettingsService.layerTest(); -const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); -const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); type LegacyProviderRuntimeEvent = { readonly type: string; @@ -102,7 +102,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { return Effect.succeed({ threadId: input.threadId, - turnId: TurnId.makeUnsafe(`turn-${String(input.threadId)}`), + turnId: TurnId.make(`turn-${String(input.threadId)}`), }); }, ); @@ -358,7 +358,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ provider: "codex", - threadId: ThreadId.makeUnsafe("thread-stale"), + threadId: ThreadId.make("thread-stale"), }); }).pipe(Effect.provide(directoryLayer)); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index d23b247f21..3ffd6941ad 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -36,7 +36,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const initialThreadId = ThreadId.makeUnsafe("thread-1"); + const initialThreadId = ThreadId.make("thread-1"); yield* directory.upsert({ provider: "codex", @@ -54,7 +54,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(resolvedBinding.value.threadId, initialThreadId); } - const nextThreadId = ThreadId.makeUnsafe("thread-2"); + const nextThreadId = ThreadId.make("thread-2"); yield* directory.upsert({ provider: "codex", @@ -93,7 +93,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const threadId = ThreadId.makeUnsafe("thread-runtime"); + const threadId = ThreadId.make("thread-runtime"); yield* directory.upsert({ provider: "codex", @@ -137,7 +137,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const threadId = ThreadId.makeUnsafe("thread-provider-change"); + const threadId = ThreadId.make("thread-provider-change"); yield* runtimeRepository.upsert({ threadId, @@ -169,7 +169,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const dbPath = path.join(tempDir, "orchestration.sqlite"); const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); - const threadId = ThreadId.makeUnsafe("thread-restart"); + const threadId = ThreadId.make("thread-restart"); yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Services/ClaudeAdapter.ts b/apps/server/src/provider/Services/ClaudeAdapter.ts index 3a3f616ea5..e8c33bd8e4 100644 --- a/apps/server/src/provider/Services/ClaudeAdapter.ts +++ b/apps/server/src/provider/Services/ClaudeAdapter.ts @@ -5,12 +5,12 @@ * provider runtime events. It does not perform cross-provider routing, shared * event fan-out, or checkpoint orchestration. * - * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * Uses Effect `Context.Service` for dependency injection and returns the * shared provider-adapter error channel with `provider: "claudeAgent"` context. * * @module ClaudeAdapter */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; @@ -25,6 +25,6 @@ export interface ClaudeAdapterShape extends ProviderAdapterShape()( +export class ClaudeAdapter extends Context.Service()( "t3/provider/Services/ClaudeAdapter", ) {} diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts index 18ee8a4f6d..7f90c549c6 100644 --- a/apps/server/src/provider/Services/ClaudeProvider.ts +++ b/apps/server/src/provider/Services/ClaudeProvider.ts @@ -1,9 +1,9 @@ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { ServerProviderShape } from "./ServerProvider"; export interface ClaudeProviderShape extends ServerProviderShape {} -export class ClaudeProvider extends ServiceMap.Service()( +export class ClaudeProvider extends Context.Service()( "t3/provider/Services/ClaudeProvider", ) {} diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts index c9f944bb96..e7a5508c9c 100644 --- a/apps/server/src/provider/Services/CodexAdapter.ts +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -5,12 +5,12 @@ * Codex provider events. It does not perform cross-provider routing, shared * event fan-out, or checkpoint orchestration. * - * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * Uses Effect `Context.Service` for dependency injection and returns the * shared provider-adapter error channel with `provider: "codex"` context. * * @module CodexAdapter */ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; @@ -25,6 +25,6 @@ export interface CodexAdapterShape extends ProviderAdapterShape()( +export class CodexAdapter extends Context.Service()( "t3/provider/Services/CodexAdapter", ) {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts index 2e9b57c89b..6820d4cb4f 100644 --- a/apps/server/src/provider/Services/CodexProvider.ts +++ b/apps/server/src/provider/Services/CodexProvider.ts @@ -1,9 +1,9 @@ -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { ServerProviderShape } from "./ServerProvider"; export interface CodexProviderShape extends ServerProviderShape {} -export class CodexProvider extends ServiceMap.Service()( +export class CodexProvider extends Context.Service()( "t3/provider/Services/CodexProvider", ) {} diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts index 490c4d3d14..b8e9d4b21c 100644 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -8,7 +8,7 @@ * @module ProviderAdapterRegistry */ import type { ProviderKind } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect } from "effect"; import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; @@ -34,7 +34,7 @@ export interface ProviderAdapterRegistryShape { /** * ProviderAdapterRegistry - Service tag for provider adapter lookup. */ -export class ProviderAdapterRegistry extends ServiceMap.Service< +export class ProviderAdapterRegistry extends Context.Service< ProviderAdapterRegistry, ProviderAdapterRegistryShape >()("t3/provider/Services/ProviderAdapterRegistry") {} diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 80710691c1..2e04fa253b 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -7,7 +7,7 @@ * @module ProviderRegistry */ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Stream } from "effect"; export interface ProviderRegistryShape { @@ -27,6 +27,6 @@ export interface ProviderRegistryShape { readonly streamChanges: Stream.Stream>; } -export class ProviderRegistry extends ServiceMap.Service()( +export class ProviderRegistry extends Context.Service()( "t3/provider/Services/ProviderRegistry", ) {} diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index ebfe8c8ab1..1e461fcd1c 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -6,7 +6,7 @@ * session-scoped calls via `ProviderSessionDirectory`, and exposes one unified * provider event stream to callers. * - * Uses Effect `ServiceMap.Service` for dependency injection and returns typed + * Uses Effect `Context.Service` for dependency injection and returns typed * domain errors for validation, session, codex, and checkpoint workflows. * * @module ProviderService @@ -24,7 +24,7 @@ import type { ThreadId, ProviderTurnStartResult, } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; +import { Context } from "effect"; import type { Effect, Stream } from "effect"; import type { ProviderServiceError } from "../Errors.ts"; @@ -110,6 +110,6 @@ export interface ProviderServiceShape { /** * ProviderService - Service tag for provider orchestration. */ -export class ProviderService extends ServiceMap.Service()( +export class ProviderService extends Context.Service()( "t3/provider/Services/ProviderService", ) {} diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 3a374976b0..aa0483620b 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -4,7 +4,7 @@ import type { RuntimeMode, ThreadId, } from "@t3tools/contracts"; -import { Option, ServiceMap } from "effect"; +import { Option, Context } from "effect"; import type { Effect } from "effect"; import type { @@ -51,7 +51,7 @@ export interface ProviderSessionDirectoryShape { >; } -export class ProviderSessionDirectory extends ServiceMap.Service< +export class ProviderSessionDirectory extends Context.Service< ProviderSessionDirectory, ProviderSessionDirectoryShape >()("t3/provider/Services/ProviderSessionDirectory") {} diff --git a/apps/server/src/provider/cliVersion.test.ts b/apps/server/src/provider/cliVersion.test.ts new file mode 100644 index 0000000000..a9c1721c4e --- /dev/null +++ b/apps/server/src/provider/cliVersion.test.ts @@ -0,0 +1,17 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; + +describe("cliVersion", () => { + it("normalizes versions with a missing patch segment", () => { + assert.strictEqual(normalizeCliVersion("2.1"), "2.1.0"); + }); + + it("compares prerelease versions before stable versions", () => { + assert.isTrue(compareCliVersions("2.1.111-beta.1", "2.1.111") < 0); + }); + + it("rejects malformed numeric segments", () => { + assert.isTrue(compareCliVersions("1.2.3abc", "1.2.10") > 0); + }); +}); diff --git a/apps/server/src/provider/cliVersion.ts b/apps/server/src/provider/cliVersion.ts new file mode 100644 index 0000000000..6308a2ff52 --- /dev/null +++ b/apps/server/src/provider/cliVersion.ts @@ -0,0 +1,123 @@ +interface ParsedCliSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray; +} + +const CLI_VERSION_NUMBER_SEGMENT = /^\d+$/; + +export function normalizeCliVersion(version: string): string { + const [main, prerelease] = version.trim().split("-", 2); + const segments = (main ?? "") + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 2) { + segments.push("0"); + } + + return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); +} + +function parseCliSemver(version: string): ParsedCliSemver | null { + const normalized = normalizeCliVersion(version); + const [main = "", prerelease] = normalized.split("-", 2); + const segments = main.split("."); + if (segments.length !== 3) { + return null; + } + + const [majorSegment, minorSegment, patchSegment] = segments; + if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { + return null; + } + if ( + !CLI_VERSION_NUMBER_SEGMENT.test(majorSegment) || + !CLI_VERSION_NUMBER_SEGMENT.test(minorSegment) || + !CLI_VERSION_NUMBER_SEGMENT.test(patchSegment) + ) { + return null; + } + + const major = Number.parseInt(majorSegment, 10); + const minor = Number.parseInt(minorSegment, 10); + const patch = Number.parseInt(patchSegment, 10); + if (![major, minor, patch].every(Number.isInteger)) { + return null; + } + + return { + major, + minor, + patch, + prerelease: + prerelease + ?.split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) ?? [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) { + return -1; + } + if (rightNumeric) { + return 1; + } + return left.localeCompare(right); +} + +export function compareCliVersions(left: string, right: string): number { + const parsedLeft = parseCliSemver(left); + const parsedRight = parseCliSemver(right); + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + if (parsedLeft.major !== parsedRight.major) { + return parsedLeft.major - parsedRight.major; + } + if (parsedLeft.minor !== parsedRight.minor) { + return parsedLeft.minor - parsedRight.minor; + } + if (parsedLeft.patch !== parsedRight.patch) { + return parsedLeft.patch - parsedRight.patch; + } + + if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { + return 0; + } + if (parsedLeft.prerelease.length === 0) { + return 1; + } + if (parsedRight.prerelease.length === 0) { + return -1; + } + + const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = parsedLeft.prerelease[index]; + const rightIdentifier = parsedRight.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (comparison !== 0) { + return comparison; + } + } + + return 0; +} diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index d25fc3533e..7b3c9eeb79 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; +import type { ServerProviderSkill } from "@t3tools/contracts"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; interface JsonRpcProbeResponse { @@ -10,10 +11,74 @@ interface JsonRpcProbeResponse { }; } +export interface CodexDiscoverySnapshot { + readonly account: CodexAccountSnapshot; + readonly skills: ReadonlyArray; +} + function readErrorMessage(response: JsonRpcProbeResponse): string | undefined { return typeof response.error?.message === "string" ? response.error.message : undefined; } +function readObject(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function readArray(value: unknown): ReadonlyArray | undefined { + return Array.isArray(value) ? value : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nonEmptyTrimmed(value: unknown): string | undefined { + const candidate = readString(value)?.trim(); + return candidate ? candidate : undefined; +} + +function parseCodexSkillsResult(result: unknown, cwd: string): ReadonlyArray { + const resultRecord = readObject(result); + const dataBuckets = readArray(resultRecord?.data) ?? []; + const matchingBucket = dataBuckets.find( + (value) => nonEmptyTrimmed(readObject(value)?.cwd) === cwd, + ); + const rawSkills = + readArray(readObject(matchingBucket)?.skills) ?? readArray(resultRecord?.skills) ?? []; + + return rawSkills.flatMap((value) => { + const skill = readObject(value); + const display = readObject(skill?.interface); + const name = nonEmptyTrimmed(skill?.name); + const path = nonEmptyTrimmed(skill?.path); + if (!name || !path) { + return []; + } + + return [ + { + name, + path, + enabled: skill?.enabled !== false, + ...(nonEmptyTrimmed(skill?.description) + ? { description: nonEmptyTrimmed(skill?.description) } + : {}), + ...(nonEmptyTrimmed(skill?.scope) ? { scope: nonEmptyTrimmed(skill?.scope) } : {}), + ...(nonEmptyTrimmed(display?.displayName) + ? { displayName: nonEmptyTrimmed(display?.displayName) } + : {}), + ...(nonEmptyTrimmed(skill?.shortDescription) || nonEmptyTrimmed(display?.shortDescription) + ? { + shortDescription: + nonEmptyTrimmed(skill?.shortDescription) ?? + nonEmptyTrimmed(display?.shortDescription), + } + : {}), + } satisfies ServerProviderSkill, + ]; + }); +} + export function buildCodexInitializeParams() { return { clientInfo: { @@ -40,11 +105,12 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo child.kill(); } -export async function probeCodexAccount(input: { +export async function probeCodexDiscovery(input: { readonly binaryPath: string; readonly homePath?: string; + readonly cwd: string; readonly signal?: AbortSignal; -}): Promise { +}): Promise { return await new Promise((resolve, reject) => { const child = spawn(input.binaryPath, ["app-server"], { env: { @@ -57,6 +123,8 @@ export async function probeCodexAccount(input: { const output = readline.createInterface({ input: child.stdout }); let completed = false; + let account: CodexAccountSnapshot | undefined; + let skills: ReadonlyArray | undefined; const cleanup = () => { output.removeAllListeners(); @@ -79,15 +147,25 @@ export async function probeCodexAccount(input: { reject( error instanceof Error ? error - : new Error(`Codex account probe failed: ${String(error)}.`), + : new Error(`Codex discovery probe failed: ${String(error)}.`), ), ); + const maybeResolve = () => { + if (account && skills !== undefined) { + const resolvedAccount = account; + const resolvedSkills = skills; + finish(() => resolve({ account: resolvedAccount, skills: resolvedSkills })); + } + }; + if (input.signal?.aborted) { - fail(new Error("Codex account probe aborted.")); + fail(new Error("Codex discovery probe aborted.")); return; } - input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted."))); + input.signal?.addEventListener("abort", () => + fail(new Error("Codex discovery probe aborted.")), + ); const writeMessage = (message: unknown) => { if (!child.stdin.writable) { @@ -103,7 +181,7 @@ export async function probeCodexAccount(input: { try { parsed = JSON.parse(line); } catch { - fail(new Error("Received invalid JSON from codex app-server during account probe.")); + fail(new Error("Received invalid JSON from codex app-server during discovery probe.")); return; } @@ -120,18 +198,27 @@ export async function probeCodexAccount(input: { } writeMessage({ method: "initialized" }); - writeMessage({ id: 2, method: "account/read", params: {} }); + writeMessage({ id: 2, method: "skills/list", params: { cwds: [input.cwd] } }); + writeMessage({ id: 3, method: "account/read", params: {} }); return; } if (response.id === 2) { + const errorMessage = readErrorMessage(response); + skills = errorMessage ? [] : parseCodexSkillsResult(response.result, input.cwd); + maybeResolve(); + return; + } + + if (response.id === 3) { const errorMessage = readErrorMessage(response); if (errorMessage) { fail(new Error(`account/read failed: ${errorMessage}`)); return; } - finish(() => resolve(readCodexAccountSnapshot(response.result))); + account = readCodexAccountSnapshot(response.result); + maybeResolve(); } }); diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts index 544020016c..8719483350 100644 --- a/apps/server/src/provider/codexCliVersion.ts +++ b/apps/server/src/provider/codexCliVersion.ts @@ -1,121 +1,10 @@ +import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; + const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; export const MINIMUM_CODEX_CLI_VERSION = "0.37.0"; -interface ParsedSemver { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray; -} - -function normalizeCodexVersion(version: string): string { - const [main, prerelease] = version.trim().split("-", 2); - const segments = (main ?? "") - .split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0); - - if (segments.length === 2) { - segments.push("0"); - } - - return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); -} - -function parseSemver(version: string): ParsedSemver | null { - const normalized = normalizeCodexVersion(version); - const [main = "", prerelease] = normalized.split("-", 2); - const segments = main.split("."); - if (segments.length !== 3) { - return null; - } - - const [majorSegment, minorSegment, patchSegment] = segments; - if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { - return null; - } - - const major = Number.parseInt(majorSegment, 10); - const minor = Number.parseInt(minorSegment, 10); - const patch = Number.parseInt(patchSegment, 10); - if (![major, minor, patch].every(Number.isInteger)) { - return null; - } - - return { - major, - minor, - patch, - prerelease: - prerelease - ?.split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) ?? [], - }; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftNumeric = /^\d+$/.test(left); - const rightNumeric = /^\d+$/.test(right); - - if (leftNumeric && rightNumeric) { - return Number.parseInt(left, 10) - Number.parseInt(right, 10); - } - if (leftNumeric) { - return -1; - } - if (rightNumeric) { - return 1; - } - return left.localeCompare(right); -} - -export function compareCodexCliVersions(left: string, right: string): number { - const parsedLeft = parseSemver(left); - const parsedRight = parseSemver(right); - if (!parsedLeft || !parsedRight) { - return left.localeCompare(right); - } - - if (parsedLeft.major !== parsedRight.major) { - return parsedLeft.major - parsedRight.major; - } - if (parsedLeft.minor !== parsedRight.minor) { - return parsedLeft.minor - parsedRight.minor; - } - if (parsedLeft.patch !== parsedRight.patch) { - return parsedLeft.patch - parsedRight.patch; - } - - if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { - return 0; - } - if (parsedLeft.prerelease.length === 0) { - return 1; - } - if (parsedRight.prerelease.length === 0) { - return -1; - } - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (comparison !== 0) { - return comparison; - } - } - - return 0; -} +export const compareCodexCliVersions = compareCliVersions; export function parseCodexCliVersion(output: string): string | null { const match = CODEX_VERSION_PATTERN.exec(output); @@ -123,12 +12,7 @@ export function parseCodexCliVersion(output: string): string | null { return null; } - const parsed = parseSemver(match[1]); - if (!parsed) { - return null; - } - - return normalizeCodexVersion(match[1]); + return normalizeCliVersion(match[1]); } export function isCodexCliVersionSupported(version: string): boolean { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 59aeac1ab5..856594c1f0 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -11,6 +11,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; + readonly initialSnapshot: (settings: Settings) => ServerProvider; readonly checkProvider: Effect.Effect; readonly refreshInterval?: Duration.Input; }): Effect.fn.Return { @@ -20,7 +21,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( PubSub.shutdown, ); const initialSettings = yield* input.getSettings; - const initialSnapshot = yield* input.checkProvider; + const initialSnapshot = input.initialSnapshot(initialSettings); const snapshotRef = yield* Ref.make(initialSnapshot); const settingsRef = yield* Ref.make(initialSettings); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e1243c4bd0..40246563ae 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,9 @@ import type { + ModelCapabilities, ServerProvider, ServerProviderAuth, + ServerProviderSkill, + ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; @@ -31,8 +34,7 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -export function isCommandMissingCause(error: unknown): boolean { - if (!(error instanceof Error)) return false; +export function isCommandMissingCause(error: Error): boolean { const lower = error.message.toLowerCase(); return lower.includes("enoent") || lower.includes("notfound"); } @@ -102,6 +104,7 @@ export function providerModelsFromSettings( builtInModels: ReadonlyArray, provider: ServerProvider["provider"], customModels: ReadonlyArray, + customModelCapabilities: ModelCapabilities, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); @@ -117,7 +120,7 @@ export function providerModelsFromSettings( slug: normalized, name: normalized, isCustom: true, - capabilities: null, + capabilities: customModelCapabilities, }); } @@ -129,6 +132,8 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + slashCommands?: ReadonlyArray; + skills?: ReadonlyArray; probe: ProviderProbeResult; }): ServerProvider { return { @@ -141,6 +146,8 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, + slashCommands: [...(input.slashCommands ?? [])], + skills: [...(input.skills ?? [])], }; } diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts new file mode 100644 index 0000000000..5f0d88322e --- /dev/null +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -0,0 +1,136 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { ServerProvider } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; + +import { + hydrateCachedProvider, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "./providerStatusCache"; + +const makeProvider = ( + provider: ServerProvider["provider"], + overrides?: Partial, +): ServerProvider => ({ + provider, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, +}); + +it.layer(NodeServices.layer)("providerStatusCache", (it) => { + it.effect("writes and reads provider status snapshots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); + const codexProvider = makeProvider("codex"); + const claudeProvider = makeProvider("claudeAgent", { + status: "warning", + auth: { status: "unknown" }, + }); + const codexPath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "codex", + }); + const claudePath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "claudeAgent", + }); + + yield* writeProviderStatusCache({ + filePath: codexPath, + provider: codexProvider, + }); + yield* writeProviderStatusCache({ + filePath: claudePath, + provider: claudeProvider, + }); + + assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); + assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); + }), + ); + + it("hydrates cached provider status onto current settings-derived models", () => { + const cachedCodex = makeProvider("codex", { + checkedAt: "2026-04-10T12:00:00.000Z", + models: [], + message: "Cached message", + skills: [ + { + name: "github:gh-fix-ci", + path: "/tmp/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + }, + ], + }); + const fallbackCodex = makeProvider("codex", { + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + message: "Pending refresh", + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: fallbackCodex, + }), + { + ...fallbackCodex, + installed: cachedCodex.installed, + version: cachedCodex.version, + status: cachedCodex.status, + auth: cachedCodex.auth, + checkedAt: cachedCodex.checkedAt, + slashCommands: cachedCodex.slashCommands, + skills: cachedCodex.skills, + message: cachedCodex.message, + }, + ); + }); + + it("ignores stale cached enabled state when the provider is now disabled", () => { + const cachedCodex = makeProvider("codex", { + checkedAt: "2026-04-10T12:00:00.000Z", + message: "Cached ready status", + }); + const disabledFallback = makeProvider("codex", { + enabled: false, + installed: false, + version: null, + status: "disabled", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: disabledFallback, + }), + disabledFallback, + ); + }); +}); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts new file mode 100644 index 0000000000..abedf99d13 --- /dev/null +++ b/apps/server/src/provider/providerStatusCache.ts @@ -0,0 +1,105 @@ +import * as nodePath from "node:path"; +import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; +import { Cause, Effect, FileSystem, Path, Schema } from "effect"; + +export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray< + ServerProvider["provider"] +>; + +const decodeProviderStatusCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(ServerProviderSchema), +); + +const providerOrderRank = (provider: ServerProvider["provider"]): number => { + const rank = PROVIDER_CACHE_IDS.indexOf(provider); + return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; +}; + +export const orderProviderSnapshots = ( + providers: ReadonlyArray, +): ReadonlyArray => + [...providers].toSorted( + (left, right) => providerOrderRank(left.provider) - providerOrderRank(right.provider), + ); + +export const hydrateCachedProvider = (input: { + readonly cachedProvider: ServerProvider; + readonly fallbackProvider: ServerProvider; +}): ServerProvider => { + if ( + !input.fallbackProvider.enabled || + input.cachedProvider.enabled !== input.fallbackProvider.enabled + ) { + return input.fallbackProvider; + } + + const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; + const hydratedProvider: ServerProvider = { + ...fallbackWithoutMessage, + installed: input.cachedProvider.installed, + version: input.cachedProvider.version, + status: input.cachedProvider.status, + auth: input.cachedProvider.auth, + checkedAt: input.cachedProvider.checkedAt, + slashCommands: input.cachedProvider.slashCommands, + skills: input.cachedProvider.skills, + }; + + return input.cachedProvider.message + ? { ...hydratedProvider, message: input.cachedProvider.message } + : hydratedProvider; +}; + +export const resolveProviderStatusCachePath = (input: { + readonly cacheDir: string; + readonly provider: ServerProvider["provider"]; +}) => nodePath.join(input.cacheDir, `${input.provider}.json`); + +export const readProviderStatusCache = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return undefined; + } + + const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return undefined; + } + + return yield* decodeProviderStatusCache(trimmed).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("failed to parse provider status cache, ignoring", { + path: filePath, + issues: Cause.pretty(cause), + }).pipe(Effect.as(undefined)), + onSuccess: Effect.succeed, + }), + ); + }); + +export const writeProviderStatusCache = (input: { + readonly filePath: string; + readonly provider: ServerProvider; +}) => { + const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const encoded = `${JSON.stringify(input.provider, null, 2)}\n`; + + yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fs.writeFileString(tempPath, encoded); + yield* fs.rename(tempPath, input.filePath); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true })); + }), + ), + ); +}; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7a23058fc7..b2c16abb42 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, DEFAULT_SERVER_SETTINGS, + EnvironmentId, + EventId, GitCommandError, KeybindingRule, MessageId, @@ -22,7 +24,17 @@ import { } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; -import { Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect"; +import { + Deferred, + Duration, + Effect, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Stream, +} from "effect"; import { FetchHttpClient, HttpBody, @@ -32,6 +44,7 @@ import { } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vitest"; import type { ServerConfigShape } from "./config.ts"; @@ -44,6 +57,11 @@ import { } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "./git/Services/GitStatusBroadcaster.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -55,7 +73,7 @@ import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { PersistenceSqlError } from "./persistence/Errors.ts"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -73,17 +91,39 @@ import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "./project/Services/RepositoryIdentityResolver.ts"; +import { + ServerEnvironment, + type ServerEnvironmentShape, +} from "./environment/Services/ServerEnvironment.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; -const defaultProjectId = ProjectId.makeUnsafe("project-default"); -const defaultThreadId = ThreadId.makeUnsafe("thread-default"); +const defaultProjectId = ProjectId.make("project-default"); +const defaultThreadId = ThreadId.make("thread-default"); +const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", } as const; - +const testEnvironmentDescriptor = { + environmentId: EnvironmentId.make("environment-test"), + label: "Test environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); return { @@ -142,6 +182,11 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); +const authTestLayer = ServerAuthLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStoreLive), +); + const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { const collector = yield* Effect.acquireRelease( @@ -252,6 +297,7 @@ const buildAppUnderTest = (options?: { open?: Partial; gitCore?: Partial; gitManager?: Partial; + gitStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -260,6 +306,8 @@ const buildAppUnderTest = (options?: { browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; + serverEnvironment?: Partial; + repositoryIdentityResolver?: Partial; }; }) => Effect.gen(function* () { @@ -279,7 +327,7 @@ const buildAppUnderTest = (options?: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", - mode: "web", + mode: "desktop", port: 0, host: "127.0.0.1", cwd: process.cwd(), @@ -288,19 +336,32 @@ const buildAppUnderTest = (options?: { staticDir: undefined, devUrl, noBrowser: true, - authToken: undefined, + startupPresentation: "browser", + desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); - - const appLayer = HttpRouter.serve(makeRoutesLayer, { + const gitManagerLayer = Layer.mock(GitManager)({ + ...options?.layers?.gitManager, + }); + const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster + ? Layer.mock(GitStatusBroadcaster)({ + ...options.layers.gitStatusBroadcaster, + }) + : GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); + + const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, disableLogger: true, }).pipe( Layer.provide( Layer.mock(Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), streamChanges: Stream.empty, ...options?.layers?.keybindings, }), @@ -333,11 +394,8 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitCore, }), ), - Layer.provide( - Layer.mock(GitManager)({ - ...options?.layers?.gitManager, - }), - ), + Layer.provide(gitManagerLayer), + Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -361,6 +419,20 @@ const buildAppUnderTest = (options?: { Layer.provide( Layer.mock(ProjectionSnapshotQuery)({ getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: new Date(0).toISOString(), + }), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), ), @@ -383,6 +455,9 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + ); + + const appLayer = servedRoutesLayer.pipe( Layer.provide( Layer.mock(BrowserTraceCollector)({ record: () => Effect.void, @@ -405,6 +480,20 @@ const buildAppUnderTest = (options?: { ...options?.layers?.serverRuntimeStartup, }), ), + Layer.provide( + Layer.mock(ServerEnvironment)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + getDescriptor: Effect.succeed(testEnvironmentDescriptor), + ...options?.layers?.serverEnvironment, + }), + ), + Layer.provide( + Layer.mock(RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + ...options?.layers?.repositoryIdentityResolver, + }), + ), + Layer.provideMerge(authTestLayer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -414,11 +503,37 @@ const buildAppUnderTest = (options?: { return config; }); -const wsRpcProtocolLayer = (wsUrl: string) => - RpcClient.layerProtocolSocket().pipe( - Layer.provide(NodeSocket.layerWebSocket(wsUrl)), +const parseSessionCookieFromWsUrl = ( + wsUrl: string, +): { readonly cookie: string | null; readonly url: string } => { + const next = new URL(wsUrl); + const cookie = next.hash.startsWith("#cookie=") + ? decodeURIComponent(next.hash.slice("#cookie=".length)) + : null; + next.hash = ""; + return { + cookie, + url: next.toString(), + }; +}; + +const wsRpcProtocolLayer = (wsUrl: string) => { + const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); + const webSocketConstructorLayer = Layer.succeed( + Socket.WebSocketConstructor, + (socketUrl, protocols) => + new NodeSocket.NodeWS.WebSocket( + socketUrl, + protocols, + cookie ? { headers: { cookie } } : undefined, + ) as unknown as globalThis.WebSocket, + ); + + return RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(url).pipe(Layer.provide(webSocketConstructorLayer))), Layer.provide(RpcSerialization.layerJson), ); +}; const makeWsRpcClient = RpcClient.make(WsRpcGroup); type WsRpcClient = @@ -429,6 +544,13 @@ const withWsRpcClient = ( f: (client: WsRpcClient) => Effect.Effect, ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); +const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => { + const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); + const next = new URL(url, "http://localhost"); + next.hash = `cookie=${encodeURIComponent(sessionCookieHeader)}`; + return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; +}; + const getHttpServerUrl = (pathname = "") => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; @@ -436,87 +558,810 @@ const getHttpServerUrl = (pathname = "") => return `http://127.0.0.1:${address.port}${pathname}`; }); -const getWsServerUrl = (pathname = "") => - Effect.gen(function* () { - const server = yield* HttpServer.HttpServer; - const address = server.address as HttpServer.TcpAddress; - return `ws://127.0.0.1:${address.port}${pathname}`; - }); +const bootstrapBrowserSession = ( + credential = defaultDesktopBootstrapToken, + options?: { + readonly headers?: Record; + }, +) => + Effect.gen(function* () { + const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap"); + const response = yield* Effect.promise(() => + fetch(bootstrapUrl, { + method: "POST", + headers: { + "content-type": "application/json", + ...options?.headers, + }, + body: JSON.stringify({ + credential, + }), + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly sessionMethod: string; + readonly expiresAt: string; + }; + return { + response, + body, + cookie: response.headers.get("set-cookie"), + }; + }); + +const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer"); + const response = yield* Effect.promise(() => + fetch(bootstrapUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + credential, + }), + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly sessionMethod: string; + readonly expiresAt: string; + readonly sessionToken?: string; + readonly error?: string; + }; + return { + response, + body, + }; + }); + +const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const { response, cookie } = yield* bootstrapBrowserSession(credential); + if (!response.ok) { + return yield* Effect.fail( + new Error(`Expected bootstrap session response to succeed, got ${response.status}`), + ); + } + + if (!cookie) { + return yield* Effect.fail(new Error("Expected bootstrap session response to set a cookie.")); + } + + return cookie.split(";")[0] ?? cookie; + }); + +const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const { response, body } = yield* bootstrapBearerSession(credential); + if (!response.ok) { + return yield* Effect.fail( + new Error(`Expected bearer bootstrap response to succeed, got ${response.status}`), + ); + } + + if (!body.sessionToken) { + return yield* Effect.fail( + new Error("Expected bearer bootstrap response to include a session token."), + ); + } + + return body.sessionToken; + }); + +const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { + const [nameValue] = cookieHeader.split(";", 1); + const token = nameValue?.split("=", 2)[1]; + if (!token) { + throw new Error("Expected session cookie header to contain a token value."); + } + return token; +}; + +const splitHeaderTokens = (value: string | null) => + (value ?? "") + .split(",") + .map((token) => token.trim()) + .filter((token) => token.length > 0) + .toSorted(); + +const getWsServerUrl = ( + pathname = "", + options?: { authenticated?: boolean; credential?: string }, +) => + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address as HttpServer.TcpAddress; + const baseUrl = `ws://127.0.0.1:${address.port}${pathname}`; + if (options?.authenticated === false) { + return baseUrl; + } + return appendSessionCookieToWsUrl( + baseUrl, + yield* getAuthenticatedSessionCookieHeader(options?.credential), + ); + }); + +it.layer(NodeServices.layer)("server router seam", (it) => { + it.effect("serves static index content for GET / when staticDir is configured", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); + const indexPath = path.join(staticDir, "index.html"); + yield* fileSystem.writeFileString(indexPath, "router-static-ok"); + + yield* buildAppUnderTest({ config: { staticDir } }); + + const response = yield* HttpClient.get("/"); + assert.equal(response.status, 200); + assert.include(yield* response.text, "router-static-ok"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("redirects to dev URL when configured", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { devUrl: new URL("http://127.0.0.1:5173") }, + }); + + const url = yield* getHttpServerUrl("/foo/bar?token=test-token"); + const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); + + assert.equal(response.status, 302); + assert.equal( + response.headers.get("location"), + "http://127.0.0.1:5173/foo/bar?token=test-token", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("serves project favicon requests before the dev URL redirect", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-project-favicon-", + }); + yield* fileSystem.writeFileString( + path.join(projectDir, "favicon.svg"), + "router-project-favicon", + ); + + yield* buildAppUnderTest({ + config: { devUrl: new URL("http://127.0.0.1:5173") }, + }); + + const response = yield* HttpClient.get( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, + ); + + assert.equal(response.status, 200); + assert.equal(yield* response.text, "router-project-favicon"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("serves the fallback project favicon when no icon exists", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const projectDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-project-favicon-fallback-", + }); + + yield* buildAppUnderTest({ + config: { devUrl: new URL("http://127.0.0.1:5173") }, + }); + + const response = yield* HttpClient.get( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, + ); + + assert.equal(response.status, 200); + assert.include(yield* response.text, 'data-fallback="project-favicon"'); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("serves the public environment descriptor without requiring auth", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* Effect.promise(() => fetch(url)); + const body = (yield* Effect.promise(() => + response.json(), + )) as typeof testEnvironmentDescriptor; + + assert.equal(response.status, 200); + assert.deepEqual(body, testEnvironmentDescriptor); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("reports unauthenticated session state without requiring auth", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const url = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* Effect.promise(() => fetch(url)); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly auth: { + readonly policy: string; + readonly bootstrapMethods: ReadonlyArray; + readonly sessionMethods: ReadonlyArray; + readonly sessionCookieName: string; + }; + }; + + assert.equal(response.status, 200); + assert.equal(body.authenticated, false); + assert.equal(body.auth.policy, "desktop-managed-local"); + assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); + assert.deepEqual(body.auth.sessionMethods, [ + "browser-session-cookie", + "bearer-session-token", + ]); + assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("bootstraps a browser session and authenticates the session endpoint via cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { + response: bootstrapResponse, + body: bootstrapBody, + cookie: setCookie, + } = yield* bootstrapBrowserSession(); + + assert.equal(bootstrapResponse.status, 200); + assert.equal(bootstrapBody.authenticated, true); + assert.equal(bootstrapBody.sessionMethod, "browser-session-cookie"); + assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); + assert.isDefined(setCookie); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* Effect.promise(() => + fetch(sessionUrl, { + headers: { + cookie: setCookie?.split(";")[0] ?? "", + }, + }), + ); + const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { + readonly authenticated: boolean; + readonly sessionMethod?: string; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "browser-session-cookie"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "bootstraps a bearer session and authenticates the session endpoint via authorization header", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: bootstrapResponse, body: bootstrapBody } = + yield* bootstrapBearerSession(); + + assert.equal(bootstrapResponse.status, 200); + assert.equal(bootstrapBody.authenticated, true); + assert.equal(bootstrapBody.sessionMethod, "bearer-session-token"); + assert.equal(typeof bootstrapBody.sessionToken, "string"); + assert.isTrue((bootstrapBody.sessionToken?.length ?? 0) > 0); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* Effect.promise(() => + fetch(sessionUrl, { + headers: { + authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, + }, + }), + ); + const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { + readonly authenticated: boolean; + readonly sessionMethod?: string; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "bearer-session-token"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("issues short-lived websocket tokens for authenticated bearer sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const bearerToken = yield* getAuthenticatedBearerSessionToken(); + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const wsTokenResponse = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "POST", + headers: { + authorization: `Bearer ${bearerToken}`, + }, + }), + ); + const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as { + readonly token: string; + readonly expiresAt: string; + }; + + assert.equal(wsTokenResponse.status, 200); + assert.equal(typeof wsTokenBody.token, "string"); + assert.isTrue(wsTokenBody.token.length > 0); + assert.equal(typeof wsTokenBody.expiresAt, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "responds to remote auth websocket-token preflight requests with authorization CORS headers", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const response = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "OPTIONS", + headers: { + origin: "http://192.168.86.35:3773", + "access-control-request-method": "POST", + "access-control-request-headers": "authorization", + }, + }), + ); + + assert.equal(response.status, 204); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ + "GET", + "OPTIONS", + "POST", + ]); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [ + "authorization", + "b3", + "content-type", + "traceparent", + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("includes CORS headers on remote websocket-token auth failures", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const response = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "POST", + headers: { + origin: "http://192.168.86.35:3773", + }, + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly error?: string; + }; + + assert.equal(response.status, 401); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.equal(body.error, "Authentication required."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("issues authenticated one-time pairing credentials for additional clients", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); + const body = (yield* response.json) as { + readonly credential: string; + readonly expiresAt: string; + }; + + assert.equal(response.status, 200); + assert.equal(typeof body.credential, "string"); + assert.isTrue(body.credential.length > 0); + assert.equal(typeof body.expiresAt, "string"); + + const bootstrapResult = yield* bootstrapBrowserSession(body.credential); + assert.equal(bootstrapResult.response.status, 200); + + const reusedResult = yield* bootstrapBrowserSession(body.credential); + assert.equal(reusedResult.response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects unauthenticated pairing credential requests", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/auth/pairing-token"); + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("lists and revokes pairing links for owner sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const createdResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + }); + const createdBody = (yield* createdResponse.json) as { + readonly id: string; + readonly credential: string; + }; + + const listResponse = yield* HttpClient.get("/api/auth/pairing-links", { + headers: { + cookie: ownerCookie, + }, + }); + const listedLinks = (yield* listResponse.json) as ReadonlyArray<{ + readonly id: string; + readonly credential: string; + }>; + + const revokeUrl = yield* getHttpServerUrl("/api/auth/pairing-links/revoke"); + const revokeResponse = yield* Effect.promise(() => + fetch(revokeUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ id: createdBody.id }), + }), + ); + const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); + + assert.equal(createdResponse.status, 200); + assert.equal(listResponse.status, 200); + assert.isTrue(listedLinks.some((entry) => entry.id === createdBody.id)); + assert.equal(revokeResponse.status, 200); + assert.equal(revokedBootstrap.response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects pairing credential requests from non-owner paired sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); + const ownerBody = (yield* ownerResponse.json) as { + readonly credential: string; + }; + assert.equal(ownerResponse.status, 200); + + const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader(ownerBody.credential); + const pairedResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookie, + }, + }); + const pairedBody = (yield* pairedResponse.json) as { + readonly error: string; + }; + + assert.equal(pairedResponse.status, 403); + assert.equal(pairedBody.error, "Only owner sessions can create pairing credentials."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("lists paired clients and revokes other sessions while keeping the owner", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const pairingTokenUrl = yield* getHttpServerUrl("/api/auth/pairing-token"); + const ownerPairingResponse = yield* Effect.promise(() => + fetch(pairingTokenUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ + label: "Julius iPhone", + }), + }), + ); + const ownerPairingBody = (yield* Effect.promise(() => ownerPairingResponse.json())) as { + readonly credential: string; + readonly label?: string; + }; + assert.equal(ownerPairingResponse.status, 200); + const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { + headers: { + "user-agent": + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + }, + }); + const pairedSessionCookie = pairedSessionBootstrap.cookie?.split(";")[0]; + assert.isDefined(pairedSessionCookie); + + const pairedSessionCookieHeader = pairedSessionCookie ?? ""; + const listClientsUrl = yield* getHttpServerUrl("/api/auth/clients"); + const listBeforeResponse = yield* Effect.promise(() => + fetch(listClientsUrl, { + headers: { + cookie: ownerCookie, + }, + }), + ); + const clientsBefore = (yield* Effect.promise(() => + listBeforeResponse.json(), + )) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + readonly client: { + readonly label?: string; + readonly deviceType: string; + readonly ipAddress?: string; + readonly os?: string; + readonly browser?: string; + }; + }>; + const pairedClientBefore = clientsBefore.find((entry) => !entry.current); + const pairedSessionId = clientsBefore.find((entry) => !entry.current)?.sessionId; + + const revokeOthersResponse = yield* HttpClient.post("/api/auth/clients/revoke-others", { + headers: { + cookie: ownerCookie, + }, + }); + const revokeOthersBody = (yield* revokeOthersResponse.json) as { + readonly revokedCount: number; + }; + + const listAfterResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { + cookie: ownerCookie, + }, + }); + const clientsAfter = (yield* listAfterResponse.json) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + }>; + + const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookieHeader, + }, + }); + const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as { + readonly error: string; + }; + + assert.equal(listBeforeResponse.status, 200); + assert.equal(ownerPairingBody.label, "Julius iPhone"); + assert.lengthOf(clientsBefore, 2); + assert.isDefined(pairedSessionId); + assert.isDefined(pairedClientBefore); + assert.deepInclude(pairedClientBefore?.client, { + label: "Julius iPhone", + deviceType: "mobile", + os: "iOS", + browser: "Safari", + ipAddress: "127.0.0.1", + }); + assert.equal(revokeOthersResponse.status, 200); + assert.equal(revokeOthersBody.revokedCount, 1); + assert.equal(listAfterResponse.status, 200); + assert.lengthOf(clientsAfter, 1); + assert.equal(clientsAfter[0]?.current, true); + assert.equal(pairedClientPairingResponse.status, 401); + assert.equal(pairedClientPairingBody.error, "Unauthorized request."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("revokes an individual paired client session", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + }); + const pairingBody = (yield* pairingResponse.json) as { + readonly credential: string; + }; + const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader( + pairingBody.credential, + ); + + const clientsResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { + cookie: ownerCookie, + }, + }); + const clients = (yield* clientsResponse.json) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + }>; + const pairedSessionId = clients.find((entry) => !entry.current)?.sessionId; + assert.isDefined(pairedSessionId); + + const revokeUrl = yield* getHttpServerUrl("/api/auth/clients/revoke"); + const revokeResponse = yield* Effect.promise(() => + fetch(revokeUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId: pairedSessionId }), + }), + ); + const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookie, + }, + }); + + assert.equal(revokeResponse.status, 200); + assert.equal(pairedClientPairingResponse.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); -it.layer(NodeServices.layer)("server router seam", (it) => { - it.effect("serves static index content for GET / when staticDir is configured", () => + it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); - const indexPath = path.join(staticDir, "index.html"); - yield* fileSystem.writeFileString(indexPath, "router-static-ok"); + yield* buildAppUnderTest(); - yield* buildAppUnderTest({ config: { staticDir } }); + const first = yield* bootstrapBrowserSession(); + const second = yield* bootstrapBrowserSession(); - const response = yield* HttpClient.get("/"); - assert.equal(response.status, 200); - assert.include(yield* response.text, "router-static-ok"); + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 401); + assert.equal( + (second.body as { readonly error?: string }).error, + "Invalid bootstrap credential.", + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("redirects to dev URL when configured", () => - Effect.gen(function* () { - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); + it.effect( + "does not accept session tokens via query parameters on authenticated HTTP routes", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const projectDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-project-favicon-query-token-", + }); - const url = yield* getHttpServerUrl("/foo/bar"); - const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); + yield* buildAppUnderTest(); - assert.equal(response.status, 302); - assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + + const response = yield* HttpClient.get( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, + ); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves project favicon requests before the dev URL redirect", () => + it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-", - }); - yield* fileSystem.writeFileString( - path.join(projectDir, "favicon.svg"), - "router-project-favicon", - ); + yield* buildAppUnderTest(); - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); + const { response: bootstrapResponse, cookie } = yield* bootstrapBrowserSession(); - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + assert.equal(bootstrapResponse.status, 200); + assert.isDefined(cookie); + + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), ); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "router-project-favicon"); + assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(response.auth.policy, "desktop-managed-local"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves the fallback project favicon when no icon exists", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-fallback-", - }); + it.effect( + "rejects websocket rpc handshake when a session token is only provided via query string", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?token=${encodeURIComponent(sessionToken)}`; - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - ); + const error = yield* Effect.flip( + Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), + ); - assert.equal(response.status, 200); - assert.include(yield* response.text, 'data-fallback="project-favicon"'); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + assert.equal(error._tag, "RpcClientError"); + assertInclude(String(error), "SocketOpenError"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "accepts websocket rpc handshake with a dedicated websocket token in the query string", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const bearerToken = yield* getAuthenticatedBearerSessionToken(); + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const wsTokenResponse = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "POST", + headers: { + authorization: `Bearer ${bearerToken}`, + }, + }), + ); + const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as { + readonly token: string; + }; + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsToken=${encodeURIComponent(wsTokenBody.token)}`; + + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(response.auth.policy, "desktop-managed-local"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("serves attachment files from state dir", () => @@ -535,7 +1380,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - const response = yield* HttpClient.get(`/attachments/${attachmentId}`); + const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-ok"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -558,6 +1407,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( "/attachments/thread%20folder/message%20folder/file%20name.png", + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-encoded-ok"); @@ -694,6 +1548,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", origin: "http://localhost:5733", }, @@ -767,8 +1622,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.status, 204); assert.equal(response.headers.get("access-control-allow-origin"), "*"); - assert.equal(response.headers.get("access-control-allow-methods"), "POST, OPTIONS"); - assert.equal(response.headers.get("access-control-allow-headers"), "content-type"); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ + "GET", + "OPTIONS", + "POST", + ]); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [ + "authorization", + "b3", + "content-type", + "traceparent", + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -802,6 +1666,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", }, body: HttpBody.text(JSON.stringify(payload), "application/json"), @@ -849,6 +1714,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( "/attachments/missing-11111111-1111-4111-8111-111111111111", + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 404); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -890,7 +1760,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("rejects websocket rpc handshake when auth token is missing", () => + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -900,13 +1770,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "export const needle = 1;", ); - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); + yield* buildAppUnderTest(); - const wsUrl = yield* getWsServerUrl("/ws"); + const wsUrl = yield* getWsServerUrl("/ws", { authenticated: false }); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.projectsSearchEntries]({ @@ -922,41 +1788,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("accepts websocket rpc handshake when auth token is provided", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" }); - yield* fs.writeFileString( - path.join(workspaceDir, "needle-file.ts"), - "export const needle = 1;", - ); - - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws?token=secret-token"); - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.projectsSearchEntries]({ - cwd: workspaceDir, - query: "needle", - limit: 10, - }), - ), - ); - - assert.isAtLeast(response.entries.length, 1); - assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { - const providers = [] as const; + const providers = [ + { + provider: "codex" as const, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ] as const; const changeEvent = { keybindings: [], issues: [], @@ -1013,7 +1860,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { - const providers = [] as const; + const nextProviders = [ + { + provider: "codex" as const, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ] as const; yield* buildAppUnderTest({ layers: { @@ -1026,7 +1886,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, providerRegistry: { getProviders: Effect.succeed([]), - streamChanges: Stream.succeed(providers), + streamChanges: Stream.succeed(nextProviders), }, }, }); @@ -1040,10 +1900,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const [first, second] = Array.from(events); assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, []); + } assert.deepEqual(second, { version: 1, type: "providerStatuses", - payload: { providers }, + payload: { providers: nextProviders }, }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1058,6 +1921,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { sequence: 1, type: "welcome" as const, payload: { + environment: testEnvironmentDescriptor, cwd: "/tmp/project", projectName: "project", }, @@ -1067,7 +1931,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { version: 1 as const, sequence: 2, type: "ready" as const, - payload: { at: new Date().toISOString() }, + payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, }); yield* buildAppUnderTest({ @@ -1175,6 +2039,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-create-" }); + const missingWorkspaceRoot = path.join(parentDir, "nested", "new-project"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-missing-root"), + projectId: ProjectId.make("project-create-missing-root"), + title: "New Project", + workspaceRoot: missingWorkspaceRoot, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt: new Date().toISOString(), + }), + ), + ); + const stat = yield* fs.stat(missingWorkspaceRoot); + + assert.isAtLeast(response.sequence, 0); + assert.equal(stat.type, "Directory"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1260,6 +2158,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.succeed({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), status: () => Effect.succeed({ isRepo: true, @@ -1373,8 +2290,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktree: { path: "/tmp/wt", branch: "feature/demo" }, }), removeWorktree: () => Effect.void, - createBranch: () => Effect.void, - checkoutBranch: () => Effect.void, + createBranch: (input) => Effect.succeed({ branch: input.branch }), + checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), initRepo: () => Effect.void, }, }, @@ -1382,16 +2299,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); - const status = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })), - ); - assert.equal(status.branch, "main"); - const pull = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), ); assert.equal(pull.status, "pulled"); + const refreshedStatus = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), + ), + ); + assert.equal(refreshedStatus.isRepo, true); + const stackedEvents = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitRunStackedAction]({ @@ -1458,61 +2377,392 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateBranch]({ - cwd: "/tmp/repo", - branch: "feature/new", - }), - ), - ); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitCreateBranch]({ + cwd: "/tmp/repo", + branch: "feature/new", + }), + ), + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitCheckout]({ + cwd: "/tmp/repo", + branch: "main", + }), + ), + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitInit]({ + cwd: "/tmp/repo", + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc git.pull errors", () => + Effect.gen(function* () { + const gitError = new GitCommandError({ + operation: "pull", + command: "git pull --ff-only", + cwd: "/tmp/repo", + detail: "upstream missing", + }); + let invalidationCalls = 0; + let statusCalls = 0; + yield* buildAppUnderTest({ + layers: { + gitCore: { + pullCurrentBranch: () => Effect.fail(gitError), + }, + gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( + Effect.result, + ), + ); + + assertFailure(result, gitError); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCheckout]({ - cwd: "/tmp/repo", - branch: "main", - }), - ), - ); + it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () => + Effect.gen(function* () { + const gitError = new GitCommandError({ + operation: "commit", + command: "git commit", + cwd: "/tmp/repo", + detail: "nothing to commit", + }); + let invalidationCalls = 0; + let statusCalls = 0; + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + runStackedAction: () => Effect.fail(gitError), + }, + }, + }); - yield* Effect.scoped( + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitInit]({ + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", cwd: "/tmp/repo", - }), + action: "commit", + }).pipe(Stream.runCollect, Effect.result), ), ); + + assertFailure(result, gitError); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc git.pull errors", () => + it.effect("completes websocket rpc git.pull before background git status refresh finishes", () => Effect.gen(function* () { - const gitError = new GitCommandError({ - operation: "pull", - command: "git pull --ff-only", - cwd: "/tmp/repo", - detail: "upstream missing", - }); yield* buildAppUnderTest({ layers: { gitCore: { - pullCurrentBranch: () => Effect.fail(gitError), + pullCurrentBranch: () => + Effect.succeed({ + status: "pulled" as const, + branch: "main", + upstreamBranch: "origin/main", + }), + }, + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( - Effect.result, - ), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), ); + const elapsedMs = Date.now() - startedAt; - assertFailure(result, gitError); + assert.equal(result.status, "pulled"); + assertTrue(elapsedMs < 1_000); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "completes websocket rpc git.runStackedAction before background git status refresh finishes", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + const elapsedMs = Date.now() - startedAt; + + assertTrue(elapsedMs < 1_000); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "starts a background local git status refresh after a successful git.runStackedAction", + () => + Effect.gen(function* () { + const localRefreshStarted = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Deferred.succeed(localRefreshStarted, undefined).pipe( + Effect.ignore, + Effect.andThen( + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + ), + ), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + + yield* Deferred.await(localRefreshStarted); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { const now = new Date().toISOString(); @@ -1521,7 +2771,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { updatedAt: now, projects: [ { - id: ProjectId.makeUnsafe("project-a"), + id: ProjectId.make("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", defaultModelSelection, @@ -1533,8 +2783,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ], threads: [ { - id: ThreadId.makeUnsafe("thread-1"), - projectId: ProjectId.makeUnsafe("project-a"), + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), title: "Thread A", modelSelection: defaultModelSelection, interactionMode: "default" as const, @@ -1567,14 +2817,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { checkpointDiffQuery: { getTurnDiff: () => Effect.succeed({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), fromTurnCount: 0, toTurnCount: 1, diff: "turn-diff", }), getFullThreadDiff: () => Effect.succeed({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), fromTurnCount: 0, toTurnCount: 1, diff: "full-diff", @@ -1584,17 +2834,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const wsUrl = yield* getWsServerUrl("/ws"); - const snapshotResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), - ); - assert.equal(snapshotResult.snapshotSequence, 1); - const dispatchResult = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.session.stop", - commandId: CommandId.makeUnsafe("cmd-1"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.make("cmd-1"), + threadId: ThreadId.make("thread-1"), createdAt: now, }), ), @@ -1604,7 +2849,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const turnDiffResult = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff]({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), fromTurnCount: 0, toTurnCount: 1, }), @@ -1615,7 +2860,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const fullDiffResult = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({ - threadId: ThreadId.makeUnsafe("thread-1"), + threadId: ThreadId.make("thread-1"), toTurnCount: 1, }), ), @@ -1633,9 +2878,76 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: (_fromSequenceExclusive) => + Stream.make({ + sequence: 1, + eventId: EventId.make("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + defaultModelSelection, + scripts: [], + createdAt: "2026-04-05T00:00:00.000Z", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const replayResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.replayEvents]({ + fromSequenceExclusive: 0, + }), + ), + ); + + const replayedEvent = replayResult[0]; + assert.equal(replayedEvent?.type, "project.created"); + assert.deepEqual( + replayedEvent && replayedEvent.type === "project.created" + ? replayedEvent.payload.repositoryIdentity + : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("closes thread terminals after a successful archive command", () => Effect.gen(function* () { - const threadId = ThreadId.makeUnsafe("thread-archive"); + const threadId = ThreadId.make("thread-archive"); const closeInputs: Array[0]> = []; yield* buildAppUnderTest({ @@ -1657,7 +2969,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.archive", - commandId: CommandId.makeUnsafe("cmd-thread-archive"), + commandId: CommandId.make("cmd-thread-archive"), threadId, }), ), @@ -1673,6 +2985,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { () => Effect.gen(function* () { const dispatchedCommands: Array = []; + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "t3code/bootstrap-branch", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); const createWorktree = vi.fn((_: Parameters[0]) => Effect.succeed({ worktree: { @@ -1697,6 +3027,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitCore: { createWorktree, }, + gitStatusBroadcaster: { + refreshStatus, + }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -1717,10 +3050,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start"), - threadId: ThreadId.makeUnsafe("thread-bootstrap"), + commandId: CommandId.make("cmd-bootstrap-turn-start"), + threadId: ThreadId.make("thread-bootstrap"), message: { - messageId: MessageId.makeUnsafe("msg-bootstrap"), + messageId: MessageId.make("msg-bootstrap"), role: "user", text: "hello", attachments: [], @@ -1769,11 +3102,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { path: null, }); assert.deepEqual(runForThread.mock.calls[0]?.[0], { - threadId: ThreadId.makeUnsafe("thread-bootstrap"), + threadId: ThreadId.make("thread-bootstrap"), projectId: defaultProjectId, projectCwd: "/tmp/project", worktreePath: "/tmp/bootstrap-worktree", }); + assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); const setupActivities = dispatchedCommands.filter( (command): command is Extract => @@ -1832,10 +3166,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-setup-failure"), - threadId: ThreadId.makeUnsafe("thread-bootstrap-setup-failure"), + commandId: CommandId.make("cmd-bootstrap-turn-start-setup-failure"), + threadId: ThreadId.make("thread-bootstrap-setup-failure"), message: { - messageId: MessageId.makeUnsafe("msg-bootstrap-setup-failure"), + messageId: MessageId.make("msg-bootstrap-setup-failure"), role: "user", text: "hello", attachments: [], @@ -1948,10 +3282,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-setup-activity-failure"), - threadId: ThreadId.makeUnsafe("thread-bootstrap-setup-activity-failure"), + commandId: CommandId.make("cmd-bootstrap-turn-start-setup-activity-failure"), + threadId: ThreadId.make("thread-bootstrap-setup-activity-failure"), message: { - messageId: MessageId.makeUnsafe("msg-bootstrap-setup-activity-failure"), + messageId: MessageId.make("msg-bootstrap-setup-activity-failure"), role: "user", text: "hello", attachments: [], @@ -2031,10 +3365,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-defect"), - threadId: ThreadId.makeUnsafe("thread-bootstrap-defect"), + commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), + threadId: ThreadId.make("thread-bootstrap-defect"), message: { - messageId: MessageId.makeUnsafe("msg-bootstrap-defect"), + messageId: MessageId.make("msg-bootstrap-defect"), role: "user", text: "hello", attachments: [], @@ -2075,95 +3409,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", - () => - Effect.gen(function* () { - const now = new Date().toISOString(); - const threadId = ThreadId.makeUnsafe("thread-1"); - let replayCursor: number | null = null; - const makeEvent = (sequence: number): OrchestrationEvent => - ({ - sequence, - eventId: `event-${sequence}`, - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.reverted", - payload: { - threadId, - turnCount: sequence, - }, - }) as OrchestrationEvent; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 1, - }), - readEvents: (fromSequenceExclusive) => { - replayCursor = fromSequenceExclusive; - return Stream.make(makeEvent(2), makeEvent(3)); - }, - streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(3), - Stream.runCollect, - ), - ), - ); - - assert.equal(replayCursor, 1); - assert.deepEqual( - Array.from(events).map((event) => event.sequence), - [2, 3, 4], - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("routes websocket rpc orchestration.getSnapshot errors", () => - Effect.gen(function* () { - yield* buildAppUnderTest({ - layers: { - projectionSnapshotQuery: { - getSnapshot: () => - Effect.fail( - new PersistenceSqlError({ - operation: "ProjectionSnapshotQuery.getSnapshot", - detail: "projection unavailable", - }), - ), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe( - Effect.result, - ), - ); - - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); - assertInclude(result.failure.message, "Failed to load orchestration snapshot"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc terminal methods", () => Effect.gen(function* () { const snapshot = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..23c53ad07f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,7 +6,9 @@ import { attachmentsRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, + serverEnvironmentRouteLayer, staticAndDevRouteLayer, + browserApiCorsLayer, } from "./http"; import { fixPath } from "./os-jank"; import { websocketRpcRouteLayer } from "./ws"; @@ -21,15 +23,11 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; -import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; -import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; -import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; -import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { GitManagerLive } from "./git/Layers/GitManager"; @@ -43,11 +41,37 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; import { ServerSettingsLive } from "./serverSettings"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +import { + authBearerBootstrapRouteLayer, + authBootstrapRouteLayer, + authClientsRevokeOthersRouteLayer, + authClientsRevokeRouteLayer, + authClientsRouteLayer, + authPairingLinksRevokeRouteLayer, + authPairingLinksRouteLayer, + authPairingCredentialRouteLayer, + authSessionRouteLayer, + authWebSocketTokenRouteLayer, +} from "./auth/http"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +import { + clearPersistedServerRuntimeState, + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "./serverRuntimeState"; +import { + orchestrationDispatchRouteLayer, + orchestrationSnapshotRouteLayer, +} from "./orchestration/http"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -105,26 +129,6 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( - OrchestrationEventStoreLive, - OrchestrationCommandReceiptRepositoryLive, -); - -const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( - Layer.provide(OrchestrationEventStoreLive), -); - -const OrchestrationInfrastructureLayerLive = Layer.mergeAll( - OrchestrationProjectionSnapshotQueryLive, - OrchestrationEventInfrastructureLayerLive, - OrchestrationProjectionPipelineLayerLive, -); - -const OrchestrationLayerLive = Layer.mergeAll( - OrchestrationInfrastructureLayerLive, - OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)), -); - const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), Layer.provideMerge(CheckpointStoreLive), @@ -161,15 +165,16 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const GitManagerLayerLive = GitManagerLive.pipe( + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(RoutingTextGenerationLive), +); + const GitLayerLive = Layer.empty.pipe( - Layer.provideMerge( - GitManagerLive.pipe( - Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(RoutingTextGenerationLive), - ), - ), + Layer.provideMerge(GitManagerLayerLive), + Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), Layer.provideMerge(GitCoreLive), ); @@ -184,6 +189,11 @@ const WorkspaceLayerLive = Layer.mergeAll( ), ); +const AuthLayerLive = ServerAuthLive.pipe( + Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerSecretStoreLive), +); + const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), @@ -197,6 +207,9 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(ServerEnvironmentLive), + Layer.provideMerge(AuthLayerLive), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), @@ -209,12 +222,25 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( ); export const makeRoutesLayer = Layer.mergeAll( + authBearerBootstrapRouteLayer, + authBootstrapRouteLayer, + authClientsRevokeOthersRouteLayer, + authClientsRevokeRouteLayer, + authClientsRouteLayer, + authPairingLinksRevokeRouteLayer, + authPairingLinksRouteLayer, + authPairingCredentialRouteLayer, + authSessionRouteLayer, + authWebSocketTokenRouteLayer, attachmentsRouteLayer, + orchestrationDispatchRouteLayer, + orchestrationSnapshotRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, + serverEnvironmentRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, -); +).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { @@ -229,12 +255,34 @@ export const makeServerLayer = Layer.unwrap( yield* startup.markHttpListening; }), ); + const runtimeStateLayer = Layer.effectDiscard( + Effect.acquireRelease( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return; + } + + const state = makePersistedServerRuntimeState({ + config, + port: address.port, + }); + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state, + }); + }), + () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), + ), + ); const serverApplicationLayer = Layer.mergeAll( HttpRouter.serve(makeRoutesLayer, { disableLogger: !config.logWebSocketEvents, }), httpListeningLayer, + runtimeStateLayer, ); return serverApplicationLayer.pipe( diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 1cd8c25c03..47e24c1b87 100644 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -1,3 +1,4 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertTrue } from "@effect/vitest/utils"; import { Effect, Option } from "effect"; @@ -9,12 +10,20 @@ it.effect( () => Effect.gen(function* () { const lifecycleEvents = yield* ServerLifecycleEvents; + const environment = { + environmentId: EnvironmentId.make("environment-test"), + label: "Test environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; const welcome = yield* lifecycleEvents .publish({ version: 1, type: "welcome", payload: { + environment, cwd: "/tmp/project", projectName: "project", }, @@ -29,6 +38,7 @@ it.effect( type: "ready", payload: { at: new Date().toISOString(), + environment, }, }) .pipe(Effect.timeoutOption("50 millis")); diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 4808a19d72..145d1cbaa4 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -1,5 +1,5 @@ import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; -import { Effect, Layer, PubSub, Ref, ServiceMap, Stream } from "effect"; +import { Effect, Layer, PubSub, Ref, Context, Stream } from "effect"; type LifecycleEventInput = | Omit, "sequence"> @@ -16,7 +16,7 @@ export interface ServerLifecycleEventsShape { readonly stream: Stream.Stream; } -export class ServerLifecycleEvents extends ServiceMap.Service< +export class ServerLifecycleEvents extends Context.Service< ServerLifecycleEvents, ServerLifecycleEventsShape >()("t3/serverLifecycleEvents") {} diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fc06d77566..836b71c7eb 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,14 +1,31 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Option, Ref } from "effect"; +import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerConfig } from "./config.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { + getAutoBootstrapDefaultModelSelection, launchStartupHeartbeat, makeCommandGate, + resolveAutoBootstrapWelcomeTargets, + resolveWelcomeBase, ServerRuntimeStartupError, } from "./serverRuntimeStartup.ts"; +it("uses the canonical Codex default for auto-bootstrapped model selection", () => { + assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }); +}); + it.effect("enqueueCommand waits for readiness and then drains queued work", () => Effect.scoped( Effect.gen(function* () { @@ -61,6 +78,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa yield* launchStartupHeartbeat.pipe( Effect.provideService(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), getCounts: () => Deferred.await(releaseCounts).pipe( Effect.as({ @@ -69,8 +87,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService, { record: () => Effect.void, @@ -80,3 +101,109 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), ); + +it.effect("resolveWelcomeBase derives cwd and project name from server config", () => + Effect.gen(function* () { + const welcome = yield* resolveWelcomeBase.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + } as never), + ); + + assert.deepStrictEqual(welcome, { + cwd: "/tmp/startup-project", + projectName: "startup-project", + }); + }), +); + +it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and thread ids", () => { + const bootstrapProjectId = ProjectId.make("project-startup-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-startup-bootstrap"); + + return Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery, { + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: bootstrapProjectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }), + ), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngineService, { + getReadModel: () => Effect.die("unused"), + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + } satisfies OrchestrationEngineShape), + Effect.provide(NodeServices.layer), + ); + + assert.deepStrictEqual(targets, { + bootstrapProjectId, + bootstrapThreadId, + }); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); + }); +}); + +it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery, { + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngineService, { + getReadModel: () => Effect.die("unused"), + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + } satisfies OrchestrationEngineShape), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7c9231ac93..823e3b4771 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,5 +1,6 @@ import { CommandId, + DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PROVIDER_INTERACTION_MODE, type ModelSelection, ProjectId, @@ -16,7 +17,8 @@ import { Queue, Ref, Scope, - ServiceMap, + Context, + Console, } from "effect"; import { ServerConfig } from "./config"; @@ -27,13 +29,15 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerSettingsService } from "./serverSettings"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; - -const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -const formatHostForUrl = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +import { ServerAuth } from "./auth/Services/ServerAuth"; +import { + formatHeadlessServeOutput, + formatHostForUrl, + isWildcardHost, + issueHeadlessServeAccessInfo, +} from "./startupAccess"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ readonly message: string; @@ -48,7 +52,7 @@ export interface ServerRuntimeStartupShape { ) => Effect.Effect; } -export class ServerRuntimeStartup extends ServiceMap.Service< +export class ServerRuntimeStartup extends Context.Service< ServerRuntimeStartup, ServerRuntimeStartupShape >()("t3/serverRuntimeStartup") {} @@ -148,7 +152,23 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( Effect.asVoid, ); -const autoBootstrapWelcome = Effect.gen(function* () { +export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, +}); + +export const resolveWelcomeBase = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; + + return { + cwd: serverConfig.cwd, + projectName, + } as const; +}); + +export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; @@ -167,15 +187,12 @@ const autoBootstrapWelcome = Effect.gen(function* () { if (Option.isNone(existingProject)) { const createdAt = new Date().toISOString(); - nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); + nextProjectId = ProjectId.make(crypto.randomUUID()); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextProjectDefaultModelSelection = { - provider: "codex", - model: "gpt-5-codex", - }; + nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); yield* orchestrationEngine.dispatch({ type: "project.create", - commandId: CommandId.makeUnsafe(crypto.randomUUID()), + commandId: CommandId.make(crypto.randomUUID()), projectId: nextProjectId, title: bootstrapProjectTitle, workspaceRoot: serverConfig.cwd, @@ -184,20 +201,18 @@ const autoBootstrapWelcome = Effect.gen(function* () { }); } else { nextProjectId = existingProject.value.id; - nextProjectDefaultModelSelection = existingProject.value.defaultModelSelection ?? { - provider: "codex", - model: "gpt-5-codex", - }; + nextProjectDefaultModelSelection = + existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); } const existingThreadId = yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); if (Option.isNone(existingThreadId)) { const createdAt = new Date().toISOString(); - const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID()); + const createdThreadId = ThreadId.make(crypto.randomUUID()); yield* orchestrationEngine.dispatch({ type: "thread.create", - commandId: CommandId.makeUnsafe(crypto.randomUUID()), + commandId: CommandId.make(crypto.randomUUID()), threadId: createdThreadId, projectId: nextProjectId, title: "New thread", @@ -217,51 +232,58 @@ const autoBootstrapWelcome = Effect.gen(function* () { }); } - const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); - const projectName = segments[segments.length - 1] ?? "project"; - return { - cwd: serverConfig.cwd, - projectName, ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), } as const; }); -const maybeOpenBrowser = Effect.gen(function* () { +const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig; - if (serverConfig.noBrowser) { - return; - } - const { openBrowser } = yield* Open; + const serverAuth = yield* ServerAuth; const localUrl = `http://localhost:${serverConfig.port}`; const bindUrl = serverConfig.host && !isWildcardHost(serverConfig.host) ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; - const target = serverConfig.devUrl?.toString() ?? bindUrl; - - yield* openBrowser(target).pipe( - Effect.catch(() => - Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, - }), + const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; + return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( + Effect.flatMap((target) => + target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), ), ); }); +const maybeOpenBrowser = (target: string) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const { openBrowser } = yield* Open; + + yield* openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + }); + const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( Effect.annotateSpans({ "startup.phase": phase }), Effect.withSpan(`server.startup.${phase}`), ); -const makeServerRuntimeStartup = Effect.gen(function* () { +export const makeServerRuntimeStartup = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment; const commandGate = yield* makeCommandGate; const httpListening = yield* Deferred.make(); @@ -306,22 +328,62 @@ const makeServerRuntimeStartup = Effect.gen(function* () { orchestrationReactor.start().pipe(Scope.provide(reactorScope)), ); + const welcomeBase = yield* resolveWelcomeBase; + const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); - const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); yield* Effect.logDebug("startup phase: publishing welcome event", { - cwd: welcome.cwd, - projectName: welcome.projectName, - bootstrapProjectId: welcome.bootstrapProjectId, - bootstrapThreadId: welcome.bootstrapThreadId, + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, }); yield* runStartupPhase( "welcome.publish", lifecycleEvents.publish({ version: 1, type: "welcome", - payload: welcome, + payload: { + environment, + ...welcomeBase, + }, }), ); + + if (serverConfig.autoBootstrapProjectFromCwd) { + yield* Effect.forkScoped( + runStartupPhase( + "welcome.autobootstrap", + Effect.gen(function* () { + const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets; + if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { + return; + } + + yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + bootstrapProjectId: bootstrapTargets.bootstrapProjectId, + bootstrapThreadId: bootstrapTargets.bootstrapThreadId, + }); + yield* lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapTargets, + }, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("startup auto-bootstrap welcome failed", { + cause, + }), + ), + ), + ), + ); + } }).pipe( Effect.annotateSpans({ "server.mode": serverConfig.mode, @@ -354,14 +416,32 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "ready", - payload: { at: new Date().toISOString() }, + payload: { + at: new Date().toISOString(), + environment: yield* serverEnvironment.getDescriptor, + }, }), ); yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; - yield* Effect.logDebug("startup phase: browser open check"); - yield* runStartupPhase("browser.open", maybeOpenBrowser); + if (serverConfig.startupPresentation === "headless") { + yield* Effect.logDebug("startup phase: headless access info"); + const accessInfo = yield* issueHeadlessServeAccessInfo(); + yield* runStartupPhase( + "headless.output", + Console.log(formatHeadlessServeOutput(accessInfo)), + ); + } else { + yield* Effect.logDebug("startup phase: browser open check"); + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* Effect.logInfo( + "Authentication required. Open T3 Code using the pairing URL.", + ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); + } + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + } yield* Effect.logDebug("startup phase: complete"); }), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts new file mode 100644 index 0000000000..00c8384468 --- /dev/null +++ b/apps/server/src/serverRuntimeState.ts @@ -0,0 +1,77 @@ +import { Effect, FileSystem, Option, Path, Schema } from "effect"; + +import { type ServerConfigShape } from "./config"; +import { formatHostForUrl, isWildcardHost } from "./startupAccess"; + +export const PersistedServerRuntimeState = Schema.Struct({ + version: Schema.Literal(1), + pid: Schema.Int, + host: Schema.optional(Schema.String), + port: Schema.Int, + origin: Schema.String, + startedAt: Schema.String, +}); +export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; + +const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( + Schema.fromJsonString(PersistedServerRuntimeState), +); + +const runtimeOriginForConfig = ( + config: Pick, + port: number, +): PersistedServerRuntimeState["origin"] => { + const hostname = + config.host && !isWildcardHost(config.host) ? formatHostForUrl(config.host) : "127.0.0.1"; + return `http://${hostname}:${port}`; +}; + +export const makePersistedServerRuntimeState = (input: { + readonly config: Pick; + readonly port: number; +}): PersistedServerRuntimeState => ({ + version: 1, + pid: process.pid, + ...(input.config.host ? { host: input.config.host } : {}), + port: input.port, + origin: runtimeOriginForConfig(input.config, input.port), + startedAt: new Date().toISOString(), +}); + +export const persistServerRuntimeState = (input: { + readonly path: string; + readonly state: PersistedServerRuntimeState; +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`; + return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe( + Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)), + Effect.flatMap(() => fs.rename(tempPath, input.path)), + Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + ); + }); + +export const clearPersistedServerRuntimeState = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { force: true }).pipe(Effect.ignore({ log: true })); + }); + +export const readPersistedServerRuntimeState = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return Option.none(); + } + + const raw = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => "")); + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return Option.none(); + } + + return yield* decodePersistedServerRuntimeState(trimmed).pipe(Effect.option); + }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 289bc68961..d8a992f0ec 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/usr/local/bin/claude", customModels: ["claude-custom"], + launchArgs: "", }); assert.deepEqual(next.textGenerationModelSelection, { provider: "codex", @@ -167,6 +168,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/claude", customModels: [], + launchArgs: "", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -176,12 +178,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsService; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: " ~/Development ", observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }); + assert.equal(next.addProjectBaseDirectory, "~/Development"); assert.deepEqual(next.observability, { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -215,6 +219,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -230,6 +235,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual(JSON.parse(raw), { + addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 79fdc29a8b..5a708d5c23 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -34,7 +34,7 @@ import { Schema, SchemaIssue, Scope, - ServiceMap, + Context, Stream, Cause, } from "effect"; @@ -62,7 +62,7 @@ export interface ServerSettingsShape { readonly streamChanges: Stream.Stream; } -export class ServerSettingsService extends ServiceMap.Service< +export class ServerSettingsService extends Context.Service< ServerSettingsService, ServerSettingsShape >()("t3/serverSettings/ServerSettingsService") { diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts new file mode 100644 index 0000000000..ef6ece31e2 --- /dev/null +++ b/apps/server/src/startupAccess.test.ts @@ -0,0 +1,79 @@ +import { assert, expect, it } from "@effect/vitest"; + +import { + buildPairingUrl, + formatHeadlessServeOutput, + renderTerminalQrCode, + resolveHeadlessConnectionHost, + resolveHeadlessConnectionString, + resolveListeningPort, +} from "./startupAccess"; + +it("prefers localhost when no explicit host is configured", () => { + expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); + expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); +}); + +it("keeps explicit bind hosts in the connection string", () => { + expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773"); + expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773"); +}); + +it("resolves wildcard hosts to a concrete external interface when one is available", () => { + const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { + en0: [ + { + address: "192.168.1.42", + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.168.1.42/24", + }, + ], + lo0: [ + { + address: "127.0.0.1", + netmask: "255.0.0.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: true, + cidr: "127.0.0.1/8", + }, + ], + }); + + expect(connectionString).toBe("http://192.168.1.42:3773"); +}); + +it("prefers the actual bound port when an http server address is available", () => { + expect(resolveListeningPort({ port: 4123 }, 3773)).toBe(4123); + expect(resolveListeningPort("pipe", 3773)).toBe(3773); + expect(resolveListeningPort(null, 3773)).toBe(3773); +}); + +it("builds a pairing URL that embeds the token in the hash", () => { + expect(buildPairingUrl("http://192.168.1.42:3773", "PAIRCODE")).toBe( + "http://192.168.1.42:3773/pair#token=PAIRCODE", + ); +}); + +it("renders terminal QR codes as a multi-line unicode block grid", () => { + const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE"); + + assert.isTrue(qrCode.includes("█")); + assert.isTrue(qrCode.split("\n").length > 10); +}); + +it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { + const output = formatHeadlessServeOutput({ + connectionString: "http://192.168.1.42:3773", + token: "PAIRCODE", + pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", + }); + + expect(output).toContain("Connection string: http://192.168.1.42:3773"); + expect(output).toContain("Token: PAIRCODE"); + expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); +}); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts new file mode 100644 index 0000000000..a350d729d0 --- /dev/null +++ b/apps/server/src/startupAccess.ts @@ -0,0 +1,148 @@ +import { networkInterfaces } from "node:os"; + +import { QrCode } from "@t3tools/shared/qrCode"; +import { Effect } from "effect"; +import { HttpServer } from "effect/unstable/http"; + +import { ServerConfig } from "./config"; +import { ServerAuth } from "./auth/Services/ServerAuth"; + +export interface HeadlessServeAccessInfo { + readonly connectionString: string; + readonly token: string; + readonly pairingUrl: string; +} + +type NetworkInterfacesMap = ReturnType; + +export const isLoopbackHost = (host: string | undefined): boolean => { + if (!host || host.length === 0) { + return true; + } + + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); +}; + +export const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +export const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +const normalizeHost = (host: string): string => + host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + +const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; + +const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6; + +export const resolveHeadlessConnectionHost = ( + host: string | undefined, + interfaces: NetworkInterfacesMap = networkInterfaces(), +): string => { + if (!host) { + return "localhost"; + } + + if (!isWildcardHost(host)) { + return normalizeHost(host); + } + + const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []); + const externalIpv4 = interfaceEntries.find( + (entry) => !entry.internal && isIpv4Family(entry.family), + ); + if (externalIpv4) { + return externalIpv4.address; + } + + const externalIpv6 = interfaceEntries.find( + (entry) => !entry.internal && isIpv6Family(entry.family), + ); + return externalIpv6 ? normalizeHost(externalIpv6.address) : "localhost"; +}; + +export const resolveHeadlessConnectionString = ( + host: string | undefined, + port: number, + interfaces: NetworkInterfacesMap = networkInterfaces(), +): string => { + const connectionHost = resolveHeadlessConnectionHost(host, interfaces); + return `http://${formatHostForUrl(connectionHost)}:${port}`; +}; + +export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { + if ( + typeof address === "object" && + address !== null && + "port" in address && + typeof address.port === "number" + ) { + return address.port; + } + return fallbackPort; +}; + +export const buildPairingUrl = (connectionString: string, token: string): string => { + const url = new URL(connectionString); + url.pathname = "/pair"; + url.searchParams.delete("token"); + url.hash = new URLSearchParams([["token", token]]).toString(); + return url.toString(); +}; + +export const renderTerminalQrCode = (value: string, margin = 2): string => { + const qrCode = QrCode.encodeText(value, QrCode.Ecc.MEDIUM); + const rows: Array = []; + const isDark = (x: number, y: number): boolean => + x >= 0 && x < qrCode.size && y >= 0 && y < qrCode.size && qrCode.getModule(x, y); + + for (let y = -margin; y < qrCode.size + margin; y += 2) { + let row = ""; + + for (let x = -margin; x < qrCode.size + margin; x += 1) { + const topDark = isDark(x, y); + const bottomDark = isDark(x, y + 1); + + row += topDark ? (bottomDark ? "█" : "▀") : bottomDark ? "▄" : " "; + } + + rows.push(row); + } + + return rows.join("\n"); +}; + +export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string => + [ + "T3 Code server is ready.", + `Connection string: ${accessInfo.connectionString}`, + `Token: ${accessInfo.token}`, + `Pairing URL: ${accessInfo.pairingUrl}`, + "", + renderTerminalQrCode(accessInfo.pairingUrl), + "", + ].join("\n"); + +export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () { + const serverConfig = yield* ServerConfig; + const httpServer = yield* HttpServer.HttpServer; + const serverAuth = yield* ServerAuth; + const connectionString = resolveHeadlessConnectionString( + serverConfig.host, + resolveListeningPort(httpServer.address, serverConfig.port), + ); + const issued = yield* serverAuth.issuePairingCredential({ role: "owner" }); + + return { + connectionString, + token: issued.credential, + pairingUrl: buildPairingUrl(connectionString, issued.credential), + } satisfies HeadlessServeAccessInfo; +}); diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts index 64f8c8de55..0e703573d4 100644 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ b/apps/server/src/telemetry/Services/AnalyticsService.ts @@ -6,7 +6,7 @@ * * @module AnalyticsService */ -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, Context } from "effect"; export interface AnalyticsServiceShape { /** @@ -23,7 +23,7 @@ export interface AnalyticsServiceShape { readonly flush: Effect.Effect; } -export class AnalyticsService extends ServiceMap.Service()( +export class AnalyticsService extends Context.Service()( "t3/telemetry/Services/AnalyticsService", ) { static readonly layerTest = Layer.succeed(AnalyticsService, { diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 5dc216e37e..4bdeba68e1 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -8,7 +8,6 @@ import { } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { - Data, Effect, Encoding, Equal, @@ -17,6 +16,7 @@ import { FileSystem, Layer, Option, + Schema, Scope, Semaphore, SynchronizedRef, @@ -54,22 +54,28 @@ const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); -type TerminalSubprocessChecker = ( - terminalPid: number, -) => Effect.Effect; - -class TerminalSubprocessCheckError extends Data.TaggedError("TerminalSubprocessCheckError")<{ - readonly message: string; - readonly cause?: unknown; - readonly terminalPid: number; - readonly command: "powershell" | "pgrep" | "ps"; -}> {} +class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( + "TerminalSubprocessCheckError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + terminalPid: Schema.Number, + command: Schema.Literals(["powershell", "pgrep", "ps"]), + }, +) {} + +class TerminalProcessSignalError extends Schema.TaggedErrorClass()( + "TerminalProcessSignalError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + signal: Schema.Literals(["SIGTERM", "SIGKILL"]), + }, +) {} -class TerminalProcessSignalError extends Data.TaggedError("TerminalProcessSignalError")<{ - readonly message: string; - readonly cause?: unknown; - readonly signal: "SIGTERM" | "SIGKILL"; -}> {} +interface TerminalSubprocessChecker { + (terminalPid: number): Effect.Effect; +} interface ShellCandidate { shell: string; @@ -271,9 +277,8 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { if (current instanceof Error) { messages.push(current.message); - const cause = (current as { cause?: unknown }).cause; - if (cause) { - queue.push(cause); + if (current.cause) { + queue.push(current.cause); } continue; } @@ -664,8 +669,8 @@ const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( function* (options: TerminalManagerOptions) { const fileSystem = yield* FileSystem.FileSystem; - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; @@ -876,7 +881,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.logWarning("failed to persist terminal history", { threadId, terminalId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ); @@ -959,7 +964,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.catch((cleanupError) => Effect.logWarning("failed to remove legacy terminal history", { threadId, - error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + error: cleanupError, }), ), ); @@ -975,7 +980,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.logWarning("failed to delete terminal history", { threadId, terminalId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ); @@ -985,7 +990,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.logWarning("failed to delete terminal history", { threadId, terminalId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ); @@ -1011,7 +1016,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.catch((error) => Effect.logWarning("failed to delete terminal histories for thread", { threadId, - error: error instanceof Error ? error.message : String(error), + error, }), ), ), @@ -1463,12 +1468,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const terminalPid = session.pid; const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe( Effect.map(Option.some), - Effect.catch((error) => + Effect.catch((reason) => Effect.logWarning("failed to check terminal subprocess activity", { threadId: session.threadId, terminalId: session.terminalId, terminalPid, - error: error instanceof Error ? error.message : String(error), + reason, }).pipe(Effect.as(Option.none())), ), ); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index ebe83e362b..b59c4721cd 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -23,7 +23,7 @@ import { TerminalWriteInput, } from "@t3tools/contracts"; import { PtyProcess } from "./PTY"; -import { Effect, ServiceMap } from "effect"; +import { Effect, Context } from "effect"; export { TerminalCwdError, @@ -122,6 +122,6 @@ export interface TerminalManagerShape { /** * TerminalManager - Service tag for terminal session orchestration. */ -export class TerminalManager extends ServiceMap.Service()( +export class TerminalManager extends Context.Service()( "t3/terminal/Services/Manager/TerminalManager", ) {} diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/Services/PTY.ts index dc164b2621..091e527ef2 100644 --- a/apps/server/src/terminal/Services/PTY.ts +++ b/apps/server/src/terminal/Services/PTY.ts @@ -6,7 +6,7 @@ * * @module PtyAdapter */ -import { Effect, Schema, ServiceMap } from "effect"; +import { Effect, Schema, Context } from "effect"; /** * PtyError - Error type for PTY adapter operations. @@ -53,6 +53,6 @@ export interface PtyAdapterShape { /** * PtyAdapter - Service tag for PTY process integration. */ -export class PtyAdapter extends ServiceMap.Service()( +export class PtyAdapter extends Context.Service()( "t3/terminal/Services/PTY/PtyAdapter", ) {} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69bf1..85b43ab37f 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -68,6 +68,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb return yield* workspaceEntries.search(input); }); +const appendSeparator = (input: string) => + input.endsWith("/") || input.endsWith("\\") + ? input + : `${input}${process.platform === "win32" ? "\\" : "/"}`; + it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { afterEach(() => { vi.restoreAllMocks(); @@ -129,6 +134,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); + it.effect("prioritizes exact basename matches ahead of broader path matches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" }); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "docs/composer.tsx-notes.md"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 }); + + expect(result.entries[0]?.path).toBe("src/components/Composer.tsx"); + }), + ); + it.effect("tracks truncation without sorting every fuzzy match", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" }); @@ -263,4 +280,85 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); }); + + describe("browse", () => { + it.effect("returns matching directories and excludes files", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); + yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); + yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); + yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n"); + + const result = yield* workspaceEntries.browse({ + partialPath: path.join(cwd, "alp"), + }); + + expect(result).toEqual({ + parentPath: cwd, + entries: [ + { name: "alpha", fullPath: path.join(cwd, "alpha") }, + { name: "alpine", fullPath: path.join(cwd, "alpine") }, + ], + }); + }), + ); + + it.effect("shows dot directories in directory mode and hidden-prefix mode", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); + yield* writeTextFile(cwd, ".config/settings.json", "{}"); + yield* writeTextFile(cwd, "config/settings.json", "{}"); + + const directoryResult = yield* workspaceEntries.browse({ + partialPath: appendSeparator(cwd), + }); + const hiddenPrefixResult = yield* workspaceEntries.browse({ + partialPath: `${appendSeparator(cwd)}.c`, + }); + + expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); + expect(hiddenPrefixResult).toEqual({ + parentPath: cwd, + entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }], + }); + }), + ); + + it.effect("supports relative paths when cwd is provided", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); + yield* writeTextFile(cwd, "packages/pkg.json", "{}"); + + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./pack", + }); + + expect(result).toEqual({ + parentPath: cwd, + entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], + }); + }), + ); + + it.effect("rejects relative paths without cwd", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + + const error = yield* workspaceEntries + .browse({ + partialPath: "./src", + }) + .pipe(Effect.flip); + + expect(error.detail).toBe("Relative filesystem browse paths require a current project."); + }), + ); + }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 12af8601ca..7cb16b652b 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,13 +1,22 @@ +import * as OS from "node:os"; import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; -import { type ProjectEntry } from "@t3tools/contracts"; +import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, + type RankedSearchResult, +} from "@t3tools/shared/searchRanking"; import { GitCore } from "../../git/Services/GitCore.ts"; import { WorkspaceEntries, + WorkspaceEntriesBrowseError, WorkspaceEntriesError, type WorkspaceEntriesShape, } from "../Services/WorkspaceEntries.ts"; @@ -40,15 +49,22 @@ interface SearchableWorkspaceEntry extends ProjectEntry { normalizedName: string; } -interface RankedWorkspaceEntry { - entry: SearchableWorkspaceEntry; - score: number; -} +type RankedWorkspaceEntry = RankedSearchResult; function toPosixPath(input: string): string { return input.replaceAll("\\", "/"); } +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(OS.homedir(), input.slice(2)); + } + return input; +} + function parentPathOf(input: string): string | undefined { const separatorIndex = input.lastIndexOf("/"); if (separatorIndex === -1) { @@ -74,45 +90,6 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt }; } -function normalizeQuery(input: string): string { - return input - .trim() - .replace(/^[@./]+/, "") - .toLowerCase(); -} - -function scoreSubsequenceMatch(value: string, query: string): number | null { - if (!query) return 0; - - let queryIndex = 0; - let firstMatchIndex = -1; - let previousMatchIndex = -1; - let gapPenalty = 0; - - for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { - if (value[valueIndex] !== query[queryIndex]) { - continue; - } - - if (firstMatchIndex === -1) { - firstMatchIndex = valueIndex; - } - if (previousMatchIndex !== -1) { - gapPenalty += valueIndex - previousMatchIndex - 1; - } - - previousMatchIndex = valueIndex; - queryIndex += 1; - if (queryIndex === query.length) { - const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; - const lengthPenalty = Math.min(64, value.length - query.length); - return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; - } - } - - return null; -} - function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { if (!query) { return entry.kind === "directory" ? 0 : 1; @@ -120,81 +97,32 @@ function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | nu const { normalizedPath, normalizedName } = entry; - if (normalizedName === query) return 0; - if (normalizedPath === query) return 1; - if (normalizedName.startsWith(query)) return 2; - if (normalizedPath.startsWith(query)) return 3; - if (normalizedPath.includes(`/${query}`)) return 4; - if (normalizedName.includes(query)) return 5; - if (normalizedPath.includes(query)) return 6; - - const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); - if (nameFuzzyScore !== null) { - return 100 + nameFuzzyScore; - } - - const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); - if (pathFuzzyScore !== null) { - return 200 + pathFuzzyScore; - } - - return null; -} - -function compareRankedWorkspaceEntries( - left: RankedWorkspaceEntry, - right: RankedWorkspaceEntry, -): number { - const scoreDelta = left.score - right.score; - if (scoreDelta !== 0) return scoreDelta; - return left.entry.path.localeCompare(right.entry.path); -} - -function findInsertionIndex( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, -): number { - let low = 0; - let high = rankedEntries.length; - - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - const current = rankedEntries[middle]; - if (!current) { - break; - } - - if (compareRankedWorkspaceEntries(candidate, current) < 0) { - high = middle; - } else { - low = middle + 1; - } + const scores = [ + scoreQueryMatch({ + value: normalizedName, + query, + exactBase: 0, + prefixBase: 2, + includesBase: 5, + fuzzyBase: 100, + }), + scoreQueryMatch({ + value: normalizedPath, + query, + exactBase: 1, + prefixBase: 3, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 200, + boundaryMarkers: ["/"], + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; } - return low; -} - -function insertRankedEntry( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, - limit: number, -): void { - if (limit <= 0) { - return; - } - - const insertionIndex = findInsertionIndex(rankedEntries, candidate); - if (rankedEntries.length < limit) { - rankedEntries.splice(insertionIndex, 0, candidate); - return; - } - - if (insertionIndex >= limit) { - return; - } - - rankedEntries.splice(insertionIndex, 0, candidate); - rankedEntries.pop(); + return Math.min(...scores); } function isPathInIgnoredDirectory(relativePath: string): boolean { @@ -214,8 +142,35 @@ function directoryAncestorsOf(relativePath: string): string[] { return directories; } -const processErrorDetail = (cause: unknown): string => - cause instanceof Error ? cause.message : String(cause); +const resolveBrowseTarget = ( + input: FilesystemBrowseInput, + pathService: Path.Path, +): Effect.Effect => + Effect.gen(function* () { + if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Windows-style paths are only supported on Windows.", + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return pathService.resolve(expandHomePath(input.partialPath, pathService)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Relative filesystem browse paths require a current project.", + }); + } + + return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); + }); export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; @@ -319,7 +274,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { new WorkspaceEntriesError({ cwd, operation: "workspaceEntries.readDirectoryEntries", - detail: processErrorDetail(cause), + detail: cause instanceof Error ? cause.message : String(cause), cause, }), }).pipe( @@ -430,12 +385,14 @@ export const makeWorkspaceEntries = Effect.gen(function* () { return yield* buildWorkspaceIndexFromFilesystem(cwd); }); - const workspaceIndexCache = yield* Cache.makeWith({ - capacity: WORKSPACE_CACHE_MAX_KEYS, - lookup: buildWorkspaceIndex, - timeToLive: (exit) => - Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, - }); + const workspaceIndexCache = yield* Cache.makeWith( + buildWorkspaceIndex, + { + capacity: WORKSPACE_CACHE_MAX_KEYS, + timeToLive: (exit) => + Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, + }, + ); const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, @@ -465,12 +422,54 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }, ); + const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.browse.readDirectory", + detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + + return { + parentPath, + entries: dirents + .filter( + (dirent) => + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")), + ) + .map((dirent) => ({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( Effect.map((index) => { - const normalizedQuery = normalizeQuery(input.query); + const normalizedQuery = normalizeSearchQuery(input.query, { + trimLeadingPattern: /^[@./]+/, + }); const limit = Math.max(0, Math.floor(input.limit)); const rankedEntries: RankedWorkspaceEntry[] = []; let matchedEntryCount = 0; @@ -482,11 +481,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () { } matchedEntryCount += 1; - insertRankedEntry(rankedEntries, { entry, score }, limit); + insertRankedSearchResult( + rankedEntries, + { item: entry, score, tieBreaker: entry.path }, + limit, + ); } return { - entries: rankedEntries.map((candidate) => candidate.entry), + entries: rankedEntries.map((candidate) => candidate.item), truncated: index.truncated || matchedEntryCount > limit, }; }), @@ -495,6 +498,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ); return { + browse, invalidate, search, } satisfies WorkspaceEntriesShape; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts index d02a5929d2..13658e9c1a 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts @@ -58,6 +58,24 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); + it.effect("creates missing directories when createIfMissing is enabled", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + const missingPath = path.join(cwd, "nested", "new-project"); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { + createIfMissing: true, + }); + const stat = yield* fileSystem.stat(resolved); + + expect(resolved).toBe(missingPath); + expect(stat.type).toBe("Directory"); + }), + ); + it.effect("rejects file paths", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index fa7a90cf07..f19bb3624d 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { WorkspacePaths, WorkspacePathOutsideRootError, + WorkspaceRootCreateFailedError, WorkspaceRootNotDirectoryError, WorkspaceRootNotExistsError, type WorkspacePathsShape, @@ -29,11 +30,25 @@ export const makeWorkspacePaths = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", - )(function* (workspaceRoot) { + )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - const workspaceStat = yield* fileSystem + let workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat && options?.createIfMissing) { + yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( + Effect.mapError( + () => + new WorkspaceRootCreateFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + }), + ), + ); + workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ workspaceRoot, diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts index 2841b1fe2f..e546bf4c5d 100644 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -6,10 +6,15 @@ * * @module WorkspaceEntries */ -import { Schema, ServiceMap } from "effect"; +import { Schema, Context } from "effect"; import type { Effect } from "effect"; -import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts"; +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( "WorkspaceEntriesError", @@ -21,11 +26,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesBrowseError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + /** * WorkspaceEntriesShape - Service API for workspace entry search and cache * invalidation. */ export interface WorkspaceEntriesShape { + /** + * Browse matching directories for the provided partial path. + */ + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + /** * Search indexed workspace entries for files and directories matching the * provided query. @@ -43,6 +66,6 @@ export interface WorkspaceEntriesShape { /** * WorkspaceEntries - Service tag for cached workspace entry search. */ -export class WorkspaceEntries extends ServiceMap.Service()( +export class WorkspaceEntries extends Context.Service()( "t3/workspace/Services/WorkspaceEntries", ) {} diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 85db1514a3..dc6cc6e9d8 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -6,7 +6,7 @@ * * @module WorkspaceFileSystem */ -import { Schema, ServiceMap } from "effect"; +import { Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; @@ -44,7 +44,7 @@ export interface WorkspaceFileSystemShape { /** * WorkspaceFileSystem - Service tag for workspace file operations. */ -export class WorkspaceFileSystem extends ServiceMap.Service< +export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, WorkspaceFileSystemShape >()("t3/workspace/Services/WorkspaceFileSystem") {} diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts index ad6d9cd3e5..1cd016284a 100644 --- a/apps/server/src/workspace/Services/WorkspacePaths.ts +++ b/apps/server/src/workspace/Services/WorkspacePaths.ts @@ -6,7 +6,7 @@ * * @module WorkspacePaths */ -import { Schema, ServiceMap } from "effect"; +import { Schema, Context } from "effect"; import type { Effect } from "effect"; export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( @@ -21,6 +21,18 @@ export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( + "WorkspaceRootCreateFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; + } +} + export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { @@ -47,6 +59,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass Effect.Effect; + options?: { readonly createIfMissing?: boolean }, + ) => Effect.Effect< + string, + WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError + >; /** * Resolve a relative path within a validated workspace root. @@ -80,6 +97,6 @@ export interface WorkspacePathsShape { /** * WorkspacePaths - Service tag for workspace path normalization and resolution. */ -export class WorkspacePaths extends ServiceMap.Service()( +export class WorkspacePaths extends Context.Service()( "t3/workspace/Services/WorkspacePaths", ) {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 33a0518611..96b5b54d71 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,5 +1,7 @@ -import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Duration, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; import { + type AuthAccessStreamEvent, + AuthSessionId, CommandId, EventId, type OrchestrationCommand, @@ -7,6 +9,7 @@ import { type GitManagerServiceError, OrchestrationDispatchCommandError, type OrchestrationEvent, + type OrchestrationShellStreamEvent, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -14,19 +17,21 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, + FilesystemBrowseError, ThreadId, type TerminalEvent, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; import { clamp } from "effect/Number"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore"; import { GitManager } from "./git/Services/GitManager"; +import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { normalizeDispatchCommand } from "./orchestration/Normalizer"; @@ -46,699 +51,1003 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; +import { ServerAuth } from "./auth/Services/ServerAuth"; +import { + BootstrapCredentialService, + type BootstrapCredentialChange, +} from "./auth/Services/BootstrapCredentialService"; +import { + SessionCredentialService, + type SessionCredentialChange, +} from "./auth/Services/SessionCredentialService"; +import { respondToAuthError } from "./auth/http"; -const WsRpcLayer = WsRpcGroup.toLayer( - Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; - const checkpointDiffQuery = yield* CheckpointDiffQuery; - const keybindings = yield* Keybindings; - const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitCore; - const terminalManager = yield* TerminalManager; - const providerRegistry = yield* ProviderRegistry; - const config = yield* ServerConfig; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const startup = yield* ServerRuntimeStartup; - const workspaceEntries = yield* WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - - const serverCommandId = (tag: string) => - CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); - - const appendSetupScriptActivity = (input: { - readonly threadId: ThreadId; - readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; - readonly summary: string; - readonly createdAt: string; - readonly payload: Record; - readonly tone: "info" | "error"; - }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: serverCommandId("setup-script-activity"), - threadId: input.threadId, - activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), - tone: input.tone, - kind: input.kind, - summary: input.summary, - payload: input.payload, - turnId: null, - createdAt: input.createdAt, +function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< + OrchestrationEvent, + { + type: + | "thread.message-sent" + | "thread.proposed-plan-upserted" + | "thread.activity-appended" + | "thread.turn-diff-completed" + | "thread.reverted" + | "thread.session-set"; + } +> { + return ( + event.type === "thread.message-sent" || + event.type === "thread.proposed-plan-upserted" || + event.type === "thread.activity-appended" || + event.type === "thread.turn-diff-completed" || + event.type === "thread.reverted" || + event.type === "thread.session-set" + ); +} + +const PROVIDER_STATUS_DEBOUNCE_MS = 200; + +function toAuthAccessStreamEvent( + change: BootstrapCredentialChange | SessionCredentialChange, + revision: number, + currentSessionId: AuthSessionId, +): AuthAccessStreamEvent { + switch (change.type) { + case "pairingLinkUpserted": + return { + version: 1, + revision, + type: "pairingLinkUpserted", + payload: change.pairingLink, + }; + case "pairingLinkRemoved": + return { + version: 1, + revision, + type: "pairingLinkRemoved", + payload: { id: change.id }, + }; + case "clientUpserted": + return { + version: 1, + revision, + type: "clientUpserted", + payload: { + ...change.clientSession, + current: change.clientSession.sessionId === currentSessionId, }, - createdAt: input.createdAt, - }); + }; + case "clientRemoved": + return { + version: 1, + revision, + type: "clientRemoved", + payload: { sessionId: change.sessionId }, + }; + } +} - const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: cause instanceof Error ? cause.message : fallbackMessage, - cause, - }); +const makeWsRpcLayer = (currentSessionId: AuthSessionId) => + WsRpcGroup.toLayer( + Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngineService; + const checkpointDiffQuery = yield* CheckpointDiffQuery; + const keybindings = yield* Keybindings; + const open = yield* Open; + const gitManager = yield* GitManager; + const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const terminalManager = yield* TerminalManager; + const providerRegistry = yield* ProviderRegistry; + const config = yield* ServerConfig; + const lifecycleEvents = yield* ServerLifecycleEvents; + const serverSettings = yield* ServerSettingsService; + const startup = yield* ServerRuntimeStartup; + const workspaceEntries = yield* WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment; + const serverAuth = yield* ServerAuth; + const bootstrapCredentials = yield* BootstrapCredentialService; + const sessions = yield* SessionCredentialService; + const serverCommandId = (tag: string) => + CommandId.make(`server:${tag}:${crypto.randomUUID()}`); - const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { - const error = Cause.squash(cause); - return Schema.is(OrchestrationDispatchCommandError)(error) - ? error - : new OrchestrationDispatchCommandError({ - message: - error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", - cause, - }); - }; + const loadAuthAccessSnapshot = () => + Effect.all({ + pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), + clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), + }); - const dispatchBootstrapTurnStart = ( - command: Extract, - ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => - Effect.gen(function* () { - const bootstrap = command.bootstrap; - const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; - let createdThread = false; - let targetProjectId = bootstrap?.createThread?.projectId; - let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; - let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; - - const cleanupCreatedThread = () => - createdThread - ? orchestrationEngine - .dispatch({ - type: "thread.delete", - commandId: serverCommandId("bootstrap-thread-delete"), - threadId: command.threadId, - }) - .pipe(Effect.ignoreCause({ log: true })) - : Effect.void; - - const recordSetupScriptLaunchFailure = (input: { - readonly error: unknown; - readonly requestedAt: string; - readonly worktreePath: string; - }) => { - const detail = - input.error instanceof Error ? input.error.message : "Unknown setup failure."; - return appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.failed", - summary: "Setup script failed to start", - createdAt: input.requestedAt, - payload: { - detail, - worktreePath: input.worktreePath, - }, - tone: "error", - }).pipe( - Effect.ignoreCause({ log: false }), - Effect.flatMap(() => - Effect.logWarning("bootstrap turn start failed to launch setup script", { - threadId: command.threadId, - worktreePath: input.worktreePath, - detail, + const appendSetupScriptActivity = (input: { + readonly threadId: ThreadId; + readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly summary: string; + readonly createdAt: string; + readonly payload: Record; + readonly tone: "info" | "error"; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("setup-script-activity"), + threadId: input.threadId, + activity: { + id: EventId.make(crypto.randomUUID()), + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + + const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: cause instanceof Error ? cause.message : fallbackMessage, + cause, + }); + + const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return Schema.is(OrchestrationDispatchCommandError)(error) + ? error + : new OrchestrationDispatchCommandError({ + message: + error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", + cause, + }); + }; + + const enrichProjectEvent = ( + event: OrchestrationEvent, + ): Effect.Effect => { + switch (event.type) { + case "project.created": + return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => ({ + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + })), + ); + case "project.meta-updated": + return Effect.gen(function* () { + const workspaceRoot = + event.payload.workspaceRoot ?? + (yield* orchestrationEngine.getReadModel()).projects.find( + (project) => project.id === event.payload.projectId, + )?.workspaceRoot ?? + null; + if (workspaceRoot === null) { + return event; + } + + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); + return { + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + } satisfies OrchestrationEvent; + }); + default: + return Effect.succeed(event); + } + }; + + const enrichOrchestrationEvents = (events: ReadonlyArray) => + Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + + const toShellStreamEvent = ( + event: OrchestrationEvent, + ): Effect.Effect, never, never> => { + switch (event.type) { + case "project.created": + case "project.meta-updated": + return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( + Effect.map((project) => + Option.map(project, (nextProject) => ({ + kind: "project-upserted" as const, + sequence: event.sequence, + project: nextProject, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + case "project.deleted": + return Effect.succeed( + Option.some({ + kind: "project-removed" as const, + sequence: event.sequence, + projectId: event.payload.projectId, }), - ), - ); - }; + ); + case "thread.deleted": + return Effect.succeed( + Option.some({ + kind: "thread-removed" as const, + sequence: event.sequence, + threadId: event.payload.threadId, + }), + ); + default: + if (event.aggregateKind !== "thread") { + return Effect.succeed(Option.none()); + } + return projectionSnapshotQuery + .getThreadShellById(ThreadId.make(event.aggregateId)) + .pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + } + }; - const recordSetupScriptStarted = (input: { - readonly requestedAt: string; - readonly worktreePath: string; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - }) => { - const payload = { - scriptId: input.scriptId, - scriptName: input.scriptName, - terminalId: input.terminalId, - worktreePath: input.worktreePath, - }; - return Effect.all([ - appendSetupScriptActivity({ + const dispatchBootstrapTurnStart = ( + command: Extract, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => + Effect.gen(function* () { + const bootstrap = command.bootstrap; + const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; + let createdThread = false; + let targetProjectId = bootstrap?.createThread?.projectId; + let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; + let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + + const cleanupCreatedThread = () => + createdThread + ? orchestrationEngine + .dispatch({ + type: "thread.delete", + commandId: serverCommandId("bootstrap-thread-delete"), + threadId: command.threadId, + }) + .pipe(Effect.ignoreCause({ log: true })) + : Effect.void; + + const recordSetupScriptLaunchFailure = (input: { + readonly error: unknown; + readonly requestedAt: string; + readonly worktreePath: string; + }) => { + const detail = + input.error instanceof Error ? input.error.message : "Unknown setup failure."; + return appendSetupScriptActivity({ threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", + kind: "setup-script.failed", + summary: "Setup script failed to start", createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: new Date().toISOString(), - payload, - tone: "info", - }), - ]).pipe( - Effect.asVoid, - Effect.catch((error) => - Effect.logWarning( - "bootstrap turn start launched setup script but failed to record setup activity", - { + payload: { + detail, + worktreePath: input.worktreePath, + }, + tone: "error", + }).pipe( + Effect.ignoreCause({ log: false }), + Effect.flatMap(() => + Effect.logWarning("bootstrap turn start failed to launch setup script", { threadId: command.threadId, worktreePath: input.worktreePath, - scriptId: input.scriptId, - terminalId: input.terminalId, - detail: - error instanceof Error - ? error.message - : "Unknown setup activity dispatch failure.", - }, + detail, + }), ), - ), - ); - }; + ); + }; - const runSetupProgram = () => - bootstrap?.runSetupScript && targetWorktreePath - ? (() => { - const worktreePath = targetWorktreePath; - const requestedAt = new Date().toISOString(); - return projectSetupScriptRunner - .runForThread({ + const recordSetupScriptStarted = (input: { + readonly requestedAt: string; + readonly worktreePath: string; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + }) => { + const payload = { + scriptId: input.scriptId, + scriptName: input.scriptName, + terminalId: input.terminalId, + worktreePath: input.worktreePath, + }; + return Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: input.requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: new Date().toISOString(), + payload, + tone: "info", + }), + ]).pipe( + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start launched setup script but failed to record setup activity", + { threadId: command.threadId, - ...(targetProjectId ? { projectId: targetProjectId } : {}), - ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), - worktreePath, - }) - .pipe( - Effect.matchEffect({ - onFailure: (error) => - recordSetupScriptLaunchFailure({ - error, - requestedAt, - worktreePath, - }), - onSuccess: (setupResult) => { - if (setupResult.status !== "started") { - return Effect.void; - } - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }); - }, - }), - ); - })() - : Effect.void; - - const bootstrapProgram = Effect.gen(function* () { - if (bootstrap?.createThread) { - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: serverCommandId("bootstrap-thread-create"), - threadId: command.threadId, - projectId: bootstrap.createThread.projectId, - title: bootstrap.createThread.title, - modelSelection: bootstrap.createThread.modelSelection, - runtimeMode: bootstrap.createThread.runtimeMode, - interactionMode: bootstrap.createThread.interactionMode, - branch: bootstrap.createThread.branch, - worktreePath: bootstrap.createThread.worktreePath, - createdAt: bootstrap.createThread.createdAt, - }); - createdThread = true; - } - - if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, - branch: bootstrap.prepareWorktree.baseBranch, - newBranch: bootstrap.prepareWorktree.branch, - path: null, - }); - targetWorktreePath = worktree.worktree.path; - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, - branch: worktree.worktree.branch, - worktreePath: targetWorktreePath, - }); - } + worktreePath: input.worktreePath, + scriptId: input.scriptId, + terminalId: input.terminalId, + detail: error.message, + }, + ), + ), + ); + }; - yield* runSetupProgram(); + const runSetupProgram = () => + bootstrap?.runSetupScript && targetWorktreePath + ? (() => { + const worktreePath = targetWorktreePath; + const requestedAt = new Date().toISOString(); + return projectSetupScriptRunner + .runForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + recordSetupScriptLaunchFailure({ + error, + requestedAt, + worktreePath, + }), + onSuccess: (setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + return recordSetupScriptStarted({ + requestedAt, + worktreePath, + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + }); + }, + }), + ); + })() + : Effect.void; - return yield* orchestrationEngine.dispatch(finalTurnStartCommand); - }); + const bootstrapProgram = Effect.gen(function* () { + if (bootstrap?.createThread) { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }); + createdThread = true; + } - return yield* bootstrapProgram.pipe( - Effect.catchCause((cause) => { - const dispatchError = toBootstrapDispatchCommandCauseError(cause); - if (Cause.hasInterruptsOnly(cause)) { - return Effect.fail(dispatchError); + if (bootstrap?.prepareWorktree) { + const worktree = yield* git.createWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + newBranch: bootstrap.prepareWorktree.branch, + path: null, + }); + targetWorktreePath = worktree.worktree.path; + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("bootstrap-thread-meta-update"), + threadId: command.threadId, + branch: worktree.worktree.branch, + worktreePath: targetWorktreePath, + }); + yield* refreshGitStatus(targetWorktreePath); } - return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); - }), - ); - }); - const dispatchNormalizedCommand = ( - normalizedCommand: OrchestrationCommand, - ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { - const dispatchEffect = - normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap - ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); + yield* runSetupProgram(); - return startup - .enqueueCommand(dispatchEffect) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); - }; + return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + }); - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; - const settings = yield* serverSettings.getSettings; + return yield* bootstrapProgram.pipe( + Effect.catchCause((cause) => { + const dispatchError = toBootstrapDispatchCommandCauseError(cause); + if (Cause.hasInterruptsOnly(cause)) { + return Effect.fail(dispatchError); + } + return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); + }), + ); + }); - return { - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors: resolveAvailableEditors(), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, + const dispatchNormalizedCommand = ( + normalizedCommand: OrchestrationCommand, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { + const dispatchEffect = + normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap + ? dispatchBootstrapTurnStart(normalizedCommand) + : orchestrationEngine + .dispatch(normalizedCommand) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + + return startup + .enqueueCommand(dispatchEffect) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); }; - }); - - return WsRpcGroup.of({ - [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getSnapshot, - projectionSnapshotQuery.getSnapshot().pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot", - cause, - }), + + const loadServerConfig = Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const providers = yield* providerRegistry.getProviders; + const settings = yield* serverSettings.getSettings; + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors: resolveAvailableEditors(), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + }; + }); + + const refreshGitStatus = (cwd: string) => + gitStatusBroadcaster + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + + return WsRpcGroup.of({ + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.dispatchCommand, + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand(command); + const result = yield* dispatchNormalizedCommand(normalizedCommand); + if (normalizedCommand.type === "thread.archive") { + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } + return result; + }).pipe( + Effect.mapError((cause) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.dispatchCommand, - Effect.gen(function* () { - const normalizedCommand = yield* normalizeDispatchCommand(command); - const result = yield* dispatchNormalizedCommand(normalizedCommand); - if (normalizedCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, + [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getTurnDiff, + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, }), - ), - ); - } - return result; - }).pipe( - Effect.mapError((cause) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: "Failed to dispatch orchestration command", + ), + ), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + checkpointDiffQuery.getFullThreadDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", cause, }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getTurnDiff, - checkpointDiffQuery.getTurnDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, + [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.replayEvents, + Stream.runCollect( + orchestrationEngine.readEvents( + clamp(input.fromSequenceExclusive, { + maximum: Number.MAX_SAFE_INTEGER, + minimum: 0, }), + ), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getFullThreadDiff, - checkpointDiffQuery.getFullThreadDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, + [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) => + observeRpcStreamEffect( + ORCHESTRATION_WS_METHODS.subscribeShell, + Effect.gen(function* () { + const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration shell snapshot", + cause, + }), + ), + ); + + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(toShellStreamEvent), + Stream.flatMap((event) => + Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + ), + ); + + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot, }), - ), + liveStream, + ); + }), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => + observeRpcStreamEffect( + ORCHESTRATION_WS_METHODS.subscribeThread, + Effect.gen(function* () { + const [threadDetail, snapshotSequence] = yield* Effect.all([ + projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load thread ${input.threadId}`, + cause, + }), + ), + ), + orchestrationEngine + .getReadModel() + .pipe(Effect.map((readModel) => readModel.snapshotSequence)), + ]); + + if (Option.isNone(threadDetail)) { + return yield* new OrchestrationGetSnapshotError({ + message: `Thread ${input.threadId} was not found`, + cause: input.threadId, + }); + } + + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === input.threadId && + isThreadDetailEvent(event), + ), + Stream.map((event) => ({ + kind: "event" as const, + event, + })), + ); + + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot: { + snapshotSequence, + thread: threadDetail.value, + }, + }), + liveStream, + ); + }), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.replayEvents, - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), + [WS_METHODS.serverGetConfig]: (_input) => + observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { + "rpc.aggregate": "server", + }), + [WS_METHODS.serverRefreshProviders]: (_input) => + observeRpcEffect( + WS_METHODS.serverRefreshProviders, + providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverUpsertKeybinding]: (rule) => + observeRpcEffect( + WS_METHODS.serverUpsertKeybinding, + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverGetSettings]: (_input) => + observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { + "rpc.aggregate": "server", + }), + [WS_METHODS.serverUpdateSettings]: ({ patch }) => + observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { + "rpc.aggregate": "server", + }), + [WS_METHODS.projectsSearchEntries]: (input) => + observeRpcEffect( + WS_METHODS.projectsSearchEntries, + workspaceEntries.search(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchEntriesError({ + message: `Failed to search workspace entries: ${cause.detail}`, + cause, + }), + ), ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.projectsWriteFile]: (input) => + observeRpcEffect( + WS_METHODS.projectsWriteFile, + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Workspace file path must stay within the project root." + : "Failed to write workspace file"; + return new ProjectWriteFileError({ + message, cause, - }), + }); + }), ), + { "rpc.aggregate": "workspace" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeOrchestrationDomainEvents, - Effect.gen(function* () { - const snapshot = yield* orchestrationEngine.getReadModel(); - const fromSequenceExclusive = snapshot.snapshotSequence; - const replayEvents: Array = yield* Stream.runCollect( - orchestrationEngine.readEvents(fromSequenceExclusive), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.catch(() => Effect.succeed([] as Array)), - ); - const replayStream = Stream.fromIterable(replayEvents); - const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents); - type SequenceState = { - readonly nextSequence: number; - readonly pendingBySequence: Map; - }; - const state = yield* Ref.make({ - nextSequence: fromSequenceExclusive + 1, - pendingBySequence: new Map(), - }); - - return source.pipe( - Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } - - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); - - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } - - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; + [WS_METHODS.shellOpenInEditor]: (input) => + observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.filesystemBrowse]: (input) => + observeRpcEffect( + WS_METHODS.filesystemBrowse, + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + message: cause.detail, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.subscribeGitStatus]: (input) => + observeRpcStream( + WS_METHODS.subscribeGitStatus, + gitStatusBroadcaster.streamStatus(input), + { + "rpc.aggregate": "git", + }, + ), + [WS_METHODS.gitRefreshStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitRefreshStatus, + gitStatusBroadcaster.refreshStatus(input.cwd), + { + "rpc.aggregate": "git", + }, + ), + [WS_METHODS.gitPull]: (input) => + observeRpcEffect( + WS_METHODS.gitPull, + git.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitRunStackedAction]: (input) => + observeRpcStream( + WS_METHODS.gitRunStackedAction, + Stream.callback((queue) => + gitManager + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), }, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), ), - ), - Stream.flatMap((events) => Stream.fromIterable(events)), - ); + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitResolvePullRequest]: (input) => + observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { + "rpc.aggregate": "git", }), - { "rpc.aggregate": "orchestration" }, - ), - [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverRefreshProviders]: (_input) => - observeRpcEffect( - WS_METHODS.serverRefreshProviders, - providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverUpsertKeybinding]: (rule) => - observeRpcEffect( - WS_METHODS.serverUpsertKeybinding, - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; + [WS_METHODS.gitPreparePullRequestThread]: (input) => + observeRpcEffect( + WS_METHODS.gitPreparePullRequestThread, + gitManager + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitListBranches]: (input) => + observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { + "rpc.aggregate": "git", }), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverGetSettings]: (_input) => - observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverUpdateSettings]: ({ patch }) => - observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { - "rpc.aggregate": "server", - }), - [WS_METHODS.projectsSearchEntries]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchEntries, - workspaceEntries.search(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${cause.detail}`, - cause, - }), + [WS_METHODS.gitCreateWorktree]: (input) => + observeRpcEffect( + WS_METHODS.gitCreateWorktree, + git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitRemoveWorktree]: (input) => + observeRpcEffect( + WS_METHODS.gitRemoveWorktree, + git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitCreateBranch]: (input) => + observeRpcEffect( + WS_METHODS.gitCreateBranch, + git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitCheckout]: (input) => + observeRpcEffect( + WS_METHODS.gitCheckout, + Effect.scoped(git.checkoutBranch(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), ), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.projectsWriteFile]: (input) => - observeRpcEffect( - WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = Schema.is(WorkspacePathOutsideRootError)(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + [WS_METHODS.gitInit]: (input) => + observeRpcEffect( + WS_METHODS.gitInit, + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { - "rpc.aggregate": "workspace", - }), - [WS_METHODS.gitStatus]: (input) => - observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitPull]: (input) => - observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitRunStackedAction]: (input) => - observeRpcStream( - WS_METHODS.gitRunStackedAction, - Stream.callback((queue) => - gitManager - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), - }), + [WS_METHODS.terminalOpen]: (input) => + observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalWrite]: (input) => + observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalResize]: (input) => + observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClear]: (input) => + observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalRestart]: (input) => + observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClose]: (input) => + observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.subscribeTerminalEvents]: (_input) => + observeRpcStream( + WS_METHODS.subscribeTerminalEvents, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribe((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), ), - ), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitPreparePullRequestThread]: (input) => - observeRpcEffect( - WS_METHODS.gitPreparePullRequestThread, - gitManager.preparePullRequestThread(input), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitListBranches]: (input) => - observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitCreateWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitRemoveWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitCreateBranch]: (input) => - observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitCheckout]: (input) => - observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitInit]: (input) => - observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }), - [WS_METHODS.terminalOpen]: (input) => - observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalWrite]: (input) => - observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalResize]: (input) => - observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClear]: (input) => - observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalRestart]: (input) => - observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClose]: (input) => - observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.subscribeTerminalEvents]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalEvents, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), ), + { "rpc.aggregate": "terminal" }, ), - { "rpc.aggregate": "terminal" }, - ), - [WS_METHODS.subscribeServerConfig]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerConfig, - Effect.gen(function* () { - const keybindingsUpdates = keybindings.streamChanges.pipe( - Stream.map((event) => ({ - version: 1 as const, - type: "keybindingsUpdated" as const, - payload: { - issues: event.issues, + [WS_METHODS.subscribeServerConfig]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeServerConfig, + Effect.gen(function* () { + const keybindingsUpdates = keybindings.streamChanges.pipe( + Stream.map((event) => ({ + version: 1 as const, + type: "keybindingsUpdated" as const, + payload: { + issues: event.issues, + }, + })), + ); + const providerStatuses = providerRegistry.streamChanges.pipe( + Stream.map((providers) => ({ + version: 1 as const, + type: "providerStatuses" as const, + payload: { providers }, + })), + Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), + ); + const settingsUpdates = serverSettings.streamChanges.pipe( + Stream.map((settings) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings }, + })), + ); + + yield* Effect.all( + [providerRegistry.refresh("codex"), providerRegistry.refresh("claudeAgent")], + { + concurrency: "unbounded", + discard: true, }, - })), - ); - const providerStatuses = providerRegistry.streamChanges.pipe( - Stream.map((providers) => ({ - version: 1 as const, - type: "providerStatuses" as const, - payload: { providers }, - })), - ); - const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => ({ - version: 1 as const, - type: "settingsUpdated" as const, - payload: { settings }, - })), - ); + ).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); - return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), - Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), - ); - }), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.subscribeServerLifecycle]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerLifecycle, - Effect.gen(function* () { - const snapshot = yield* lifecycleEvents.snapshot; - const snapshotEvents = Array.from(snapshot.events).toSorted( - (left, right) => left.sequence - right.sequence, - ); - const liveEvents = lifecycleEvents.stream.pipe( - Stream.filter((event) => event.sequence > snapshot.sequence), - ); - return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); - }), - { "rpc.aggregate": "server" }, - ), - }); - }), -); + const liveUpdates = Stream.merge( + keybindingsUpdates, + Stream.merge(providerStatuses, settingsUpdates), + ); + + return Stream.concat( + Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: yield* loadServerConfig, + }), + liveUpdates, + ); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.subscribeServerLifecycle]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeServerLifecycle, + Effect.gen(function* () { + const snapshot = yield* lifecycleEvents.snapshot; + const snapshotEvents = Array.from(snapshot.events).toSorted( + (left, right) => left.sequence - right.sequence, + ); + const liveEvents = lifecycleEvents.stream.pipe( + Stream.filter((event) => event.sequence > snapshot.sequence), + ); + return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.subscribeAuthAccess]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeAuthAccess, + Effect.gen(function* () { + const initialSnapshot = yield* loadAuthAccessSnapshot(); + const revisionRef = yield* Ref.make(1); + const accessChanges: Stream.Stream< + BootstrapCredentialChange | SessionCredentialChange + > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); + + const liveEvents: Stream.Stream = accessChanges.pipe( + Stream.mapEffect((change) => + Ref.updateAndGet(revisionRef, (revision) => revision + 1).pipe( + Effect.map((revision) => + toAuthAccessStreamEvent(change, revision, currentSessionId), + ), + ), + ), + ); + + return Stream.concat( + Stream.make({ + version: 1 as const, + revision: 1, + type: "snapshot" as const, + payload: initialSnapshot, + }), + liveEvents, + ); + }), + { "rpc.aggregate": "auth" }, + ), + }); + }), + ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.gen(function* () { - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - spanPrefix: "ws.rpc", - spanAttributes: { - "rpc.transport": "websocket", - "rpc.system": "effect-rpc", - }, - }).pipe(Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson))); - return HttpRouter.add( + Effect.succeed( + HttpRouter.add( "GET", "/ws", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const config = yield* ServerConfig; - if (config.authToken) { - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); - } - const token = url.value.searchParams.get("token"); - if (token !== config.authToken) { - return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); - } - } - return yield* rpcWebSocketHttpEffect; - }), - ); - }), + const serverAuth = yield* ServerAuth; + const sessions = yield* SessionCredentialService; + const session = yield* serverAuth.authenticateWebSocketUpgrade(request); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + spanPrefix: "ws.rpc", + spanAttributes: { + "rpc.transport": "websocket", + "rpc.system": "effect-rpc", + }, + }).pipe( + Effect.provide( + makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + ), + ); + return yield* Effect.acquireUseRelease( + sessions.markConnected(session.sessionId), + () => rpcWebSocketHttpEffect, + () => sessions.markDisconnected(session.sessionId), + ); + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), + ), + ), ); diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d019..9f0329b602 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,9 +2,84 @@ - + + + + + + T3 Code (Alpha) -
+
+
+
+ +
+
+
diff --git a/apps/web/package.json b/apps/web/package.json index 499943c3f0..362eeecc02 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.15", + "version": "0.0.17", "private": true, "type": "module", "scripts": { @@ -21,14 +21,15 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", + "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts new file mode 100644 index 0000000000..b06c4248fc --- /dev/null +++ b/apps/web/src/authBootstrap.test.ts @@ -0,0 +1,495 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json", + }, + status: 200, + ...init, + }); +} + +type TestWindow = { + location: URL; + history: { + replaceState: (_data: unknown, _unused: string, url: string) => void; + }; + desktopBridge?: DesktopBridge; +}; + +function installTestBrowser(url: string) { + const testWindow: TestWindow = { + location: new URL(url), + history: { + replaceState: (_data, _unused, nextUrl) => { + testWindow.location = new URL(nextUrl, testWindow.location.href); + }, + }, + }; + + vi.stubGlobal("window", testWindow); + vi.stubGlobal("document", { title: "T3 Code" }); + + return testWindow; +} + +function sessionResponse(body: unknown, init?: ResponseInit) { + return jsonResponse(body, init); +} + +describe("resolveInitialServerAuthGateState", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + installTestBrowser("http://localhost/"); + }); + + afterEach(async () => { + const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); + __resetServerAuthBootstrapForTests(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("reuses an in-flight silent bootstrap attempt", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3773/api/auth/session"); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3773/api/auth/bootstrap"); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); + }); + + it("uses https fetch urls when the primary environment uses wss", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); + vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", { + credentials: "include", + }); + }); + + it("uses the current origin as an auth proxy base for local dev environments", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + installTestBrowser("http://localhost:5735/"); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", { + credentials: "include", + }); + }); + + it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); + + const testWindow = installTestBrowser("http://127.0.0.1:5733/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/api/auth/session", { + credentials: "include", + }); + }); + + it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + }); + + it("retries transient auth session bootstrap failures after restart", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("takes a pairing token from the location hash and strips it immediately", async () => { + const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); + const { takePairingTokenFromUrl } = await import("./environments/primary"); + + expect(takePairingTokenFromUrl()).toBe("pairing-token"); + expect(testWindow.location.hash).toBe(""); + expect(testWindow.location.searchParams.get("token")).toBeNull(); + }); + + it("accepts query-string pairing tokens as a backward-compatible fallback", async () => { + const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); + const { takePairingTokenFromUrl } = await import("./environments/primary"); + + expect(takePairingTokenFromUrl()).toBe("pairing-token"); + expect(testWindow.location.searchParams.get("token")).toBeNull(); + }); + + it("allows manual token submission after the initial auth check requires pairing", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + installTestBrowser("http://localhost/"); + + const { resolveInitialServerAuthGateState, submitServerAuthCredential } = + await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(100); + + await expect(gateStatePromise).resolves.toEqual({ + status: "authenticated", + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); + expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); + }); + + it("memoizes the authenticated gate state after the first successful read", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("creates a pairing credential from the authenticated auth endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + id: "pairing-link-1", + credential: "pairing-token", + label: "Julius iPhone", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { createServerPairingCredential } = await import("./environments/primary"); + + await expect(createServerPairingCredential("Julius iPhone")).resolves.toEqual({ + id: "pairing-link-1", + credential: "pairing-token", + label: "Julius iPhone", + expiresAt: "2026-04-05T00:00:00.000Z", + }); + expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/pairing-token", { + body: JSON.stringify({ label: "Julius iPhone" }), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + }); +}); diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts new file mode 100644 index 0000000000..b6643d2a72 --- /dev/null +++ b/apps/web/src/branding.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalWindow = globalThis.window; + +afterEach(() => { + vi.resetModules(); + + if (originalWindow === undefined) { + Reflect.deleteProperty(globalThis, "window"); + return; + } + + globalThis.window = originalWindow; +}); + +describe("branding", () => { + it("uses injected desktop branding when available", async () => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + desktopBridge: { + getAppBranding: () => ({ + baseName: "T3 Code", + stageLabel: "Nightly", + displayName: "T3 Code (Nightly)", + }), + }, + }, + }); + + const branding = await import("./branding"); + + expect(branding.APP_BASE_NAME).toBe("T3 Code"); + expect(branding.APP_STAGE_LABEL).toBe("Nightly"); + expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); + }); +}); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index bffd983815..99775a4c55 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,4 +1,18 @@ -export const APP_BASE_NAME = "T3 Code"; -export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; -export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; +import type { DesktopAppBranding } from "@t3tools/contracts"; + +function readInjectedDesktopAppBranding(): DesktopAppBranding | null { + if (typeof window === "undefined") { + return null; + } + + return window.desktopBridge?.getAppBranding?.() ?? null; +} + +const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); + +export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; +export const APP_STAGE_LABEL = + injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); +export const APP_DISPLAY_NAME = + injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts deleted file mode 100644 index 5311fb40ae..0000000000 --- a/apps/web/src/chat-scroll.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; - -describe("isScrollContainerNearBottom", () => { - it("returns true when already at bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 600, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns true when within the auto-scroll threshold", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns false when the user is meaningfully above the bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 520, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(false); - }); - - it("clamps negative thresholds to zero", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 539, - clientHeight: 400, - scrollHeight: 1_000, - }, - -1, - ), - ).toBe(false); - }); - - it("falls back to the default threshold for non-finite values", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }, - Number.NaN, - ), - ).toBe(true); - expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); - }); -}); diff --git a/apps/web/src/chat-scroll.ts b/apps/web/src/chat-scroll.ts deleted file mode 100644 index 35190ab1b9..0000000000 --- a/apps/web/src/chat-scroll.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; - -interface ScrollPosition { - scrollTop: number; - clientHeight: number; - scrollHeight: number; -} - -export function isScrollContainerNearBottom( - position: ScrollPosition, - thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, -): boolean { - const threshold = Number.isFinite(thresholdPx) - ? Math.max(0, thresholdPx) - : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - - const { scrollTop, clientHeight, scrollHeight } = position; - if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { - return true; - } - - const distanceFromBottom = scrollHeight - clientHeight - scrollTop; - return distanceFromBottom <= threshold; -} diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts new file mode 100644 index 0000000000..a74ce18ac3 --- /dev/null +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -0,0 +1,80 @@ +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const testEnvironmentId = EnvironmentId.make("environment-1"); + +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { + environmentId: testEnvironmentId, + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: null, +}; + +function createLocalStorageStub(): Storage { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} + +function getTestWindow(): Window & typeof globalThis { + const localStorage = createLocalStorageStub(); + const testWindow = { + localStorage, + } as Window & typeof globalThis; + vi.stubGlobal("window", testWindow); + vi.stubGlobal("localStorage", localStorage); + return testWindow; +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("clientPersistenceStorage", () => { + it("stores browser secrets inline with the saved environment record", async () => { + const testWindow = getTestWindow(); + const { + SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, + readBrowserSavedEnvironmentRegistry, + readBrowserSavedEnvironmentSecret, + writeBrowserSavedEnvironmentRegistry, + writeBrowserSavedEnvironmentSecret, + } = await import("./clientPersistenceStorage"); + + writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); + expect(writeBrowserSavedEnvironmentSecret(testEnvironmentId, "bearer-token")).toBe(true); + writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); + + expect(readBrowserSavedEnvironmentRegistry()).toEqual([savedRegistryRecord]); + expect(readBrowserSavedEnvironmentSecret(testEnvironmentId)).toBe("bearer-token"); + expect( + JSON.parse(testWindow.localStorage.getItem(SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY)!), + ).toEqual({ + version: 1, + records: [ + { + ...savedRegistryRecord, + bearerToken: "bearer-token", + }, + ], + }); + }); +}); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts new file mode 100644 index 0000000000..70f51d5c30 --- /dev/null +++ b/apps/web/src/clientPersistenceStorage.ts @@ -0,0 +1,194 @@ +import { + ClientSettingsSchema, + EnvironmentId, + type ClientSettings, + type EnvironmentId as EnvironmentIdValue, + type PersistedSavedEnvironmentRecord, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; + +export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; + +const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + bearerToken: Schema.optionalKey(Schema.String), +}); +type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; + +const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), + records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)), +}); +type BrowserSavedEnvironmentRegistryDocument = + typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type; + +function hasWindow(): boolean { + return typeof window !== "undefined"; +} + +function toPersistedSavedEnvironmentRecord( + record: PersistedSavedEnvironmentRecord, +): PersistedSavedEnvironmentRecord { + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; +} + +export function readBrowserClientSettings(): ClientSettings | null { + if (!hasWindow()) { + return null; + } + + try { + return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); + } catch { + return null; + } +} + +export function writeBrowserClientSettings(settings: ClientSettings): void { + if (!hasWindow()) { + return; + } + + setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); +} + +function readBrowserSavedEnvironmentRegistryDocument(): BrowserSavedEnvironmentRegistryDocument { + if (!hasWindow()) { + return {}; + } + + try { + const parsed = getLocalStorageItem( + SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, + BrowserSavedEnvironmentRegistryDocumentSchema, + ); + return parsed ?? {}; + } catch { + return {}; + } +} + +function writeBrowserSavedEnvironmentRegistryDocument( + document: BrowserSavedEnvironmentRegistryDocument, +): void { + if (!hasWindow()) { + return; + } + + setLocalStorageItem( + SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, + document, + BrowserSavedEnvironmentRegistryDocumentSchema, + ); +} + +function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray { + return readBrowserSavedEnvironmentRegistryDocument().records ?? []; +} + +function writeBrowserSavedEnvironmentRecords( + records: ReadonlyArray, +): void { + writeBrowserSavedEnvironmentRegistryDocument({ + version: 1, + records, + }); +} + +export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray { + return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) => + toPersistedSavedEnvironmentRecord(record), + ); +} + +export function writeBrowserSavedEnvironmentRegistry( + records: ReadonlyArray, +): void { + const existing = new Map( + readBrowserSavedEnvironmentRecordsWithSecrets().map( + (record) => [record.environmentId, record] as const, + ), + ); + writeBrowserSavedEnvironmentRecords( + records.map((record) => { + const bearerToken = existing.get(record.environmentId)?.bearerToken; + return bearerToken + ? { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + bearerToken, + } + : toPersistedSavedEnvironmentRecord(record); + }), + ); +} + +export function readBrowserSavedEnvironmentSecret( + environmentId: EnvironmentIdValue, +): string | null { + return ( + readBrowserSavedEnvironmentRecordsWithSecrets().find( + (record) => record.environmentId === environmentId, + )?.bearerToken ?? null + ); +} + +export function writeBrowserSavedEnvironmentSecret( + environmentId: EnvironmentIdValue, + secret: string, +): boolean { + const document = readBrowserSavedEnvironmentRegistryDocument(); + const records = document.records ?? []; + let found = false; + writeBrowserSavedEnvironmentRegistryDocument({ + version: document.version ?? 1, + records: records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + found = true; + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + bearerToken: secret, + } satisfies BrowserSavedEnvironmentRecord; + }), + }); + return found; +} + +export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentIdValue): void { + const document = readBrowserSavedEnvironmentRegistryDocument(); + writeBrowserSavedEnvironmentRegistryDocument({ + version: document.version ?? 1, + records: (document.records ?? []).map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); +} diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts new file mode 100644 index 0000000000..04b25529f2 --- /dev/null +++ b/apps/web/src/commandPaletteStore.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; + +interface CommandPaletteOpenIntent { + kind: "add-project"; + requestId: number; +} + +interface CommandPaletteStore { + open: boolean; + openIntent: CommandPaletteOpenIntent | null; + setOpen: (open: boolean) => void; + toggleOpen: () => void; + openAddProject: () => void; + clearOpenIntent: () => void; +} + +export const useCommandPaletteStore = create((set) => ({ + open: false, + openIntent: null, + setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }), + toggleOpen: () => + set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })), + openAddProject: () => + set((state) => ({ + open: true, + openIntent: { + kind: "add-project", + requestId: (state.openIntent?.requestId ?? 0) + 1, + }, + })), + clearOpenIntent: () => set({ openIntent: null }), +})); diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 2f66e063ad..7beaec808d 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,14 +1,22 @@ -import type { GitBranch } from "@t3tools/contracts"; +import { EnvironmentId, type GitBranch } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, + resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, + resolveEffectiveEnvMode, + resolveEnvModeLabel, resolveBranchToolbarValue, + resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; +const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -76,6 +84,90 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveEnvironmentOptionLabel", () => { + it("prefers the primary environment's machine label", () => { + expect( + resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: localEnvironmentId, + runtimeLabel: "Julius's Mac mini", + savedLabel: "Local environment", + }), + ).toBe("Julius's Mac mini"); + }); + + it("falls back to 'This device' for generic primary labels", () => { + expect( + resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: localEnvironmentId, + runtimeLabel: "Local environment", + savedLabel: "Local", + }), + ).toBe("This device"); + }); + + it("keeps configured labels for non-primary environments", () => { + expect( + resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId: remoteEnvironmentId, + runtimeLabel: null, + savedLabel: "Build box", + }), + ).toBe("Build box"); + }); +}); + +describe("resolveEffectiveEnvMode", () => { + it("treats draft threads already attached to a worktree as current-checkout mode", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: "/repo/.t3/worktrees/feature-a", + hasServerThread: false, + draftThreadEnvMode: "worktree", + }), + ).toBe("local"); + }); + + it("keeps explicit new-worktree mode for draft threads without a worktree path", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: false, + draftThreadEnvMode: "worktree", + }), + ).toBe("worktree"); + }); +}); + +describe("resolveEnvModeLabel", () => { + it("uses explicit workspace labels", () => { + expect(resolveEnvModeLabel("local")).toBe("Current checkout"); + expect(resolveEnvModeLabel("worktree")).toBe("New worktree"); + }); +}); + +describe("resolveCurrentWorkspaceLabel", () => { + it("describes the main repo checkout when no worktree path is active", () => { + expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout"); + }); + + it("describes the active checkout as a worktree when one is attached", () => { + expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree"); + }); +}); + +describe("resolveLockedWorkspaceLabel", () => { + it("uses a shorter label for the main repo checkout", () => { + expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout"); + }); + + it("uses a shorter label for an attached worktree", () => { + expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree"); + }); +}); + describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index c9e336bf48..7adab1a2e1 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,22 +1,72 @@ -import type { GitBranch } from "@t3tools/contracts"; +import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; import { Schema } from "effect"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, } from "@t3tools/shared/git"; +export interface EnvironmentOption { + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; +} + export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; +const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); + +function normalizeDisplayLabel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +export function resolveEnvironmentOptionLabel(input: { + isPrimary: boolean; + environmentId: EnvironmentId; + runtimeLabel?: string | null; + savedLabel?: string | null; +}): string { + const runtimeLabel = normalizeDisplayLabel(input.runtimeLabel); + const savedLabel = normalizeDisplayLabel(input.savedLabel); + + if (input.isPrimary) { + const preferredLocalLabel = [runtimeLabel, savedLabel].find((label) => { + if (!label) return false; + return !GENERIC_LOCAL_ENVIRONMENT_LABELS.has(label.toLowerCase()); + }); + return preferredLocalLabel ?? "This device"; + } + + return runtimeLabel ?? savedLabel ?? input.environmentId; +} + +export function resolveEnvModeLabel(mode: EnvMode): string { + return mode === "worktree" ? "New worktree" : "Current checkout"; +} + +export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { + return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); +} + +export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { + return activeWorktreePath ? "Worktree" : "Local checkout"; +} + export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; draftThreadEnvMode: EnvMode | undefined; }): EnvMode { const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input; - return activeWorktreePath || (!hasServerThread && draftThreadEnvMode === "worktree") - ? "worktree" - : "local"; + if (!hasServerThread) { + if (activeWorktreePath) { + return "local"; + } + return draftThreadEnvMode === "worktree" ? "worktree" : "local"; + } + return activeWorktreePath ? "worktree" : "local"; } export function resolveDraftEnvModeAfterBranchChange(input: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 79c453c0f5..c6f37c42a9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,171 +1,117 @@ -import type { ThreadId } from "@t3tools/contracts"; -import { FolderIcon, GitForkIcon } from "lucide-react"; -import { useCallback } from "react"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { memo, useMemo } from "react"; -import { newCommandId } from "../lib/utils"; -import { readNativeApi } from "../nativeApi"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { - EnvMode, - resolveDraftEnvModeAfterBranchChange, + type EnvMode, + type EnvironmentOption, resolveEffectiveEnvMode, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; - -const envModeItems = [ - { value: "local", label: "Local" }, - { value: "worktree", label: "New worktree" }, -] as const; +import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; +import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { Separator } from "./ui/separator"; interface BranchToolbarProps { + environmentId: EnvironmentId; threadId: ThreadId; + draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; + effectiveEnvModeOverride?: EnvMode; + activeThreadBranchOverride?: string | null; + onActiveThreadBranchOverrideChange?: (branch: string | null) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; + availableEnvironments?: readonly EnvironmentOption[]; + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } -export default function BranchToolbar({ +export const BranchToolbar = memo(function BranchToolbar({ + environmentId, threadId, + draftId, onEnvModeChange, + effectiveEnvModeOverride, + activeThreadBranchOverride, + onActiveThreadBranchOverrideChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, + availableEnvironments, + onEnvironmentChange, }: BranchToolbarProps) { - const threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); - const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - - const serverThread = threads.find((thread) => thread.id === threadId); - const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const activeProject = projects.find((project) => project.id === activeProjectId); - const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); - const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; + const threadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThread = useStore(serverThreadSelector); + const draftThread = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), + ); + const activeProjectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const activeProjectSelector = useMemo( + () => createProjectSelectorByRef(activeProjectRef), + [activeProjectRef], + ); + const activeProject = useStore(activeProjectSelector); + const hasActiveThread = serverThread !== undefined || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; - const hasServerThread = serverThread !== undefined; - const effectiveEnvMode = resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread, - draftThreadEnvMode: draftThread?.envMode, - }); - - const setThreadBranch = useCallback( - (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId) return; - const api = readNativeApi(); - // If the effective cwd is about to change, stop the running session so the - // next message creates a new one with the correct cwd. - if (serverThread?.session && worktreePath !== activeWorktreePath && api) { - void api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: activeThreadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); - } - if (api && hasServerThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, - }); - } - if (hasServerThread) { - setThreadBranchAction(activeThreadId, branch, worktreePath); - return; - } - const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ - nextWorktreePath: worktreePath, - currentWorktreePath: activeWorktreePath, - effectiveEnvMode, - }); - setDraftThreadContext(threadId, { - branch, - worktreePath, - envMode: nextDraftEnvMode, - }); - }, - [ - activeThreadId, - serverThread?.session, + const effectiveEnvMode = + effectiveEnvModeOverride ?? + resolveEffectiveEnvMode({ activeWorktreePath, - hasServerThread, - setThreadBranchAction, - setDraftThreadContext, - threadId, - effectiveEnvMode, - ], - ); + hasServerThread: serverThread !== undefined, + draftThreadEnvMode: draftThread?.envMode, + }); + const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); - if (!activeThreadId || !activeProject) return null; + const showEnvironmentPicker = + availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; + + if (!hasActiveThread || !activeProject) return null; return ( -
- {envLocked || activeWorktreePath ? ( - - {activeWorktreePath ? ( - <> - - Worktree - - ) : ( - <> - - Local - - )} - - ) : ( - - )} +
+
+ {showEnvironmentPicker && ( + <> + + + + )} + +
); -} +}); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e1dbb8756c..996fca4b39 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,9 +1,9 @@ -import type { GitBranch } from "@t3tools/contracts"; -import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon } from "lucide-react"; import { - type CSSProperties, useCallback, useDeferredValue, useEffect, @@ -14,19 +14,20 @@ import { useTransition, } from "react"; -import { - gitBranchSearchInfiniteQueryOptions, - gitQueryKeys, - gitStatusQueryOptions, - invalidateGitQueries, -} from "../lib/gitReactQuery"; -import { readNativeApi } from "../nativeApi"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { readEnvironmentApi } from "../environmentApi"; +import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; +import { useGitStatus } from "../lib/gitStatusState"; +import { newCommandId } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; +import { useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { deriveLocalBranchNameFromRemoteRef, - EnvMode, resolveBranchSelectionTarget, resolveBranchToolbarValue, + resolveDraftEnvModeAfterBranchChange, + resolveEffectiveEnvMode, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; @@ -36,6 +37,7 @@ import { ComboboxInput, ComboboxItem, ComboboxList, + ComboboxListVirtualized, ComboboxPopup, ComboboxStatus, ComboboxTrigger, @@ -43,13 +45,13 @@ import { import { toastManager } from "./ui/toast"; interface BranchToolbarBranchSelectorProps { - activeProjectCwd: string; - activeThreadBranch: string | null; - activeWorktreePath: string | null; - branchCwd: string | null; - effectiveEnvMode: EnvMode; + environmentId: EnvironmentId; + threadId: ThreadId; + draftId?: DraftId; envLocked: boolean; - onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; + effectiveEnvModeOverride?: "local" | "worktree"; + activeThreadBranchOverride?: string | null; + onActiveThreadBranchOverrideChange?: (branch: string | null) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -60,7 +62,7 @@ function toBranchActionErrorMessage(error: unknown): string { function getBranchTriggerLabel(input: { activeWorktreePath: string | null; - effectiveEnvMode: EnvMode; + effectiveEnvMode: "local" | "worktree"; resolvedActiveBranch: string | null; }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; @@ -74,31 +76,137 @@ function getBranchTriggerLabel(input: { } export function BranchToolbarBranchSelector({ - activeProjectCwd, - activeThreadBranch, - activeWorktreePath, - branchCwd, - effectiveEnvMode, + environmentId, + threadId, + draftId, envLocked, - onSetThreadBranch, + effectiveEnvModeOverride, + activeThreadBranchOverride, + onActiveThreadBranchOverrideChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + // --------------------------------------------------------------------------- + // Thread / project state (pushed down from parent to colocate with mutation) + // --------------------------------------------------------------------------- + const threadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThread = useStore(serverThreadSelector); + const serverSession = serverThread?.session ?? null; + const setThreadBranchAction = useStore((store) => store.setThreadBranch); + const draftThread = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), + ); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + + const activeProjectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const activeProjectSelector = useMemo( + () => createProjectSelectorByRef(activeProjectRef), + [activeProjectRef], + ); + const activeProject = useStore(activeProjectSelector); + + const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); + const activeThreadBranch = + activeThreadBranchOverride !== undefined + ? activeThreadBranchOverride + : (serverThread?.branch ?? draftThread?.branch ?? null); + const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeProjectCwd = activeProject?.cwd ?? null; + const branchCwd = activeWorktreePath ?? activeProjectCwd; + const hasServerThread = serverThread !== undefined; + const effectiveEnvMode = + effectiveEnvModeOverride ?? + resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread, + draftThreadEnvMode: draftThread?.envMode, + }); + + // --------------------------------------------------------------------------- + // Thread branch mutation (colocated — only this component calls it) + // --------------------------------------------------------------------------- + const setThreadBranch = useCallback( + (branch: string | null, worktreePath: string | null) => { + if (!activeThreadId || !activeProject) return; + const api = readEnvironmentApi(environmentId); + if (serverSession && worktreePath !== activeWorktreePath && api) { + void api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: activeThreadId, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + if (api && hasServerThread) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadId, + branch, + worktreePath, + }); + } + if (hasServerThread) { + onActiveThreadBranchOverrideChange?.(branch); + setThreadBranchAction(threadRef, branch, worktreePath); + return; + } + const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: worktreePath, + currentWorktreePath: activeWorktreePath, + effectiveEnvMode, + }); + setDraftThreadContext(draftId ?? threadRef, { + branch, + worktreePath, + envMode: nextDraftEnvMode, + projectRef: scopeProjectRef(environmentId, activeProject.id), + }); + }, + [ + activeThreadId, + activeProject, + serverSession, + activeWorktreePath, + hasServerThread, + onActiveThreadBranchOverrideChange, + setThreadBranchAction, + setDraftThreadContext, + draftId, + threadRef, + environmentId, + effectiveEnvMode, + ], + ); + + // --------------------------------------------------------------------------- + // Git branch queries + // --------------------------------------------------------------------------- const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); useEffect(() => { if (!branchCwd) return; void queryClient.prefetchInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), + gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }), ); - }, [branchCwd, queryClient]); + }, [branchCwd, environmentId, queryClient]); const { data: branchesSearchData, @@ -108,9 +216,9 @@ export function BranchToolbarBranchSelector({ isPending: isBranchesSearchPending, } = useInfiniteQuery( gitBranchSearchInfiniteQueryOptions({ + environmentId, cwd: branchCwd, query: deferredTrimmedBranchQuery, - enabled: isBranchMenuOpen, }), ); const branches = useMemo( @@ -185,20 +293,24 @@ export function BranchToolbarBranchSelector({ ? `Showing ${branches.length} of ${totalBranchCount} branches` : null; + // --------------------------------------------------------------------------- + // Branch actions + // --------------------------------------------------------------------------- const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); - await invalidateGitQueries(queryClient).catch(() => undefined); + await queryClient + .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) + .catch(() => undefined); }); }; const selectBranch = (branch: GitBranch) => { - const api = readNativeApi(); - if (!api || !branchCwd || isBranchActionPending) return; + const api = readEnvironmentApi(environmentId); + if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; - // In new-worktree mode, selecting a branch sets the base branch. if (isSelectingWorktreeBase) { - onSetThreadBranch(branch.name, null); + setThreadBranch(branch.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -210,9 +322,8 @@ export function BranchToolbarBranchSelector({ branch, }); - // If the branch already lives in a worktree, point the thread there. if (selectionTarget.reuseExistingWorktree) { - onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath); + setThreadBranch(branch.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -226,67 +337,56 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest?.(); runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); - await invalidateGitQueries(queryClient); + const checkoutResult = await api.git.checkout({ + cwd: selectionTarget.checkoutCwd, + branch: branch.name, + }); + const nextBranchName = branch.isRemote + ? (checkoutResult.branch ?? selectedBranchName) + : selectedBranchName; + setOptimisticBranch(nextBranchName); + setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { + setOptimisticBranch(previousBranch); toastManager.add({ type: "error", title: "Failed to checkout branch.", description: toBranchActionErrorMessage(error), }); - return; - } - - let nextBranchName = selectedBranchName; - if (branch.isRemote) { - const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); - if (status?.branch) { - nextBranchName = status.branch; - } } - - setOptimisticBranch(nextBranchName); - onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); }); }; const createBranch = (rawName: string) => { const name = rawName.trim(); - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); - try { - await api.git.createBranch({ cwd: branchCwd, branch: name }); - try { - await api.git.checkout({ cwd: branchCwd, branch: name }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), - }); - return; - } + const createBranchResult = await api.git.createBranch({ + cwd: branchCwd, + branch: name, + checkout: true, + }); + setOptimisticBranch(createBranchResult.branch); + setThreadBranch(createBranchResult.branch, activeWorktreePath); } catch (error) { + setOptimisticBranch(previousBranch); toastManager.add({ type: "error", - title: "Failed to create branch.", + title: "Failed to create and checkout branch.", description: toBranchActionErrorMessage(error), }); - return; } - - setOptimisticBranch(name); - onSetThreadBranch(name, activeWorktreePath); - setBranchQuery(""); }); }; @@ -299,15 +399,12 @@ export function BranchToolbarBranchSelector({ ) { return; } - onSetThreadBranch(currentGitBranch, null); - }, [ - activeThreadBranch, - activeWorktreePath, - currentGitBranch, - effectiveEnvMode, - onSetThreadBranch, - ]); + setThreadBranch(currentGitBranch, null); + }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + // --------------------------------------------------------------------------- + // Combobox / list plumbing + // --------------------------------------------------------------------------- const handleOpenChange = useCallback( (open: boolean) => { setIsBranchMenuOpen(open); @@ -316,10 +413,10 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(branchCwd), + queryKey: gitQueryKeys.branches(environmentId, branchCwd), }); }, - [branchCwd, queryClient], + [branchCwd, environmentId, queryClient], ); const branchListScrollElementRef = useRef(null); @@ -341,49 +438,22 @@ export function BranchToolbarBranchSelector({ void fetchNextPage().catch(() => undefined); }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); - const branchListVirtualizer = useVirtualizer({ - count: filteredBranchPickerItems.length, - estimateSize: (index) => - filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28, - getScrollElement: () => branchListScrollElementRef.current, - overscan: 12, - enabled: isBranchMenuOpen && shouldVirtualizeBranchList, - initialRect: { - height: 224, - width: 0, - }, - }); - const virtualBranchRows = branchListVirtualizer.getVirtualItems(); - const setBranchListRef = useCallback( - (element: HTMLDivElement | null) => { - branchListScrollElementRef.current = - (element?.parentElement as HTMLDivElement | null) ?? null; - if (element) { - branchListVirtualizer.measure(); - } - }, - [branchListVirtualizer], - ); - - useEffect(() => { - if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; - queueMicrotask(() => { - branchListVirtualizer.measure(); - }); - }, [ - branchListVirtualizer, - filteredBranchPickerItems.length, - isBranchMenuOpen, - shouldVirtualizeBranchList, - ]); + const branchListRef = useRef(null); + const setBranchListRef = useCallback((element: HTMLDivElement | null) => { + branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; + }, []); useEffect(() => { if (!isBranchMenuOpen) { return; } - branchListScrollElementRef.current?.scrollTo({ top: 0 }); - }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); + if (shouldVirtualizeBranchList) { + branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + } else { + branchListScrollElementRef.current?.scrollTo({ top: 0 }); + } + }, [deferredTrimmedBranchQuery, isBranchMenuOpen, shouldVirtualizeBranchList]); useEffect(() => { const scrollElement = branchListScrollElementRef.current; @@ -403,8 +473,9 @@ export function BranchToolbarBranchSelector({ }, [isBranchMenuOpen, maybeFetchNextBranchPage]); useEffect(() => { + if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [branches.length, maybeFetchNextBranchPage]); + }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -412,7 +483,7 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, }); - function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( { if (!prReference || !onCheckoutPullRequestRequest) { return; @@ -445,10 +515,9 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} onClick={() => createBranch(trimmedBranchQuery)} > - Create new branch "{trimmedBranchQuery}" + Create new branch "{trimmedBranchQuery}" ); } @@ -456,7 +525,8 @@ export function BranchToolbarBranchSelector({ const branch = branchByName.get(itemValue); if (!branch) return null; - const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd; + const hasSecondaryWorktree = + branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; const badge = branch.current ? "current" : hasSecondaryWorktree @@ -472,7 +542,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} onClick={() => selectBranch(branch)} >
@@ -490,8 +559,13 @@ export function BranchToolbarBranchSelector({ autoHighlight virtualized={shouldVirtualizeBranchList} onItemHighlighted={(_value, eventDetails) => { - if (!isBranchMenuOpen || eventDetails.index < 0) return; - branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); + if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { + return; + } + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); }} onOpenChange={handleOpenChange} open={isBranchMenuOpen} @@ -519,30 +593,30 @@ export function BranchToolbarBranchSelector({
No branches found. - - {shouldVirtualizeBranchList ? ( -
+ + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage().catch(() => undefined); + } }} - > - {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)) - )} -
+ style={{ maxHeight: "14rem" }} + /> + + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} + + )} {branchStatusText ? {branchStatusText} : null} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx new file mode 100644 index 0000000000..6e1c80f557 --- /dev/null +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -0,0 +1,98 @@ +import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; +import { memo, useMemo } from "react"; + +import { + resolveCurrentWorkspaceLabel, + resolveEnvModeLabel, + resolveLockedWorkspaceLabel, + type EnvMode, +} from "./BranchToolbar.logic"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "./ui/select"; + +interface BranchToolbarEnvModeSelectorProps { + envLocked: boolean; + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + onEnvModeChange: (mode: EnvMode) => void; +} + +export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ + envLocked, + effectiveEnvMode, + activeWorktreePath, + onEnvModeChange, +}: BranchToolbarEnvModeSelectorProps) { + const envModeItems = useMemo( + () => [ + { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, + { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ], + [activeWorktreePath], + ); + + if (envLocked) { + return ( + + {activeWorktreePath ? ( + <> + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + + ) : ( + <> + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + + )} + + ); + } + + return ( + + ); +}); diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx new file mode 100644 index 0000000000..abfa21365e --- /dev/null +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -0,0 +1,88 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { CloudIcon, MonitorIcon } from "lucide-react"; +import { memo, useMemo } from "react"; + +import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "./ui/select"; + +interface BranchToolbarEnvironmentSelectorProps { + envLocked: boolean; + environmentId: EnvironmentId; + availableEnvironments: readonly EnvironmentOption[]; + onEnvironmentChange: (environmentId: EnvironmentId) => void; +} + +export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + envLocked, + environmentId, + availableEnvironments, + onEnvironmentChange, +}: BranchToolbarEnvironmentSelectorProps) { + const activeEnvironment = useMemo(() => { + return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; + }, [availableEnvironments, environmentId]); + + const environmentItems = useMemo( + () => + availableEnvironments.map((env) => ({ + value: env.environmentId, + label: env.label, + })), + [availableEnvironments], + ); + + if (envLocked) { + return ( + + {activeEnvironment?.isPrimary ? ( + + ) : ( + + )} + {activeEnvironment?.label ?? "Run on"} + + ); + } + + return ( + + ); +}); diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx new file mode 100644 index 0000000000..a397d52a37 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -0,0 +1,141 @@ +import "../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ + openInPreferredEditorMock: vi.fn(async () => "vscode"), + readLocalApiMock: vi.fn(() => ({ + server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, + shell: { openInEditor: vi.fn(async () => undefined) }, + })), +})); + +vi.mock("../editorPreferences", () => ({ + openInPreferredEditor: openInPreferredEditorMock, +})); + +vi.mock("../localApi", () => ({ + ensureLocalApi: vi.fn(() => { + throw new Error("ensureLocalApi not implemented in browser test"); + }), + readLocalApi: readLocalApiMock, +})); + +import ChatMarkdown from "./ChatMarkdown"; + +describe("ChatMarkdown", () => { + afterEach(() => { + openInPreferredEditorMock.mockClear(); + readLocalApiMock.mockClear(); + localStorage.clear(); + document.body.innerHTML = ""; + }); + + it("rewrites file uri hrefs into direct paths before rendering", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", filePath); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); + }); + } finally { + await screen.unmount(); + } + }); + + it("keeps line anchors working after rewriting file uri hrefs", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:1`); + }); + } finally { + await screen.unmount(); + } + }); + + it("shows column information inline when present", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L1C7`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith( + expect.anything(), + `${filePath}:1:7`, + ); + }); + } finally { + await screen.unmount(); + } + }); + + it("disambiguates duplicate file basenames inline", async () => { + const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx"; + const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx"; + const screen = await render( + , + ); + + try { + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) + .toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); + + it("keeps normal web links unchanged", async () => { + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); + await expect.element(link).toHaveAttribute("target", "_blank"); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a1..ba1c944cc8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3,6 +3,7 @@ import { CheckIcon, CopyIcon } from "lucide-react"; import React, { Children, Suspense, + type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, @@ -15,14 +16,19 @@ import React, { } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkTarget } from "../markdown-links"; -import { readNativeApi } from "../nativeApi"; +import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; +import { readLocalApi } from "../localApi"; +import { cn } from "../lib/utils"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -164,7 +170,7 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo ); return ( -
+
- ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} - -
- - {/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : ( -
-
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - - ) : ( - <> - {providerTraitsPicker ? ( - <> - - {providerTraitsPicker} - - ) : null} - - - - - - - - - - {activePlan || sidebarProposedPlan || planSidebarOpen ? ( - <> - - - - ) : null} - - )} -
- - {/* Right side: send / stop button */} -
- {activeContextWindow ? ( - - ) : null} - {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - 0} - isSendBusy={isSendBusy} - isConnecting={isConnecting} - isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} - onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={() => void onInterrupt()} - onImplementPlanInNewThread={() => void onImplementPlanInNewThread()} - /> -
-
- )} - - - + {isGitRepo && ( )} {pullRequestDialogState ? ( { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; - if (turnKey) { - planSidebarDismissedForTurnRef.current = turnKey; - } + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; }} /> ) : null} {/* end horizontal flex container */} - {mountedTerminalThreadIds.map((mountedThreadId) => ( + {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( ))} - {expandedImage && expandedImageItem && ( -
- - )} -
- - {expandedImageItem.name} -

- {expandedImageItem.name} - {expandedImage.images.length > 1 - ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` - : ""} -

-
- {expandedImage.images.length > 1 && ( - - )} -
+ {expandedImage && ( + )} ); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts new file mode 100644 index 0000000000..a49dadc851 --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { Thread } from "../types"; +import { + buildThreadActionItems, + filterCommandPaletteGroups, + type CommandPaletteGroup, +} from "./CommandPalette.logic"; + +const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const PROJECT_ID = ProjectId.make("project-1"); + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.make("thread-1"), + environmentId: LOCAL_ENVIRONMENT_ID, + codexThreadId: null, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-01T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +describe("buildThreadActionItems", () => { + it("orders threads by most recent activity and formats timestamps from updatedAt", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); + + try { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-older"), + title: "Older thread", + updatedAt: "2026-03-24T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-newer"), + title: "Newer thread", + createdAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + expect(items.map((item) => item.value)).toEqual([ + "thread:thread-older", + "thread:thread-newer", + ]); + expect(items[0]?.timestamp).toBe("1d ago"); + expect(items[1]?.timestamp).toBe("5d ago"); + } finally { + vi.useRealTimers(); + } + }); + + it("ranks thread title matches ahead of contextual project-name matches", () => { + const threadItems = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-context-match"), + title: "Fix navbar spacing", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-title-match"), + title: "Project kickoff notes", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-19T00:00:00.000Z", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "project", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: threadItems, + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("threads-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:thread-title-match", + "thread:thread-context-match", + ]); + }); + + it("preserves thread project-name matches when there is no stronger title match", () => { + const group: CommandPaletteGroup = { + value: "threads-search", + label: "Threads", + items: [ + { + kind: "action", + value: "thread:project-context-only", + searchTerms: ["Fix navbar spacing", "Project"], + title: "Fix navbar spacing", + description: "Project", + icon: null, + run: async () => undefined, + }, + ], + }; + + const groups = filterCommandPaletteGroups({ + activeGroups: [group], + query: "project", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); + }); + + it("filters archived threads out of thread search items", () => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-active"), + title: "Active thread", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-19T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-archived"), + title: "Archived thread", + archivedAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts new file mode 100644 index 0000000000..3e2f1ec890 --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -0,0 +1,346 @@ +import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; +import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { type ReactNode } from "react"; +import { sortThreads } from "../lib/threadSort"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { type Project, type SidebarThreadSummary, type Thread } from "../types"; + +export const RECENT_THREAD_LIMIT = 12; +export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ADDON_ICON_CLASS = "size-4"; + +export interface CommandPaletteItem { + readonly kind: "action" | "submenu"; + readonly value: string; + readonly searchTerms: ReadonlyArray; + readonly title: ReactNode; + readonly description?: string; + readonly timestamp?: string; + readonly icon: ReactNode; + readonly shortcutCommand?: KeybindingCommand; +} + +export interface CommandPaletteActionItem extends CommandPaletteItem { + readonly kind: "action"; + readonly keepOpen?: boolean; + readonly run: () => Promise; +} + +export interface CommandPaletteSubmenuItem extends CommandPaletteItem { + readonly kind: "submenu"; + readonly addonIcon: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; +} + +export interface CommandPaletteGroup { + readonly value: string; + readonly label: string; + readonly items: ReadonlyArray; +} + +export interface CommandPaletteView { + readonly addonIcon: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; +} + +export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; + +export function filterBrowseEntries(input: { + browseEntries: ReadonlyArray; + browseFilterQuery: string; + highlightedItemValue: string | null; +}): { + filteredEntries: FilesystemBrowseEntry[]; + highlightedEntry: FilesystemBrowseEntry | null; + exactEntry: FilesystemBrowseEntry | null; +} { + const lowerFilter = input.browseFilterQuery.toLowerCase(); + const showHidden = input.browseFilterQuery.startsWith("."); + + const filteredEntries = input.browseEntries.filter( + (entry) => + entry.name.toLowerCase().startsWith(lowerFilter) && + (showHidden || !entry.name.startsWith(".")), + ); + + let highlightedEntry: FilesystemBrowseEntry | null = null; + if (input.highlightedItemValue?.startsWith("browse:")) { + const highlightedPath = input.highlightedItemValue.slice("browse:".length); + highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null; + } + + const exactEntry = + input.browseFilterQuery.length > 0 + ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) + : null; + + return { filteredEntries, highlightedEntry, exactEntry }; +} + +export function normalizeSearchText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +export function buildProjectActionItems(input: { + projects: ReadonlyArray; + valuePrefix: string; + icon: (project: Project) => ReactNode; + runProject: (project: Project) => Promise; +}): CommandPaletteActionItem[] { + return input.projects.map((project) => ({ + kind: "action", + value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, + searchTerms: [project.name, project.cwd], + title: project.name, + description: project.cwd, + icon: input.icon(project), + run: async () => { + await input.runProject(project); + }, + })); +} + +export function buildThreadActionItems(input: { + threads: ReadonlyArray< + Pick< + SidebarThreadSummary, + "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" + > & { + updatedAt?: string | undefined; + latestUserMessageAt?: string | null; + } + >; + activeThreadId?: Thread["id"]; + projectTitleById: ReadonlyMap; + sortOrder: SidebarThreadSortOrder; + icon: ReactNode; + runThread: (thread: Pick) => Promise; + limit?: number; +}): CommandPaletteActionItem[] { + const sortedThreads = sortThreads( + input.threads.filter((thread) => thread.archivedAt === null), + input.sortOrder, + ); + const visibleThreads = + input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit); + + return visibleThreads.map((thread) => { + const projectTitle = input.projectTitleById.get(thread.projectId); + const descriptionParts: string[] = []; + + if (projectTitle) { + descriptionParts.push(projectTitle); + } + if (thread.branch) { + descriptionParts.push(`#${thread.branch}`); + } + if (thread.id === input.activeThreadId) { + descriptionParts.push("Current thread"); + } + + return { + kind: "action", + value: `thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], + title: thread.title, + description: descriptionParts.join(" · "), + timestamp: formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ), + icon: input.icon, + run: async () => { + await input.runThread(thread); + }, + }; + }); +} + +function rankSearchFieldMatch(field: string, normalizedQuery: string): number { + const normalizedField = normalizeSearchText(field); + if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { + return Number.NEGATIVE_INFINITY; + } + if (normalizedField === normalizedQuery) { + return 3; + } + if (normalizedField.startsWith(normalizedQuery)) { + return 2; + } + return 1; +} + +function rankCommandPaletteItemMatch( + item: CommandPaletteActionItem | CommandPaletteSubmenuItem, + normalizedQuery: string, +): number { + const terms = item.searchTerms.filter((term) => term.length > 0); + if (terms.length === 0) { + return 0; + } + + for (const [index, field] of terms.entries()) { + const fieldRank = rankSearchFieldMatch(field, normalizedQuery); + if (fieldRank !== Number.NEGATIVE_INFINITY) { + return 1_000 - index * 100 + fieldRank; + } + } + + return 0; +} + +export function filterCommandPaletteGroups(input: { + activeGroups: ReadonlyArray; + query: string; + isInSubmenu: boolean; + projectSearchItems: ReadonlyArray; + threadSearchItems: ReadonlyArray; +}): CommandPaletteGroup[] { + const isActionsFilter = input.query.startsWith(">"); + const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; + const normalizedQuery = normalizeSearchText(searchQuery); + + if (normalizedQuery.length === 0) { + if (isActionsFilter) { + return input.activeGroups.filter((group) => group.value === "actions"); + } + return [...input.activeGroups]; + } + + let baseGroups = [...input.activeGroups]; + if (isActionsFilter) { + baseGroups = baseGroups.filter((group) => group.value === "actions"); + } else if (!input.isInSubmenu) { + baseGroups = baseGroups.filter((group) => group.value !== "recent-threads"); + } + + const searchableGroups = [...baseGroups]; + if (!input.isInSubmenu && !isActionsFilter) { + if (input.projectSearchItems.length > 0) { + searchableGroups.push({ + value: "projects-search", + label: "Projects", + items: input.projectSearchItems, + }); + } + if (input.threadSearchItems.length > 0) { + searchableGroups.push({ + value: "threads-search", + label: "Threads", + items: input.threadSearchItems, + }); + } + } + + return searchableGroups.flatMap((group) => { + const items = group.items + .map((item, index) => { + const haystack = normalizeSearchText(item.searchTerms.join(" ")); + if (!haystack.includes(normalizedQuery)) { + return null; + } + + return { + item, + index, + rank: rankCommandPaletteItemMatch(item, normalizedQuery), + }; + }) + .filter( + (entry): entry is { item: (typeof group.items)[number]; index: number; rank: number } => + entry !== null, + ) + .toSorted((left, right) => right.rank - left.rank || left.index - right.index) + .map((entry) => entry.item); + + if (items.length === 0) { + return []; + } + + return [{ value: group.value, label: group.label, items }]; + }); +} + +export function buildBrowseGroups(input: { + browseEntries: ReadonlyArray; + browseQuery: string; + canBrowseUp: boolean; + upIcon: ReactNode; + directoryIcon: ReactNode; + browseUp: () => void; + browseTo: (name: string) => void; +}): CommandPaletteGroup[] { + const items: CommandPaletteActionItem[] = []; + + if (input.canBrowseUp) { + items.push({ + kind: "action", + value: "browse:up", + searchTerms: [input.browseQuery, ".."], + title: "..", + icon: input.upIcon, + keepOpen: true, + run: async () => { + input.browseUp(); + }, + }); + } + + for (const entry of input.browseEntries) { + items.push({ + kind: "action", + value: `browse:${entry.fullPath}`, + searchTerms: [input.browseQuery, entry.fullPath, entry.name], + title: entry.name, + icon: input.directoryIcon, + keepOpen: true, + run: async () => { + input.browseTo(entry.name); + }, + }); + } + + return [{ value: "directories", label: "Directories", items }]; +} + +export function getCommandPaletteMode(input: { + currentView: CommandPaletteView | null; + isBrowsing: boolean; +}): CommandPaletteMode { + if (input.currentView) { + return input.isBrowsing ? "submenu-browse" : "submenu"; + } + return input.isBrowsing ? "root-browse" : "root"; +} + +export function buildRootGroups(input: { + actionItems: ReadonlyArray; + recentThreadItems: ReadonlyArray; +}): CommandPaletteGroup[] { + const groups: CommandPaletteGroup[] = []; + if (input.actionItems.length > 0) { + groups.push({ value: "actions", label: "Actions", items: input.actionItems }); + } + if (input.recentThreadItems.length > 0) { + groups.push({ + value: "recent-threads", + label: "Recent Threads", + items: input.recentThreadItems, + }); + } + return groups; +} + +export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { + switch (mode) { + case "root": + return "Search commands, projects, and threads..."; + case "root-browse": + return "Enter project path (e.g. ~/projects/my-app)"; + case "submenu": + return "Search..."; + case "submenu-browse": + return "Enter path (e.g. ~/projects/my-app)"; + } +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000000..fbbeda1013 --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,1112 @@ +"use client"; + +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { + DEFAULT_MODEL_BY_PROVIDER, + type EnvironmentId, + type FilesystemBrowseResult, + type ProjectId, +} from "@t3tools/contracts"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowUpIcon, + CornerLeftUpIcon, + FolderIcon, + FolderPlusIcon, + MessageSquareIcon, + SettingsIcon, + SquarePenIcon, +} from "lucide-react"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useCommandPaletteStore } from "../commandPaletteStore"; +import { readEnvironmentApi } from "../environmentApi"; +import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useSettings } from "../hooks/useSettings"; +import { readLocalApi } from "../localApi"; +import { + startNewThreadInProjectFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, + findProjectByPath, + getBrowseDirectoryPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + inferProjectTitleFromPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, + isUnsupportedWindowsProjectPath, + resolveProjectPathForDispatch, +} from "../lib/projectPaths"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { getLatestThreadForProject } from "../lib/threadSort"; +import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { + selectProjectsAcrossEnvironments, + selectSidebarThreadsAcrossEnvironments, + useStore, +} from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { + ADDON_ICON_CLASS, + buildBrowseGroups, + buildProjectActionItems, + buildRootGroups, + buildThreadActionItems, + type CommandPaletteActionItem, + type CommandPaletteSubmenuItem, + type CommandPaletteView, + filterBrowseEntries, + filterCommandPaletteGroups, + getCommandPaletteInputPlaceholder, + getCommandPaletteMode, + ITEM_ICON_CLASS, + RECENT_THREAD_LIMIT, +} from "./CommandPalette.logic"; +import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { CommandPaletteResults } from "./CommandPaletteResults"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { useServerKeybindings } from "../rpc/serverState"; +import { resolveShortcutCommand } from "../keybindings"; +import { + Command, + CommandDialog, + CommandDialogPopup, + CommandFooter, + CommandInput, + CommandPanel, +} from "./ui/command"; +import { Button } from "./ui/button"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { toastManager } from "./ui/toast"; +import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; +import type { ChatComposerHandle } from "./chat/ChatComposer"; + +const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const BROWSE_STALE_TIME_MS = 30_000; + +function getLocalFileManagerName(platform: string): string { + if (isMacPlatform(platform)) { + return "Finder"; + } + if (isWindowsPlatform(platform)) { + return "Explorer"; + } + return "Files"; +} + +function getEnvironmentBrowsePlatform(os: string | null | undefined): string { + if (os === "windows") { + return "Win32"; + } + if (os === "darwin") { + return "MacIntel"; + } + if (os === "linux") { + return "Linux"; + } + return typeof navigator === "undefined" ? "" : navigator.platform; +} + +interface AddProjectEnvironmentOption { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; +} + +export function CommandPalette({ children }: { children: ReactNode }) { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); + const keybindings = useServerKeybindings(); + const composerHandleRef = useRef(null); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const terminalOpen = useTerminalStateStore((state) => + routeThreadRef + ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + if (command !== "commandPalette.toggle") { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleOpen(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, terminalOpen, toggleOpen]); + + return ( + + + {children} + + + + ); +} + +function CommandPaletteDialog() { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + + useEffect(() => { + return () => { + setOpen(false); + }; + }, [setOpen]); + + if (!open) { + return null; + } + + return ; +} + +function OpenCommandPaletteDialog() { + const navigate = useNavigate(); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + const openIntent = useCommandPaletteStore((store) => store.openIntent); + const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); + const composerHandleRef = useComposerHandleContext(); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const isActionsOnly = deferredQuery.startsWith(">"); + const queryClient = useQueryClient(); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const settings = useSettings(); + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = + useHandleNewThread(); + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const keybindings = useServerKeybindings(); + const [viewStack, setViewStack] = useState([]); + const currentView = viewStack.at(-1) ?? null; + const [browseGeneration, setBrowseGeneration] = useState(0); + const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( + null, + ); + const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + + const addProjectEnvironmentOptions = useMemo(() => { + const options: AddProjectEnvironmentOption[] = []; + const seenEnvironmentIds = new Set(); + + if (primaryEnvironmentId) { + seenEnvironmentIds.add(primaryEnvironmentId); + options.push({ + environmentId: primaryEnvironmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: primaryEnvironmentId, + runtimeLabel: primaryEnvironmentLabel, + }), + isPrimary: true, + }); + } + + for (const record of Object.values(savedEnvironmentRegistry)) { + if (seenEnvironmentIds.has(record.environmentId)) { + continue; + } + + const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; + options.push({ + environmentId: record.environmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId: record.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? null, + savedLabel: record.label, + }), + isPrimary: false, + }); + } + + options.sort((left, right) => { + if (left.isPrimary !== right.isPrimary) { + return left.isPrimary ? -1 : 1; + } + return left.label.localeCompare(right.label); + }); + + return options; + }, [ + primaryEnvironmentId, + primaryEnvironmentLabel, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; + const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; + const browseEnvironmentPlatform = useMemo(() => { + const os = + browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId + ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) + : browseEnvironmentId + ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? + savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform + .os ?? + null) + : null; + return getEnvironmentBrowsePlatform(os); + }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); + const isBrowsing = isFilesystemBrowseQuery(query, browseEnvironmentPlatform); + const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); + const getAddProjectInitialQueryForEnvironment = useCallback( + (environmentId: EnvironmentId | null): string => { + const environmentSettings = + environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId + ? settings + : environmentId + ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings + : null; + const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; + if (baseDirectory.length === 0) { + return "~/"; + } + return ensureBrowseDirectoryPath(baseDirectory); + }, + [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + ); + + const projectCwdById = useMemo( + () => new Map(projects.map((project) => [project.id, project.cwd])), + [projects], + ); + const projectTitleById = useMemo( + () => new Map(projects.map((project) => [project.id, project.name])), + [projects], + ); + + const activeThreadId = activeThread?.id; + const currentProjectEnvironmentId = + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; + const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + const currentProjectCwd = currentProjectId + ? (projectCwdById.get(currentProjectId) ?? null) + : null; + const currentProjectCwdForBrowse = + browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId + ? currentProjectCwd + : null; + const relativePathNeedsActiveProject = + isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; + const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; + const browseFilterQuery = + isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; + + const fetchBrowseResult = useCallback( + async (partialPath: string): Promise => { + if (!browseEnvironmentId) return null; + const api = readEnvironmentApi(browseEnvironmentId); + if (!api) return null; + return api.filesystem.browse({ + partialPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse], + ); + + const { data: browseResult, isPending: isBrowsePending } = useQuery({ + queryKey: [ + "filesystemBrowse", + browseEnvironmentId, + browseDirectoryPath, + currentProjectCwdForBrowse, + ], + queryFn: () => fetchBrowseResult(browseDirectoryPath), + staleTime: BROWSE_STALE_TIME_MS, + enabled: + isBrowsing && + browseDirectoryPath.length > 0 && + browseEnvironmentId !== null && + !relativePathNeedsActiveProject, + }); + const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; + const { + filteredEntries: filteredBrowseEntries, + highlightedEntry: highlightedBrowseEntry, + exactEntry: exactBrowseEntry, + } = useMemo( + () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), + [browseEntries, browseFilterQuery, highlightedItemValue], + ); + + const prefetchBrowsePath = useCallback( + (partialPath: string) => { + void queryClient.prefetchQuery({ + queryKey: [ + "filesystemBrowse", + browseEnvironmentId, + partialPath, + currentProjectCwdForBrowse, + ], + queryFn: () => fetchBrowseResult(partialPath), + staleTime: BROWSE_STALE_TIME_MS, + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], + ); + + // Prefetch the parent and the most likely next child so browse navigation + // stays warm without scanning every child directory in large trees. + useEffect(() => { + if (!isBrowsing || filteredBrowseEntries.length === 0) return; + + if (canNavigateUp(query)) { + prefetchBrowsePath(getBrowseParentPath(query)!); + } + + const nextChild = highlightedBrowseEntry ?? exactBrowseEntry; + if (nextChild) { + prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name)); + } + }, [ + exactBrowseEntry, + filteredBrowseEntries.length, + highlightedBrowseEntry, + isBrowsing, + prefetchBrowsePath, + query, + ]); + + const openProjectFromSearch = useMemo( + () => async (project: (typeof projects)[number]) => { + const latestThread = getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === project.environmentId), + project.id, + settings.sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(latestThread.environmentId, latestThread.id), + ), + }); + return; + } + + await handleNewThread(scopeProjectRef(project.environmentId, project.id), { + envMode: settings.defaultThreadEnvMode, + }); + }, + [ + handleNewThread, + navigate, + settings.defaultThreadEnvMode, + settings.sidebarThreadSortOrder, + threads, + ], + ); + + const projectSearchItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "project", + icon: (project) => ( + + ), + runProject: openProjectFromSearch, + }), + [openProjectFromSearch, projects], + ); + + const projectThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: (project) => ( + + ), + runProject: async (project) => { + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + [ + activeDraftThread, + activeThread, + defaultProjectRef, + handleNewThread, + projects, + settings.defaultThreadEnvMode, + ], + ); + + const allThreadItems = useMemo( + () => + buildThreadActionItems({ + threads, + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: settings.sidebarThreadSortOrder, + icon: , + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }), + [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads], + ); + const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + + function pushPaletteView(view: CommandPaletteView): void { + setViewStack((previousViews) => [ + ...previousViews, + { + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), + }, + ]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + } + + function pushView(item: CommandPaletteSubmenuItem): void { + pushPaletteView({ + addonIcon: item.addonIcon, + groups: item.groups, + ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + }); + } + + function popView(): void { + if (viewStack.length <= 1) { + setAddProjectEnvironmentId(null); + } + setViewStack((previousViews) => previousViews.slice(0, -1)); + setHighlightedItemValue(null); + setQuery(""); + } + + function handleQueryChange(nextQuery: string): void { + setHighlightedItemValue(null); + setQuery(nextQuery); + if (nextQuery === "" && currentView?.initialQuery) { + popView(); + } + } + + const startAddProjectBrowse = useCallback( + (environmentId: EnvironmentId): void => { + setAddProjectEnvironmentId(environmentId); + pushPaletteView({ + addonIcon: , + groups: [], + initialQuery: getAddProjectInitialQueryForEnvironment(environmentId), + }); + }, + [getAddProjectInitialQueryForEnvironment], + ); + + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( + (option) => ({ + kind: "action", + value: `action:add-project:environment:${option.environmentId}`, + searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], + title: option.label, + description: option.isPrimary ? "This device" : option.environmentId, + icon: , + keepOpen: true, + run: async () => { + startAddProjectBrowse(option.environmentId); + }, + }), + ); + + const addProjectEnvironmentGroups = useMemo( + () => [ + { + value: "environments", + label: "Environments", + items: addProjectEnvironmentItems, + }, + ], + [addProjectEnvironmentItems], + ); + + const openAddProjectFlow = useCallback(() => { + if (addProjectEnvironmentOptions.length > 1) { + pushPaletteView({ + addonIcon: , + groups: addProjectEnvironmentGroups, + }); + return; + } + + const environmentId = defaultAddProjectEnvironmentId; + if (!environmentId) { + toastManager.add({ + type: "error", + title: "Unable to browse projects", + description: "No environment is available.", + }); + return; + } + + startAddProjectBrowse(environmentId); + }, [ + addProjectEnvironmentGroups, + addProjectEnvironmentOptions.length, + defaultAddProjectEnvironmentId, + startAddProjectBrowse, + ]); + + useEffect(() => { + if (openIntent?.kind !== "add-project") { + return; + } + clearOpenIntent(); + openAddProjectFlow(); + }, [clearOpenIntent, openAddProjectFlow, openIntent]); + + const actionItems: Array = []; + + if (projects.length > 0) { + const activeProjectTitle = currentProjectId + ? (projectTitleById.get(currentProjectId) ?? null) + : null; + + if (activeProjectTitle) { + actionItems.push({ + kind: "action", + value: "action:new-thread", + searchTerms: ["new thread", "chat", "create", "draft"], + title: ( + <> + New thread in {activeProjectTitle} + + ), + icon: , + shortcutCommand: "chat.new", + run: async () => { + await startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + }); + }, + }); + } + + actionItems.push({ + kind: "submenu", + value: "action:new-thread-in", + searchTerms: ["new thread", "project", "pick", "choose", "select"], + title: "New thread in...", + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + }); + } + + if (addProjectEnvironmentOptions.length > 1) { + actionItems.push({ + kind: "submenu", + value: "action:add-project", + searchTerms: ["add project", "folder", "directory", "browse", "environment"], + title: "Add project", + icon: , + addonIcon: , + groups: addProjectEnvironmentGroups, + }); + } else { + actionItems.push({ + kind: "action", + value: "action:add-project", + searchTerms: ["add project", "folder", "directory", "browse"], + title: "Add project", + icon: , + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }); + } + + actionItems.push({ + kind: "action", + value: "action:settings", + searchTerms: ["settings", "preferences", "configuration", "keybindings"], + title: "Open settings", + icon: , + run: async () => { + await navigate({ to: "/settings" }); + }, + }); + + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const activeGroups = currentView ? currentView.groups : rootGroups; + + const filteredGroups = filterCommandPaletteGroups({ + activeGroups, + query: deferredQuery, + isInSubmenu: currentView !== null, + projectSearchItems: projectSearchItems, + threadSearchItems: allThreadItems, + }); + + const handleAddProject = useCallback( + async (rawCwd: string) => { + if (!browseEnvironmentId) return; + const api = readEnvironmentApi(browseEnvironmentId); + if (!api) return; + + if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: "Windows-style paths are only supported on Windows.", + }); + return; + } + + if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: "Relative paths require an active project.", + }); + return; + } + + const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse); + if (cwd.length === 0) return; + + const existing = findProjectByPath( + projects.filter((project) => project.environmentId === browseEnvironmentId), + cwd, + ); + if (existing) { + const latestThread = getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === existing.environmentId), + existing.id, + settings.sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(latestThread.environmentId, latestThread.id), + ), + }); + } else { + await handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { + envMode: settings.defaultThreadEnvMode, + }).catch(() => undefined); + } + setOpen(false); + return; + } + + try { + const projectId = newProjectId(); + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: inferProjectTitleFromPath(cwd), + workspaceRoot: cwd, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + createdAt: new Date().toISOString(), + }); + await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { + envMode: settings.defaultThreadEnvMode, + }).catch(() => undefined); + setOpen(false); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, + [ + browseEnvironmentId, + browseEnvironmentPlatform, + currentProjectCwdForBrowse, + handleNewThread, + navigate, + projects, + setOpen, + settings.defaultThreadEnvMode, + settings.sidebarThreadSortOrder, + threads, + ], + ); + + function browseTo(name: string): void { + const nextQuery = appendBrowsePathSegment(query, name); + setHighlightedItemValue(null); + setQuery(nextQuery); + setBrowseGeneration((generation) => generation + 1); + } + + function browseUp(): void { + const parentPath = getBrowseParentPath(query); + if (parentPath === null) { + return; + } + + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); + } + + // Resolve the add-project path from browse data when available. When the + // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the + // directory itself. Otherwise the user typed a partial leaf name, so we need + // the exact browse entry's fullPath or fall back to the raw query. + const resolvedAddProjectPath = hasTrailingPathSeparator(query) + ? (browseResult?.parentPath ?? query.trim()) + : (exactBrowseEntry?.fullPath ?? query.trim()); + + const canBrowseUp = + isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath); + + const browseGroups = buildBrowseGroups({ + browseEntries: filteredBrowseEntries, + browseQuery: query, + canBrowseUp, + upIcon: , + directoryIcon: , + browseUp, + browseTo, + }); + + let displayedGroups = filteredGroups; + if (isBrowsing) { + displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; + } + + const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); + const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; + const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; + const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; + const willCreateProjectPath = + canSubmitBrowsePath && + !isBrowsePending && + query.trim().length > 0 && + !hasHighlightedBrowseItem && + (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); + const useMetaForMod = isMacPlatform(navigator.platform); + const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; + const submitActionLabel = willCreateProjectPath ? "Create & Add" : "Add"; + const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"; + const fileManagerName = getLocalFileManagerName(navigator.platform); + const canOpenProjectFromFileManager = + isBrowsing && + browseEnvironmentId !== null && + primaryEnvironmentId !== null && + browseEnvironmentId === primaryEnvironmentId && + typeof window !== "undefined" && + window.desktopBridge !== undefined; + const fileManagerInitialPath = useMemo(() => { + if (!canOpenProjectFromFileManager) { + return undefined; + } + + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) { + return undefined; + } + + const initialPath = hasTrailingPathSeparator(query) + ? (browseResult?.parentPath ?? trimmedQuery) + : browseDirectoryPath || trimmedQuery; + + const resolvedPath = resolveProjectPathForDispatch(initialPath, currentProjectCwdForBrowse); + return resolvedPath.length > 0 ? resolvedPath : undefined; + }, [ + browseDirectoryPath, + browseResult?.parentPath, + canOpenProjectFromFileManager, + currentProjectCwdForBrowse, + query, + ]); + + function isPrimaryModifierPressed(event: KeyboardEvent): boolean { + return useMetaForMod ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + } + + function handleKeyDown(event: KeyboardEvent): void { + const shouldSubmitBrowsePath = + canSubmitBrowsePath && + event.key === "Enter" && + (!hasHighlightedBrowseItem || isPrimaryModifierPressed(event)); + + if (shouldSubmitBrowsePath) { + event.preventDefault(); + void handleAddProject(resolvedAddProjectPath); + return; + } + + if (event.key === "Backspace" && query === "" && isSubmenu) { + event.preventDefault(); + popView(); + } + } + + function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { + if (item.kind === "submenu") { + pushView(item); + return; + } + + if (!item.keepOpen) { + setOpen(false); + } + + void item.run().catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Unable to run command", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + + const handleOpenProjectFromFileManager = useCallback(async () => { + if (!canOpenProjectFromFileManager || isPickingProjectFolder) { + return; + } + const api = readLocalApi(); + if (!api) { + return; + } + + setIsPickingProjectFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder( + fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : undefined, + ); + } catch { + // Ignore picker failures and leave the palette open. + setIsPickingProjectFolder(false); + return; + } + setIsPickingProjectFolder(false); + if (!pickedPath) { + return; + } + await handleAddProject(pickedPath); + }, [ + canOpenProjectFromFileManager, + fileManagerInitialPath, + handleAddProject, + isPickingProjectFolder, + ]); + + return ( + { + composerHandleRef?.current?.focusAtEnd(); + return false; + }} + > + { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} + onValueChange={handleQueryChange} + value={query} + > +
+ + + + ), + } + : isBrowsing && !isSubmenu + ? { + startAddon: , + } + : {})} + onKeyDown={handleKeyDown} + /> + {isBrowsing ? ( + + ) : null} +
+ + + + +
+ + + + + + + + Navigate + + {!canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + + Enter + Select + + ) : null} + {isSubmenu ? ( + + Backspace + Back + + ) : null} + + Esc + Close + +
+ {canOpenProjectFromFileManager ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx new file mode 100644 index 0000000000..e2841d5880 --- /dev/null +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -0,0 +1,110 @@ +import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { ChevronRightIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../keybindings"; +import { + type CommandPaletteActionItem, + type CommandPaletteGroup, + type CommandPaletteSubmenuItem, +} from "./CommandPalette.logic"; +import { + CommandCollection, + CommandGroup, + CommandGroupLabel, + CommandItem, + CommandList, + CommandShortcut, +} from "./ui/command"; +import { cn } from "~/lib/utils"; + +interface CommandPaletteResultsProps { + emptyStateMessage?: string; + groups: ReadonlyArray; + highlightedItemValue?: string | null; + isActionsOnly: boolean; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +} + +export function CommandPaletteResults(props: CommandPaletteResultsProps) { + if (props.groups.length === 0) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); + } + + return ( + + {props.groups.map((group) => ( + + {group.label} + + {(item) => ( + + )} + + + ))} + + ); +} + +function CommandPaletteResultRow(props: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + isActive: boolean; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +}) { + const shortcutLabel = props.item.shortcutCommand + ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand) + : null; + + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onExecuteItem(props.item); + }} + > + {props.item.icon} + {props.item.description ? ( + + {props.item.title} + + {props.item.description} + + + ) : ( + + {props.item.title} + + )} + {props.item.timestamp ? ( + + {props.item.timestamp} + + ) : null} + {shortcutLabel ? {shortcutLabel} : null} + {props.item.kind === "submenu" ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1..8113099638 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -5,6 +5,7 @@ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; +import { type ServerProviderSkill } from "@t3tools/contracts"; import { $applyNodeReplacement, $createRangeSelection, @@ -26,15 +27,13 @@ import { COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, $getRoot, + HISTORY_MERGE_TAG, DecoratorNode, type ElementNode, type LexicalNode, type SerializedLexicalNode, - TextNode, - type EditorConfig, type EditorState, type NodeKey, - type SerializedTextNode, type Spread, } from "lexical"; import { @@ -58,7 +57,10 @@ import { expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; -import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { + selectionTouchesMentionBoundary, + splitPromptIntoComposerSegments, +} from "~/composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, @@ -69,10 +71,28 @@ import { COMPOSER_INLINE_CHIP_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; +const SURROUND_SYMBOLS: [string, string][] = [ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["'", "'"], + ['"', '"'], + ["“", "”"], + ["`", "`"], + ["<", ">"], + ["«", "»"], + ["*", "*"], + ["_", "_"], +]; +const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); +const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; type SerializedComposerMentionNode = Spread< { @@ -80,7 +100,18 @@ type SerializedComposerMentionNode = Spread< type: "composer-mention"; version: 1; }, - SerializedTextNode + SerializedLexicalNode +>; + +type SerializedComposerSkillNode = Spread< + { + skillName: string; + skillLabel?: string; + skillDescription?: string; + type: "composer-skill"; + version: 1; + }, + SerializedLexicalNode >; type SerializedComposerTerminalContextNode = Spread< @@ -98,7 +129,40 @@ const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: () => {}, }); -class ComposerMentionNode extends TextNode { +function ComposerMentionDecorator(props: { path: string }) { + const theme = resolvedThemeFromDocument(); + const chip = ( + + + {basenameOfPath(props.path)} + + ); + + return ( + + + + {props.path} + + + ); +} + +class ComposerMentionNode extends DecoratorNode { __path: string; static override getType(): string { @@ -110,12 +174,12 @@ class ComposerMentionNode extends TextNode { } static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path); + return $createComposerMentionNode(serializedNode.path).updateFromJSON(serializedNode); } constructor(path: string, key?: NodeKey) { + super(key); const normalizedPath = path.startsWith("@") ? path.slice(1) : path; - super(`@${normalizedPath}`, key); this.__path = normalizedPath; } @@ -128,46 +192,180 @@ class ComposerMentionNode extends TextNode { }; } - override createDOM(_config: EditorConfig): HTMLElement { + override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderMentionChipDom(dom, this.__path); + dom.className = "inline-flex align-middle leading-none"; return dom; } - override updateDOM( - prevNode: ComposerMentionNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.contentEditable = "false"; - if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { - renderMentionChipDom(dom, this.__path); - } + override updateDOM(): false { return false; } - override canInsertTextBefore(): false { - return false; + override getTextContent(): string { + return `@${this.__path}`; + } + + override isInline(): true { + return true; + } + + override decorate(): ReactElement { + return ; } +} + +function $createComposerMentionNode(path: string): ComposerMentionNode { + return $applyNodeReplacement(new ComposerMentionNode(path)); +} - override canInsertTextAfter(): false { +const SKILL_CHIP_ICON_SVG = ``; + +function resolveSkillDescription( + skill: Pick, +): string | null { + const shortDescription = skill.shortDescription?.trim(); + if (shortDescription) { + return shortDescription; + } + const description = skill.description?.trim(); + return description || null; +} + +type ComposerSkillMetadata = { + label: string; + description: string | null; +}; + +function skillMetadataByName( + skills: ReadonlyArray, +): ReadonlyMap { + return new Map( + skills.map((skill) => [ + skill.name, + { + label: formatProviderSkillDisplayName(skill), + description: resolveSkillDescription(skill), + }, + ]), + ); +} + +function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) { + const chip = ( + + + ); + + if (!props.skillDescription) { + return chip; + } + + return ( + + + + {props.skillDescription} + + + ); +} + +class ComposerSkillNode extends DecoratorNode { + __skillName: string; + __skillLabel: string; + __skillDescription: string | null; + + static override getType(): string { + return "composer-skill"; + } + + static override clone(node: ComposerSkillNode): ComposerSkillNode { + return new ComposerSkillNode( + node.__skillName, + node.__skillLabel, + node.__skillDescription, + node.__key, + ); + } + + static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { + return $createComposerSkillNode( + serializedNode.skillName, + serializedNode.skillLabel ?? serializedNode.skillName, + serializedNode.skillDescription ?? null, + ).updateFromJSON(serializedNode); + } + + constructor( + skillName: string, + skillLabel: string, + skillDescription: string | null, + key?: NodeKey, + ) { + super(key); + const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; + this.__skillName = normalizedSkillName; + this.__skillLabel = skillLabel; + this.__skillDescription = skillDescription; + } + + override exportJSON(): SerializedComposerSkillNode { + return { + ...super.exportJSON(), + skillName: this.__skillName, + skillLabel: this.__skillLabel, + ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), + type: "composer-skill", + version: 1, + }; + } + + override createDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.className = "inline-flex align-middle leading-none"; + return dom; + } + + override updateDOM(): false { return false; } - override isTextEntity(): true { - return true; + override getTextContent(): string { + return `$${this.__skillName}`; } - override isToken(): true { + override isInline(): true { return true; } + + override decorate(): ReactElement { + return ( + + ); + } } -function $createComposerMentionNode(path: string): ComposerMentionNode { - return $applyNodeReplacement(new ComposerMentionNode(path)); +function $createComposerSkillNode( + skillName: string, + skillLabel: string, + skillDescription: string | null, +): ComposerSkillNode { + return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); } function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { @@ -234,11 +432,16 @@ function $createComposerTerminalContextNode( return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } -type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; +type ComposerInlineTokenNode = + | ComposerMentionNode + | ComposerSkillNode + | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( - candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode + candidate instanceof ComposerMentionNode || + candidate instanceof ComposerSkillNode || + candidate instanceof ComposerTerminalContextNode ); } @@ -246,26 +449,6 @@ function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } -function renderMentionChipDom(container: HTMLElement, pathValue: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); - - const theme = resolvedThemeFromDocument(); - const icon = document.createElement("img"); - icon.alt = ""; - icon.ariaHidden = "true"; - icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; - icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); - - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = basenameOfPath(pathValue); - - container.append(icon, label); -} - function terminalContextSignature(contexts: ReadonlyArray): string { return contexts .map((context) => @@ -283,6 +466,22 @@ function terminalContextSignature(contexts: ReadonlyArray) .join("\u001e"); } +function skillSignature(skills: ReadonlyArray): string { + return skills + .map((skill) => + [ + skill.name, + skill.displayName ?? "", + skill.shortDescription ?? "", + skill.description ?? "", + skill.path, + skill.scope ?? "", + skill.enabled ? "1" : "0", + ].join("\u001f"), + ) + .join("\u001e"); +} + function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); @@ -391,12 +590,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -438,12 +634,9 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -469,10 +662,7 @@ function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { - if (node instanceof ComposerMentionNode) { - return findSelectionPointForInlineToken(node, remainingRef); - } - if (node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return findSelectionPointForInlineToken(node, remainingRef); } @@ -553,6 +743,53 @@ function $setSelectionAtComposerOffset(nextOffset: number): void { $setSelection(selection); } +function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void { + const root = $getRoot(); + const composerLength = $getComposerRootLength(); + const boundedStart = Math.max(0, Math.min(startOffset, composerLength)); + const boundedEnd = Math.max(0, Math.min(endOffset, composerLength)); + const anchorRemainingRef = { value: boundedStart }; + const focusRemainingRef = { value: boundedEnd }; + const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? { + key: root.getKey(), + offset: root.getChildren().length, + type: "element" as const, + }; + const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? { + key: root.getKey(), + offset: root.getChildren().length, + type: "element" as const, + }; + const selection = $createRangeSelection(); + selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type); + selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type); + $setSelection(selection); +} + +function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType): { + start: number; + end: number; +} | null { + if (!$isRangeSelection(selection)) { + return null; + } + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset); + return { + start: Math.min(anchorOffset, focusOffset), + end: Math.max(anchorOffset, focusOffset), + }; +} + +function $selectionTouchesInlineToken(selection: ReturnType): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); +} + function $readSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { @@ -591,6 +828,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, + skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); root.clear(); @@ -603,6 +841,17 @@ function $setComposerEditorPrompt( paragraph.append($createComposerMentionNode(segment.path)); continue; } + if (segment.type === "skill") { + const metadata = skillMetadata.get(segment.name); + paragraph.append( + $createComposerSkillNode( + segment.name, + metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), + metadata?.description ?? null, + ), + ); + continue; + } if (segment.type === "terminal-context") { if (segment.context) { paragraph.append($createComposerTerminalContextNode(segment.context)); @@ -639,6 +888,7 @@ interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; + skills: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; @@ -878,10 +1128,280 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +function ComposerSurroundSelectionPlugin(props: { + terminalContexts: ReadonlyArray; + skills: ReadonlyArray; +}) { + const [editor] = useLexicalComposerContext(); + const terminalContextsRef = useRef(props.terminalContexts); + const skillMetadataRef = useRef(skillMetadataByName(props.skills)); + const pendingSurroundSelectionRef = useRef<{ + value: string; + expandedStart: number; + expandedEnd: number; + } | null>(null); + const pendingDeadKeySelectionRef = useRef<{ + value: string; + expandedStart: number; + expandedEnd: number; + } | null>(null); + + useEffect(() => { + terminalContextsRef.current = props.terminalContexts; + }, [props.terminalContexts]); + + useEffect(() => { + skillMetadataRef.current = skillMetadataByName(props.skills); + }, [props.skills]); + + const applySurroundInsertion = useCallback( + (inputData: string): boolean => { + const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); + const pendingSurroundSelection = pendingSurroundSelectionRef.current; + if (!surroundCloseSymbol) { + pendingSurroundSelectionRef.current = null; + return false; + } + + let handled = false; + editor.update(() => { + const selectionSnapshot = + pendingSurroundSelection ?? + (() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return null; + } + if ($selectionTouchesInlineToken(selection)) { + return null; + } + const range = getSelectionRangeForExpandedComposerOffsets(selection); + if (!range || range.start === range.end) { + return null; + } + const value = $getRoot().getTextContent(); + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + return null; + } + return { + value, + expandedStart: range.start, + expandedEnd: range.end, + }; + })(); + + if (!selectionSnapshot || !surroundCloseSymbol) { + return; + } + + const selectedText = selectionSnapshot.value.slice( + selectionSnapshot.expandedStart, + selectionSnapshot.expandedEnd, + ); + const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; + $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); + const selectionStart = collapseExpandedComposerCursor( + nextValue, + selectionSnapshot.expandedStart, + ); + $setSelectionRangeAtComposerOffsets( + selectionStart + inputData.length, + selectionStart + inputData.length + selectedText.length, + ); + handled = true; + pendingSurroundSelectionRef.current = null; + }); + + return handled; + }, + [editor], + ); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (pendingDeadKeySelectionRef.current) { + if (event.key === "Dead" || event.key === " " || event.code === "Space") { + return; + } + pendingDeadKeySelectionRef.current = null; + } + + if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + if ($selectionTouchesInlineToken(selection)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + const range = getSelectionRangeForExpandedComposerOffsets(selection); + if (!range || range.start === range.end) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + const value = $getRoot().getTextContent(); + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + const snapshot = { + value, + expandedStart: range.start, + expandedEnd: range.end, + }; + pendingSurroundSelectionRef.current = snapshot; + pendingDeadKeySelectionRef.current = null; + }); + }; + + const onBeforeInput = (event: InputEvent) => { + if ( + event.inputType === "insertCompositionText" && + event.data === "`" && + BACKTICK_SURROUND_CLOSE_SYMBOL !== null && + pendingSurroundSelectionRef.current + ) { + pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current; + return; + } + + if (pendingDeadKeySelectionRef.current) { + return; + } + + if (event.inputType === "insertCompositionText") { + return; + } + + if (typeof event.data !== "string") { + pendingSurroundSelectionRef.current = null; + return; + } + const inputData = event.inputType === "insertText" ? event.data : null; + if (!inputData || inputData.length !== 1) { + pendingSurroundSelectionRef.current = null; + return; + } + if (!applySurroundInsertion(inputData)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => { + queueMicrotask(() => { + editor.update( + () => { + const pendingDeadKeySelection = pendingDeadKeySelectionRef.current; + if (!pendingDeadKeySelection) { + return; + } + + const currentValue = $getRoot().getTextContent(); + const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL; + if (backtickCloseSymbol === null) { + pendingDeadKeySelectionRef.current = null; + return; + } + + const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`; + if (currentValue !== expectedResolvedValue) { + if (options?.finalAttempt) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + } + return; + } + + const selectedText = pendingDeadKeySelection.value.slice( + pendingDeadKeySelection.expandedStart, + pendingDeadKeySelection.expandedEnd, + ); + const replacementStart = collapseExpandedComposerCursor( + currentValue, + pendingDeadKeySelection.expandedStart, + ); + $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); + const replacementSelection = $getSelection(); + if (!$isRangeSelection(replacementSelection)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); + $setSelectionRangeAtComposerOffsets( + replacementStart + 1, + replacementStart + 1 + selectedText.length, + ); + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + }, + { tag: HISTORY_MERGE_TAG }, + ); + }); + }; + + const onInput = (event: Event) => { + const inputEvent = event as InputEvent; + if ( + inputEvent.inputType === "insertText" || + inputEvent.inputType === "insertCompositionText" + ) { + tryApplyDeadKeyBacktickSurround(); + } + }; + + const onCompositionEnd = () => { + tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); + }; + + let activeRootElement: HTMLElement | null = null; + const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { + prevRootElement?.removeEventListener("keydown", onKeyDown); + prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); + prevRootElement?.removeEventListener("input", onInput); + prevRootElement?.removeEventListener("compositionend", onCompositionEnd); + rootElement?.addEventListener("keydown", onKeyDown); + rootElement?.addEventListener("beforeinput", onBeforeInput, true); + rootElement?.addEventListener("input", onInput); + rootElement?.addEventListener("compositionend", onCompositionEnd); + activeRootElement = rootElement; + }); + + return () => { + if (activeRootElement) { + activeRootElement.removeEventListener("keydown", onKeyDown); + activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); + activeRootElement.removeEventListener("input", onInput); + activeRootElement.removeEventListener("compositionend", onCompositionEnd); + } + unregisterRootListener(); + }; + }, [applySurroundInsertion, editor]); + + return null; +} + function ComposerPromptEditorInner({ value, cursor, terminalContexts, + skills, disabled, placeholder, className, @@ -896,6 +1416,9 @@ function ComposerPromptEditorInner({ const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); + const skillsSignature = skillSignature(skills); + const skillsSignatureRef = useRef(skillsSignature); + const skillMetadataRef = useRef(skillMetadataByName(skills)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -912,6 +1435,10 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useLayoutEffect(() => { + skillMetadataRef.current = skillMetadataByName(skills); + }, [skills]); + useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); @@ -920,10 +1447,12 @@ function ComposerPromptEditorInner({ const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; + const skillsChanged = skillsSignatureRef.current !== skillsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && - !contextsChanged + !contextsChanged && + !skillsChanged ) { return; } @@ -935,18 +1464,20 @@ function ComposerPromptEditorInner({ terminalContextIds: terminalContexts.map((context) => context.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; + skillsSignatureRef.current = skillsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !isFocused) { + if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; + const shouldRewriteEditorState = + previousSnapshot.value !== value || contextsChanged || skillsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts); + $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -955,7 +1486,7 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, terminalContexts, terminalContextsSignature, value]); + }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { @@ -1113,6 +1644,7 @@ function ComposerPromptEditorInner({ /> + @@ -1130,6 +1662,7 @@ export const ComposerPromptEditor = forwardRef< value, cursor, terminalContexts, + skills, disabled, placeholder, className, @@ -1142,13 +1675,18 @@ export const ComposerPromptEditor = forwardRef< ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); + const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerTerminalContextNode], + nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], editorState: () => { - $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); + $setComposerEditorPrompt( + initialValueRef.current, + initialTerminalContextsRef.current, + initialSkillMetadataRef.current, + ); }, onError: (error) => { throw error; @@ -1163,6 +1701,7 @@ export const ComposerPromptEditor = forwardRef< value={value} cursor={cursor} terminalContexts={terminalContexts} + skills={skills} disabled={disabled} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dc376a5b3d..e6dbb57cc7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { ThreadId, type TurnId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime"; +import type { TurnId } from "@t3tools/contracts"; import { ChevronLeftIcon, ChevronRightIcon, @@ -19,17 +20,19 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; -import { readNativeApi } from "../nativeApi"; +import { readLocalApi } from "../localApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useStore } from "../store"; +import { selectProjectByRef, useStore } from "../store"; +import { createThreadSelectorByRef } from "../storeSelectors"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; @@ -174,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const previousDiffOpenRef = useRef(false); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); - const routeThreadId = useParams({ + const routeThreadRef = useParams({ strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => resolveThreadRouteRef(params), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadId; - const activeThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + const activeThreadId = routeThreadRef?.threadId ?? null; + const activeThread = useStore( + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => - activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, + activeThread && activeProjectId + ? selectProjectByRef(store, { + environmentId: activeThread.environmentId, + projectId: activeProjectId, + }) + : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); + const gitStatusQuery = useGitStatus({ + environmentId: activeThread?.environmentId ?? null, + cwd: activeCwd ?? null, + }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -262,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiffQuery = useQuery( checkpointDiffQueryOptions({ + environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, @@ -321,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const openDiffFileInEditor = useCallback( (filePath: string) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; void openInPreferredEditor(api, targetPath).catch((error) => { @@ -334,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectTurn = (turnId: TurnId) => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1", diffTurnId: turnId }; @@ -345,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectWholeConversation = () => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; @@ -417,12 +429,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const headerRow = ( <>
- {canScrollTurnStripLeft && ( -
- )} - {canScrollTurnStripRight && ( -
- )} - + ); } @@ -177,10 +273,13 @@ describe("GitActionsControl thread-scoped progress toast", () => { afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + activeRunStackedActionDeferredRef.current = createDeferredPromise(); + activeDraftThreadRef.current = null; + hasServerThreadRef.current = true; document.body.innerHTML = ""; }); - it("keeps an in-flight git action toast pinned to the thread that started it", async () => { + it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => { vi.useFakeTimers(); const host = document.createElement("div"); @@ -197,7 +296,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ - data: { threadId: THREAD_A }, + data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), @@ -208,28 +307,153 @@ describe("GitActionsControl thread-scoped progress toast", () => { expect(toastUpdateSpy).toHaveBeenLastCalledWith( "toast-1", expect.objectContaining({ - data: { threadId: THREAD_A }, + data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), ); - const switchThreadButton = findButtonByText("Switch thread"); - expect(switchThreadButton, 'Unable to find button containing "Switch thread"').toBeTruthy(); - if (!(switchThreadButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Switch thread"'); + const switchEnvironmentButton = findButtonByText("Switch environment"); + expect( + switchEnvironmentButton, + 'Unable to find button containing "Switch environment"', + ).toBeTruthy(); + if (!(switchEnvironmentButton instanceof HTMLButtonElement)) { + throw new Error('Unable to find button containing "Switch environment"'); } - switchThreadButton.click(); + switchEnvironmentButton.click(); await vi.advanceTimersByTimeAsync(1_000); expect(toastUpdateSpy).toHaveBeenLastCalledWith( "toast-1", expect.objectContaining({ - data: { threadId: THREAD_A }, + data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), ); + } finally { + activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); + await Promise.resolve(); + vi.useRealTimers(); + await screen.unmount(); + host.remove(); + } + }); + + it("debounces focus-driven git status refreshes", async () => { + vi.useFakeTimers(); + + const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + let visibilityState: DocumentVisibilityState = "hidden"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { + container: host, + }, + ); + + try { + window.dispatchEvent(new Event("focus")); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(249); + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); + expect(refreshGitStatusSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_A, + cwd: GIT_CWD, + }); + } finally { + if (originalVisibilityState) { + Object.defineProperty(document, "visibilityState", originalVisibilityState); + } + vi.useRealTimers(); + await screen.unmount(); + host.remove(); + } + }); + + it("syncs the live branch into the active draft thread when no server thread exists", async () => { + hasServerThreadRef.current = false; + activeDraftThreadRef.current = { + threadId: SHARED_THREAD_ID, + environmentId: ENVIRONMENT_A, + branch: null, + worktreePath: null, + }; + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { + container: host, + }, + ); + + try { + await Promise.resolve(); + + expect(setDraftThreadContextSpy).toHaveBeenCalledWith( + scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), + { + branch: BRANCH_NAME, + worktreePath: null, + }, + ); + expect(setThreadBranchSpy).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + host.remove(); + } + }); + + it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { + hasServerThreadRef.current = false; + activeDraftThreadRef.current = { + threadId: SHARED_THREAD_ID, + environmentId: ENVIRONMENT_A, + branch: "feature/base-branch", + worktreePath: null, + envMode: "worktree", + }; + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { + container: host, + }, + ); + + try { + await Promise.resolve(); + + expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); + expect(setThreadBranchSpy).not.toHaveBeenCalled(); } finally { await screen.unmount(); host.remove(); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 267cbec180..c6a50b82c2 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1020,6 +1020,15 @@ describe("resolveLiveThreadBranchUpdate", () => { assert.equal(update, null); }); + + it("does not regress a semantic thread branch back to a temporary worktree branch", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "t3code/github-query-rate-limit", + gitStatus: status({ branch: "t3code/bda76797" }), + }); + + assert.equal(update, null); + }); }); describe("resolveAutoFeatureBranchName", () => { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index b4b0b98b0b..e4e611fb87 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -3,6 +3,7 @@ import type { GitStackedAction, GitStatusResult, } from "@t3tools/contracts"; +import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -354,6 +355,15 @@ export function resolveLiveThreadBranchUpdate(input: { return null; } + if ( + input.threadBranch !== null && + input.gitStatus.branch !== null && + !isTemporaryWorktreeBranch(input.threadBranch) && + isTemporaryWorktreeBranch(input.gitStatus.branch) + ) { + return null; + } + return { branch: input.gitStatus.branch, }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..6d2312e4aa 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,11 +1,11 @@ +import { type ScopedThreadRef } from "@t3tools/contracts"; import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, GitStatusResult, - ThreadId, } from "@t3tools/contracts"; -import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; @@ -45,17 +45,20 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, - gitStatusQueryOptions, - invalidateGitStatusQuery, } from "~/lib/gitReactQuery"; +import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; -import { readNativeApi } from "~/nativeApi"; +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { readEnvironmentApi } from "~/environmentApi"; +import { readLocalApi } from "~/localApi"; import { useStore } from "~/store"; +import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; - activeThreadId: ThreadId | null; + activeThreadRef: ScopedThreadRef | null; + draftId?: DraftId; } interface PendingDefaultBranchAction { @@ -92,6 +95,8 @@ interface RunGitActionWithToastInput { filePaths?: string[]; } +const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; + function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -205,14 +210,29 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadRef, + draftId, +}: GitActionsControlProps) { + const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( - () => (activeThreadId ? { threadId: activeThreadId } : undefined), - [activeThreadId], + () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), + [activeThreadRef], + ); + const activeServerThreadSelector = useMemo( + () => createThreadSelectorByRef(activeThreadRef), + [activeThreadRef], ); - const activeServerThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + const activeServerThread = useStore(activeServerThreadSelector); + const activeDraftThread = useComposerDraftStore((store) => + draftId + ? store.getDraftSession(draftId) + : activeThreadRef + ? store.getDraftThreadByRef(activeThreadRef) + : null, ); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); @@ -240,27 +260,50 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const persistThreadBranchSync = useCallback( (branch: string | null) => { - if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) { + if (!activeThreadRef) { + return; + } + + if (activeServerThread) { + if (activeServerThread.branch === branch) { + return; + } + + const worktreePath = activeServerThread.worktreePath; + const api = readEnvironmentApi(activeThreadRef.environmentId); + if (api) { + void api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadRef.threadId, + branch, + worktreePath, + }) + .catch(() => undefined); + } + + setThreadBranch(activeThreadRef, branch, worktreePath); return; } - const worktreePath = activeServerThread.worktreePath; - const api = readNativeApi(); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, - }) - .catch(() => undefined); + if (!activeDraftThread || activeDraftThread.branch === branch) { + return; } - setThreadBranch(activeThreadId, branch, worktreePath); + setDraftThreadContext(draftId ?? activeThreadRef, { + branch, + worktreePath: activeDraftThread.worktreePath, + }); }, - [activeServerThread, activeThreadId, setThreadBranch], + [ + activeDraftThread, + activeServerThread, + activeThreadRef, + draftId, + setDraftThreadContext, + setThreadBranch, + ], ); const syncThreadBranchAfterGitAction = useCallback( @@ -275,7 +318,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); + const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ + environmentId: activeEnvironmentId, + cwd: gitCwd, + }); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; @@ -286,28 +332,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; - const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); + const initMutation = useMutation( + gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), + ); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ + environmentId: activeEnvironmentId, cwd: gitCwd, queryClient, }), ); - const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); + const pullMutation = useMutation( + gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), + ); const isRunStackedActionRunning = - useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; - const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; + useIsMutating({ + mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), + }) > 0; + const isPullRunning = + useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + const isSelectingWorktreeBase = + !activeServerThread && + activeDraftThread?.envMode === "worktree" && + activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning) { + if (isGitActionRunning || isSelectingWorktreeBase) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? null, + threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { @@ -317,8 +375,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions persistThreadBranchSync(branchUpdate.branch); }, [ activeServerThread?.branch, + activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, + isSelectingWorktreeBase, persistThreadBranchSync, ]); @@ -359,8 +419,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }; }, [updateActiveProgressToast]); + useEffect(() => { + if (gitCwd === null) { + return; + } + + let refreshTimeout: number | null = null; + const scheduleRefreshCurrentGitStatus = () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + refreshTimeout = window.setTimeout(() => { + refreshTimeout = null; + void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( + () => undefined, + ); + }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + scheduleRefreshCurrentGitStatus(); + } + }; + + window.addEventListener("focus", scheduleRefreshCurrentGitStatus); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [activeEnvironmentId, gitCwd]); + const openExistingPr = useCallback(async () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) { toastManager.add({ type: "error", @@ -378,7 +473,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); return; } - void api.shell.openExternal(prUrl).catch((err) => { + void api.shell.openExternal(prUrl).catch((err: unknown) => { toastManager.add({ type: "error", title: "Unable to open PR link", @@ -567,7 +662,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions toastActionProps = { children: toastCta.label, onClick: () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) return; closeResultToast(); void api.shell.openExternal(toastCta.url); @@ -726,7 +821,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const openChangedFileInEditor = useCallback( (filePath: string) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api || !gitCwd) { toastManager.add({ type: "error", @@ -801,7 +896,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions { - if (open) void invalidateGitStatusQuery(queryClient, gitCwd); + if (open) { + void refreshGitStatus({ + environmentId: activeEnvironmentId, + cwd: gitCwd, + }).catch(() => undefined); + } }} > ( ); +export const KiroIcon: Icon = (props) => ( + + + + + + +); + export const VisualStudioCode: Icon = (props) => { const id = useId(); const maskId = `${id}-vscode-a`; @@ -128,6 +146,127 @@ export const VisualStudioCode: Icon = (props) => { ); }; +export const VisualStudioCodeInsiders: Icon = (props) => { + const id = useId(); + const maskId = `${id}-vscode-insiders-a`; + const topShadowFilterId = `${id}-vscode-insiders-b`; + const sideShadowFilterId = `${id}-vscode-insiders-c`; + const overlayGradientId = `${id}-vscode-insiders-d`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const VSCodium: Icon = (props) => { + const id = useId(); + const gradientId = `${id}-vscodium-gradient`; + + return ( + + + + + + + + + + ); +}; + export const Zed: Icon = (props) => { const id = useId(); const clipPathId = `${id}-zed-logo-a`; diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 187ecf497a..6449a71587 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -18,13 +19,25 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; -import { __resetNativeApiForTests } from "../nativeApi"; +import { __resetLocalApiForTests } from "../localApi"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { getServerConfig, getServerConfigUpdatedNotification } from "../rpc/serverState"; +import { getWsConnectionStatus } from "../rpc/wsConnectionState"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; +vi.mock("../lib/gitStatusState", () => ({ + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + refreshGitStatus: () => Promise.resolve(null), + resetGitStatusStateForTests: () => undefined, +})); + const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { @@ -40,6 +53,19 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: LOCAL_ENVIRONMENT_ID, + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -54,6 +80,8 @@ function createBaseServerConfig(): ServerConfig { auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], + slashCommands: [], + skills: [], }, ], availableEditors: [], @@ -70,7 +98,7 @@ function createBaseServerConfig(): ServerConfig { textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, - claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, }, }, }; @@ -141,11 +169,55 @@ function createMinimalSnapshot(): OrchestrationReadModel { }; } +function toShellSnapshot(snapshot: OrchestrationReadModel) { + return { + snapshotSequence: snapshot.snapshotSequence, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection, + scripts: project.scripts, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })), + threads: snapshot.threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + session: thread.session, + latestUserMessageAt: + thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + })), + updatedAt: snapshot.updatedAt, + }; +} + function buildFixture(): TestFixture { return { snapshot: createMinimalSnapshot(), serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: LOCAL_ENVIRONMENT_ID, + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, @@ -155,9 +227,6 @@ function buildFixture(): TestFixture { } function resolveWsRpc(tag: string): unknown { - if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { - return fixture.snapshot; - } if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } @@ -170,20 +239,6 @@ function resolveWsRpc(tag: string): unknown { branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } - if (tag === WS_METHODS.gitStatus) { - return { - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], truncated: false }; } @@ -199,6 +254,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); @@ -239,6 +295,22 @@ async function waitForComposerEditor(): Promise { ); } +async function waitForToastViewport(): Promise { + return waitForElement( + () => document.querySelector('[data-slot="toast-viewport"]'), + "App should render the toast viewport before server config updates are pushed", + ); +} + +async function waitForWsConnection(): Promise { + await vi.waitFor( + () => { + expect(getWsConnectionStatus().phase).toBe("connected"); + }, + { timeout: 8_000, interval: 16 }, + ); +} + async function waitForToast(title: string, count = 1): Promise { await vi.waitFor( () => { @@ -258,6 +330,65 @@ async function waitForNoToast(title: string): Promise { ); } +async function waitForNoToasts(): Promise { + await vi.waitFor( + () => { + expect(queryToastTitles()).toHaveLength(0); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForInitialWsSubscriptions(): Promise { + await vi.waitFor( + () => { + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForServerConfigSnapshot(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForServerConfigStreamReady(): Promise { + const previousNotificationId = getServerConfigUpdatedNotification()?.id ?? 0; + for (let attempt = 0; attempt < 20; attempt += 1) { + rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { + version: 1, + type: "settingsUpdated", + payload: { settings: fixture.serverConfig.settings }, + }); + + try { + await vi.waitFor( + () => { + const notification = getServerConfigUpdatedNotification(); + expect(notification?.id).toBeGreaterThan(previousNotificationId); + expect(notification?.source).toBe("settingsUpdated"); + }, + { timeout: 200, interval: 16 }, + ); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + throw new Error("Timed out waiting for the server config stream to deliver updates."); +} + async function mountApp(): Promise<{ cleanup: () => Promise }> { const host = document.createElement("div"); host.style.position = "fixed"; @@ -268,10 +399,23 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { host.style.overflow = "hidden"; document.body.append(host); - const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), + ); - const screen = await render(, { container: host }); + const screen = await render( + + + , + { container: host }, + ); await waitForComposerEditor(); + await waitForToastViewport(); + await waitForInitialWsSubscriptions(); + await waitForWsConnection(); + await waitForServerConfigSnapshot(); + await waitForServerConfigStreamReady(); + await waitForNoToasts(); return { cleanup: async () => { @@ -319,21 +463,42 @@ describe("Keybindings update toast", () => { }, ]; } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { + return [ + { + kind: "snapshot", + snapshot: toShellSnapshot(fixture.snapshot), + }, + ]; + } + if ( + request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && + request.threadId === THREAD_ID + ) { + return [ + { + kind: "snapshot", + snapshot: { + snapshotSequence: fixture.snapshot.snapshotSequence, + thread: fixture.snapshot.threads[0], + }, + }, + ]; + } return []; }, }); - __resetNativeApiForTests(); + await __resetLocalApiForTests(); localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); useStore.setState({ - projects: [], - threads: [], - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, }); }); diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx new file mode 100644 index 0000000000..cd1f76ed2c --- /dev/null +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -0,0 +1,45 @@ +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; +import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; +import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; + +export function NoActiveThreadState() { + return ( + +
+
+ {isElectron ? ( + + No active thread + + ) : ( +
+ + + No active thread + +
+ )} +
+ + +
+ + Pick a thread to continue + + Select an existing thread or create a new one to get started. + + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 01341dc803..489e38f48d 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useState, useCallback } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -24,7 +25,7 @@ import { stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; import { toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -53,6 +54,8 @@ function stepStatusIcon(status: string): React.ReactNode { interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; + label?: string; + environmentId: EnvironmentId; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; @@ -62,6 +65,8 @@ interface PlanSidebarProps { const PlanSidebar = memo(function PlanSidebar({ activePlan, activeProposedPlan, + label = "Plan", + environmentId, markdownCwd, workspaceRoot, timestampFormat, @@ -87,7 +92,7 @@ const PlanSidebar = memo(function PlanSidebar({ }, [planMarkdown]); const handleSaveToWorkspace = useCallback(() => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !workspaceRoot || !planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); setIsSavingToWorkspace(true); @@ -115,7 +120,7 @@ const PlanSidebar = memo(function PlanSidebar({ () => setIsSavingToWorkspace(false), () => setIsSavingToWorkspace(false), ); - }, [planMarkdown, workspaceRoot]); + }, [environmentId, planMarkdown, workspaceRoot]); return (
@@ -126,7 +131,7 @@ const PlanSidebar = memo(function PlanSidebar({ variant="secondary" className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase" > - Plan + {label} {activePlan ? ( @@ -167,7 +172,7 @@ const PlanSidebar = memo(function PlanSidebar({ size="icon-xs" variant="ghost" onClick={onClose} - aria-label="Close plan sidebar" + aria-label={`Close ${label.toLowerCase()} sidebar`} className="text-muted-foreground/50 hover:text-foreground/70" > diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 58426f50ba..38e07f59ce 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,14 +1,19 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { FolderIcon } from "lucide-react"; import { useState } from "react"; -import { resolveServerUrl } from "~/lib/utils"; +import { resolveEnvironmentHttpUrl } from "../environments/runtime"; const loadedProjectFaviconSrcs = new Set(); -export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { - const src = resolveServerUrl({ - protocol: "http", +export function ProjectFavicon(input: { + environmentId: EnvironmentId; + cwd: string; + className?: string; +}) { + const src = resolveEnvironmentHttpUrl({ + environmentId: input.environmentId, pathname: "/api/project-favicon", - searchParams: { cwd }, + searchParams: { cwd: input.cwd }, }); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", @@ -17,12 +22,14 @@ export function ProjectFavicon({ cwd, className }: { cwd: string; className?: st return ( <> {status !== "loaded" ? ( - + ) : null} { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 8fa899343e..6c134f95a0 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,4 @@ -import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; interface PullRequestThreadDialogProps { open: boolean; + environmentId: EnvironmentId; threadId: ThreadId; cwd: string | null; initialReference: string | null; @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { export function PullRequestThreadDialog({ open, + environmentId, threadId, cwd, initialReference, @@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ const parsedDebouncedReference = parsePullRequestReference(debouncedReference); const resolvePullRequestQuery = useQuery( gitResolvePullRequestQueryOptions({ + environmentId, cwd, reference: open ? parsedDebouncedReference : null, }), @@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ const cached = queryClient.getQueryData([ "git", "pull-request", + environmentId, cwd, parsedReference, ]); return cached?.pullRequest ?? null; - }, [cwd, parsedReference, queryClient]); + }, [cwd, environmentId, parsedReference, queryClient]); const preparePullRequestThreadMutation = useMutation( - gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), + gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }), ); const liveResolvedPullRequest = diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..6aa042a169 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createThreadJumpHintVisibilityController, + getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -17,10 +18,9 @@ import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -28,6 +28,8 @@ import { type Thread, } from "../types"; +const localEnvironmentId = EnvironmentId.make("environment-local"); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -120,6 +122,20 @@ describe("createThreadJumpHintVisibilityController", () => { }); }); +describe("getSidebarThreadIdsToPrewarm", () => { + it("returns only the first visible thread ids up to the prewarm limit", () => { + expect(getSidebarThreadIdsToPrewarm(["t1", "t2", "t3"], 2)).toEqual(["t1", "t2"]); + }); + + it("returns all visible thread ids when they fit within the limit", () => { + expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 10)).toEqual(["t1", "t2"]); + }); + + it("returns no thread ids when the limit is zero", () => { + expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 0)).toEqual([]); + }); +}); + describe("shouldClearThreadSelectionOnMouseDown", () => { it("preserves selection for thread items", () => { const child = { @@ -168,6 +184,28 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); describe("resolveSidebarNewThreadSeedContext", () => { + it("prefers the default worktree mode over active thread context", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-1", + defaultEnvMode: "worktree", + activeThread: { + projectId: "project-1", + branch: "feature/existing", + worktreePath: "/repo/.t3/worktrees/existing", + }, + activeDraftThread: { + projectId: "project-1", + branch: "feature/draft", + worktreePath: "/repo/.t3/worktrees/draft", + envMode: "worktree", + }, + }), + ).toEqual({ + envMode: "worktree", + }); + }); + it("inherits the active server thread context when creating a new thread in the same project", () => { expect( resolveSidebarNewThreadSeedContext({ @@ -233,42 +271,42 @@ describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ items: [ - { id: ProjectId.makeUnsafe("project-1"), name: "One" }, - { id: ProjectId.makeUnsafe("project-2"), name: "Two" }, - { id: ProjectId.makeUnsafe("project-3"), name: "Three" }, + { id: ProjectId.make("project-1"), name: "One" }, + { id: ProjectId.make("project-2"), name: "Two" }, + { id: ProjectId.make("project-3"), name: "Three" }, ], preferredIds: [ - ProjectId.makeUnsafe("project-3"), - ProjectId.makeUnsafe("project-missing"), - ProjectId.makeUnsafe("project-1"), + ProjectId.make("project-3"), + ProjectId.make("project-missing"), + ProjectId.make("project-1"), ], getId: (project) => project.id, }); expect(ordered.map((project) => project.id)).toEqual([ - ProjectId.makeUnsafe("project-3"), - ProjectId.makeUnsafe("project-1"), - ProjectId.makeUnsafe("project-2"), + ProjectId.make("project-3"), + ProjectId.make("project-1"), + ProjectId.make("project-2"), ]); }); it("does not duplicate items when preferred ids repeat", () => { const ordered = orderItemsByPreferredIds({ items: [ - { id: ProjectId.makeUnsafe("project-1"), name: "One" }, - { id: ProjectId.makeUnsafe("project-2"), name: "Two" }, + { id: ProjectId.make("project-1"), name: "One" }, + { id: ProjectId.make("project-2"), name: "Two" }, ], preferredIds: [ - ProjectId.makeUnsafe("project-2"), - ProjectId.makeUnsafe("project-1"), - ProjectId.makeUnsafe("project-2"), + ProjectId.make("project-2"), + ProjectId.make("project-1"), + ProjectId.make("project-2"), ], getId: (project) => project.id, }); expect(ordered.map((project) => project.id)).toEqual([ - ProjectId.makeUnsafe("project-2"), - ProjectId.makeUnsafe("project-1"), + ProjectId.make("project-2"), + ProjectId.make("project-1"), ]); }); }); @@ -276,9 +314,9 @@ describe("orderItemsByPreferredIds", () => { describe("resolveAdjacentThreadId", () => { it("resolves adjacent thread ids in ordered sidebar traversal", () => { const threads = [ - ThreadId.makeUnsafe("thread-1"), - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-3"), + ThreadId.make("thread-1"), + ThreadId.make("thread-2"), + ThreadId.make("thread-3"), ]; expect( @@ -325,21 +363,21 @@ describe("getVisibleSidebarThreadIds", () => { getVisibleSidebarThreadIds([ { renderedThreadIds: [ - ThreadId.makeUnsafe("thread-12"), - ThreadId.makeUnsafe("thread-11"), - ThreadId.makeUnsafe("thread-10"), + ThreadId.make("thread-12"), + ThreadId.make("thread-11"), + ThreadId.make("thread-10"), ], }, { - renderedThreadIds: [ThreadId.makeUnsafe("thread-8"), ThreadId.makeUnsafe("thread-6")], + renderedThreadIds: [ThreadId.make("thread-8"), ThreadId.make("thread-6")], }, ]), ).toEqual([ - ThreadId.makeUnsafe("thread-12"), - ThreadId.makeUnsafe("thread-11"), - ThreadId.makeUnsafe("thread-10"), - ThreadId.makeUnsafe("thread-8"), - ThreadId.makeUnsafe("thread-6"), + ThreadId.make("thread-12"), + ThreadId.make("thread-11"), + ThreadId.make("thread-10"), + ThreadId.make("thread-8"), + ThreadId.make("thread-6"), ]); }); @@ -348,17 +386,14 @@ describe("getVisibleSidebarThreadIds", () => { getVisibleSidebarThreadIds([ { shouldShowThreadPanel: false, - renderedThreadIds: [ - ThreadId.makeUnsafe("thread-hidden-2"), - ThreadId.makeUnsafe("thread-hidden-1"), - ], + renderedThreadIds: [ThreadId.make("thread-hidden-2"), ThreadId.make("thread-hidden-1")], }, { shouldShowThreadPanel: true, - renderedThreadIds: [ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")], + renderedThreadIds: [ThreadId.make("thread-12"), ThreadId.make("thread-11")], }, ]), - ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]); + ).toEqual([ThreadId.make("thread-12"), ThreadId.make("thread-11")]); }); }); @@ -572,43 +607,41 @@ describe("getVisibleThreadsForProject", () => { it("includes the active thread even when it falls below the folded preview", () => { const threads = Array.from({ length: 8 }, (_, index) => makeThread({ - id: ThreadId.makeUnsafe(`thread-${index + 1}`), + id: ThreadId.make(`thread-${index + 1}`), title: `Thread ${index + 1}`, }), ); const result = getVisibleThreadsForProject({ threads, - activeThreadId: ThreadId.makeUnsafe("thread-8"), + activeThreadId: ThreadId.make("thread-8"), isThreadListExpanded: false, previewLimit: 6, }); expect(result.hasHiddenThreads).toBe(true); expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-1"), - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-3"), - ThreadId.makeUnsafe("thread-4"), - ThreadId.makeUnsafe("thread-5"), - ThreadId.makeUnsafe("thread-6"), - ThreadId.makeUnsafe("thread-8"), - ]); - expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-7"), + ThreadId.make("thread-1"), + ThreadId.make("thread-2"), + ThreadId.make("thread-3"), + ThreadId.make("thread-4"), + ThreadId.make("thread-5"), + ThreadId.make("thread-6"), + ThreadId.make("thread-8"), ]); + expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ThreadId.make("thread-7")]); }); it("returns all threads when the list is expanded", () => { const threads = Array.from({ length: 8 }, (_, index) => makeThread({ - id: ThreadId.makeUnsafe(`thread-${index + 1}`), + id: ThreadId.make(`thread-${index + 1}`), }), ); const result = getVisibleThreadsForProject({ threads, - activeThreadId: ThreadId.makeUnsafe("thread-8"), + activeThreadId: ThreadId.make("thread-8"), isThreadListExpanded: true, previewLimit: 6, }); @@ -624,7 +657,8 @@ describe("getVisibleThreadsForProject", () => { function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { - id: ProjectId.makeUnsafe("project-1"), + id: ProjectId.make("project-1"), + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -641,9 +675,10 @@ function makeProject(overrides: Partial = {}): Project { function makeThread(overrides: Partial = {}): Thread { return { - id: ThreadId.makeUnsafe("thread-1"), + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, - projectId: ProjectId.makeUnsafe("project-1"), + projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { provider: "codex", @@ -668,212 +703,81 @@ function makeThread(overrides: Partial = {}): Thread { }; } -describe("sortThreadsForSidebar", () => { - it("sorts threads by the latest user message in recency mode", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older", - createdAt: "2026-03-09T10:01:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer", - createdAt: "2026-03-09T10:06:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", - }, - ], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("falls back to thread timestamps when there is no user message", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "assistant", - text: "assistant only", - createdAt: "2026-03-09T10:02:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("can sort threads by createdAt when configured", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - }), - ], - "created_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-1"), - ThreadId.makeUnsafe("thread-2"), - ]); - }); -}); - describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads: [ makeThread({ - id: ThreadId.makeUnsafe("thread-oldest"), - projectId: ProjectId.makeUnsafe("project-1"), + id: ThreadId.make("thread-oldest"), + projectId: ProjectId.make("project-1"), createdAt: "2026-03-09T10:00:00.000Z", messages: [], }), makeThread({ - id: ThreadId.makeUnsafe("thread-active"), - projectId: ProjectId.makeUnsafe("project-1"), + id: ThreadId.make("thread-active"), + projectId: ProjectId.make("project-1"), createdAt: "2026-03-09T10:05:00.000Z", messages: [], }), makeThread({ - id: ThreadId.makeUnsafe("thread-newest"), - projectId: ProjectId.makeUnsafe("project-1"), + id: ThreadId.make("thread-newest"), + projectId: ProjectId.make("project-1"), createdAt: "2026-03-09T10:10:00.000Z", messages: [], }), makeThread({ - id: ThreadId.makeUnsafe("thread-other-project"), - projectId: ProjectId.makeUnsafe("project-2"), + id: ThreadId.make("thread-other-project"), + projectId: ProjectId.make("project-2"), createdAt: "2026-03-09T10:20:00.000Z", messages: [], }), ], - deletedThreadId: ThreadId.makeUnsafe("thread-active"), + deletedThreadId: ThreadId.make("thread-active"), sortOrder: "created_at", }); - expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-newest")); + expect(fallbackThreadId).toBe(ThreadId.make("thread-newest")); }); it("skips other threads being deleted in the same action", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads: [ makeThread({ - id: ThreadId.makeUnsafe("thread-active"), - projectId: ProjectId.makeUnsafe("project-1"), + id: ThreadId.make("thread-active"), + projectId: ProjectId.make("project-1"), createdAt: "2026-03-09T10:05:00.000Z", messages: [], }), makeThread({ - id: ThreadId.makeUnsafe("thread-newest"), - projectId: ProjectId.makeUnsafe("project-1"), + id: ThreadId.make("thread-newest"), + projectId: ProjectId.make("project-1"), createdAt: "2026-03-09T10:10:00.000Z", messages: [], }), makeThread({ - id: ThreadId.makeUnsafe("thread-next"), - projectId: ProjectId.makeUnsafe("project-1"), + id: ThreadId.make("thread-next"), + projectId: ProjectId.make("project-1"), createdAt: "2026-03-09T10:07:00.000Z", messages: [], }), ], - deletedThreadId: ThreadId.makeUnsafe("thread-active"), - deletedThreadIds: new Set([ - ThreadId.makeUnsafe("thread-active"), - ThreadId.makeUnsafe("thread-newest"), - ]), + deletedThreadId: ThreadId.make("thread-active"), + deletedThreadIds: new Set([ThreadId.make("thread-active"), ThreadId.make("thread-newest")]), sortOrder: "created_at", }); - expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-next")); + expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); }); }); - describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ - makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Older project" }), - makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Newer project" }), + makeProject({ id: ProjectId.make("project-1"), name: "Older project" }), + makeProject({ id: ProjectId.make("project-2"), name: "Newer project" }), ]; const threads = [ makeThread({ - projectId: ProjectId.makeUnsafe("project-1"), + projectId: ProjectId.make("project-1"), updatedAt: "2026-03-09T10:20:00.000Z", messages: [ { @@ -887,8 +791,8 @@ describe("sortProjectsForSidebar", () => { ], }), makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - projectId: ProjectId.makeUnsafe("project-2"), + id: ThreadId.make("thread-2"), + projectId: ProjectId.make("project-2"), updatedAt: "2026-03-09T10:05:00.000Z", messages: [ { @@ -906,8 +810,8 @@ describe("sortProjectsForSidebar", () => { const sorted = sortProjectsForSidebar(projects, threads, "updated_at"); expect(sorted.map((project) => project.id)).toEqual([ - ProjectId.makeUnsafe("project-2"), - ProjectId.makeUnsafe("project-1"), + ProjectId.make("project-2"), + ProjectId.make("project-1"), ]); }); @@ -915,12 +819,12 @@ describe("sortProjectsForSidebar", () => { const sorted = sortProjectsForSidebar( [ makeProject({ - id: ProjectId.makeUnsafe("project-1"), + id: ProjectId.make("project-1"), name: "Older project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ - id: ProjectId.makeUnsafe("project-2"), + id: ProjectId.make("project-2"), name: "Newer project", updatedAt: "2026-03-09T10:05:00.000Z", }), @@ -930,8 +834,8 @@ describe("sortProjectsForSidebar", () => { ); expect(sorted.map((project) => project.id)).toEqual([ - ProjectId.makeUnsafe("project-2"), - ProjectId.makeUnsafe("project-1"), + ProjectId.make("project-2"), + ProjectId.make("project-1"), ]); }); @@ -939,13 +843,13 @@ describe("sortProjectsForSidebar", () => { const sorted = sortProjectsForSidebar( [ makeProject({ - id: ProjectId.makeUnsafe("project-2"), + id: ProjectId.make("project-2"), name: "Beta", createdAt: undefined, updatedAt: undefined, }), makeProject({ - id: ProjectId.makeUnsafe("project-1"), + id: ProjectId.make("project-1"), name: "Alpha", createdAt: undefined, updatedAt: undefined, @@ -956,22 +860,22 @@ describe("sortProjectsForSidebar", () => { ); expect(sorted.map((project) => project.id)).toEqual([ - ProjectId.makeUnsafe("project-1"), - ProjectId.makeUnsafe("project-2"), + ProjectId.make("project-1"), + ProjectId.make("project-2"), ]); }); it("preserves manual project ordering", () => { const projects = [ - makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Second" }), - makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "First" }), + makeProject({ id: ProjectId.make("project-2"), name: "Second" }), + makeProject({ id: ProjectId.make("project-1"), name: "First" }), ]; const sorted = sortProjectsForSidebar(projects, [], "manual"); expect(sorted.map((project) => project.id)).toEqual([ - ProjectId.makeUnsafe("project-2"), - ProjectId.makeUnsafe("project-1"), + ProjectId.make("project-2"), + ProjectId.make("project-1"), ]); }); @@ -979,26 +883,26 @@ describe("sortProjectsForSidebar", () => { const sorted = sortProjectsForSidebar( [ makeProject({ - id: ProjectId.makeUnsafe("project-1"), + id: ProjectId.make("project-1"), name: "Visible project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ - id: ProjectId.makeUnsafe("project-2"), + id: ProjectId.make("project-2"), name: "Archived-only project", updatedAt: "2026-03-09T10:00:00.000Z", }), ], [ makeThread({ - id: ThreadId.makeUnsafe("thread-visible"), - projectId: ProjectId.makeUnsafe("project-1"), + id: ThreadId.make("thread-visible"), + projectId: ProjectId.make("project-1"), updatedAt: "2026-03-09T10:02:00.000Z", archivedAt: null, }), makeThread({ - id: ThreadId.makeUnsafe("thread-archived"), - projectId: ProjectId.makeUnsafe("project-2"), + id: ThreadId.make("thread-archived"), + projectId: ProjectId.make("project-2"), updatedAt: "2026-03-09T10:10:00.000Z", archivedAt: "2026-03-09T10:11:00.000Z", }), @@ -1007,8 +911,8 @@ describe("sortProjectsForSidebar", () => { ); expect(sorted.map((project) => project.id)).toEqual([ - ProjectId.makeUnsafe("project-1"), - ProjectId.makeUnsafe("project-2"), + ProjectId.make("project-1"), + ProjectId.make("project-2"), ]); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..39b759ac31 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,11 +1,20 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, + type ThreadSortInput, +} from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +// Visible sidebar rows are prewarmed into the thread-detail cache so opening a +// nearby thread usually reuses an already-hot subscription. +export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -13,10 +22,6 @@ type SidebarProject = { createdAt?: string | undefined; updatedAt?: string | undefined; }; -type SidebarThreadSortInput = Pick & { - latestUserMessageAt?: string | null; - messages?: Pick[]; -}; export type ThreadTraversalDirection = "previous" | "next"; @@ -181,6 +186,12 @@ export function resolveSidebarNewThreadSeedContext(input: { worktreePath?: string | null; envMode: SidebarNewThreadEnvMode; } { + if (input.defaultEnvMode === "worktree") { + return { + envMode: "worktree", + }; + } + if (input.activeDraftThread?.projectId === input.projectId) { return { branch: input.activeDraftThread.branch, @@ -241,6 +252,13 @@ export function getVisibleSidebarThreadIds( ); } +export function getSidebarThreadIdsToPrewarm( + visibleThreadIds: readonly TThreadId[], + limit = SIDEBAR_THREAD_PREWARM_LIMIT, +): TThreadId[] { + return visibleThreadIds.slice(0, Math.max(0, limit)); +} + export function resolveAdjacentThreadId(input: { threadIds: readonly T[]; currentThreadId: T | null; @@ -441,61 +459,8 @@ export function getVisibleThreadsForProject>(input: }; } -function toSortableTimestamp(iso: string | undefined): number | null { - if (!iso) return null; - const ms = Date.parse(iso); - return Number.isFinite(ms) ? ms : null; -} - -function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { - if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; - } - - let latestUserMessageTimestamp: number | null = null; - - for (const message of thread.messages ?? []) { - if (message.role !== "user") continue; - const messageTimestamp = toSortableTimestamp(message.createdAt); - if (messageTimestamp === null) continue; - latestUserMessageTimestamp = - latestUserMessageTimestamp === null - ? messageTimestamp - : Math.max(latestUserMessageTimestamp, messageTimestamp); - } - - if (latestUserMessageTimestamp !== null) { - return latestUserMessageTimestamp; - } - - return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; -} - -function getThreadSortTimestamp( - thread: SidebarThreadSortInput, - sortOrder: SidebarThreadSortOrder | Exclude, -): number { - if (sortOrder === "created_at") { - return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY; - } - return getLatestUserMessageTimestamp(thread); -} - -export function sortThreadsForSidebar< - T extends Pick & SidebarThreadSortInput, ->(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { - return threads.toSorted((left, right) => { - const rightTimestamp = getThreadSortTimestamp(right, sortOrder); - const leftTimestamp = getThreadSortTimestamp(left, sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - if (byTimestamp !== 0) return byTimestamp; - return right.id.localeCompare(left.id); - }); -} - export function getFallbackThreadIdAfterDelete< - T extends Pick & SidebarThreadSortInput, + T extends Pick & ThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; @@ -509,7 +474,7 @@ export function getFallbackThreadIdAfterDelete< } return ( - sortThreadsForSidebar( + sortThreads( threads.filter( (thread) => thread.projectId === deletedThread.projectId && @@ -520,10 +485,9 @@ export function getFallbackThreadIdAfterDelete< )[0]?.id ?? null ); } - export function getProjectSortTimestamp( project: SidebarProject, - projectThreads: readonly SidebarThreadSortInput[], + projectThreads: readonly ThreadSortInput[], sortOrder: Exclude, ): number { if (projectThreads.length > 0) { @@ -541,7 +505,7 @@ export function getProjectSortTimestamp( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & SidebarThreadSortInput, + TThread extends Pick & ThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..cff71cf62a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,9 +2,10 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, - FolderIcon, + CloudIcon, GitPullRequestIcon, PlusIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -12,20 +13,7 @@ import { } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type Dispatch, - type KeyboardEvent, - type MouseEvent, - type MutableRefObject, - type PointerEvent, - type ReactNode, - type SetStateAction, -} from "react"; +import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -43,23 +31,40 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, + type ScopedProjectRef, + type ScopedThreadRef, + type ThreadEnvMode, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; -import { useQueries } from "@tanstack/react-query"; -import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { + parseScopedThreadKey, + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; +import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; -import { useStore } from "../store"; +import { isMacPlatform, newCommandId } from "../lib/utils"; +import { + selectProjectByRef, + selectProjectsAcrossEnvironments, + selectSidebarThreadsForProjectRef, + selectSidebarThreadsForProjectRefs, + selectSidebarThreadsAcrossEnvironments, + selectThreadByRef, + useStore, +} from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -70,15 +75,22 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { gitStatusQueryOptions } from "../lib/gitReactQuery"; -import { readNativeApi } from "../nativeApi"; +import { useGitStatus } from "../lib/gitStatusState"; +import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; -import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions"; +import { + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -97,7 +109,6 @@ import { SidebarFooter, SidebarGroup, SidebarHeader, - SidebarMenuAction, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -108,10 +119,9 @@ import { SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { - getVisibleSidebarThreadIds, - getVisibleThreadsForProject, + getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -122,15 +132,22 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, useThreadJumpHintVisibility, + ThreadStatusPill, } from "./Sidebar.logic"; +import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { CommandDialogTrigger } from "./ui/command"; +import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { useSidebarThreadSummaryById } from "../storeSelectors"; -import type { Project } from "../types"; +import { deriveLogicalProjectKey } from "../logicalProject"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import type { Project, SidebarThreadSummary } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -145,9 +162,64 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; +const EMPTY_THREAD_JUMP_LABELS = new Map(); + +function threadJumpLabelMapsEqual( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left === right) { + return true; + } + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +function buildThreadJumpLabelMap(input: { + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} + +type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; type SidebarProjectSnapshot = Project & { - expanded: boolean; + projectKey: string; + environmentPresence: EnvironmentPresence; + memberProjectRefs: readonly ScopedProjectRef[]; + /** Labels for remote environments this project lives in. */ + remoteEnvironmentLabels: readonly string[]; }; interface TerminalStatusIndicator { label: "Terminal process running"; @@ -168,7 +240,7 @@ function ThreadStatusLabel({ status, compact = false, }: { - status: NonNullable>; + status: ThreadStatusPill; compact?: boolean; }) { if (compact) { @@ -245,55 +317,116 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { return null; } +function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + interface SidebarThreadRowProps { - threadId: ThreadId; - orderedProjectThreadIds: readonly ThreadId[]; - routeThreadId: ThreadId | null; - selectedThreadIds: ReadonlySet; - showThreadJumpHints: boolean; + thread: SidebarThreadSummary; + projectCwd: string | null; + orderedProjectThreadKeys: readonly string[]; + isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; - renamingThreadId: ThreadId | null; + renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; - renamingInputRef: MutableRefObject; - renamingCommittedRef: MutableRefObject; - confirmingArchiveThreadId: ThreadId | null; - setConfirmingArchiveThreadId: Dispatch>; - confirmArchiveButtonRefs: MutableRefObject>; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; handleThreadClick: ( - event: MouseEvent, - threadId: ThreadId, - orderedProjectThreadIds: readonly ThreadId[], + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], ) => void; - navigateToThread: (threadId: ThreadId) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; - commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; cancelRename: () => void; - attemptArchiveThread: (threadId: ThreadId) => Promise; - openPrLink: (event: MouseEvent, prUrl: string) => void; - pr: ThreadPr | null; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; } -function SidebarThreadRow(props: SidebarThreadRowProps) { - const thread = useSidebarThreadSummaryById(props.threadId); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { + orderedProjectThreadKeys, + isActive, + jumpLabel, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + thread, + } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); - - if (!thread) { - return null; - } - - const isActive = props.routeThreadId === thread.id; - const isSelected = props.selectedThreadIds.has(thread.id); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (s) => s.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + // For grouped projects, the thread may belong to a different environment + // than the representative project. Look up the thread's own project cwd + // so git status (and thus PR detection) queries the correct path. + const threadProjectCwd = useStore( + useMemo( + () => (state: import("../store").AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -303,34 +436,176 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { lastVisitedAt, }, }); - const prStatus = prStatusIndicator(props.pr); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; + const clearConfirmingArchive = useCallback(() => { + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); + }, [setConfirmingArchiveThreadKey, threadKey]); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + handleThreadClick(event, threadRef, orderedProjectThreadKeys); + }, + [handleThreadClick, orderedProjectThreadKeys, threadRef], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, + threadRef, + ], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(threadRef, renamingTitle, thread.title); + } + }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const handleConfirmArchiveRef = useCallback( + (element: HTMLButtonElement | null) => { + if (element) { + confirmArchiveButtonRefs.current.set(threadKey, element); + } else { + confirmArchiveButtonRefs.current.delete(threadKey); + } + }, + [confirmArchiveButtonRefs, threadKey], + ); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, clearConfirmingArchive, threadRef], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmingArchiveThreadKey(threadKey); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + }); + }, + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, threadRef], + ); + const rowButtonRender = useMemo(() =>
, []); return ( { - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }} - onBlurCapture={(event) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }); - }} + onMouseLeave={handleMouseLeave} + onBlurCapture={handleBlurCapture} > } + render={rowButtonRender} size="sm" isActive={isActive} data-testid={`thread-row-${thread.id}`} @@ -338,31 +613,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { isActive, isSelected, })} relative isolate`} - onClick={(event) => { - props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - props.navigateToThread(thread.id); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { - void props.handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (props.selectedThreadIds.size > 0) { - props.clearSelection(); - } - void props.handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} + onClick={handleRowClick} + onKeyDown={handleRowKeyDown} + onContextMenu={handleRowContextMenu} >
{prStatus && ( @@ -373,9 +626,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { type="button" aria-label={prStatus.tooltip} className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} - onClick={(event) => { - props.openPrLink(event, prStatus.url); - }} + onClick={handlePrClick} > @@ -385,39 +636,32 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { )} {threadStatus && } - {props.renamingThreadId === thread.id ? ( + {renamingThreadKey === threadKey ? ( { - if (element && props.renamingInputRef.current !== element) { - props.renamingInputRef.current = element; - element.focus(); - element.select(); - } - }} + ref={handleRenameInputRef} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={props.renamingTitle} - onChange={(event) => props.setRenamingTitle(event.target.value)} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - props.renamingCommittedRef.current = true; - void props.commitRename(thread.id, props.renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - props.renamingCommittedRef.current = true; - props.cancelRename(); - } - }} - onBlur={() => { - if (!props.renamingCommittedRef.current) { - void props.commitRename(thread.id, props.renamingTitle, thread.title); - } - }} - onClick={(event) => event.stopPropagation()} + value={renamingTitle} + onChange={handleRenameInputChange} + onKeyDown={handleRenameInputKeyDown} + onBlur={handleRenameInputBlur} + onClick={handleRenameInputClick} /> ) : ( - {thread.title} + + + {thread.title} + + } + /> + + {thread.title} + + )}
@@ -434,34 +678,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( - props.appSettingsConfirmThreadArchive ? ( + appSettingsConfirmThreadArchive ? (
@@ -495,14 +715,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { data-testid={`thread-archive-${thread.id}`} aria-label={`Archive ${thread.title}`} className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" - onPointerDown={(event) => { - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void props.attemptArchiveThread(thread.id); - }} + onPointerDown={stopPropagationOnPointerDown} + onClick={handleArchiveImmediateClick} > @@ -514,307 +728,331 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { ) ) : null} - {props.showThreadJumpHints && props.jumpLabel ? ( - - {props.jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} + + {isRemoteThread && ( + + + } + > + + + {threadEnvironmentLabel} + + )} + {jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + )} +
); +}); + +interface SidebarProjectThreadListProps { + projectKey: string; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadStatus: ThreadStatusPill | null; + orderedProjectThreadKeys: readonly string[]; + renderedThreads: readonly SidebarThreadSummary[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + projectCwd: string; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; } -function T3Wordmark() { - return ( - - - - ); -} - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( + props: SidebarProjectThreadListProps, +) { + const { + projectKey, + projectExpanded, + hasOverflowingThreads, + hiddenThreadStatus, + orderedProjectThreadKeys, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + projectCwd, + activeRouteThreadKey, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + expandThreadListForProject, + collapseThreadListForProject, + } = props; + const showMoreButtonRender = useMemo(() => +
+ } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
+ + + + ); +}); - const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ); +const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { + return ( + + + + ); +}); - if (clicked === "mark-unread") { - for (const id of ids) { - const thread = sidebarThreadsById[id]; - markThreadUnread(id, thread?.latestTurn?.completedAt); - } - clearSelection(); - return; - } +function T3Wordmark() { + return ( + + + + ); +} - if (clicked !== "delete") return; +type SortableProjectHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" +>; - 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; - } +function ProjectSortMenu({ + projectSortOrder, + threadSortOrder, + onProjectSortOrderChange, + onThreadSortOrderChange, +}: { + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; +}) { + return ( + + + + } + > + + + Sort projects + + + +
+ Sort projects +
+ { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
+ +
+ Sort threads +
+ { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
+
+
+ ); +} - const deletedIds = new Set(ids); - for (const id of ids) { - await deleteThread(id, { deletedThreadIds: deletedIds }); - } - removeFromSelection(ids); +function SortableProjectItem({ + projectId, + disabled = false, + children, +}: { + projectId: string; + disabled?: boolean; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); + return ( +
  • + {children({ attributes, listeners, setActivatorNodeRef })} +
  • + ); +} + +const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + const wordmark = ( +
    + + + + + + Code + + + {APP_STAGE_LABEL} + + + } + /> + + Version {APP_VERSION} + + +
    + ); + + return isElectron ? ( + + {wordmark} + + ) : ( + {wordmark} + ); +}); + +const SidebarChromeFooter = memo(function SidebarChromeFooter() { + const navigate = useNavigate(); + const handleSettingsClick = useCallback(() => { + void navigate({ to: "/settings" }); + }, [navigate]); + + return ( + + + + + + + Settings + + + + + ); +}); + +interface SidebarProjectsContentProps { + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + updateSettings: ReturnType["updateSettings"]; + openAddProject: () => void; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + handleNewThread: ReturnType["handleNewThread"]; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + sortedProjects: readonly SidebarProjectSnapshot[]; + expandedThreadListsByProject: ReadonlySet; + activeRouteProjectKey: string | null; + routeThreadKey: string | null; + newThreadShortcutLabel: string | null; + commandPaletteShortcutLabel: string | null; + threadJumpLabelByKey: ReadonlyMap; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; +} + +const SidebarProjectsContent = memo(function SidebarProjectsContent( + props: SidebarProjectsContentProps, +) { + const { + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + updateSettings, + openAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + handleNewThread, + archiveThread, + deleteThread, + sortedProjects, + expandedThreadListsByProject, + activeRouteProjectKey, + routeThreadKey, + newThreadShortcutLabel, + commandPaletteShortcutLabel, + threadJumpLabelByKey, + attachThreadListAutoAnimateRef, + expandThreadListForProject, + collapseThreadListForProject, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); }, - [ - appSettings.confirmThreadDelete, - clearSelection, - deleteThread, - markThreadUnread, - removeFromSelection, - selectedThreadIds, - sidebarThreadsById, - ], + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], ); - 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; + return ( + + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + +
    + + Projects + +
    + + + + } + > + + + Add project + +
    +
    - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadId); - return; - } + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} - if (isShiftClick) { - event.preventDefault(); - rangeSelectTo(threadId, orderedProjectThreadIds); - return; + {projectsLength === 0 && ( +
    + No projects yet +
    + )} +
    +
    + ); +}); + +export default function Sidebar() { + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const pathname = useLocation({ select: (loc) => loc.pathname }); + const isOnSettings = pathname.startsWith("/settings"); + const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const { updateSettings } = useUpdateSettings(); + const { handleNewThread } = useNewThreadHandler(); + const { archiveThread, deleteThread } = useThreadActions(); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const keybindings = useServerKeybindings(); + const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const platform = navigator.platform; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + }); + }, [projectOrder, projects]); + + // Build a mapping from physical project key → logical project key for + // cross-environment grouping. Projects that share a repositoryIdentity + // canonicalKey are treated as one logical project in the sidebar. + const physicalToLogicalKey = useMemo(() => { + const mapping = new Map(); + for (const project of orderedProjects) { + const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + mapping.set(physicalKey, deriveLogicalProjectKey(project)); + } + return mapping; + }, [orderedProjects]); + + const sidebarProjects = useMemo(() => { + // Group projects by logical key while preserving insertion order from + // orderedProjects. + const groupedMembers = new Map(); + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(project); + } else { + groupedMembers.set(logicalKey, [project]); } + } - // Plain click — clear selection, set anchor for future shift-clicks, and navigate - if (selectedThreadIds.size > 0) { - clearSelection(); + const result: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + if (seen.has(logicalKey)) continue; + seen.add(logicalKey); + + const members = groupedMembers.get(logicalKey)!; + // Prefer the primary environment's project as the representative. + const representative: Project | undefined = + (primaryEnvironmentId + ? members.find((p) => p.environmentId === primaryEnvironmentId) + : undefined) ?? members[0]; + if (!representative) continue; + const hasLocal = + primaryEnvironmentId !== null && + members.some((p) => p.environmentId === primaryEnvironmentId); + const hasRemote = + primaryEnvironmentId !== null + ? members.some((p) => p.environmentId !== primaryEnvironmentId) + : false; + + const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); + const remoteLabels = members + .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) + .map((p) => { + const rt = savedEnvironmentRuntimeById[p.environmentId]; + const saved = savedEnvironmentRegistry[p.environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; + }); + const snapshot: SidebarProjectSnapshot = { + id: representative.id, + environmentId: representative.environmentId, + name: representative.name, + cwd: representative.cwd, + repositoryIdentity: representative.repositoryIdentity ?? null, + defaultModelSelection: representative.defaultModelSelection, + createdAt: representative.createdAt, + updatedAt: representative.updatedAt, + scripts: representative.scripts, + projectKey: logicalKey, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjectRefs: refs, + remoteEnvironmentLabels: remoteLabels, + }; + result.push(snapshot); + } + return result; + }, [ + orderedProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + const sidebarThreadByKey = useMemo( + () => + new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreads], + ); + // Resolve the active route's project key to a logical key so it matches the + // sidebar's grouped project entries. + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadKey) { + return null; + } + const activeThread = sidebarThreadByKey.get(routeThreadKey); + if (!activeThread) return null; + const physicalKey = scopedProjectKey( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + + // Group threads by logical project key so all threads from grouped projects + // are displayed together. + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreads) { + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); } - setSelectionAnchor(threadId); - void navigate({ - to: "/$threadId", - params: { threadId }, - }); - }, - [ - clearSelection, - navigate, - rangeSelectTo, - selectedThreadIds.size, - setSelectionAnchor, - toggleThreadSelection, - ], + } + return next; + }, [sidebarThreads, physicalToLogicalKey]); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const newThreadShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }), + [platform], ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); const navigateToThread = useCallback( - (threadId: ThreadId) => { - if (selectedThreadIds.size > 0) { + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } - setSelectionAnchor(threadId); + setSelectionAnchor(scopedThreadKey(threadRef)); void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), }); }, - [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], - ); - - const handleProjectContextMenu = useCallback( - async (projectId: ProjectId, position: { x: number; y: number }) => { - const api = readNativeApi(); - if (!api) return; - const project = projects.find((entry) => entry.id === projectId); - if (!project) return; - - const clicked = await api.contextMenu.show( - [ - { id: "copy-path", label: "Copy Project Path" }, - { id: "delete", label: "Remove project", destructive: true }, - ], - position, - ); - if (clicked === "copy-path") { - copyPathToClipboard(project.cwd, { path: project.cwd }); - return; - } - if (clicked !== "delete") return; - - const projectThreadIds = threadIdsByProjectId[projectId] ?? []; - if (projectThreadIds.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - }); - return; - } - - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - - try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); - } - clearProjectDraftThreadId(projectId); - await api.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); - } - }, - [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - copyPathToClipboard, - getDraftThreadByProjectId, - projects, - threadIdsByProjectId, - ], + [clearSelection, navigate, setSelectionAnchor], ); const projectDnDSensors = useSensors( @@ -1312,30 +2497,32 @@ export default function Sidebar() { const handleProjectDragEnd = useCallback( (event: DragEndEvent) => { - if (appSettings.sidebarProjectSortOrder !== "manual") { + if (sidebarProjectSortOrder !== "manual") { dragInProgressRef.current = false; return; } dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.id === active.id); - const overProject = sidebarProjects.find((project) => project.id === over.id); + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - reorderProjects(activeProject.id, overProject.id); + const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey); + const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey); + reorderProjects(activeMemberKeys, overMemberKeys); }, - [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], + [sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); const handleProjectDragStart = useCallback( (_event: DragStartEvent) => { - if (appSettings.sidebarProjectSortOrder !== "manual") { + if (sidebarProjectSortOrder !== "manual") { return; } dragInProgressRef.current = true; suppressProjectClickAfterDragRef.current = true; }, - [appSettings.sidebarProjectSortOrder], + [sidebarProjectSortOrder], ); const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { @@ -1360,148 +2547,179 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const handleProjectTitlePointerDownCapture = useCallback( - (event: PointerEvent) => { - suppressProjectClickForContextMenuRef.current = false; - if ( - isContextMenuPointerDown({ - button: event.button, - ctrlKey: event.ctrlKey, - isMac: isMacPlatform(navigator.platform), - }) - ) { - // Keep context-menu gestures from arming the sortable drag sensor. - event.stopPropagation(); - } - - suppressProjectClickAfterDragRef.current = false; - }, - [], - ); - const visibleThreads = useMemo( () => sidebarThreads.filter((thread) => thread.archivedAt === null), [sidebarThreads], ); - const sortedProjects = useMemo( - () => - sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), - [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], - ); - const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; - const renderedProjects = useMemo( + const sortedProjects = useMemo(() => { + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = visibleThreads.map((thread) => { + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + return { + ...thread, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + }; + }); + return sortProjectsForSidebar( + sortableProjects, + sortableThreads, + sidebarProjectSortOrder, + ).flatMap((project) => { + const resolvedProject = sidebarProjectByKey.get(project.id); + return resolvedProject ? [resolvedProject] : []; + }); + }, [ + sidebarProjectSortOrder, + physicalToLogicalKey, + sidebarProjectByKey, + sidebarProjects, + visibleThreads, + ]); + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const visibleSidebarThreadKeys = useMemo( () => - sortedProjects.map((project) => { - const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => - resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt: threadLastVisitedAtById[thread.id], - }, - }); - const projectThreads = sortThreadsForSidebar( - (threadIdsByProjectId[project.id] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), - appSettings.sidebarThreadSortOrder, - ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => resolveProjectThreadStatus(thread)), + sortedProjects.flatMap((project) => { + const projectThreads = sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, ); - const activeThreadId = routeThreadId ?? undefined; - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const projectExpanded = projectExpandedById[project.projectKey] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; - const { - hasHiddenThreads, - hiddenThreads, - visibleThreads: visibleProjectThreads, - } = getVisibleThreadsForProject({ - threads: projectThreads, - activeThreadId, - isThreadListExpanded, - previewLimit: THREAD_PREVIEW_LIMIT, - }); - const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreadIds = pinnedCollapsedThread - ? [pinnedCollapsedThread.id] - : visibleProjectThreads.map((thread) => thread.id); - const showEmptyThreadState = project.expanded && projectThreads.length === 0; - - return { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - }; }), [ - appSettings.sidebarThreadSortOrder, + sidebarThreadSortOrder, expandedThreadListsByProject, - routeThreadId, + projectExpandedById, + routeThreadKey, sortedProjects, - sidebarThreadsById, - threadIdsByProjectId, - threadLastVisitedAtById, + threadsByProjectKey, ], ); - const visibleSidebarThreadIds = useMemo( - () => getVisibleSidebarThreadIds(renderedProjects), - [renderedProjects], - ); - const threadJumpCommandById = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); if (!jumpCommand) { return mapping; } - mapping.set(threadId, jumpCommand); + mapping.set(threadKey, jumpCommand); } return mapping; - }, [visibleSidebarThreadIds]); - const threadJumpThreadIds = useMemo( - () => [...threadJumpCommandById.keys()], - [threadJumpCommandById], - ); - const threadJumpLabelById = useMemo(() => { - const mapping = new Map(); - for (const [threadId, command] of threadJumpCommandById) { - const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); - if (label) { - mapping.set(threadId, label); + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const [threadJumpLabelByKey, setThreadJumpLabelByKey] = + useState>(EMPTY_THREAD_JUMP_LABELS); + const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const prewarmedSidebarThreadKeys = useMemo( + () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const prewarmedSidebarThreadRefs = useMemo( + () => + prewarmedSidebarThreadKeys.flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref ? [ref] : []; + }), + [prewarmedSidebarThreadKeys], + ); + + useEffect(() => { + const releases = prewarmedSidebarThreadRefs.map((ref) => + retainThreadDetailSubscription(ref.environmentId, ref.threadId), + ); + + return () => { + for (const release of releases) { + release(); } - } - return mapping; - }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); - const orderedSidebarThreadIds = visibleSidebarThreadIds; + }; + }, [prewarmedSidebarThreadRefs]); useEffect(() => { - const getShortcutContext = () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - }); + const clearThreadJumpHints = () => { + setThreadJumpLabelByKey((current) => + current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, + ); + updateThreadJumpHintsVisibility(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { - platform, - context: getShortcutContext(), - }), - ); + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + setThreadJumpLabelByKey((current) => { + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(true); + } if (event.defaultPrevented || event.repeat) { return; @@ -1509,22 +2727,26 @@ export default function Sidebar() { const command = resolveShortcutCommand(event, keybindings, { platform, - context: getShortcutContext(), + context: shortcutContext, }); const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { - const targetThreadId = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadIds, - currentThreadId: routeThreadId, + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, direction: traversalDirection, }); - if (!targetThreadId) { + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(targetThreadId); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return; } @@ -1533,27 +2755,47 @@ export default function Sidebar() { return; } - const targetThreadId = threadJumpThreadIds[jumpIndex]; - if (!targetThreadId) { + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(targetThreadId); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + setThreadJumpLabelByKey((current) => { + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, platform, - context: getShortcutContext(), - }), - ); + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(true); }; const onWindowBlur = () => { - updateThreadJumpHintsVisibility(false); + clearThreadJumpHints(); }; window.addEventListener("keydown", onWindowKeyDown); @@ -1566,266 +2808,21 @@ export default function Sidebar() { window.removeEventListener("blur", onWindowBlur); }; }, [ + getCurrentSidebarShortcutContext, keybindings, navigateToThread, - orderedSidebarThreadIds, + orderedSidebarThreadKeys, platform, - routeTerminalOpen, - routeThreadId, - threadJumpThreadIds, + routeThreadKey, + sidebarThreadByKey, + threadJumpCommandByKey, + threadJumpThreadKeys, updateThreadJumpHintsVisibility, ]); - function renderProjectItem( - renderedProject: (typeof renderedProjects)[number], - dragHandleProps: SortableProjectHandleProps | null, - ) { - const { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - } = renderedProject; - return ( - <> -
    - handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - suppressProjectClickForContextMenuRef.current = true; - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - {!project.expanded && projectStatus ? ( - - - - } - showOnHover - className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, - defaultEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), - activeThread: - activeThread && activeThread.projectId === project.id - ? { - projectId: activeThread.projectId, - branch: activeThread.branch, - worktreePath: activeThread.worktreePath, - } - : null, - activeDraftThread: - activeDraftThread && activeDraftThread.projectId === project.id - ? { - projectId: activeDraftThread.projectId, - branch: activeDraftThread.branch, - worktreePath: activeDraftThread.worktreePath, - envMode: activeDraftThread.envMode, - } - : null, - }); - void handleNewThread(project.id, { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, - }); - }} - > - - - } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - -
    - - - {shouldShowThreadPanel && showEmptyThreadState ? ( - -
    - No threads yet -
    -
    - ) : null} - {shouldShowThreadPanel && - renderedThreadIds.map((threadId) => ( - - ))} - - {project.expanded && 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); - }} - > - - {hiddenThreadStatus && } - Show more - - - - )} - {project.expanded && 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 - - - )} -
    - - ); - } - - const handleProjectTitleClick = useCallback( - (event: MouseEvent, projectId: ProjectId) => { - if (suppressProjectClickForContextMenuRef.current) { - suppressProjectClickForContextMenuRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - 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: KeyboardEvent, projectId: ProjectId) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (dragInProgressRef.current) { - return; - } - toggleProject(projectId); - }, - [toggleProject], - ); - useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadIds.size === 0) return; + if (selectedThreadCount === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; if (!shouldClearThreadSelectionOnMouseDown(target)) return; clearSelection(); @@ -1835,7 +2832,7 @@ export default function Sidebar() { return () => { window.removeEventListener("mousedown", onMouseDown); }; - }, [clearSelection, selectedThreadIds.size]); + }, [clearSelection, selectedThreadCount]); useEffect(() => { if (!isElectron) return; @@ -1880,10 +2877,11 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); - + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + newThreadShortcutLabelOptions, + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -1946,246 +2944,70 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectId: ProjectId) => { + const expandThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { - if (current.has(projectId)) return current; + if (current.has(projectKey)) return current; const next = new Set(current); - next.add(projectId); + next.add(projectKey); return next; }); }, []); - const collapseThreadListForProject = useCallback((projectId: ProjectId) => { + const collapseThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { - if (!current.has(projectId)) return current; + if (!current.has(projectKey)) return current; const next = new Set(current); - next.delete(projectId); + next.delete(projectKey); return next; }); }, []); - const wordmark = ( -
    - - - - - - Code - - - {APP_STAGE_LABEL} - - - } - /> - - Version {APP_VERSION} - - -
    - ); - return ( <> - {isElectron ? ( - - {wordmark} - - ) : ( - - {wordmark} - - )} + {isOnSettings ? ( ) : ( <> - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
    - - Projects - -
    - { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }} - onThreadSortOrderChange={(sortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }} - /> - - - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
    -
    - {shouldShowProjectPathEntry && ( -
    - {isElectron && ( - - )} -
    - { - setNewCwd(event.target.value); - setAddProjectError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }} - autoFocus - /> - -
    - {addProjectError && ( -

    - {addProjectError} -

    - )} -
    - )} - - {isManualProjectSorting ? ( - - - renderedProject.project.id)} - strategy={verticalListSortingStrategy} - > - {renderedProjects.map((renderedProject) => ( - - {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} - - ))} - - - - ) : ( - - {renderedProjects.map((renderedProject) => ( - - {renderProjectItem(renderedProject, null)} - - ))} - - )} - - {projects.length === 0 && !shouldShowProjectPathEntry && ( -
    - No projects yet -
    - )} -
    -
    + - - - - - void navigate({ to: "/settings" })} - > - - Settings - - - - + )} diff --git a/apps/web/src/components/SplashScreen.tsx b/apps/web/src/components/SplashScreen.tsx new file mode 100644 index 0000000000..a0b593a950 --- /dev/null +++ b/apps/web/src/components/SplashScreen.tsx @@ -0,0 +1,9 @@ +export function SplashScreen() { + return ( +
    +
    + T3 Code +
    +
    + ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx new file mode 100644 index 0000000000..37e0df1cc4 --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -0,0 +1,318 @@ +import "../index.css"; + +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const { + terminalConstructorSpy, + terminalDisposeSpy, + fitAddonFitSpy, + fitAddonLoadSpy, + environmentApiById, + readEnvironmentApiMock, + readLocalApiMock, +} = vi.hoisted(() => ({ + terminalConstructorSpy: vi.fn(), + terminalDisposeSpy: vi.fn(), + fitAddonFitSpy: vi.fn(), + fitAddonLoadSpy: vi.fn(), + environmentApiById: new Map } }>(), + readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), + readLocalApiMock: vi.fn< + () => + | { + contextMenu: { show: ReturnType }; + shell: { openExternal: ReturnType }; + } + | undefined + >(() => ({ + contextMenu: { show: vi.fn(async () => null) }, + shell: { openExternal: vi.fn(async () => undefined) }, + })), +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class MockFitAddon { + fit = fitAddonFitSpy; + }, +})); + +vi.mock("@xterm/xterm", () => ({ + Terminal: class MockTerminal { + cols = 80; + rows = 24; + options: { theme?: unknown } = {}; + buffer = { + active: { + viewportY: 0, + baseY: 0, + getLine: vi.fn(() => null), + }, + }; + + constructor(options: unknown) { + terminalConstructorSpy(options); + } + + loadAddon(addon: unknown) { + fitAddonLoadSpy(addon); + } + + open() {} + + write() {} + + clear() {} + + clearSelection() {} + + focus() {} + + refresh() {} + + scrollToBottom() {} + + hasSelection() { + return false; + } + + getSelection() { + return ""; + } + + getSelectionPosition() { + return null; + } + + attachCustomKeyEventHandler() { + return true; + } + + registerLinkProvider() { + return { dispose: vi.fn() }; + } + + onData() { + return { dispose: vi.fn() }; + } + + onSelectionChange() { + return { dispose: vi.fn() }; + } + + dispose() { + terminalDisposeSpy(); + } + }, +})); + +vi.mock("~/environmentApi", () => ({ + readEnvironmentApi: readEnvironmentApiMock, +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: vi.fn(() => { + throw new Error("ensureLocalApi not implemented in browser test"); + }), + readLocalApi: readLocalApiMock, +})); + +import { TerminalViewport } from "./ThreadTerminalDrawer"; + +const THREAD_ID = ThreadId.make("thread-terminal-browser"); + +function createEnvironmentApi() { + return { + terminal: { + open: vi.fn(async () => ({ + threadId: THREAD_ID, + terminalId: "default", + cwd: "/repo/project", + worktreePath: null, + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-04-07T00:00:00.000Z", + })), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + }, + }; +} + +async function mountTerminalViewport(props: { + threadRef: ReturnType; + drawerBackgroundColor?: string; + drawerTextColor?: string; +}) { + const drawer = document.createElement("div"); + drawer.className = "thread-terminal-drawer"; + if (props.drawerBackgroundColor) { + drawer.style.backgroundColor = props.drawerBackgroundColor; + } + if (props.drawerTextColor) { + drawer.style.color = props.drawerTextColor; + } + + const host = document.createElement("div"); + host.style.width = "800px"; + host.style.height = "400px"; + drawer.append(host); + document.body.append(drawer); + + const screen = await render( + undefined} + onAddTerminalContext={() => undefined} + focusRequestId={0} + autoFocus={false} + resizeEpoch={0} + drawerHeight={320} + />, + { container: host }, + ); + + return { + rerender: async (nextProps: { threadRef: ReturnType }) => { + await screen.rerender( + undefined} + onAddTerminalContext={() => undefined} + focusRequestId={0} + autoFocus={false} + resizeEpoch={0} + drawerHeight={320} + />, + ); + }, + cleanup: async () => { + await screen.unmount(); + drawer.remove(); + }, + }; +} + +describe("TerminalViewport", () => { + afterEach(() => { + environmentApiById.clear(); + readEnvironmentApiMock.mockClear(); + readLocalApiMock.mockClear(); + terminalConstructorSpy.mockClear(); + terminalDisposeSpy.mockClear(); + fitAddonFitSpy.mockClear(); + fitAddonLoadSpy.mockClear(); + }); + + it("does not create a terminal when APIs are unavailable", async () => { + readEnvironmentApiMock.mockReturnValueOnce(undefined); + readLocalApiMock.mockReturnValueOnce(undefined); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(terminalConstructorSpy).not.toHaveBeenCalled(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("reopens the terminal when the scoped thread reference changes", async () => { + const environmentA = createEnvironmentApi(); + const environmentB = createEnvironmentApi(); + environmentApiById.set("environment-a", environmentA); + environmentApiById.set("environment-b", environmentB); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); + }); + + await mounted.rerender({ + threadRef: scopeThreadRef("environment-b" as never, THREAD_ID), + }); + + await vi.waitFor(() => { + expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); + }); + expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); + } finally { + await mounted.cleanup(); + } + }); + + it("does not reopen the terminal when the scoped thread reference values stay the same", async () => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environment.terminal.open).toHaveBeenCalledTimes(1); + }); + + await mounted.rerender({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + await vi.waitFor(() => { + expect(environment.terminal.open).toHaveBeenCalledTimes(1); + }); + expect(terminalDisposeSpy).not.toHaveBeenCalled(); + } finally { + await mounted.cleanup(); + } + }); + + it("uses the drawer surface colors for the terminal theme", async () => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + drawerBackgroundColor: "rgb(24, 28, 36)", + drawerTextColor: "rgb(228, 232, 240)", + }); + + try { + await vi.waitFor(() => { + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); + }); + + expect(terminalConstructorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + theme: expect.objectContaining({ + background: "rgb(24, 28, 36)", + foreground: "rgb(228, 232, 240)", + }), + }), + ); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ffb7c1e4d0..14f4f64050 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { + type ScopedThreadRef, type TerminalEvent, type TerminalSessionSnapshot, type ThreadId, @@ -20,18 +21,26 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { + collectWrappedTerminalLinkLine, extractTerminalLinks, isTerminalLinkActivation, resolvePathLinkTarget, + resolveWrappedTerminalLinkRange, + wrappedTerminalLinkRangeIntersectsBufferLine, } from "../terminal-links"; -import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; +import { + isTerminalClearShortcut, + terminalDeleteShortcutData, + terminalNavigationShortcutData, +} from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; +import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; const MIN_DRAWER_HEIGHT = 180; @@ -74,12 +83,37 @@ export function selectPendingTerminalEventEntries( return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); } -function terminalThemeFromApp(): ITheme { +function normalizeComputedColor(value: string | null | undefined, fallback: string): string { + const normalizedValue = value?.trim().toLowerCase(); + if ( + !normalizedValue || + normalizedValue === "transparent" || + normalizedValue === "rgba(0, 0, 0, 0)" || + normalizedValue === "rgba(0 0 0 / 0)" + ) { + return fallback; + } + return value ?? fallback; +} + +function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { const isDark = document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; + const drawerSurface = + mountElement?.closest(".thread-terminal-drawer") ?? + document.querySelector(".thread-terminal-drawer") ?? + document.body; + const drawerStyles = getComputedStyle(drawerSurface); const bodyStyles = getComputedStyle(document.body); - const background = - bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); - const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); + const background = normalizeComputedColor( + drawerStyles.backgroundColor, + normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), + ); + const foreground = normalizeComputedColor( + drawerStyles.color, + normalizeComputedColor(bodyStyles.color, fallbackForeground), + ); if (isDark) { return { @@ -208,6 +242,7 @@ export function shouldHandleTerminalSelectionMouseUp( } interface TerminalViewportProps { + threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; terminalLabel: string; @@ -222,7 +257,8 @@ interface TerminalViewportProps { drawerHeight: number; } -function TerminalViewport({ +export function TerminalViewport({ + threadRef, threadId, terminalId, terminalLabel, @@ -239,6 +275,7 @@ function TerminalViewport({ const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); + const environmentId = threadRef.environmentId; const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); @@ -260,6 +297,9 @@ function TerminalViewport({ if (!mount) return; let disposed = false; + const api = readEnvironmentApi(environmentId); + const localApi = readLocalApi(); + if (!api || !localApi) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -268,7 +308,7 @@ function TerminalViewport({ fontSize: 12, scrollback: 5_000, fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', - theme: terminalThemeFromApp(), + theme: terminalThemeFromApp(mount), }); terminal.loadAddon(fitAddon); terminal.open(mount); @@ -277,9 +317,6 @@ function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; - const api = readNativeApi(); - if (!api) return; - const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; if (selectionActionTimerRef.current !== null) { @@ -340,7 +377,7 @@ function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; selectionActionOpenRef.current = true; try { - const clicked = await api.contextMenu.show( + const clicked = await localApi.contextMenu.show( [{ id: "add-to-chat", label: "Add to chat" }], nextAction.position, ); @@ -374,6 +411,14 @@ function TerminalViewport({ return false; } + const deleteData = terminalDeleteShortcutData(event); + if (deleteData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(deleteData, "Failed to delete terminal input"); + return false; + } + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); @@ -389,26 +434,31 @@ function TerminalViewport({ return; } - const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1); - if (!line) { + const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => + activeTerminal.buffer.active.getLine(bufferLineIndex), + ); + if (!wrappedLine) { callback(undefined); return; } - const lineText = line.translateToString(true); - const matches = extractTerminalLinks(lineText); - if (matches.length === 0) { + const links = extractTerminalLinks(wrappedLine.text) + .map((match) => ({ + match, + range: resolveWrappedTerminalLinkRange(wrappedLine, match), + })) + .filter(({ range }) => + wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), + ); + if (links.length === 0) { callback(undefined); return; } callback( - matches.map((match) => ({ + links.map(({ match, range }) => ({ text: match.text, - range: { - start: { x: match.start + 1, y: bufferLineNumber }, - end: { x: match.end, y: bufferLineNumber }, - }, + range, activate: (event: MouseEvent) => { if (!isTerminalLinkActivation(event)) return; @@ -416,7 +466,7 @@ function TerminalViewport({ if (!latestTerminal) return; if (match.kind === "url") { - void api.shell.openExternal(match.text).catch((error) => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open link", @@ -426,7 +476,7 @@ function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void openInPreferredEditor(api, target).catch((error) => { + void openInPreferredEditor(localApi, target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", @@ -484,7 +534,7 @@ function TerminalViewport({ const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; - activeTerminal.options.theme = terminalThemeFromApp(); + activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); activeTerminal.refresh(0, activeTerminal.rows - 1); }); themeObserver.observe(document.documentElement, { @@ -573,12 +623,12 @@ function TerminalViewport({ const previousLastEntryId = selectTerminalEventEntries( previousState.terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ).at(-1)?.id ?? 0; const nextEntries = selectTerminalEventEntries( state.terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ); const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; @@ -608,7 +658,7 @@ function TerminalViewport({ writeTerminalSnapshot(activeTerminal, snapshot); const bufferedEntries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ); const replayEntries = selectTerminalEventEntriesAfterSnapshot( @@ -677,7 +727,7 @@ function TerminalViewport({ // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cwd, runtimeEnv, terminalId, threadId]); + }, [cwd, environmentId, runtimeEnv, terminalId, threadId]); useEffect(() => { if (!autoFocus) return; @@ -692,7 +742,7 @@ function TerminalViewport({ }, [autoFocus, focusRequestId]); useEffect(() => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!api || !terminal || !fitAddon) return; @@ -714,13 +764,17 @@ function TerminalViewport({ return () => { window.cancelAnimationFrame(frame); }; - }, [drawerHeight, resizeEpoch, terminalId, threadId]); + }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); return ( -
    +
    ); } interface ThreadTerminalDrawerProps { + threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; worktreePath?: string | null; @@ -773,6 +827,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + threadRef, threadId, cwd, worktreePath, @@ -1098,6 +1153,7 @@ export default function ThreadTerminalDrawer({ >
    = {}): WsConnectionStatus { return { @@ -80,4 +80,34 @@ describe("WebSocketConnectionSurface.logic", () => { ), ).toBe(true); }); + + it("restarts a stalled reconnect window after the scheduled retry time passes", () => { + expect( + shouldRestartStalledReconnect( + makeStatus({ + hasConnected: true, + nextRetryAt: "2026-04-03T20:00:01.000Z", + online: true, + phase: "disconnected", + reconnectAttemptCount: 3, + reconnectPhase: "waiting", + }), + "2026-04-03T20:00:01.000Z", + ), + ).toBe(true); + + expect( + shouldRestartStalledReconnect( + makeStatus({ + hasConnected: true, + nextRetryAt: "2026-04-03T20:00:01.000Z", + online: true, + phase: "disconnected", + reconnectAttemptCount: 3, + reconnectPhase: "attempting", + }), + "2026-04-03T20:00:01.000Z", + ), + ).toBe(false); + }); }); diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 1855046a62..7b350e9849 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -1,11 +1,7 @@ -import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react"; import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "../branding"; import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { useServerConfig } from "../rpc/serverState"; import { - exhaustWsReconnectIfStillWaiting, getWsConnectionStatus, getWsConnectionUiState, setBrowserOnlineStatus, @@ -14,9 +10,8 @@ import { useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; -import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; -import { getWsRpcClient } from "~/wsRpcClient"; +import { getPrimaryEnvironmentConnection } from "../environments/runtime"; const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; type WsAutoReconnectTrigger = "focus" | "online"; @@ -58,11 +53,7 @@ function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } -function buildReconnectTitle(status: WsConnectionStatus): string { - if (status.nextRetryAt === null) { - return "Disconnected from T3 Server"; - } - +function buildReconnectTitle(_status: WsConnectionStatus): string { return "Disconnected from T3 Server"; } @@ -113,152 +104,15 @@ export function shouldAutoReconnect( ); } -function buildBlockingCopy( - uiState: WsConnectionUiState, +export function shouldRestartStalledReconnect( status: WsConnectionStatus, -): { - readonly description: string; - readonly eyebrow: string; - readonly title: string; -} { - if (uiState === "connecting") { - return { - description: `Opening the WebSocket connection to the ${APP_DISPLAY_NAME} server and waiting for the initial config snapshot.`, - eyebrow: "Starting Session", - title: `Connecting to ${APP_DISPLAY_NAME}`, - }; - } - - if (uiState === "offline") { - return { - description: - "Your browser is offline, so the web client cannot reach the T3 server. Reconnect to the network and the app will retry automatically.", - eyebrow: "Offline", - title: "WebSocket connection unavailable", - }; - } - - if (status.lastError?.trim()) { - return { - description: `${status.lastError} Verify that the T3 server is running and reachable, then reload the app if needed.`, - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; - } - - return { - description: - "The web client could not complete its initial WebSocket connection to the T3 server. It will keep retrying in the background.", - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; -} - -function buildConnectionDetails(status: WsConnectionStatus, uiState: WsConnectionUiState): string { - const details = [ - `state: ${uiState}`, - `online: ${status.online ? "yes" : "no"}`, - `attempts: ${status.attemptCount}`, - ]; - - if (status.socketUrl) { - details.push(`socket: ${status.socketUrl}`); - } - if (status.connectedAt) { - details.push(`connectedAt: ${status.connectedAt}`); - } - if (status.disconnectedAt) { - details.push(`disconnectedAt: ${status.disconnectedAt}`); - } - if (status.lastErrorAt) { - details.push(`lastErrorAt: ${status.lastErrorAt}`); - } - if (status.lastError) { - details.push(`lastError: ${status.lastError}`); - } - if (status.closeCode !== null) { - details.push(`closeCode: ${status.closeCode}`); - } - if (status.closeReason) { - details.push(`closeReason: ${status.closeReason}`); - } - - return details.join("\n"); -} - -function WebSocketBlockingState({ - status, - uiState, -}: { - readonly status: WsConnectionStatus; - readonly uiState: WsConnectionUiState; -}) { - const copy = buildBlockingCopy(uiState, status); - const disconnectedAt = formatConnectionMoment(status.disconnectedAt ?? status.lastErrorAt); - const Icon = - uiState === "connecting" ? LoaderCircle : uiState === "offline" ? CloudOff : AlertTriangle; - + expectedNextRetryAt: string, +): boolean { return ( -
    -
    -
    -
    -
    - -
    -
    -
    -

    - {copy.eyebrow} -

    -

    {copy.title}

    -
    -
    - -
    -
    - -

    {copy.description}

    - -
    -
    -

    - Connection -

    -

    - {uiState === "connecting" - ? "Opening WebSocket" - : uiState === "offline" - ? "Waiting for network" - : "Retrying server connection"} -

    -
    -
    -

    - Latest Event -

    -

    {disconnectedAt ?? "Pending"}

    -
    -
    - -
    - -
    - -
    - - Show connection details - Hide connection details - -
    -            {buildConnectionDetails(status, uiState)}
    -          
    -
    -
    -
    + status.reconnectPhase === "waiting" && + status.nextRetryAt === expectedNextRetryAt && + status.online && + status.hasConnected ); } @@ -277,7 +131,7 @@ export function WebSocketConnectionCoordinator() { toastResetTimerRef.current = null; } lastForcedReconnectAtRef.current = Date.now(); - void getWsRpcClient() + void getPrimaryEnvironmentConnection() .reconnect() .catch((error) => { if (!showFailureToast) { @@ -362,7 +216,12 @@ export function WebSocketConnectionCoordinator() { const nextRetryAt = status.nextRetryAt; const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; const timeoutId = window.setTimeout(() => { - exhaustWsReconnectIfStillWaiting(nextRetryAt); + const currentStatus = getWsConnectionStatus(); + if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { + return; + } + + runReconnect(false); }, timeoutMs); return () => { @@ -527,18 +386,5 @@ export function SlowRpcAckToastCoordinator() { } export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - const serverConfig = useServerConfig(); - const status = useWsConnectionStatus(); - - if (serverConfig === null) { - const uiState = getWsConnectionUiState(status); - return ( - - ); - } - return children; } diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx new file mode 100644 index 0000000000..f583af72ec --- /dev/null +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -0,0 +1,195 @@ +import type { AuthSessionState } from "@t3tools/contracts"; +import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; + +import { APP_DISPLAY_NAME } from "../../branding"; +import { + peekPairingTokenFromUrl, + stripPairingTokenFromUrl, + submitServerAuthCredential, +} from "../../environments/primary"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +export function PairingPendingSurface() { + return ( +
    +
    +
    +
    +
    +
    + +
    +

    + {APP_DISPLAY_NAME} +

    +

    + Pairing with this environment +

    +

    + Validating the pairing link and preparing your session. +

    +
    +
    + ); +} + +export function PairingRouteSurface({ + auth, + initialErrorMessage, + onAuthenticated, +}: { + auth: AuthSessionState["auth"]; + initialErrorMessage?: string; + onAuthenticated: () => void; +}) { + const autoPairTokenRef = useRef(peekPairingTokenFromUrl()); + const [credential, setCredential] = useState(() => autoPairTokenRef.current ?? ""); + const [errorMessage, setErrorMessage] = useState(initialErrorMessage ?? ""); + const [isSubmitting, setIsSubmitting] = useState(false); + const autoSubmitAttemptedRef = useRef(false); + + const submitCredential = useCallback( + async (nextCredential: string) => { + setIsSubmitting(true); + setErrorMessage(""); + + const submitError = await submitServerAuthCredential(nextCredential).then( + () => null, + (error) => errorMessageFromUnknown(error), + ); + + setIsSubmitting(false); + + if (submitError) { + setErrorMessage(submitError); + return; + } + + startTransition(() => { + onAuthenticated(); + }); + }, + [onAuthenticated], + ); + + const handleSubmit = useCallback( + async (event?: React.SubmitEvent) => { + event?.preventDefault(); + await submitCredential(credential); + }, + [submitCredential, credential], + ); + + useEffect(() => { + const token = autoPairTokenRef.current; + if (!token || autoSubmitAttemptedRef.current) { + return; + } + + autoSubmitAttemptedRef.current = true; + stripPairingTokenFromUrl(); + void submitCredential(token); + }, [submitCredential]); + + return ( +
    +
    +
    +
    +
    +
    + +
    +

    + {APP_DISPLAY_NAME} +

    +

    + Pair with this environment +

    +

    + {describeAuthGate(auth.bootstrapMethods)} +

    + +
    void handleSubmit(event)}> +
    + + setCredential(event.currentTarget.value)} + placeholder="Paste a one-time token or pairing secret" + spellCheck={false} + value={credential} + /> +
    + + {errorMessage ? ( +
    + {errorMessage} +
    + ) : null} + +
    + + +
    +
    + +
    + {describeSupportedMethods(auth.bootstrapMethods)} +
    +
    +
    + ); +} + +function errorMessageFromUnknown(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + return "Authentication failed."; +} + +function describeAuthGate(bootstrapMethods: ReadonlyArray): string { + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment expects a trusted pairing credential before the app can connect."; + } + + return "Enter a pairing token to start a session with this environment."; +} + +function describeSupportedMethods(bootstrapMethods: ReadonlyArray): string { + if ( + bootstrapMethods.includes("desktop-bootstrap") && + bootstrapMethods.includes("one-time-token") + ) { + return "Desktop-managed pairing and one-time pairing tokens are both accepted for this environment."; + } + + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment is desktop-managed. Open it from the desktop app or paste a bootstrap credential if one was issued explicitly."; + } + + return "This environment accepts one-time pairing tokens. Pairing links can open this page directly, or you can paste the token here."; +} diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index 5b72fd237d..4e2a12ee12 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -39,7 +39,7 @@ describe("ChangedFilesTree", () => { ({ files, visibleLabels, hiddenLabels }) => { const markup = renderToStaticMarkup( { ({ files, visibleLabels }) => { const markup = renderToStaticMarkup( = { + "approval-required": { + label: "Supervised", + description: "Ask before commands and file changes.", + icon: LockIcon, + }, + "auto-accept-edits": { + label: "Auto-accept edits", + description: "Auto-approve edits, ask before other actions.", + icon: PenLineIcon, + }, + "full-access": { + label: "Full access", + description: "Allow commands and edits without prompts.", + icon: LockOpenIcon, + }, +}; + +const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; +const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; +const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; + +const extendReplacementRangeForTrailingSpace = ( + text: string, + rangeEnd: number, + replacement: string, +): number => { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; +}; + +const syncTerminalContextsByIds = ( + contexts: ReadonlyArray, + ids: ReadonlyArray, +): TerminalContextDraft[] => { + const contextsById = new Map(contexts.map((context) => [context.id, context])); + return ids.flatMap((id) => { + const context = contextsById.get(id); + return context ? [context] : []; + }); +}; + +const terminalContextIdListsEqual = ( + contexts: ReadonlyArray, + ids: ReadonlyArray, +): boolean => + contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); + +const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + interactionMode: ProviderInteractionMode; + runtimeMode: RuntimeMode; + showPlanToggle: boolean; + planSidebarLabel: string; + planSidebarOpen: boolean; + onToggleInteractionMode: () => void; + onRuntimeModeChange: (mode: RuntimeMode) => void; + onTogglePlanSidebar: () => void; +}) { + const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; + const RuntimeModeIcon = runtimeModeOption.icon; + + return ( + <> + + + + + + + + + {props.showPlanToggle ? ( + <> + + + + ) : null} + + ); +}); + +const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { + compact: boolean; + activeContextWindow: ReturnType; + isPreparingWorktree: boolean; + pendingAction: { + questionIndex: number; + isLastQuestion: boolean; + canAdvance: boolean; + isResponding: boolean; + isComplete: boolean; + } | null; + isRunning: boolean; + showPlanFollowUpPrompt: boolean; + promptHasText: boolean; + isSendBusy: boolean; + isConnecting: boolean; + hasSendableContent: boolean; + onPreviousPendingQuestion: () => void; + onInterrupt: () => void; + onImplementPlanInNewThread: () => void; +}) { + return ( + <> + {props.activeContextWindow ? : null} + {props.isPreparingWorktree ? ( + Preparing worktree... + ) : null} + + + ); +}); + +// -------------------------------------------------------------------------- +// Handle exposed to ChatView +// -------------------------------------------------------------------------- + +export interface ChatComposerHandle { + focusAtEnd: () => void; + focusAt: (cursor: number) => void; + readSnapshot: () => { + value: string; + cursor: number; + expandedCursor: number; + terminalContextIds: string[]; + }; + /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */ + resetCursorState: (options?: { + cursor?: number; + prompt?: string; + detectTrigger?: boolean; + }) => void; + /** Insert a terminal context from the terminal drawer. */ + addTerminalContext: (selection: TerminalContextSelection) => void; + /** Get the current prompt/effort/model state for use in send. */ + getSendContext: () => { + prompt: string; + images: ComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + selectedPromptEffort: string | null; + selectedModelOptionsForDispatch: unknown; + selectedModelSelection: ModelSelection; + selectedProvider: ProviderKind; + selectedModel: string; + selectedProviderModels: ReadonlyArray; + }; +} + +// -------------------------------------------------------------------------- +// Props +// -------------------------------------------------------------------------- + +export interface ChatComposerProps { + composerDraftTarget: ScopedThreadRef | DraftId; + environmentId: EnvironmentId; + routeKind: "server" | "draft"; + routeThreadRef: ScopedThreadRef; + draftId: DraftId | null; + + // Thread context + activeThreadId: ThreadId | null; + activeThreadEnvironmentId: EnvironmentId | undefined; + activeThread: Thread | undefined; + isServerThread: boolean; + isLocalDraftThread: boolean; + + // Session phase + phase: SessionPhase; + isConnecting: boolean; + isSendBusy: boolean; + isPreparingWorktree: boolean; + + // Pending approvals / inputs + activePendingApproval: PendingApproval | null; + pendingApprovals: PendingApproval[]; + pendingUserInputs: PendingUserInput[]; + activePendingProgress: { + questionIndex: number; + isLastQuestion: boolean; + canAdvance: boolean; + customAnswer: string; + activeQuestion: { id: string } | null; + } | null; + activePendingResolvedAnswers: Record | null; + activePendingIsResponding: boolean; + activePendingDraftAnswers: Record; + activePendingQuestionIndex: number; + respondingRequestIds: ApprovalRequestId[]; + + // Plan + showPlanFollowUpPrompt: boolean; + activeProposedPlan: Thread["proposedPlans"][number] | null; + activePlan: { turnId?: TurnId } | null; + sidebarProposedPlan: { turnId?: TurnId } | null; + planSidebarLabel: string; + planSidebarOpen: boolean; + + // Mode + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + + // Provider / model + lockedProvider: ProviderKind | null; + providerStatuses: ServerProvider[]; + activeProjectDefaultModelSelection: ModelSelection | null | undefined; + activeThreadModelSelection: ModelSelection | null | undefined; + + // Context window + activeThreadActivities: Thread["activities"] | undefined; + + // Misc + resolvedTheme: "light" | "dark"; + settings: UnifiedSettings; + gitCwd: string | null; + + // Refs the parent needs kept in sync + promptRef: React.MutableRefObject; + composerImagesRef: React.MutableRefObject; + composerTerminalContextsRef: React.MutableRefObject; + + // Scroll + shouldAutoScrollRef: React.MutableRefObject; + scheduleStickToBottom: () => void; + + // Callbacks + onSend: (e?: { preventDefault: () => void }) => void; + onInterrupt: () => void; + onImplementPlanInNewThread: () => void; + onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; + onAdvanceActivePendingUserInput: () => void; + onPreviousActivePendingUserInputQuestion: () => void; + onChangeActivePendingUserInputCustomAnswer: ( + questionId: string, + value: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => void; + + onProviderModelSelect: (provider: ProviderKind, model: string) => void; + toggleInteractionMode: () => void; + handleRuntimeModeChange: (mode: RuntimeMode) => void; + handleInteractionModeChange: (mode: ProviderInteractionMode) => void; + togglePlanSidebar: () => void; + + focusComposer: () => void; + scheduleComposerFocus: () => void; + setThreadError: (threadId: ThreadId | null, error: string | null) => void; + onExpandImage: (preview: ExpandedImagePreview) => void; +} + +// -------------------------------------------------------------------------- +// Component +// -------------------------------------------------------------------------- + +export const ChatComposer = memo( + forwardRef(function ChatComposer(props, ref) { + const { + composerDraftTarget, + environmentId, + routeKind, + routeThreadRef, + draftId, + activeThreadId, + activeThreadEnvironmentId: _activeThreadEnvironmentId, + activeThread, + isServerThread: _isServerThread, + isLocalDraftThread: _isLocalDraftThread, + phase, + isConnecting, + isSendBusy, + isPreparingWorktree, + activePendingApproval, + pendingApprovals, + pendingUserInputs, + activePendingProgress, + activePendingResolvedAnswers, + activePendingIsResponding, + activePendingDraftAnswers, + activePendingQuestionIndex, + respondingRequestIds, + showPlanFollowUpPrompt, + activeProposedPlan, + activePlan, + sidebarProposedPlan, + planSidebarLabel, + planSidebarOpen, + runtimeMode, + interactionMode, + lockedProvider, + providerStatuses, + activeProjectDefaultModelSelection, + activeThreadModelSelection, + activeThreadActivities, + resolvedTheme, + settings, + gitCwd, + promptRef, + composerImagesRef, + composerTerminalContextsRef, + shouldAutoScrollRef, + scheduleStickToBottom, + onSend, + onInterrupt, + onImplementPlanInNewThread, + onRespondToApproval, + onSelectActivePendingUserInputOption, + onAdvanceActivePendingUserInput, + onPreviousActivePendingUserInputQuestion, + onChangeActivePendingUserInputCustomAnswer, + onProviderModelSelect, + toggleInteractionMode, + handleRuntimeModeChange, + handleInteractionModeChange, + togglePlanSidebar, + focusComposer, + scheduleComposerFocus, + setThreadError, + onExpandImage, + } = props; + + // ------------------------------------------------------------------ + // Store subscriptions (prompt / images / terminal contexts) + // ------------------------------------------------------------------ + const composerDraft = useComposerThreadDraft(composerDraftTarget); + const prompt = composerDraft.prompt; + const composerImages = composerDraft.images; + const composerTerminalContexts = composerDraft.terminalContexts; + const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; + + const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); + const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const insertComposerDraftTerminalContext = useComposerDraftStore( + (store) => store.insertTerminalContext, + ); + const removeComposerDraftTerminalContext = useComposerDraftStore( + (store) => store.removeTerminalContext, + ); + const setComposerDraftTerminalContexts = useComposerDraftStore( + (store) => store.setTerminalContexts, + ); + const clearComposerDraftPersistedAttachments = useComposerDraftStore( + (store) => store.clearPersistedAttachments, + ); + const syncComposerDraftPersistedAttachments = useComposerDraftStore( + (store) => store.syncPersistedAttachments, + ); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + + // ------------------------------------------------------------------ + // Model state + // ------------------------------------------------------------------ + const selectedProviderByThreadId = composerDraft.activeProvider ?? null; + const threadProvider = + activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null; + + const unlockedSelectedProvider = resolveSelectableProvider( + providerStatuses, + selectedProviderByThreadId ?? threadProvider ?? "codex", + ); + const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ + threadRef: composerDraftTarget, + providers: providerStatuses, + selectedProvider, + threadModelSelection: activeThreadModelSelection, + projectModelSelection: activeProjectDefaultModelSelection, + settings, + }); + + const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); + const selectedProviderStatus = useMemo( + () => providerStatuses.find((provider) => provider.provider === selectedProvider), + [providerStatuses, selectedProvider], + ); + + const composerProviderState = useMemo( + () => + getComposerProviderState({ + provider: selectedProvider, + model: selectedModel, + models: selectedProviderModels, + prompt, + modelOptions: composerModelOptions, + }), + [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], + ); + + const selectedPromptEffort = composerProviderState.promptEffort; + const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + const selectedModelSelection = useMemo( + () => ({ + provider: selectedProvider, + model: selectedModel, + ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), + }), + [selectedModel, selectedModelOptionsForDispatch, selectedProvider], + ); + const selectedModelForPicker = selectedModel; + const modelOptionsByProvider = useMemo< + Record> + >( + () => ({ + codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], + claudeAgent: + providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + }), + [providerStatuses], + ); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + const searchableModelOptions = useMemo( + () => + AVAILABLE_PROVIDER_OPTIONS.filter( + (option) => lockedProvider === null || option.value === lockedProvider, + ).flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider], + ); + + // ------------------------------------------------------------------ + // Context window + // ------------------------------------------------------------------ + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), + [activeThreadActivities], + ); + + // ------------------------------------------------------------------ + // Composer-local state + // ------------------------------------------------------------------ + const [composerCursor, setComposerCursor] = useState(() => + collapseExpandedComposerCursor(prompt, prompt.length), + ); + const [composerTrigger, setComposerTrigger] = useState(() => + detectComposerTrigger(prompt, prompt.length), + ); + const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( + null, + ); + const [isDragOverComposer, setIsDragOverComposer] = useState(false); + const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); + const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); + + // ------------------------------------------------------------------ + // Refs + // ------------------------------------------------------------------ + const composerEditorRef = useRef(null); + const composerFormRef = useRef(null); + const composerFormHeightRef = useRef(0); + const composerSelectLockRef = useRef(false); + const composerMenuOpenRef = useRef(false); + const composerMenuItemsRef = useRef([]); + const activeComposerMenuItemRef = useRef(null); + const dragDepthRef = useRef(0); + + // ------------------------------------------------------------------ + // Derived: composer send state + // ------------------------------------------------------------------ + const composerSendState = useMemo( + () => + deriveComposerSendState({ + prompt, + imageCount: composerImages.length, + terminalContexts: composerTerminalContexts, + }), + [composerImages.length, composerTerminalContexts, prompt], + ); + + // ------------------------------------------------------------------ + // Derived: composer trigger / menu + // ------------------------------------------------------------------ + const composerTriggerKind = composerTrigger?.kind ?? null; + const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const isPathTrigger = composerTriggerKind === "path"; + const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( + pathTriggerQuery, + { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, + (debouncerState) => ({ isPending: debouncerState.isPending }), + ); + const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; + const workspaceEntriesQuery = useQuery( + projectSearchEntriesQueryOptions({ + environmentId, + cwd: gitCwd, + query: effectivePathQuery, + enabled: isPathTrigger, + limit: 80, + }), + ); + const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + + const composerMenuItems = useMemo(() => { + if (!composerTrigger) return []; + if (composerTrigger.kind === "path") { + return workspaceEntries.map((entry) => ({ + id: `path:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOfPath(entry.path), + description: entry.parentPath ?? "", + })); + } + if (composerTrigger.kind === "slash-command") { + const builtInSlashCommandItems = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] satisfies ReadonlyArray>; + const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( + (command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + }), + ); + const query = composerTrigger.query.trim().toLowerCase(); + const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; + if (!query) { + return slashCommandItems; + } + return searchSlashCommandItems(slashCommandItems, query); + } + if (composerTrigger.kind === "skill") { + return searchProviderSkills( + selectedProviderStatus?.skills ?? [], + composerTrigger.query, + ).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); + } + return searchableModelOptions + .filter(({ searchSlug, searchName, searchProvider }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return ( + searchSlug.includes(query) || + searchName.includes(query) || + searchProvider.includes(query) + ); + }) + .map(({ provider, providerLabel, slug, name }) => ({ + id: `model:${provider}:${slug}`, + type: "model", + provider, + model: slug, + label: name, + description: `${providerLabel} · ${slug}`, + })); + }, [ + composerTrigger, + searchableModelOptions, + selectedProvider, + selectedProviderStatus, + workspaceEntries, + ]); + + const composerMenuOpen = Boolean(composerTrigger); + const composerMenuSearchKey = composerTrigger + ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` + : null; + const activeComposerMenuItem = useMemo(() => { + const activeItemId = resolveComposerMenuActiveItemId({ + items: composerMenuItems, + highlightedItemId: composerHighlightedItemId, + currentSearchKey: composerMenuSearchKey, + highlightedSearchKey: composerHighlightedSearchKey, + }); + return composerMenuItems.find((item) => item.id === activeItemId) ?? null; + }, [ + composerHighlightedItemId, + composerHighlightedSearchKey, + composerMenuItems, + composerMenuSearchKey, + ]); + + composerMenuOpenRef.current = composerMenuOpen; + composerMenuItemsRef.current = composerMenuItems; + activeComposerMenuItemRef.current = activeComposerMenuItem; + + const nonPersistedComposerImageIdSet = useMemo( + () => new Set(nonPersistedComposerImageIds), + [nonPersistedComposerImageIds], + ); + + const isComposerApprovalState = activePendingApproval !== null; + const activePendingUserInput = pendingUserInputs[0] ?? null; + const hasComposerHeader = + isComposerApprovalState || + pendingUserInputs.length > 0 || + (showPlanFollowUpPrompt && activeProposedPlan !== null); + + const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); + const composerFooterActionLayoutKey = useMemo(() => { + if (activePendingProgress) { + return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; + } + if (phase === "running") { + return "running"; + } + if (showPlanFollowUpPrompt) { + return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; + } + return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; + }, [ + activePendingIsResponding, + activePendingProgress, + composerSendState.hasSendableContent, + isConnecting, + isPreparingWorktree, + isSendBusy, + phase, + prompt, + showPlanFollowUpPrompt, + ]); + + const isComposerMenuLoading = + composerTriggerKind === "path" && + ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + workspaceEntriesQuery.isLoading || + workspaceEntriesQuery.isFetching); + const composerMenuEmptyState = useMemo(() => { + if (composerTriggerKind === "skill") { + return "No skills found. Try / to browse provider commands."; + } + return composerTriggerKind === "path" + ? "No matching files or folders." + : "No matching command."; + }, [composerTriggerKind]); + + // ------------------------------------------------------------------ + // Provider traits UI + // ------------------------------------------------------------------ + const setPromptFromTraits = useCallback( + (nextPrompt: string) => { + if (nextPrompt === promptRef.current) { + scheduleComposerFocus(); + return; + } + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + scheduleComposerFocus(); + }, + [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + ); + + const providerTraitsMenuContent = renderProviderTraitsMenuContent({ + provider: selectedProvider, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), + model: selectedModel, + models: selectedProviderModels, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, + onPromptChange: setPromptFromTraits, + }); + const providerTraitsPicker = renderProviderTraitsPicker({ + provider: selectedProvider, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), + model: selectedModel, + models: selectedProviderModels, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, + onPromptChange: setPromptFromTraits, + }); + const pendingPrimaryAction = useMemo( + () => + activePendingProgress + ? { + questionIndex: activePendingProgress.questionIndex, + isLastQuestion: activePendingProgress.isLastQuestion, + canAdvance: activePendingProgress.canAdvance, + isResponding: activePendingIsResponding, + isComplete: Boolean(activePendingResolvedAnswers), + } + : null, + [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], + ); + + // ------------------------------------------------------------------ + // Prompt helpers + // ------------------------------------------------------------------ + const setPrompt = useCallback( + (nextPrompt: string) => { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + }, + [composerDraftTarget, setComposerDraftPrompt], + ); + + const addComposerImage = useCallback( + (image: ComposerImageAttachment) => { + addComposerDraftImage(composerDraftTarget, image); + }, + [composerDraftTarget, addComposerDraftImage], + ); + + const addComposerImagesToDraft = useCallback( + (images: ComposerImageAttachment[]) => { + addComposerDraftImages(composerDraftTarget, images); + }, + [composerDraftTarget, addComposerDraftImages], + ); + + const removeComposerImageFromDraft = useCallback( + (imageId: string) => { + removeComposerDraftImage(composerDraftTarget, imageId); + }, + [composerDraftTarget, removeComposerDraftImage], + ); + + const removeComposerTerminalContextFromDraft = useCallback( + (contextId: string) => { + const contextIndex = composerTerminalContexts.findIndex( + (context) => context.id === contextId, + ); + if (contextIndex < 0) return; + const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); + promptRef.current = removal.prompt; + setPrompt(removal.prompt); + removeComposerDraftTerminalContext(composerDraftTarget, contextId); + const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); + }, + [ + composerDraftTarget, + composerTerminalContexts, + promptRef, + removeComposerDraftTerminalContext, + setPrompt, + ], + ); + + // ------------------------------------------------------------------ + // Sync refs back to parent + // ------------------------------------------------------------------ + useEffect(() => { + promptRef.current = prompt; + setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); + }, [prompt, promptRef]); + + useEffect(() => { + composerImagesRef.current = composerImages; + }, [composerImages, composerImagesRef]); + + useEffect(() => { + composerTerminalContextsRef.current = composerTerminalContexts; + }, [composerTerminalContexts, composerTerminalContextsRef]); + + // ------------------------------------------------------------------ + // Composer menu highlight sync + // ------------------------------------------------------------------ + useEffect(() => { + if (!composerMenuOpen) { + setComposerHighlightedItemId(null); + setComposerHighlightedSearchKey(null); + return; + } + const nextActiveItemId = resolveComposerMenuActiveItemId({ + items: composerMenuItems, + highlightedItemId: composerHighlightedItemId, + currentSearchKey: composerMenuSearchKey, + highlightedSearchKey: composerHighlightedSearchKey, + }); + setComposerHighlightedItemId((existing) => + existing === nextActiveItemId ? existing : nextActiveItemId, + ); + setComposerHighlightedSearchKey((existing) => + existing === composerMenuSearchKey ? existing : composerMenuSearchKey, + ); + }, [ + composerHighlightedItemId, + composerHighlightedSearchKey, + composerMenuItems, + composerMenuOpen, + composerMenuSearchKey, + ]); + + const lastSyncedPendingInputRef = useRef<{ + requestId: string | null; + questionId: string | null; + } | null>(null); + + useEffect(() => { + const nextCustomAnswer = activePendingProgress?.customAnswer; + if (typeof nextCustomAnswer !== "string") { + lastSyncedPendingInputRef.current = null; + return; + } + + const nextRequestId = activePendingUserInput?.requestId ?? null; + const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; + const questionChanged = + lastSyncedPendingInputRef.current?.requestId !== nextRequestId || + lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; + const textChangedExternally = promptRef.current !== nextCustomAnswer; + + lastSyncedPendingInputRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + }; + + if (!questionChanged && !textChangedExternally) { + return; + } + + promptRef.current = nextCustomAnswer; + const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger( + nextCustomAnswer, + expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), + ), + ); + setComposerHighlightedItemId(null); + }, [ + activePendingProgress?.customAnswer, + activePendingProgress?.activeQuestion?.id, + activePendingUserInput?.requestId, + promptRef, + ]); + + // ------------------------------------------------------------------ + // Reset compositor state on thread/draft change + // ------------------------------------------------------------------ + useEffect(() => { + setComposerHighlightedItemId(null); + setComposerCursor( + collapseExpandedComposerCursor(promptRef.current, promptRef.current.length), + ); + setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + dragDepthRef.current = 0; + setIsDragOverComposer(false); + }, [draftId, activeThreadId, promptRef]); + + // ------------------------------------------------------------------ + // Footer compact layout observation + // ------------------------------------------------------------------ + useLayoutEffect(() => { + const composerForm = composerFormRef.current; + if (!composerForm) return; + const measureComposerFormWidth = () => composerForm.clientWidth; + const measureFooterCompactness = () => { + const composerFormWidth = measureComposerFormWidth(); + const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { + hasWideActions: composerFooterHasWideActions, + }); + const primaryActionsCompact = + footerCompact && + shouldUseCompactComposerPrimaryActions(composerFormWidth, { + hasWideActions: composerFooterHasWideActions, + }); + return { + primaryActionsCompact, + footerCompact, + }; + }; + + composerFormHeightRef.current = composerForm.getBoundingClientRect().height; + const initialCompactness = measureFooterCompactness(); + setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); + setIsComposerFooterCompact(initialCompactness.footerCompact); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver((entries) => { + const [entry] = entries; + if (!entry) return; + const nextCompactness = measureFooterCompactness(); + setIsComposerPrimaryActionsCompact((previous) => + previous === nextCompactness.primaryActionsCompact + ? previous + : nextCompactness.primaryActionsCompact, + ); + setIsComposerFooterCompact((previous) => + previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, + ); + const nextHeight = entry.contentRect.height; + const previousHeight = composerFormHeightRef.current; + composerFormHeightRef.current = nextHeight; + if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }); + + observer.observe(composerForm); + return () => { + observer.disconnect(); + }; + }, [ + activeThreadId, + composerFooterActionLayoutKey, + composerFooterHasWideActions, + scheduleStickToBottom, + shouldAutoScrollRef, + ]); + + // ------------------------------------------------------------------ + // Image persist effect + // ------------------------------------------------------------------ + useEffect(() => { + let cancelled = false; + void (async () => { + if (composerImages.length === 0) { + clearComposerDraftPersistedAttachments(composerDraftTarget); + return; + } + const getPersistedAttachmentsForThread = () => + getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; + try { + const currentPersistedAttachments = getPersistedAttachmentsForThread(); + const existingPersistedById = new Map( + currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), + ); + const stagedAttachmentById = new Map(); + await Promise.all( + composerImages.map(async (image) => { + try { + const dataUrl = await readFileAsDataUrl(image.file); + stagedAttachmentById.set(image.id, { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }); + } catch { + const existingPersisted = existingPersistedById.get(image.id); + if (existingPersisted) { + stagedAttachmentById.set(image.id, existingPersisted); + } + } + }), + ); + const serialized = Array.from(stagedAttachmentById.values()); + if (cancelled) return; + syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); + } catch { + const currentImageIds = new Set(composerImages.map((image) => image.id)); + const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); + const fallbackPersistedIds = fallbackPersistedAttachments + .map((attachment) => attachment.id) + .filter((id) => currentImageIds.has(id)); + const fallbackPersistedIdSet = new Set(fallbackPersistedIds); + const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => + fallbackPersistedIdSet.has(attachment.id), + ); + if (cancelled) return; + syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); + } + })(); + return () => { + cancelled = true; + }; + }, [ + composerDraftTarget, + clearComposerDraftPersistedAttachments, + composerImages, + getComposerDraft, + syncComposerDraftPersistedAttachments, + ]); + + // ------------------------------------------------------------------ + // Callbacks: prompt change + // ------------------------------------------------------------------ + const onPromptChange = useCallback( + ( + nextPrompt: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + terminalContextIds: string[], + ) => { + if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + ); + onChangeActivePendingUserInputCustomAnswer( + activePendingProgress.activeQuestion.id, + nextPrompt, + nextCursor, + expandedCursor, + cursorAdjacentToMention, + ); + return; + } + promptRef.current = nextPrompt; + setPrompt(nextPrompt); + if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { + setComposerDraftTerminalContexts( + composerDraftTarget, + syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), + ); + } + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + ); + }, + [ + activePendingProgress?.activeQuestion, + pendingUserInputs.length, + onChangeActivePendingUserInputCustomAnswer, + promptRef, + setPrompt, + composerDraftTarget, + composerTerminalContexts, + setComposerDraftTerminalContexts, + ], + ); + + // ------------------------------------------------------------------ + // Callbacks: prompt replacement / menu + // ------------------------------------------------------------------ + const applyPromptReplacement = useCallback( + ( + rangeStart: number, + rangeEnd: number, + replacement: string, + options?: { expectedText?: string }, + ): boolean => { + const currentText = promptRef.current; + const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); + const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); + if ( + options?.expectedText !== undefined && + currentText.slice(safeStart, safeEnd) !== options.expectedText + ) { + return false; + } + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); + const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); + promptRef.current = next.text; + const activePendingQuestion = activePendingProgress?.activeQuestion; + if (activePendingQuestion && activePendingUserInput) { + onChangeActivePendingUserInputCustomAnswer( + activePendingQuestion.id, + next.text, + nextCursor, + nextExpandedCursor, + false, + ); + } else { + setPrompt(next.text); + } + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + return true; + }, + [ + activePendingProgress?.activeQuestion, + activePendingUserInput, + onChangeActivePendingUserInputCustomAnswer, + promptRef, + setPrompt, + ], + ); + + const readComposerSnapshot = useCallback((): { + value: string; + cursor: number; + expandedCursor: number; + terminalContextIds: string[]; + } => { + const editorSnapshot = composerEditorRef.current?.readSnapshot(); + if (editorSnapshot) { + return editorSnapshot; + } + return { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + terminalContextIds: composerTerminalContexts.map((context) => context.id), + }; + }, [composerCursor, composerTerminalContexts, promptRef]); + + const resolveActiveComposerTrigger = useCallback((): { + snapshot: { value: string; cursor: number; expandedCursor: number }; + trigger: ComposerTrigger | null; + } => { + const snapshot = readComposerSnapshot(); + return { + snapshot, + trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + }; + }, [readComposerSnapshot]); + + const onSelectComposerItem = useCallback( + (item: ComposerCommandItem) => { + if (composerSelectLockRef.current) return; + composerSelectLockRef.current = true; + window.requestAnimationFrame(() => { + composerSelectLockRef.current = false; + }); + const { snapshot, trigger } = resolveActiveComposerTrigger(); + if (!trigger) return; + if (item.type === "path") { + const replacement = `@${item.path} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "slash-command") { + if (item.command === "model") { + const replacement = "/model "; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "provider-slash-command") { + const replacement = `/${item.command.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "skill") { + const replacement = `$${item.skill.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + onProviderModelSelect(item.provider, item.model); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + }, + [ + applyPromptReplacement, + handleInteractionModeChange, + onProviderModelSelect, + resolveActiveComposerTrigger, + ], + ); + + const onComposerMenuItemHighlighted = useCallback( + (itemId: string | null) => { + setComposerHighlightedItemId(itemId); + setComposerHighlightedSearchKey(composerMenuSearchKey); + }, + [composerMenuSearchKey], + ); + + const nudgeComposerMenuHighlight = useCallback( + (key: "ArrowDown" | "ArrowUp") => { + if (composerMenuItems.length === 0) return; + const highlightedIndex = composerMenuItems.findIndex( + (item) => item.id === composerHighlightedItemId, + ); + const normalizedIndex = + highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; + const offset = key === "ArrowDown" ? 1 : -1; + const nextIndex = + (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; + const nextItem = composerMenuItems[nextIndex]; + setComposerHighlightedItemId(nextItem?.id ?? null); + }, + [composerHighlightedItemId, composerMenuItems], + ); + + // ------------------------------------------------------------------ + // Callbacks: command key + // ------------------------------------------------------------------ + const onComposerCommandKey = ( + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + event: KeyboardEvent, + ) => { + if (key === "Tab" && event.shiftKey) { + toggleInteractionMode(); + return true; + } + const { trigger } = resolveActiveComposerTrigger(); + const menuIsActive = composerMenuOpenRef.current || trigger !== null; + if (menuIsActive) { + const currentItems = composerMenuItemsRef.current; + const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; + if (key === "ArrowDown" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowDown"); + return true; + } + if (key === "ArrowUp" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowUp"); + return true; + } + if ((key === "Enter" || key === "Tab") && selectedItem) { + onSelectComposerItem(selectedItem); + return true; + } + } + if (key === "Enter" && !event.shiftKey) { + void onSend(); + return true; + } + return false; + }; + + // ------------------------------------------------------------------ + // Callbacks: images + // ------------------------------------------------------------------ + const addComposerImages = (files: File[]) => { + if (!activeThreadId || files.length === 0) return; + if (pendingUserInputs.length > 0) { + toastManager.add({ + type: "error", + title: "Attach images after answering plan questions.", + }); + return; + } + const nextImages: ComposerImageAttachment[] = []; + let nextImageCount = composerImagesRef.current.length; + let error: string | null = null; + for (const file of files) { + if (!file.type.startsWith("image/")) { + error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + continue; + } + if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + continue; + } + if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + break; + } + const previewUrl = URL.createObjectURL(file); + nextImages.push({ + type: "image", + id: randomUUID(), + name: file.name || "image", + mimeType: file.type, + sizeBytes: file.size, + previewUrl, + file, + }); + nextImageCount += 1; + } + if (nextImages.length === 1 && nextImages[0]) { + addComposerImage(nextImages[0]); + } else if (nextImages.length > 1) { + addComposerImagesToDraft(nextImages); + } + setThreadError(activeThreadId, error); + }; + + const removeComposerImage = (imageId: string) => { + removeComposerImageFromDraft(imageId); + }; + + // ------------------------------------------------------------------ + // Callbacks: paste / drag + // ------------------------------------------------------------------ + const onComposerPaste = (event: React.ClipboardEvent) => { + const files = Array.from(event.clipboardData.files); + if (files.length === 0) return; + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + if (imageFiles.length === 0) return; + event.preventDefault(); + addComposerImages(imageFiles); + }; + + const onComposerDragEnter = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + dragDepthRef.current += 1; + setIsDragOverComposer(true); + }; + + const onComposerDragOver = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsDragOverComposer(true); + }; + + const onComposerDragLeave = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragOverComposer(false); + } + }; + + const onComposerDrop = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragOverComposer(false); + const files = Array.from(event.dataTransfer.files); + addComposerImages(files); + focusComposer(); + }; + const handleInterruptPrimaryAction = useCallback(() => { + void onInterrupt(); + }, [onInterrupt]); + const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { + void onImplementPlanInNewThread(); + }, [onImplementPlanInNewThread]); + + // ------------------------------------------------------------------ + // Imperative handle + // ------------------------------------------------------------------ + useImperativeHandle( + ref, + () => ({ + focusAtEnd: () => { + composerEditorRef.current?.focusAtEnd(); + }, + focusAt: (cursor: number) => { + composerEditorRef.current?.focusAt(cursor); + }, + readSnapshot: () => { + return readComposerSnapshot(); + }, + resetCursorState: (options?: { + cursor?: number; + prompt?: string; + detectTrigger?: boolean; + }) => { + const promptForState = options?.prompt ?? promptRef.current; + const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); + setComposerHighlightedItemId(null); + setComposerCursor(cursor); + setComposerTrigger( + options?.detectTrigger + ? detectComposerTrigger( + promptForState, + expandCollapsedComposerCursor(promptForState, cursor), + ) + : null, + ); + }, + addTerminalContext: (selection: TerminalContextSelection) => { + if (!activeThread) return; + const snapshot = composerEditorRef.current?.readSnapshot() ?? { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + terminalContextIds: composerTerminalContexts.map((context) => context.id), + }; + const insertion = insertInlineTerminalContextPlaceholder( + snapshot.value, + snapshot.expandedCursor, + ); + const nextCollapsedCursor = collapseExpandedComposerCursor( + insertion.prompt, + insertion.cursor, + ); + const inserted = insertComposerDraftTerminalContext( + composerDraftTarget, + insertion.prompt, + { + id: randomUUID(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + ...selection, + }, + insertion.contextIndex, + ); + if (!inserted) return; + promptRef.current = insertion.prompt; + setComposerCursor(nextCollapsedCursor); + setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCollapsedCursor); + }); + }, + getSendContext: () => ({ + prompt: promptRef.current, + images: composerImagesRef.current, + terminalContexts: composerTerminalContextsRef.current, + selectedPromptEffort, + selectedModelOptionsForDispatch, + selectedModelSelection, + selectedProvider, + selectedModel, + selectedProviderModels, + }), + }), + [ + activeThread, + composerDraftTarget, + composerCursor, + composerTerminalContexts, + insertComposerDraftTerminalContext, + promptRef, + composerImagesRef, + composerTerminalContextsRef, + readComposerSnapshot, + selectedModel, + selectedModelOptionsForDispatch, + selectedModelSelection, + selectedPromptEffort, + selectedProvider, + selectedProviderModels, + ], + ); + + // Render + // ------------------------------------------------------------------ + return ( +
    +
    +
    + {activePendingApproval ? ( +
    + +
    + ) : pendingUserInputs.length > 0 ? ( +
    + +
    + ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
    + +
    + ) : null} + +
    + {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. + + + )} + +
    + ))} +
    + )} + + +
    + + {/* Bottom toolbar */} + {activePendingApproval ? ( +
    + +
    + ) : ( +
    +
    + + + {isComposerFooterCompact ? ( + + ) : ( + <> + {providerTraitsPicker ? ( + <> + + {providerTraitsPicker} + + ) : null} + + + )} +
    + + {/* Right side: send / stop button */} +
    + 0} + isSendBusy={isSendBusy} + isConnecting={isConnecting} + isPreparingWorktree={isPreparingWorktree} + hasSendableContent={composerSendState.hasSendableContent} + onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} + onInterrupt={handleInterruptPrimaryAction} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + /> +
    +
    + )} +
    +
    +
    + ); + }), +); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f04c9879fa..cda0bb1367 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,11 +1,14 @@ import { + type EnvironmentId, type EditorId, type ProjectScript, type ResolvedKeybindingsConfig, type ThreadId, } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; +import { type DraftId } from "~/composerDraftStore"; import { DiffIcon, TerminalSquareIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -15,7 +18,9 @@ import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; interface ChatHeaderProps { + activeThreadEnvironmentId: EnvironmentId; activeThreadId: ThreadId; + draftId?: DraftId; activeThreadTitle: string; activeProjectName: string | undefined; isGitRepo: boolean; @@ -39,7 +44,9 @@ interface ChatHeaderProps { } export const ChatHeader = memo(function ChatHeader({ + activeThreadEnvironmentId, activeThreadId, + draftId, activeThreadTitle, activeProjectName, isGitRepo, @@ -101,7 +108,13 @@ export const ChatHeader = memo(function ChatHeader({ openInCwd={openInCwd} /> )} - {activeProjectName && } + {activeProjectName && ( + + )} ["draftsByThreadId"]; const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; - draftsByThreadId[threadId] = { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - [provider]: { - provider, - model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + useComposerDraftStore.setState({ + draftsByThreadKey: { + [threadKey]: { + prompt: props?.prompt ?? "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + [provider]: { + provider, + model, + ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + }, + }, + activeProvider: provider, + runtimeMode: null, + interactionMode: null, }, }, - activeProvider: provider, - runtimeMode: null, - interactionMode: null, - }; - useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); const host = document.createElement("div"); document.body.append(host); @@ -115,13 +123,14 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str , { container: host }, ); @@ -150,9 +159,9 @@ describe("CompactComposerControlsMenu", () => { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index db38ed8c1e..8024c2303a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -15,12 +15,13 @@ import { export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; interactionMode: ProviderInteractionMode; + planSidebarLabel: string; planSidebarOpen: boolean; runtimeMode: RuntimeMode; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; - onToggleRuntimeMode: () => void; + onRuntimeModeChange: (mode: RuntimeMode) => void; }) { return ( @@ -60,10 +61,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls value={props.runtimeMode} onValueChange={(value) => { if (!value || value === props.runtimeMode) return; - props.onToggleRuntimeMode(); + props.onRuntimeModeChange(value as RuntimeMode); }} > Supervised + Auto-accept edits Full access {props.activePlan ? ( @@ -71,7 +73,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls - {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} + {props.planSidebarOpen + ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` + : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} ) : null} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29..de7cf2b2b8 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,10 +1,24 @@ -import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useRef } from "react"; -import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { + type ProjectEntry, + type ProviderKind, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; import { BotIcon } from "lucide-react"; +import { memo, useLayoutEffect, useMemo, useRef } from "react"; + +import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { formatProviderSkillInstallSource } from "~/providerSkillPresentation"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; -import { Command, CommandItem, CommandList } from "../ui/command"; +import { + Command, + CommandGroup, + CommandGroupLabel, + CommandItem, + CommandList, + CommandSeparator, +} from "../ui/command"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = @@ -23,6 +37,14 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "provider-slash-command"; + provider: ProviderKind; + command: ServerProviderSlashCommand; + label: string; + description: string; + } | { id: string; type: "model"; @@ -30,18 +52,83 @@ export type ComposerCommandItem = model: string; label: string; description: string; + } + | { + id: string; + type: "skill"; + provider: ProviderKind; + skill: ServerProviderSkill; + label: string; + description: string; }; +type ComposerCommandGroup = { + id: string; + label: string | null; + items: ComposerCommandItem[]; +}; + +function SkillGlyph(props: { className?: string }) { + return ( + + ); +} + +function groupCommandItems( + items: ComposerCommandItem[], + triggerKind: ComposerTriggerKind | null, + groupSlashCommandSections: boolean, +): ComposerCommandGroup[] { + if (triggerKind === "skill") { + return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : []; + } + if (triggerKind !== "slash-command" || !groupSlashCommandSections) { + return [{ id: "default", label: null, items }]; + } + + const builtInItems = items.filter((item) => item.type === "slash-command"); + const providerItems = items.filter((item) => item.type === "provider-slash-command"); + + const groups: ComposerCommandGroup[] = []; + if (builtInItems.length > 0) { + groups.push({ id: "built-in", label: "Built-in", items: builtInItems }); + } + if (providerItems.length > 0) { + groups.push({ id: "provider", label: "Provider", items: providerItems }); + } + return groups; +} + export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; + groupSlashCommandSections?: boolean; + emptyStateText?: string; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { const listRef = useRef(null); + const groups = useMemo( + () => + groupCommandItems(props.items, props.triggerKind, props.groupSlashCommandSections ?? true), + [props.groupSlashCommandSections, props.items, props.triggerKind], + ); useLayoutEffect(() => { if (!props.activeItemId || !listRef.current) return; @@ -65,27 +152,56 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ref={listRef} className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" > - - {props.items.map((item) => ( - + + {groups.map((group, groupIndex) => ( +
    + {groupIndex > 0 ? : null} + + {group.label ? ( + + {group.label} + + ) : null} + {group.items.map((item) => ( + + ))} + +
    ))}
    - {props.items.length === 0 && ( -

    - {props.isLoading - ? "Searching workspace files..." - : props.triggerKind === "path" - ? "No matching files or folders." - : "No matching command."} -

    - )} + {props.items.length === 0 ? ( +
    + {props.triggerKind === "skill" ? ( + + + Skills + +

    + {props.isLoading + ? "Searching workspace skills..." + : (props.emptyStateText ?? + "No skills found. Try / to browse provider commands.")} +

    +
    + ) : ( +

    + {props.isLoading + ? "Searching workspace files..." + : (props.emptyStateText ?? + (props.triggerKind === "path" + ? "No matching files or folders." + : "No matching command."))} +

    + )} +
    + ) : null}
    ); @@ -98,6 +214,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { onHighlight: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { + const skillSourceLabel = + props.item.type === "skill" ? formatProviderSkillInstallSource(props.item.skill) : null; + return ( ) : null} {props.item.type === "slash-command" ? ( - + + ) : null} + {props.item.type === "provider-slash-command" ? ( + + + + ) : null} + {props.item.type === "skill" ? ( + + + ) : null} {props.item.type === "model" ? ( model ) : null} - - {props.item.label} + + {props.item.label} + + {props.item.description} + - {props.item.description} + {skillSourceLabel ? ( + {skillSourceLabel} + ) : null} ); }); diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx index 060f197e97..1807fb4265 100644 --- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx @@ -10,7 +10,7 @@ describe("ComposerPendingTerminalContextChip", () => { ; questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; + onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; } @@ -22,7 +22,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn respondingRequestIds, answers, questionIndex, - onSelectOption, + onToggleOption, onAdvance, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; @@ -36,7 +36,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn isResponding={respondingRequestIds.includes(activePrompt.requestId)} answers={answers} questionIndex={questionIndex} - onSelectOption={onSelectOption} + onToggleOption={onToggleOption} onAdvance={onAdvance} /> ); @@ -47,19 +47,24 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isResponding, answers, questionIndex, - onSelectOption, + onToggleOption, onAdvance, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; + onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; const autoAdvanceTimerRef = useRef(null); + const onAdvanceRef = useRef(onAdvance); + + useEffect(() => { + onAdvanceRef.current = onAdvance; + }, [onAdvance]); // Clear auto-advance timer on unmount useEffect(() => { @@ -70,24 +75,23 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; }, []); - const selectOptionAndAutoAdvance = useCallback( - (questionId: string, optionLabel: string) => { - onSelectOption(questionId, optionLabel); - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null; - onAdvance(); - }, 200); - }, - [onSelectOption, onAdvance], - ); + const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { + onToggleOption(questionId, optionLabel); + if (activeQuestion?.multiSelect) { + return; + } + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + autoAdvanceTimerRef.current = window.setTimeout(() => { + autoAdvanceTimerRef.current = null; + onAdvanceRef.current(); + }, 200); + }); - // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. - // Works even when the Lexical composer (contenteditable) has focus — the composer - // doubles as a custom-answer field during user input, and when it's empty the digit - // keys should pick options instead of typing into the editor. + // Keyboard shortcut: number keys 1-9 select corresponding options when focus is + // outside editable fields. Multi-select prompts toggle options in place; single- + // select prompts keep the existing auto-advance behavior. useEffect(() => { if (!activeQuestion || isResponding) return; const handler = (event: globalThis.KeyboardEvent) => { @@ -96,11 +100,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } - // If the user has started typing a custom answer in the contenteditable - // composer, let digit keys pass through so they can type numbers. - if (target instanceof HTMLElement && target.isContentEditable) { - const hasCustomText = progress.customAnswer.length > 0; - if (hasCustomText) return; + if ( + target instanceof HTMLElement && + target.closest('[contenteditable]:not([contenteditable="false"])') + ) { + return; } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return; @@ -109,11 +113,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const option = activeQuestion.options[optionIndex]; if (!option) return; event.preventDefault(); - selectOptionAndAutoAdvance(activeQuestion.id, option.label); + handleOptionSelection(activeQuestion.id, option.label); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); + }, [activeQuestion, isResponding]); if (!activeQuestion) { return null; @@ -134,16 +138,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(

    {activeQuestion.question}

    + {activeQuestion.multiSelect ? ( +

    Select one or more options.

    + ) : null}
    {activeQuestion.options.map((option, index) => { - const isSelected = progress.selectedOptionLabel === option.label; + const isSelected = progress.selectedOptionLabels.includes(option.label); const shortcutKey = index < 9 ? index + 1 : null; return (
    @@ -135,7 +140,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ + )} +
    + + {item.name} +

    + {item.name} + {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} +

    +
    + {preview.images.length > 1 && ( + + )} +
    + ); +}); diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index cf1e798912..ad5d56dd5a 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -1,20 +1,82 @@ -import { memo } from "react"; +import { memo, useRef } from "react"; import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { cn } from "~/lib/utils"; +import { anchoredToastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); +const ANCHORED_TOAST_TIMEOUT_MS = 1000; +const onCopy = (ref: React.RefObject) => { + if (ref.current) { + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_TOAST_TIMEOUT_MS, + title: "Copied!", + }); + } +}; + +const onCopyError = (ref: React.RefObject, error: Error) => { + if (ref.current) { + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_TOAST_TIMEOUT_MS, + title: "Failed to copy", + description: error.message, + }); + } +}; + +export const MessageCopyButton = memo(function MessageCopyButton({ + text, + size = "xs", + variant = "outline", + className, +}: { + text: string; + size?: "xs" | "icon-xs"; + variant?: "outline" | "ghost"; + className?: string; +}) { + const ref = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => onCopy(ref), + onError: (error: Error) => onCopyError(ref, error), + timeout: ANCHORED_TOAST_TIMEOUT_MS, + }); return ( - + + copyToClipboard(text)} + ref={ref} + type="button" + size={size} + variant={variant} + className={cn(className)} + /> + } + > + {isCopied ? : } + + +

    Copy to clipboard

    +
    +
    ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx new file mode 100644 index 0000000000..0eb5c8a1fc --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -0,0 +1,160 @@ +import "../../index.css"; + +import { EnvironmentId } from "@t3tools/contracts"; +import { createRef } from "react"; +import type { LegendListRef } from "@legendapp/list/react"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const scrollToEndSpy = vi.fn(); +const getStateSpy = vi.fn(() => ({ isAtEnd: true })); + +vi.mock("@legendapp/list/react", async () => { + const React = await import("react"); + + const LegendList = React.forwardRef(function MockLegendList( + props: { + data: Array<{ id: string }>; + keyExtractor: (item: { id: string }) => string; + renderItem: (args: { item: { id: string } }) => React.ReactNode; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + }, + ref: React.ForwardedRef, + ) { + React.useImperativeHandle( + ref, + () => + ({ + scrollToEnd: scrollToEndSpy, + getState: getStateSpy, + }) as unknown as LegendListRef, + ); + + return ( +
    + {props.ListHeaderComponent} + {props.data.map((item) => ( +
    {props.renderItem({ item })}
    + ))} + {props.ListFooterComponent} +
    + ); + }); + + return { LegendList }; +}); + +import { MessagesTimeline } from "./MessagesTimeline"; + +function buildProps() { + return { + isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, + activeTurnStartedAt: null, + listRef: createRef(), + completionDividerBeforeEntryId: null, + completionSummary: null, + turnDiffSummaryByAssistantMessageId: new Map(), + routeThreadKey: "environment-local:thread-1", + onOpenTurnDiff: vi.fn(), + revertTurnCountByUserMessageId: new Map(), + onRevertUserMessage: vi.fn(), + isRevertingCheckpoint: false, + onImageExpand: vi.fn(), + activeThreadEnvironmentId: EnvironmentId.make("environment-local"), + markdownCwd: undefined, + resolvedTheme: "dark" as const, + timestampFormat: "24-hour" as const, + workspaceRoot: undefined, + onIsAtEndChange: vi.fn(), + }; +} + +describe("MessagesTimeline", () => { + afterEach(() => { + scrollToEndSpy.mockReset(); + getStateSpy.mockClear(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("renders activity rows instead of the empty placeholder when a thread has non-message timeline data", async () => { + const screen = await render( + , + ); + + try { + await expect + .element(page.getByText("Send a message to start the conversation.")) + .not.toBeInTheDocument(); + await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible(); + } finally { + await screen.unmount(); + } + }); + + it("snaps to the bottom when timeline rows appear after an initially empty render", async () => { + const requestAnimationFrameSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + + const props = buildProps(); + const screen = await render(); + + try { + await expect + .element(page.getByText("Send a message to start the conversation.")) + .toBeVisible(); + + await screen.rerender( + , + ); + + await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible(); + expect(props.onIsAtEndChange).toHaveBeenCalledWith(true); + expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false }); + expect(requestAnimationFrameSpy).toHaveBeenCalled(); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dee42a8586..7dc6583b13 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + computeStableMessagesTimelineRows, + computeMessageDurationStart, + deriveMessagesTimelineRows, + normalizeCompactToolLabel, + resolveAssistantMessageCopyState, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +149,287 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("resolveAssistantMessageCopyState", () => { + it("returns enabled copy state for completed assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: true, + text: "Ship it", + streaming: false, + }), + ).toEqual({ + text: "Ship it", + visible: true, + }); + }); + + it("hides copy while an assistant message is still streaming", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: true, + text: "Still streaming", + streaming: true, + }), + ).toEqual({ + text: "Still streaming", + visible: false, + }); + }); + + it("hides copy for empty completed assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: true, + text: " ", + streaming: false, + }), + ).toEqual({ + text: null, + visible: false, + }); + }); + + it("hides copy for non-terminal assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: false, + text: "Interim thought", + streaming: false, + }), + ).toEqual({ + text: "Interim thought", + visible: false, + }); + }); +}); + +describe("deriveMessagesTimelineRows", () => { + it("only enables assistant copy for the terminal assistant message in a turn", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "user-1-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "user-1" as never, + role: "user", + text: "Write a poem", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "assistant-thought-entry", + kind: "message", + createdAt: "2026-01-01T00:00:10Z", + message: { + id: "assistant-thought" as never, + role: "assistant", + text: "I should ground this first.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:10Z", + completedAt: "2026-01-01T00:00:11Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-final" as never, + role: "assistant", + text: "Here is the poem.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:20Z", + completedAt: "2026-01-01T00:00:30Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: "assistant-final-entry", + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const assistantRows = rows.filter( + (row): row is Extract<(typeof rows)[number], { kind: "message" }> => + row.kind === "message" && row.message.role === "assistant", + ); + + expect(assistantRows).toHaveLength(2); + expect(assistantRows[0]?.showAssistantCopyButton).toBe(false); + expect(assistantRows[1]?.showAssistantCopyButton).toBe(true); + expect(assistantRows[1]?.showCompletionDivider).toBe(true); + }); + + it("projects assistant diff summaries and user revert counts onto the affected rows", () => { + const assistantTurnDiffSummary = { + turnId: "turn-1" as never, + completedAt: "2026-01-01T00:00:30Z", + assistantMessageId: "assistant-1" as never, + checkpointTurnCount: 2, + files: [{ path: "src/index.ts", additions: 3, deletions: 1 }], + }; + + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "user-1" as never, + role: "user", + text: "Do the thing", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "assistant-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-1" as never, + role: "assistant", + text: "Done", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:20Z", + completedAt: "2026-01-01T00:00:30Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map([ + ["assistant-1" as never, assistantTurnDiffSummary], + ]), + revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]), + }); + + const userRow = rows.find( + (row): row is Extract<(typeof rows)[number], { kind: "message" }> => + row.kind === "message" && row.message.role === "user", + ); + const assistantRow = rows.find( + (row): row is Extract<(typeof rows)[number], { kind: "message" }> => + row.kind === "message" && row.message.role === "assistant", + ); + + expect(userRow?.revertTurnCount).toBe(1); + expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary); + }); +}); + +describe("computeStableMessagesTimelineRows", () => { + it("returns the previous result when row order and content are unchanged", () => { + const firstUserMessage = { + id: "user-1" as never, + role: "user" as const, + text: "First", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }; + const secondUserMessage = { + id: "user-2" as never, + role: "user" as const, + text: "Second", + turnId: null, + createdAt: "2026-01-01T00:00:10Z", + streaming: false, + }; + + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "entry-user-1", + kind: "message", + createdAt: firstUserMessage.createdAt, + message: firstUserMessage, + }, + { + id: "entry-user-2", + kind: "message", + createdAt: secondUserMessage.createdAt, + message: secondUserMessage, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const initial = computeStableMessagesTimelineRows(rows, { + byId: new Map(), + result: [], + }); + + const repeated = computeStableMessagesTimelineRows(rows, initial); + + expect(repeated).toBe(initial); + expect(repeated.result).toBe(initial.result); + }); + + it("returns a new result when row order changes without content changes", () => { + const firstUserMessage = { + id: "user-1" as never, + role: "user" as const, + text: "First", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }; + const secondUserMessage = { + id: "user-2" as never, + role: "user" as const, + text: "Second", + turnId: null, + createdAt: "2026-01-01T00:00:10Z", + streaming: false, + }; + + const firstRows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "entry-user-1", + kind: "message", + createdAt: firstUserMessage.createdAt, + message: firstUserMessage, + }, + { + id: "entry-user-2", + kind: "message", + createdAt: secondUserMessage.createdAt, + message: secondUserMessage, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const initial = computeStableMessagesTimelineRows(firstRows, { + byId: new Map(), + result: [], + }); + + const reordered = computeStableMessagesTimelineRows([firstRows[1]!, firstRows[0]!], initial); + + expect(reordered).not.toBe(initial); + expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 16b02ea9b7..a56643d84b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,8 +1,6 @@ -import { type MessageId } from "@t3tools/contracts"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; -import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { estimateTimelineMessageHeight } from "../timelineHeight"; +import { type MessageId } from "@t3tools/contracts"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; @@ -27,6 +25,9 @@ export type MessagesTimelineRow = message: ChatMessage; durationStart: string; showCompletionDivider: boolean; + showAssistantCopyButton: boolean; + assistantTurnDiffSummary?: TurnDiffSummary | undefined; + revertTurnCount?: number | undefined; } | { kind: "proposed-plan"; @@ -36,6 +37,11 @@ export type MessagesTimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; +export interface StableMessagesTimelineRowsState { + byId: Map; + result: MessagesTimelineRow[]; +} + export function computeMessageDurationStart( messages: ReadonlyArray, ): Map { @@ -59,16 +65,61 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +export function resolveAssistantMessageCopyState({ + text, + showCopyButton, + streaming, +}: { + text: string | null; + showCopyButton: boolean; + streaming: boolean; +}) { + const hasText = text !== null && text.trim().length > 0; + return { + text: hasText ? text : null, + visible: showCopyButton && hasText && !streaming, + }; +} + +function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { + const lastAssistantMessageIdByResponseKey = new Map(); + let nullTurnResponseIndex = 0; + + for (const timelineEntry of timelineEntries) { + if (timelineEntry.kind !== "message") { + continue; + } + const { message } = timelineEntry; + if (message.role === "user") { + nullTurnResponseIndex += 1; + continue; + } + if (message.role !== "assistant") { + continue; + } + + const responseKey = message.turnId + ? `turn:${message.turnId}` + : `unkeyed:${nullTurnResponseIndex}`; + lastAssistantMessageIdByResponseKey.set(responseKey, message.id); + } + + return new Set(lastAssistantMessageIdByResponseKey.values()); +} + export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; completionDividerBeforeEntryId: string | null; isWorking: boolean; activeTurnStartedAt: string | null; + turnDiffSummaryByAssistantMessageId: ReadonlyMap; + revertTurnCountByUserMessageId: ReadonlyMap; }): MessagesTimelineRow[] { const nextRows: MessagesTimelineRow[] = []; const durationStartByMessageId = computeMessageDurationStart( input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); + const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; @@ -115,6 +166,17 @@ export function deriveMessagesTimelineRows(input: { showCompletionDivider: timelineEntry.message.role === "assistant" && input.completionDividerBeforeEntryId === timelineEntry.id, + showAssistantCopyButton: + timelineEntry.message.role === "assistant" && + terminalAssistantMessageIds.has(timelineEntry.message.id), + assistantTurnDiffSummary: + timelineEntry.message.role === "assistant" + ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) + : undefined, + revertTurnCount: + timelineEntry.message.role === "user" + ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id) + : undefined, }); } @@ -129,71 +191,50 @@ export function deriveMessagesTimelineRows(input: { return nextRows; } -export function estimateMessagesTimelineRowHeight( - row: MessagesTimelineRow, - input: { - timelineWidthPx: number | null; - expandedWorkGroups?: Readonly>; - turnDiffSummaryByAssistantMessageId?: ReadonlyMap; - }, -): number { - switch (row.kind) { - case "work": - return estimateWorkRowHeight(row, input); - case "proposed-plan": - return estimateTimelineProposedPlanHeight(row.proposedPlan); - case "working": - return 40; - case "message": { - let estimate = estimateTimelineMessageHeight(row.message, { - timelineWidthPx: input.timelineWidthPx, - }); - const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id); - if (turnDiffSummary && turnDiffSummary.files.length > 0) { - estimate += estimateChangedFilesCardHeight(turnDiffSummary); - } - return estimate; +export function computeStableMessagesTimelineRows( + rows: MessagesTimelineRow[], + previous: StableMessagesTimelineRowsState, +): StableMessagesTimelineRowsState { + const next = new Map(); + let anyChanged = rows.length !== previous.byId.size; + + const result = rows.map((row, index) => { + const prevRow = previous.byId.get(row.id); + const nextRow = prevRow && isRowUnchanged(prevRow, row) ? prevRow : row; + next.set(row.id, nextRow); + if (!anyChanged && previous.result[index] !== nextRow) { + anyChanged = true; } - } -} + return nextRow; + }); -function estimateWorkRowHeight( - row: Extract, - input: { - expandedWorkGroups?: Readonly>; - }, -): number { - const isExpanded = input.expandedWorkGroups?.[row.id] ?? false; - const hasOverflow = row.groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded ? MAX_VISIBLE_WORK_LOG_ENTRIES : row.groupedEntries.length; - const onlyToolEntries = row.groupedEntries.every((entry) => entry.tone === "tool"); - const showHeader = hasOverflow || !onlyToolEntries; - - // Card chrome, optional header, and one compact work-entry row per visible entry. - return 28 + (showHeader ? 26 : 0) + visibleEntries * 32; + return anyChanged ? { byId: next, result } : previous; } -function estimateTimelineProposedPlanHeight(proposedPlan: ProposedPlan): number { - const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); - return 120 + Math.min(estimatedLines * 22, 880); -} +/** Shallow field comparison per row variant — avoids deep equality cost. */ +function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean { + if (a.kind !== b.kind || a.id !== b.id) return false; -function estimateChangedFilesCardHeight(turnDiffSummary: TurnDiffSummary): number { - const treeNodes = buildTurnDiffTree(turnDiffSummary.files); - const visibleNodeCount = countTurnDiffTreeNodes(treeNodes); + switch (a.kind) { + case "working": + return a.createdAt === (b as typeof a).createdAt; - // Card chrome: top/bottom padding, header row, and tree spacing. - return 60 + visibleNodeCount * 25; -} + case "proposed-plan": + return a.proposedPlan === (b as typeof a).proposedPlan; + + case "work": + return a.groupedEntries === (b as typeof a).groupedEntries; -function countTurnDiffTreeNodes(nodes: ReadonlyArray): number { - let count = 0; - for (const node of nodes) { - count += 1; - if (node.kind === "directory") { - count += countTurnDiffTreeNodes(node.children); + case "message": { + const bm = b as typeof a; + return ( + a.message === bm.message && + a.durationStart === bm.durationStart && + a.showCompletionDivider === bm.showCompletionDivider && + a.showAssistantCopyButton === bm.showAssistantCopyButton && + a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && + a.revertTurnCount === bm.revertTurnCount + ); } } - return count; } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..a8a53831a2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,35 @@ -import { MessageId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { createRef } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { LegendListRef } from "@legendapp/list/react"; + +vi.mock("@legendapp/list/react", async () => { + const React = await import("react"); + + const LegendList = React.forwardRef(function MockLegendList( + props: { + data: Array<{ id: string }>; + keyExtractor: (item: { id: string }) => string; + renderItem: (args: { item: { id: string } }) => React.ReactNode; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + }, + _ref: React.ForwardedRef, + ) { + return ( +
    + {props.ListHeaderComponent} + {props.data.map((item) => ( +
    {props.renderItem({ item })}
    + ))} + {props.ListFooterComponent} +
    + ); + }); + + return { LegendList }; +}); function matchMedia() { return { @@ -28,6 +57,11 @@ beforeAll(() => { matchMedia, addEventListener: () => {}, removeEventListener: () => {}, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 0; + }, + cancelAnimationFrame: () => {}, desktopBridge: undefined, }); vi.stubGlobal("document", { @@ -36,29 +70,48 @@ beforeAll(() => { offsetHeight: 0, }, }); - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - callback(0); - return 0; - }); }); +const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); + +function buildProps() { + return { + isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, + activeTurnStartedAt: null, + listRef: createRef(), + completionDividerBeforeEntryId: null, + completionSummary: null, + turnDiffSummaryByAssistantMessageId: new Map(), + routeThreadKey: "environment-local:thread-1", + onOpenTurnDiff: () => {}, + revertTurnCountByUserMessageId: new Map(), + onRevertUserMessage: () => {}, + isRevertingCheckpoint: false, + onImageExpand: () => {}, + activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, + markdownCwd: undefined, + resolvedTheme: "light" as const, + timestampFormat: "locale" as const, + workspaceRoot: undefined, + onIsAtEndChange: () => {}, + }; +} + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }, }, ]} - completionDividerBeforeEntryId={null} - completionSummary={null} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} />, ); expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); - }); + }, 20_000); it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }, }, ]} - completionDividerBeforeEntryId={null} - completionSummary={null} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} />, ); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("formats changed file paths from the workspace root", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8cb8b89684..8fac10eb98 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,21 +1,17 @@ -import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; import { + createContext, memo, + use, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; -import { - measureElement as measureVirtualElement, - type VirtualItem, - useVirtualizer, -} from "@tanstack/react-virtual"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import ChatMarkdown from "../ChatMarkdown"; @@ -34,324 +30,272 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; -import { clamp } from "effect/Number"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { + computeStableMessagesTimelineRows, MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, - estimateMessagesTimelineRowHeight, normalizeCompactToolLabel, + resolveAssistantMessageCopyState, + type StableMessagesTimelineRowsState, type MessagesTimelineRow, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; +import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; + import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { formatWorkspaceRelativePath } from "../../filePathDisplay"; + +// --------------------------------------------------------------------------- +// Context — shared state consumed by every row component via useContext. +// Propagates through LegendList's memo boundaries for shared callbacks and +// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking +// components (WorkingTimer, LiveElapsed) handle it. +// --------------------------------------------------------------------------- + +interface TimelineRowSharedState { + activeTurnInProgress: boolean; + activeTurnId: TurnId | null | undefined; + isWorking: boolean; + isRevertingCheckpoint: boolean; + completionSummary: string | null; + timestampFormat: TimestampFormat; + routeThreadKey: string; + markdownCwd: string | undefined; + resolvedTheme: "light" | "dark"; + workspaceRoot: string | undefined; + activeThreadEnvironmentId: EnvironmentId; + onRevertUserMessage: (messageId: MessageId) => void; + onImageExpand: (preview: ExpandedImagePreview) => void; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +} -const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; +const TimelineRowCtx = createContext(null!); + +// --------------------------------------------------------------------------- +// Props (public API) +// --------------------------------------------------------------------------- interface MessagesTimelineProps { - hasMessages: boolean; isWorking: boolean; activeTurnInProgress: boolean; + activeTurnId?: TurnId | null; activeTurnStartedAt: string | null; - scrollContainer: HTMLDivElement | null; + listRef: React.RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; - nowIso: string; - expandedWorkGroups: Record; - onToggleWorkGroup: (groupId: string) => void; + routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; + activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; - onVirtualizerSnapshot?: (snapshot: { - totalSize: number; - measurements: ReadonlyArray<{ - id: string; - kind: MessagesTimelineRow["kind"]; - index: number; - size: number; - start: number; - end: number; - }>; - }) => void; + onIsAtEndChange: (isAtEnd: boolean) => void; } +// --------------------------------------------------------------------------- +// MessagesTimeline — list owner +// --------------------------------------------------------------------------- + export const MessagesTimeline = memo(function MessagesTimeline({ - hasMessages, isWorking, activeTurnInProgress, + activeTurnId, activeTurnStartedAt, - scrollContainer, + listRef, timelineEntries, completionDividerBeforeEntryId, completionSummary, turnDiffSummaryByAssistantMessageId, - nowIso, - expandedWorkGroups, - onToggleWorkGroup, + routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, + activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, - onVirtualizerSnapshot, + onIsAtEndChange, }: MessagesTimelineProps) { - const timelineRootRef = useRef(null); - const [timelineWidthPx, setTimelineWidthPx] = useState(null); - - useLayoutEffect(() => { - const timelineRoot = timelineRootRef.current; - if (!timelineRoot) return; - - const updateWidth = (nextWidth: number) => { - setTimelineWidthPx((previousWidth) => { - if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { - return previousWidth; - } - return nextWidth; - }); - }; - - updateWidth(timelineRoot.getBoundingClientRect().width); - - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { - updateWidth(timelineRoot.getBoundingClientRect().width); - }); - observer.observe(timelineRoot); - return () => { - observer.disconnect(); - }; - }, [hasMessages, isWorking]); - - const rows = useMemo( + const rawRows = useMemo( () => deriveMessagesTimelineRows({ timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt, + turnDiffSummaryByAssistantMessageId, + revertTurnCountByUserMessageId, }), - [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], + [ + timelineEntries, + completionDividerBeforeEntryId, + isWorking, + activeTurnStartedAt, + turnDiffSummaryByAssistantMessageId, + revertTurnCountByUserMessageId, + ], ); + const rows = useStableRows(rawRows); - const firstUnvirtualizedRowIndex = useMemo(() => { - const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); - if (!activeTurnInProgress) return firstTailRowIndex; - - const turnStartedAtMs = - typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; - let firstCurrentTurnRowIndex = -1; - if (!Number.isNaN(turnStartedAtMs)) { - firstCurrentTurnRowIndex = rows.findIndex((row) => { - if (row.kind === "working") return true; - if (!row.createdAt) return false; - const rowCreatedAtMs = Date.parse(row.createdAt); - return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; - }); - } - - if (firstCurrentTurnRowIndex < 0) { - firstCurrentTurnRowIndex = rows.findIndex( - (row) => row.kind === "message" && row.message.streaming, - ); + const handleScroll = useCallback(() => { + const state = listRef.current?.getState?.(); + if (state) { + onIsAtEndChange(state.isAtEnd); } + }, [listRef, onIsAtEndChange]); - if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; + const previousRowCountRef = useRef(rows.length); + useEffect(() => { + const previousRowCount = previousRowCountRef.current; + previousRowCountRef.current = rows.length; - for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { - const previousRow = rows[index]; - if (!previousRow || previousRow.kind !== "message") continue; - if (previousRow.message.role === "user") { - return Math.min(index, firstTailRowIndex); - } - if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { - break; - } + if (previousRowCount > 0 || rows.length === 0) { + return; } - return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); - }, [activeTurnInProgress, activeTurnStartedAt, rows]); - - const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { - minimum: 0, - maximum: rows.length, - }); - const virtualMeasurementScopeKey = - timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; - - const rowVirtualizer = useVirtualizer({ - count: virtualizedRowCount, - getScrollElement: () => scrollContainer, - // Scope cached row measurements to the current timeline width so offscreen - // rows do not keep stale heights after wrapping changes. - getItemKey: (index: number) => { - const rowId = rows[index]?.id ?? String(index); - return `${virtualMeasurementScopeKey}:${rowId}`; - }, - estimateSize: (index: number) => { - const row = rows[index]; - if (!row) return 96; - return estimateMessagesTimelineRowHeight(row, { - expandedWorkGroups, - timelineWidthPx, - turnDiffSummaryByAssistantMessageId, - }); - }, - measureElement: measureVirtualElement, - useAnimationFrameWithResizeObserver: true, - overscan: 8, - }); - useEffect(() => { - if (timelineWidthPx === null) return; - rowVirtualizer.measure(); - }, [rowVirtualizer, timelineWidthPx]); - useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0; - const scrollOffset = instance.scrollOffset ?? 0; - const itemIntersectsViewport = - item.end > scrollOffset && item.start < scrollOffset + viewportHeight; - if (itemIntersectsViewport) { - return false; - } - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - }; - return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; - }; - }, [rowVirtualizer]); - const pendingMeasureFrameRef = useRef(null); - const onTimelineImageLoad = useCallback(() => { - if (pendingMeasureFrameRef.current !== null) return; - pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { - pendingMeasureFrameRef.current = null; - rowVirtualizer.measure(); + onIsAtEndChange(true); + const frameId = window.requestAnimationFrame(() => { + void listRef.current?.scrollToEnd?.({ animated: false }); }); - }, [rowVirtualizer]); - useEffect(() => { return () => { - const frame = pendingMeasureFrameRef.current; - if (frame !== null) { - window.cancelAnimationFrame(frame); - } + window.cancelAnimationFrame(frameId); }; - }, []); - useLayoutEffect(() => { - if (!onVirtualizerSnapshot) { - return; - } - onVirtualizerSnapshot({ - totalSize: rowVirtualizer.getTotalSize(), - measurements: rowVirtualizer.measurementsCache - .slice(0, virtualizedRowCount) - .flatMap((measurement) => { - const row = rows[measurement.index]; - if (!row) { - return []; - } - return [ - { - id: row.id, - kind: row.kind, - index: measurement.index, - size: measurement.size, - start: measurement.start, - end: measurement.end, - }, - ]; - }), - }); - }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< - Record - >({}); - const onToggleAllDirectories = useCallback((turnId: TurnId) => { - setAllDirectoriesExpandedByTurnId((current) => ({ - ...current, - [turnId]: !(current[turnId] ?? true), - })); - }, []); - - const renderRowContent = (row: TimelineRow) => ( + }, [listRef, onIsAtEndChange, rows.length]); + + // Memoised context value — only changes on state transitions, NOT on + // every streaming chunk. Callbacks from ChatView are useCallback-stable. + const sharedState = useMemo( + () => ({ + activeTurnInProgress, + activeTurnId: activeTurnId ?? null, + isWorking, + isRevertingCheckpoint, + completionSummary, + timestampFormat, + routeThreadKey, + markdownCwd, + resolvedTheme, + workspaceRoot, + activeThreadEnvironmentId, + onRevertUserMessage, + onImageExpand, + onOpenTurnDiff, + }), + [ + activeTurnInProgress, + activeTurnId, + isWorking, + isRevertingCheckpoint, + completionSummary, + timestampFormat, + routeThreadKey, + markdownCwd, + resolvedTheme, + workspaceRoot, + activeThreadEnvironmentId, + onRevertUserMessage, + onImageExpand, + onOpenTurnDiff, + ], + ); + + // Stable renderItem — no closure deps. Row components read shared state + // from TimelineRowCtx, which propagates through LegendList's memo. + const renderItem = useCallback( + ({ item }: { item: MessagesTimelineRow }) => ( +
    + +
    + ), + [], + ); + + if (rows.length === 0 && !isWorking) { + return ( +
    +

    + Send a message to start the conversation. +

    +
    + ); + } + + return ( + + + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd + maintainScrollAtEnd + maintainScrollAtEndThreshold={0.1} + maintainVisibleContentPosition + onScroll={handleScroll} + className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" + ListHeaderComponent={
    } + ListFooterComponent={
    } + /> + + ); +}); + +function keyExtractor(item: MessagesTimelineRow) { + return item.id; +} + +// --------------------------------------------------------------------------- +// TimelineRowContent — the actual row component +// --------------------------------------------------------------------------- + +type TimelineEntry = ReturnType[number]; +type TimelineMessage = Extract["message"]; +type TimelineWorkEntry = Extract["groupedEntries"][number]; +type TimelineRow = MessagesTimelineRow; + +function TimelineRowContent({ row }: { row: TimelineRow }) { + const ctx = use(TimelineRowCtx); + + return (
    - {row.kind === "work" && - (() => { - const groupId = row.id; - const groupedEntries = row.groupedEntries; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const showHeader = hasOverflow || !onlyToolEntries; - const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; - - return ( -
    - {showHeader && ( -
    -

    - {groupLabel} ({groupedEntries.length}) -

    - {hasOverflow && ( - - )} -
    - )} -
    - {visibleEntries.map((workEntry) => ( - - ))} -
    -
    - ); - })()} + {row.kind === "work" && } {row.kind === "message" && row.message.role === "user" && @@ -359,7 +303,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
    @@ -379,15 +323,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onClick={() => { const preview = buildExpandedImagePreview(userImages, image.id); if (!preview) return; - onImageExpand(preview); + ctx.onImageExpand(preview); }} > {image.name} ) : ( @@ -417,16 +359,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ type="button" size="xs" variant="outline" - disabled={isRevertingCheckpoint || isWorking} - onClick={() => onRevertUserMessage(row.message.id)} + disabled={ctx.isRevertingCheckpoint || ctx.isWorking} + onClick={() => ctx.onRevertUserMessage(row.message.id)} title="Revert to this message" > )}
    -

    - {formatTimestamp(row.message.createdAt, timestampFormat)} +

    + {formatTimestamp(row.message.createdAt, ctx.timestampFormat)}

    @@ -438,13 +380,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const assistantTurnStillInProgress = + ctx.activeTurnInProgress && + ctx.activeTurnId !== null && + ctx.activeTurnId !== undefined && + row.message.turnId === ctx.activeTurnId; + const assistantCopyState = resolveAssistantMessageCopyState({ + text: row.message.text ?? null, + showCopyButton: row.showAssistantCopyButton, + streaming: row.message.streaming || assistantTurnStillInProgress, + }); return ( <> {row.showCompletionDivider && (
    - {completionSummary ? `Response • ${completionSummary}` : "Response"} + {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"}
    @@ -452,75 +404,42 @@ export const MessagesTimeline = memo(function MessagesTimeline({
    - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); - const allDirectoriesExpanded = - allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; - return ( -
    -
    -

    - Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - - - - )} -

    -
    - - -
    -
    - +
    +

    + {row.message.streaming ? ( + + ) : ( + formatMessageMeta( + row.message.createdAt, + formatElapsed(row.durationStart, row.message.completedAt), + ctx.timestampFormat, + ) + )} +

    + {assistantCopyState.visible ? ( +
    +
    - ); - })()} -

    - {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

    + ) : null} +
    ); @@ -530,8 +449,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
    )} @@ -545,100 +465,210 @@ export const MessagesTimeline = memo(function MessagesTimeline({ - {row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working..."} + {row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )}
    )}
    ); +} - if (!hasMessages && !isWorking) { - return ( -
    -

    - Send a message to start the conversation. -

    -
    - ); - } +// --------------------------------------------------------------------------- +// Self-ticking components — bypass LegendList memoisation entirely. +// Each owns a `nowMs` state value consumed in the render output so the +// React Compiler cannot elide the re-render as a no-op. +// --------------------------------------------------------------------------- - return ( -
    - {virtualizedRowCount > 0 && ( -
    - {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; +/** Live "Working for Xs" label. */ +function WorkingTimer({ createdAt }: { createdAt: string }) { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, [createdAt]); + return <>{formatWorkingTimer(createdAt, new Date(nowMs).toISOString()) ?? "0s"}; +} - return ( -
    - {renderRowContent(row)} -
    - ); - })} +/** Live timestamp + elapsed duration for a streaming assistant message. */ +function LiveMessageMeta({ + createdAt, + durationStart, + timestampFormat, +}: { + createdAt: string; + durationStart: string | null | undefined; + timestampFormat: TimestampFormat; +}) { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, [durationStart]); + const elapsed = durationStart + ? formatElapsed(durationStart, new Date(nowMs).toISOString()) + : null; + return <>{formatMessageMeta(createdAt, elapsed, timestampFormat)}; +} + +// --------------------------------------------------------------------------- +// Extracted row sections — own their state / store subscriptions so changes +// re-render only the affected row, not the entire list. +// --------------------------------------------------------------------------- + +/** Owns its own expand/collapse state so toggling re-renders only this row. + * State resets on unmount which is fine — work groups start collapsed. */ +const WorkGroupSection = memo(function WorkGroupSection({ + groupedEntries, +}: { + groupedEntries: Extract["groupedEntries"]; +}) { + const { workspaceRoot } = use(TimelineRowCtx); + const [isExpanded, setIsExpanded] = useState(false); + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const showHeader = hasOverflow || !onlyToolEntries; + const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; + + return ( +
    + {showHeader && ( +
    +

    + {groupLabel} ({groupedEntries.length}) +

    + {hasOverflow && ( + + )}
    )} - - {nonVirtualizedRows.map((row) => ( -
    {renderRowContent(row)}
    - ))} +
    + {visibleEntries.map((workEntry) => ( + + ))} +
    ); }); -type TimelineEntry = ReturnType[number]; -type TimelineMessage = Extract["message"]; -type TimelineWorkEntry = Extract["groupedEntries"][number]; -type TimelineRow = MessagesTimelineRow; - -function formatWorkingTimer(startIso: string, endIso: string): string | null { - const startedAtMs = Date.parse(startIso); - const endedAtMs = Date.parse(endIso); - if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { - return null; - } - - const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s`; - } +/** Subscribes directly to the UI state store for expand/collapse state, + * so toggling re-renders only this component — not the entire list. */ +const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ + turnSummary, + routeThreadKey, + resolvedTheme, + onOpenTurnDiff, +}: { + turnSummary: TurnDiffSummary | undefined; + routeThreadKey: string; + resolvedTheme: "light" | "dark"; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; - const hours = Math.floor(elapsedSeconds / 3600); - const minutes = Math.floor((elapsedSeconds % 3600) / 60); - const seconds = elapsedSeconds % 60; + return ( + + ); +}); - if (hours > 0) { - return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; - } +/** Inner component that only mounts when there are actual changed files, + * so the store subscription is unconditional (no hooks after early return). */ +function AssistantChangedFilesSectionInner({ + turnSummary, + checkpointFiles, + routeThreadKey, + resolvedTheme, + onOpenTurnDiff, +}: { + turnSummary: TurnDiffSummary; + checkpointFiles: TurnDiffSummary["files"]; + routeThreadKey: string; + resolvedTheme: "light" | "dark"; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + const allDirectoriesExpanded = useUiStateStore( + (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + ); + const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); + const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const changedFileCountLabel = String(checkpointFiles.length); - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + return ( +
    +
    +

    + Changed files ({changedFileCountLabel}) + {hasNonZeroStat(summaryStat) && ( + <> + + + + )} +

    +
    + + +
    +
    + +
    + ); } -function formatMessageMeta( - createdAt: string, - duration: string | null, - timestampFormat: TimestampFormat, -): string { - if (!duration) return formatTimestamp(createdAt, timestampFormat); - return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; -} +// --------------------------------------------------------------------------- +// Leaf components +// --------------------------------------------------------------------------- const UserMessageTerminalContextInlineLabel = memo( function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { @@ -699,7 +729,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
    +
    {inlineNodes}
    ); @@ -727,7 +757,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
    +
    {inlineNodes}
    ); @@ -738,12 +768,68 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
    +    
    {props.text} -
    +
    ); }); +// --------------------------------------------------------------------------- +// Structural sharing — reuse old row references when data hasn't changed +// so LegendList (and React) can skip re-rendering unchanged items. +// --------------------------------------------------------------------------- + +/** Returns a structurally-shared copy of `rows`: for each row whose content + * hasn't changed since last call, the previous object reference is reused. */ +function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { + const prevState = useRef({ + byId: new Map(), + result: [], + }); + + return useMemo(() => { + const nextState = computeStableMessagesTimelineRows(rows, prevState.current); + prevState.current = nextState; + return nextState.result; + }, [rows]); +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +function formatWorkingTimer(startIso: string, endIso: string): string | null { + const startedAtMs = Date.parse(startIso); + const endedAtMs = Date.parse(endIso); + if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { + return null; + } + + const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s`; + } + + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +function formatMessageMeta( + createdAt: string, + duration: string | null, + timestampFormat: TimestampFormat, +): string { + if (!duration) return formatTimestamp(createdAt, timestampFormat); + return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; +} + function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string; @@ -781,15 +867,27 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { function workEntryPreview( workEntry: Pick, + workspaceRoot: string | undefined, ) { if (workEntry.command) return workEntry.command; if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; if (!firstPath) return null; + const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); return workEntry.changedFiles!.length === 1 - ? firstPath - : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; +} + +function workEntryRawCommand( + workEntry: Pick, +): string | null { + const rawCommand = workEntry.rawCommand?.trim(); + if (!rawCommand || !workEntry.command) { + return null; + } + return rawCommand === workEntry.command.trim() ? null : rawCommand; } function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { @@ -834,12 +932,14 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; + workspaceRoot: string | undefined; }) { - const { workEntry } = props; + const { workEntry, workspaceRoot } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); - const preview = workEntryPreview(workEntry); + const preview = workEntryPreview(workEntry, workspaceRoot); + const rawCommand = workEntryRawCommand(workEntry); const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; @@ -853,32 +953,62 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
    -

    - - {heading} - - {preview && - {preview}} -

    +
    +

    + + {heading} + + {preview && + (rawCommand ? ( + + + {" "} + - {preview} + + } + /> + +

    + {rawCommand} +
    + + + ) : ( + - {preview} + ))} +

    +
    {hasChangedFiles && !previewIsChangedFiles && (
    - {workEntry.changedFiles?.slice(0, 4).map((filePath) => ( - - {filePath} - - ))} + {workEntry.changedFiles?.slice(0, 4).map((filePath) => { + const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); + return ( + + {displayPath} + + ); + })} {(workEntry.changedFiles?.length ?? 0) > 4 && ( +{(workEntry.changedFiles?.length ?? 0) - 4} diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx deleted file mode 100644 index 1e947a3c84..0000000000 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ /dev/null @@ -1,1037 +0,0 @@ -import "../../index.css"; - -import { MessageId, type TurnId } from "@t3tools/contracts"; -import { page } from "vitest/browser"; -import { useCallback, useState, type ComponentProps } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic"; -import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { MessagesTimeline } from "./MessagesTimeline"; -import { - deriveMessagesTimelineRows, - estimateMessagesTimelineRowHeight, -} from "./MessagesTimeline.logic"; - -const DEFAULT_VIEWPORT = { - width: 960, - height: 1_100, -}; -const MARKDOWN_CWD = "/repo/project"; - -interface RowMeasurement { - actualHeightPx: number; - estimatedHeightPx: number; - timelineWidthPx: number; - virtualizerSizePx: number; - renderedInVirtualizedRegion: boolean; -} - -interface VirtualizationScenario { - name: string; - targetRowId: string; - props: Omit, "scrollContainer">; - maxEstimateDeltaPx: number; -} - -interface VirtualizerSnapshot { - totalSize: number; - measurements: ReadonlyArray<{ - id: string; - kind: string; - index: number; - size: number; - start: number; - end: number; - }>; -} - -function MessagesTimelineBrowserHarness( - props: Omit, "scrollContainer">, -) { - const [scrollContainer, setScrollContainer] = useState(null); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>( - () => props.expandedWorkGroups, - ); - const handleToggleWorkGroup = useCallback( - (groupId: string) => { - setExpandedWorkGroups((current) => ({ - ...current, - [groupId]: !(current[groupId] ?? false), - })); - props.onToggleWorkGroup(groupId); - }, - [props], - ); - - return ( -
    - -
    - ); -} - -function isoAt(offsetSeconds: number): string { - return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString(); -} - -function createMessage(input: { - id: string; - role: ChatMessage["role"]; - text: string; - offsetSeconds: number; - attachments?: ChatMessage["attachments"]; -}): ChatMessage { - return { - id: MessageId.makeUnsafe(input.id), - role: input.role, - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - createdAt: isoAt(input.offsetSeconds), - ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}), - streaming: false, - }; -} - -function createToolWorkEntry(input: { - id: string; - offsetSeconds: number; - label?: string; - detail?: string; -}): WorkLogEntry { - return { - id: input.id, - createdAt: isoAt(input.offsetSeconds), - label: input.label ?? "exec_command completed", - ...(input.detail ? { detail: input.detail } : {}), - tone: "tool", - toolTitle: "exec_command", - }; -} - -function createPlan(input: { - id: string; - offsetSeconds: number; - planMarkdown: string; -}): ProposedPlan { - return { - id: input.id as ProposedPlan["id"], - turnId: null, - planMarkdown: input.planMarkdown, - implementedAt: null, - implementationThreadId: null, - createdAt: isoAt(input.offsetSeconds), - updatedAt: isoAt(input.offsetSeconds + 1), - }; -} - -function createBaseTimelineProps(input: { - messages?: ChatMessage[]; - proposedPlans?: ProposedPlan[]; - workEntries?: WorkLogEntry[]; - expandedWorkGroups?: Record; - completionDividerBeforeEntryId?: string | null; - turnDiffSummaryByAssistantMessageId?: Map; - onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"]; -}): Omit, "scrollContainer"> { - return { - hasMessages: true, - isWorking: false, - activeTurnInProgress: false, - activeTurnStartedAt: null, - timelineEntries: deriveTimelineEntries( - input.messages ?? [], - input.proposedPlans ?? [], - input.workEntries ?? [], - ), - completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null, - completionSummary: null, - turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(), - nowIso: isoAt(10_000), - expandedWorkGroups: input.expandedWorkGroups ?? {}, - onToggleWorkGroup: () => {}, - onOpenTurnDiff: () => {}, - revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: () => {}, - isRevertingCheckpoint: false, - onImageExpand: () => {}, - markdownCwd: MARKDOWN_CWD, - resolvedTheme: "light", - timestampFormat: "locale", - workspaceRoot: MARKDOWN_CWD, - ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}), - }; -} - -function createFillerMessages(input: { - prefix: string; - startOffsetSeconds: number; - pairCount: number; -}): ChatMessage[] { - const messages: ChatMessage[] = []; - for (let index = 0; index < input.pairCount; index += 1) { - const baseOffset = input.startOffsetSeconds + index * 4; - messages.push( - createMessage({ - id: `${input.prefix}-user-${index}`, - role: "user", - text: `filler user message ${index}`, - offsetSeconds: baseOffset, - }), - ); - messages.push( - createMessage({ - id: `${input.prefix}-assistant-${index}`, - role: "assistant", - text: `filler assistant message ${index}`, - offsetSeconds: baseOffset + 1, - }), - ); - } - return messages; -} - -function createChangedFilesSummary( - targetMessageId: MessageId, - files: TurnDiffSummary["files"], -): Map { - return new Map([ - [ - targetMessageId, - { - turnId: "turn-changed-files" as TurnId, - completedAt: isoAt(10), - assistantMessageId: targetMessageId, - files, - }, - ], - ]); -} - -function createChangedFilesScenario(input: { - name: string; - rowId: string; - files: TurnDiffSummary["files"]; - maxEstimateDeltaPx?: number; -}): VirtualizationScenario { - const beforeMessages = createFillerMessages({ - prefix: `${input.rowId}-before`, - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: `${input.rowId}-after`, - startOffsetSeconds: 40, - pairCount: 8, - }); - const changedFilesMessage = createMessage({ - id: input.rowId, - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - - return { - name: input.name, - targetRowId: changedFilesMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, changedFilesMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary( - changedFilesMessage.id, - input.files, - ), - }), - maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72, - }; -} - -function createAssistantMessageScenario(input: { - name: string; - rowId: string; - text: string; - maxEstimateDeltaPx?: number; -}): VirtualizationScenario { - const beforeMessages = createFillerMessages({ - prefix: `${input.rowId}-before`, - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: `${input.rowId}-after`, - startOffsetSeconds: 40, - pairCount: 8, - }); - const assistantMessage = createMessage({ - id: input.rowId, - role: "assistant", - text: input.text, - offsetSeconds: 12, - }); - - return { - name: input.name, - targetRowId: assistantMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, assistantMessage, ...afterMessages], - }), - maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16, - }; -} - -function buildStaticScenarios(): VirtualizationScenario[] { - const beforeMessages = createFillerMessages({ - prefix: "before", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after", - startOffsetSeconds: 40, - pairCount: 8, - }); - - const longUserMessage = createMessage({ - id: "target-user-long", - role: "user", - text: "x".repeat(3_200), - offsetSeconds: 12, - }); - const workEntries = Array.from({ length: 4 }, (_, index) => - createToolWorkEntry({ - id: `target-work-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ); - const moderatePlan = createPlan({ - id: "target-plan", - offsetSeconds: 12, - planMarkdown: [ - "# Stabilize virtualization", - "", - "- Gather baseline measurements", - "- Add browser harness coverage", - "- Compare estimated and rendered heights", - "- Fix the broken rows without broad refactors", - "- Re-run lint and typecheck", - ].join("\n"), - }); - return [ - { - name: "long user message", - targetRowId: longUserMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, longUserMessage, ...afterMessages], - }), - maxEstimateDeltaPx: 56, - }, - { - name: "grouped work log row", - targetRowId: workEntries[0]!.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries, - }), - maxEstimateDeltaPx: 56, - }, - { - name: "expanded grouped work log row with show more enabled", - targetRowId: "target-work-expanded-0", - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries: Array.from({ length: 10 }, (_, index) => - createToolWorkEntry({ - id: `target-work-expanded-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ), - expandedWorkGroups: { - "target-work-expanded-0": true, - }, - }), - maxEstimateDeltaPx: 72, - }, - { - name: "proposed plan row", - targetRowId: moderatePlan.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - proposedPlans: [moderatePlan], - }), - maxEstimateDeltaPx: 96, - }, - createAssistantMessageScenario({ - name: "assistant single-paragraph row with plain prose", - rowId: "target-assistant-plain-prose", - text: [ - "The host is still expanding to content somewhere in the grid layout.", - "I'm stripping it back further to a plain block container so the test width", - "is actually the timeline width.", - ].join(" "), - }), - createAssistantMessageScenario({ - name: "assistant single-paragraph row with inline code", - rowId: "target-assistant-inline-code", - text: [ - "Typecheck found one exact-optional-property issue in the browser harness:", - "I was always passing `onVirtualizerSnapshot`, including `undefined`.", - "I'm tightening that object construction and rerunning the checks.", - ].join(" "), - maxEstimateDeltaPx: 28, - }), - createChangedFilesScenario({ - name: "assistant changed-files row with a compacted single-chain directory", - rowId: "target-assistant-changed-files-single-chain", - files: [ - { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx", - additions: 0, - deletions: 26, - }, - ], - }), - createChangedFilesScenario({ - name: "assistant changed-files row with a branch after compaction", - rowId: "target-assistant-changed-files-branch-point", - files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/server/src/provider/Layers/CodexAdapter.ts", - additions: 27, - deletions: 8, - }, - { - path: "apps/server/src/provider/Layers/CodexAdapter.test.ts", - additions: 36, - deletions: 0, - }, - ], - }), - createChangedFilesScenario({ - name: "assistant changed-files row with mixed root and nested entries", - rowId: "target-assistant-changed-files-mixed-root", - files: [ - { path: "README.md", additions: 5, deletions: 1 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ], - }), - ]; -} - -async function nextFrame(): Promise { - await new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); - }); -} - -async function waitForLayout(): Promise { - await nextFrame(); - await nextFrame(); - await nextFrame(); -} - -async function setViewport(viewport: { width: number; height: number }): Promise { - await page.viewport(viewport.width, viewport.height); - await waitForLayout(); -} - -async function waitForProductionStyles(): Promise { - await vi.waitFor( - () => { - expect( - getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), - ).not.toBe(""); - expect(getComputedStyle(document.body).marginTop).toBe("0px"); - }, - { timeout: 4_000, interval: 16 }, - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - if (!element) { - throw new Error(errorMessage); - } - return element; -} - -async function measureTimelineRow(input: { - host: HTMLElement; - props: Omit, "scrollContainer">; - targetRowId: string; -}): Promise { - const scrollContainer = await waitForElement( - () => - input.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - - const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`; - const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`; - - let timelineWidthPx = 0; - let actualHeightPx = 0; - let virtualizerSizePx = 0; - let renderedInVirtualizedRegion = false; - - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - const rowElement = input.host.querySelector(rowSelector); - const virtualRowElement = input.host.querySelector(virtualRowSelector); - const timelineRoot = input.host.querySelector('[data-timeline-root="true"]'); - - expect(rowElement, "Unable to locate target timeline row.").toBeTruthy(); - expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy(); - expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy(); - - timelineWidthPx = timelineRoot!.getBoundingClientRect().width; - actualHeightPx = rowElement!.getBoundingClientRect().height; - virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0"); - renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index"); - - expect(timelineWidthPx).toBeGreaterThan(0); - expect(actualHeightPx).toBeGreaterThan(0); - expect(virtualizerSizePx).toBeGreaterThan(0); - expect(renderedInVirtualizedRegion).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - - const rows = deriveMessagesTimelineRows({ - timelineEntries: input.props.timelineEntries, - completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId, - isWorking: input.props.isWorking, - activeTurnStartedAt: input.props.activeTurnStartedAt, - }); - const targetRow = rows.find((row) => row.id === input.targetRowId); - expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy(); - - return { - actualHeightPx, - estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, { - expandedWorkGroups: input.props.expandedWorkGroups, - timelineWidthPx, - turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId, - }), - timelineWidthPx, - virtualizerSizePx, - renderedInVirtualizedRegion, - }; -} - -async function mountMessagesTimeline(input: { - props: Omit, "scrollContainer">; - viewport?: { width: number; height: number }; -}) { - const viewport = input.viewport ?? DEFAULT_VIEWPORT; - await setViewport(viewport); - await waitForProductionStyles(); - - const host = document.createElement("div"); - host.style.width = `${viewport.width}px`; - host.style.minWidth = `${viewport.width}px`; - host.style.maxWidth = `${viewport.width}px`; - host.style.height = `${viewport.height}px`; - host.style.minHeight = `${viewport.height}px`; - host.style.maxHeight = `${viewport.height}px`; - host.style.display = "block"; - host.style.overflow = "hidden"; - document.body.append(host); - - const screen = await render(, { - container: host, - }); - await waitForLayout(); - - return { - host, - rerender: async ( - nextProps: Omit, "scrollContainer">, - ) => { - await screen.rerender(); - await waitForLayout(); - }, - setContainerSize: async (nextViewport: { width: number; height: number }) => { - await setViewport(nextViewport); - host.style.width = `${nextViewport.width}px`; - host.style.minWidth = `${nextViewport.width}px`; - host.style.maxWidth = `${nextViewport.width}px`; - host.style.height = `${nextViewport.height}px`; - host.style.minHeight = `${nextViewport.height}px`; - host.style.maxHeight = `${nextViewport.height}px`; - await waitForLayout(); - }, - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -async function measureRenderedRowActualHeight(input: { - host: HTMLElement; - targetRowId: string; -}): Promise { - const rowElement = await waitForElement( - () => input.host.querySelector(`[data-timeline-row-id="${input.targetRowId}"]`), - `Unable to locate rendered row ${input.targetRowId}.`, - ); - return rowElement.getBoundingClientRect().height; -} - -describe("MessagesTimeline virtualization harness", () => { - beforeEach(async () => { - document.body.innerHTML = ""; - await setViewport(DEFAULT_VIEWPORT); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => { - const mounted = await mountMessagesTimeline({ props: scenario.props }); - - try { - const measurement = await measureTimelineRow({ - host: mounted.host, - props: scenario.props, - targetRowId: scenario.targetRowId, - }); - - expect( - Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx), - `estimate delta for ${scenario.name}`, - ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => { - const beforeMessages = createFillerMessages({ - prefix: "before-collapse", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after-collapse", - startOffsetSeconds: 40, - pairCount: 8, - }); - const targetMessage = createMessage({ - id: "target-assistant-collapse", - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - const props = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ - { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", - additions: 4, - deletions: 3, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", - additions: 131, - deletions: 128, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", - additions: 1, - deletions: 1, - }, - { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, - { - path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", - additions: 106, - deletions: 112, - }, - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/web/src/components/chat/MessagesTimeline.tsx", - additions: 52, - deletions: 7, - }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.tsx", - additions: 32, - deletions: 4, - }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ]), - }); - const mounted = await mountMessagesTimeline({ - props, - viewport: { width: 320, height: 700 }, - }); - - try { - const beforeCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - const targetRowElement = mounted.host.querySelector( - `[data-timeline-row-id="${targetMessage.id}"]`, - ); - expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy(); - - const collapseAllButton = - Array.from(targetRowElement!.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Collapse all", - ) ?? null; - expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy(); - - collapseAllButton!.click(); - - await vi.waitFor( - async () => { - const afterCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24); - }, - { timeout: 8_000, interval: 16 }, - ); - - const afterCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - expect( - Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx), - ).toBeLessThanOrEqual(8); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => { - const beforeMessages = createFillerMessages({ - prefix: "before-worklog-expand", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after-worklog-expand", - startOffsetSeconds: 40, - pairCount: 8, - }); - const workEntries = Array.from({ length: 10 }, (_, index) => - createToolWorkEntry({ - id: `target-work-toggle-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ); - const props = createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries, - }); - const mounted = await mountMessagesTimeline({ props }); - - try { - const beforeExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - const targetRowElement = mounted.host.querySelector( - `[data-timeline-row-id="${workEntries[0]!.id}"]`, - ); - expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy(); - - const showMoreButton = - Array.from(targetRowElement!.querySelectorAll("button")).find((button) => - button.textContent?.includes("Show 4 more"), - ) ?? null; - expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy(); - - showMoreButton!.click(); - - await vi.waitFor( - async () => { - const afterExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72); - }, - { timeout: 8_000, interval: 16 }, - ); - - const afterExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - expect( - Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx), - ).toBeLessThanOrEqual(8); - } finally { - await mounted.cleanup(); - } - }); - - it("preserves measured tail row heights when rows transition into virtualization", async () => { - const beforeMessages = createFillerMessages({ - prefix: "tail-transition-before", - startOffsetSeconds: 0, - pairCount: 1, - }); - const afterMessages = createFillerMessages({ - prefix: "tail-transition-after", - startOffsetSeconds: 40, - pairCount: 3, - }); - const targetMessage = createMessage({ - id: "target-tail-transition", - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - let latestSnapshot: VirtualizerSnapshot | null = null; - const initialProps = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ - { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", - additions: 4, - deletions: 3, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", - additions: 131, - deletions: 128, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", - additions: 1, - deletions: 1, - }, - { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, - { - path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", - additions: 106, - deletions: 112, - }, - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/web/src/components/chat/MessagesTimeline.tsx", - additions: 52, - deletions: 7, - }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.tsx", - additions: 32, - deletions: 4, - }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ]), - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, - }); - - const mounted = await mountMessagesTimeline({ props: initialProps }); - - try { - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ - host: mounted.host, - targetRowId: targetMessage.id, - }); - - const appendedProps = createBaseTimelineProps({ - messages: [ - ...beforeMessages, - targetMessage, - ...afterMessages, - ...createFillerMessages({ - prefix: "tail-transition-extra", - startOffsetSeconds: 120, - pairCount: 8, - }), - ], - turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId, - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, - }); - await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("preserves measured tail image row heights when rows transition into virtualization", async () => { - const beforeMessages = createFillerMessages({ - prefix: "tail-image-before", - startOffsetSeconds: 0, - pairCount: 1, - }); - const afterMessages = createFillerMessages({ - prefix: "tail-image-after", - startOffsetSeconds: 40, - pairCount: 3, - }); - const targetMessage = createMessage({ - id: "target-tail-image-transition", - role: "user", - text: "Here is a narrow screenshot.", - offsetSeconds: 12, - attachments: [ - { - type: "image", - id: "target-tail-image", - name: "narrow.svg", - mimeType: "image/svg+xml", - sizeBytes: 512, - previewUrl: - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='72'%3E%3Crect width='240' height='72' fill='%23dbeafe'/%3E%3C/svg%3E", - }, - ], - }); - let latestSnapshot: VirtualizerSnapshot | null = null; - const initialProps = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, - }); - const mounted = await mountMessagesTimeline({ props: initialProps }); - - try { - await vi.waitFor( - () => { - const image = mounted.host.querySelector( - `[data-timeline-row-id="${targetMessage.id}"] img`, - ); - expect(image?.naturalHeight ?? 0).toBeGreaterThan(0); - }, - { timeout: 8_000, interval: 16 }, - ); - - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ - host: mounted.host, - targetRowId: targetMessage.id, - }); - const appendedProps = createBaseTimelineProps({ - messages: [ - ...beforeMessages, - targetMessage, - ...afterMessages, - ...createFillerMessages({ - prefix: "tail-image-extra", - startOffsetSeconds: 120, - pairCount: 8, - }), - ], - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, - }); - await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target image row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 703bfadaa3..11480074a4 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -10,13 +10,16 @@ import { AntigravityIcon, CursorIcon, Icon, + KiroIcon, TraeIcon, IntelliJIdeaIcon, VisualStudioCode, + VisualStudioCodeInsiders, + VSCodium, Zed, } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; +import { readLocalApi } from "~/localApi"; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ @@ -30,6 +33,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray { - const api = readNativeApi(); + const api = readLocalApi(); if (!api || !openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; @@ -108,7 +116,7 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !openInCwd) return; if (!preferredEditor) return; diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index fc52c33225..a36cb097cb 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -1,4 +1,5 @@ import { memo, useState, useId } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -24,15 +25,17 @@ import { DialogTitle, } from "../ui/dialog"; import { toastManager } from "../ui/toast"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, + environmentId, cwd, workspaceRoot, }: { planMarkdown: string; + environmentId: EnvironmentId; cwd: string | undefined; workspaceRoot: string | undefined; }) { @@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const handleSaveToWorkspace = () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); const relativePath = savePath.trim(); if (!api || !workspaceRoot) { return; diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 13fe6faba2..abedcd6eeb 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -24,6 +24,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], models: [ { slug: "gpt-5-codex", @@ -59,6 +61,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], models: [ { slug: "claude-opus-4-6", @@ -120,6 +124,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), models, + slashCommands: [], + skills: [], }; } diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 74c22e6431..686a36b60d 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -6,10 +6,11 @@ import { CodexModelOptions, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - ProjectId, + EnvironmentId, type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { page } from "vitest/browser"; import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; // ── Claude TraitsPicker tests ───────────────────────────────────────── -const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); +const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const CLAUDE_THREAD_ID = ThreadId.make("thread-claude-traits"); +const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID); +const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF); +const CODEX_THREAD_ID = ThreadId.make("thread-codex-traits"); +const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID); +const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF); const TEST_PROVIDERS: ReadonlyArray = [ { provider: "codex", @@ -37,6 +44,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + slashCommands: [], + skills: [], models: [ { slug: "gpt-5.4", @@ -63,6 +72,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + slashCommands: [], + skills: [], models: [ { slug: "claude-opus-4-6", @@ -120,10 +131,10 @@ function ClaudeTraitsPickerHarness(props: { fallbackModelSelection: ModelSelection | null; triggerVariant?: "ghost" | "outline"; }) { - const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt; + const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; const setPrompt = useComposerDraftStore((store) => store.setPrompt); const { modelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId: CLAUDE_THREAD_ID, + threadRef: CLAUDE_THREAD_REF, providers: TEST_PROVIDERS, selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, @@ -135,7 +146,7 @@ function ClaudeTraitsPickerHarness(props: { }); const handlePromptChange = useCallback( (nextPrompt: string) => { - setPrompt(CLAUDE_THREAD_ID, nextPrompt); + setPrompt(CLAUDE_THREAD_REF, nextPrompt); }, [setPrompt], ); @@ -144,7 +155,7 @@ function ClaudeTraitsPickerHarness(props: { = { - [CLAUDE_THREAD_ID]: { + const draftsByThreadKey: Record = { + [CLAUDE_THREAD_KEY]: { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], @@ -192,9 +203,9 @@ async function mountClaudePicker(props?: { }, }; useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); const host = document.createElement("div"); document.body.append(host); @@ -230,9 +241,9 @@ describe("TraitsPicker (Claude)", () => { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); @@ -369,10 +380,9 @@ describe("TraitsPicker (Claude)", () => { // ── Codex TraitsPicker tests ────────────────────────────────────────── async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { - const threadId = ThreadId.makeUnsafe("thread-codex-traits"); const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; - const draftsByThreadId: Record = { - [threadId]: { + const draftsByThreadKey: Record = { + [CODEX_THREAD_KEY]: { prompt: "", images: [], nonPersistedImageIds: [], @@ -392,10 +402,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt }; useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: { - [ProjectId.makeUnsafe("project-codex-traits")]: threadId, + draftsByThreadKey, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + "environment-local:project-codex-traits": CODEX_THREAD_KEY, }, }); const host = document.createElement("div"); @@ -404,7 +414,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt { document.body.innerHTML = ""; localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 061594ad53..14b5cdfb3c 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -3,8 +3,8 @@ import { type CodexModelOptions, type ProviderKind, type ProviderModelOptions, + type ScopedThreadRef, type ServerProviderModel, - type ThreadId, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -28,18 +28,19 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; type ProviderOptions = ProviderModelOptions[ProviderKind]; type TraitsPersistence = | { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; onModelOptionsChange?: never; } | { - threadId?: undefined; + threadRef?: undefined; onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; }; @@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ persistence.onModelOptionsChange(nextOptions); return; } - setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true }); + const threadTarget = persistence.threadRef ?? persistence.draftId; + if (!threadTarget) { + return; + } + setProviderModelOptions(threadTarget, provider, nextOptions, { + persistSticky: true, + }); }, [persistence, provider, setProviderModelOptions], ); diff --git a/apps/web/src/components/chat/composerMenuHighlight.test.ts b/apps/web/src/components/chat/composerMenuHighlight.test.ts new file mode 100644 index 0000000000..08c0f2f24d --- /dev/null +++ b/apps/web/src/components/chat/composerMenuHighlight.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; + +describe("resolveComposerMenuActiveItemId", () => { + const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const; + + it("defaults to the first item when nothing is highlighted", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: null, + currentSearchKey: "skill:u", + highlightedSearchKey: null, + }), + ).toBe("top"); + }); + + it("preserves the highlighted item within the same query", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "second", + currentSearchKey: "skill:u", + highlightedSearchKey: "skill:u", + }), + ).toBe("second"); + }); + + it("resets to the top result when the query changes", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "second", + currentSearchKey: "skill:ui", + highlightedSearchKey: "skill:u", + }), + ).toBe("top"); + }); + + it("falls back to the first item when the highlighted item disappears", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "missing", + currentSearchKey: "skill:ui", + highlightedSearchKey: "skill:ui", + }), + ).toBe("top"); + }); +}); diff --git a/apps/web/src/components/chat/composerMenuHighlight.ts b/apps/web/src/components/chat/composerMenuHighlight.ts new file mode 100644 index 0000000000..3cc3d4324f --- /dev/null +++ b/apps/web/src/components/chat/composerMenuHighlight.ts @@ -0,0 +1,20 @@ +export function resolveComposerMenuActiveItemId(input: { + items: ReadonlyArray<{ id: string }>; + highlightedItemId: string | null; + currentSearchKey: string | null; + highlightedSearchKey: string | null; +}): string | null { + if (input.items.length === 0) { + return null; + } + + if ( + input.currentSearchKey === input.highlightedSearchKey && + input.highlightedItemId && + input.items.some((item) => item.id === input.highlightedItemId) + ) { + return input.highlightedItemId; + } + + return input.items[0]?.id ?? null; +} diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 4dc79832d4..1735117837 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { ServerProviderModel } from "@t3tools/contracts"; -import { getComposerProviderState } from "./composerProviderRegistry"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./composerProviderRegistry"; const CODEX_MODELS: ReadonlyArray = [ { @@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); }); }); + +describe("provider traits render guards", () => { + it("returns null for codex traits picker when no thread target is provided", () => { + const content = renderProviderTraitsPicker({ + provider: "codex", + model: "gpt-5.4", + models: CODEX_MODELS, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + + expect(content).toBeNull(); + }); + + it("returns null for claude traits menu content when no thread target is provided", () => { + const content = renderProviderTraitsMenuContent({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + + expect(content).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3307442db2..74d8d85cff 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,11 +1,12 @@ import { type ProviderKind, type ProviderModelOptions, + type ScopedThreadRef, type ServerProviderModel, - type ThreadId, } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; +import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; import { @@ -33,7 +34,8 @@ export type ComposerProviderState = { type ProviderRegistryEntry = { getState: (input: ComposerProviderStateInput) => ComposerProviderState; renderTraitsMenuContent: (input: { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -41,7 +43,8 @@ type ProviderRegistryEntry = { onPromptChange: (prompt: string) => void; }) => ReactNode; renderTraitsPicker: (input: { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -50,6 +53,13 @@ type ProviderRegistryEntry = { }) => ReactNode; }; +function hasComposerTraitsTarget(input: { + threadRef: ScopedThreadRef | undefined; + draftId: DraftId | undefined; +}): boolean { + return input.threadRef !== undefined || input.draftId !== undefined; +} + function getProviderStateFromCapabilities( input: ComposerProviderStateInput, ): ComposerProviderState { @@ -94,66 +104,92 @@ const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ - threadId, + threadRef, + draftId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), + renderTraitsPicker: ({ + threadRef, + draftId, model, models, modelOptions, prompt, onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), }, claudeAgent: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ - threadId, + threadRef, + draftId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), + renderTraitsPicker: ({ + threadRef, + draftId, model, models, modelOptions, prompt, onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), }, }; @@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ - threadId: input.threadId, + ...(input.threadRef ? { threadRef: input.threadRef } : {}), + ...(input.draftId ? { draftId: input.draftId } : {}), model: input.model, models: input.models, modelOptions: input.modelOptions, @@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { export function renderProviderTraitsPicker(input: { provider: ProviderKind; - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsPicker({ - threadId: input.threadId, + ...(input.threadRef ? { threadRef: input.threadRef } : {}), + ...(input.draftId ? { draftId: input.draftId } : {}), model: input.model, models: input.models, modelOptions: input.modelOptions, diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts new file mode 100644 index 0000000000..3da69933d0 --- /dev/null +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; +import { searchSlashCommandItems } from "./composerSlashCommandSearch"; + +describe("searchSlashCommandItems", () => { + it("moves exact provider command matches ahead of broader description matches", () => { + const items = [ + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + { + id: "provider-slash-command:claudeAgent:ui", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "ui" }, + label: "/ui", + description: "Explore, build, and refine UI.", + }, + { + id: "provider-slash-command:claudeAgent:frontend-design", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "frontend-design" }, + label: "/frontend-design", + description: "Create distinctive, production-grade frontend interfaces", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([ + "provider-slash-command:claudeAgent:ui", + "slash:default", + ]); + }); + + it("supports fuzzy provider command matches", () => { + const items = [ + { + id: "provider-slash-command:claudeAgent:gh-fix-ci", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "gh-fix-ci" }, + label: "/gh-fix-ci", + description: "Fix failing GitHub Actions", + }, + { + id: "provider-slash-command:claudeAgent:github", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "github" }, + label: "/github", + description: "General GitHub help", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([ + "provider-slash-command:claudeAgent:gh-fix-ci", + ]); + }); +}); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts new file mode 100644 index 0000000000..c4919b1924 --- /dev/null +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -0,0 +1,83 @@ +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; + +function scoreSlashCommandItem( + item: Extract, + query: string, +): number | null { + const primaryValue = + item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); + const description = item.description.toLowerCase(); + + const scores = [ + scoreQueryMatch({ + value: primaryValue, + query, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: description, + query, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; + } + + return Math.min(...scores); +} + +export function searchSlashCommandItems( + items: ReadonlyArray< + Extract + >, + query: string, +): Array> { + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); + if (!normalizedQuery) { + return [...items]; + } + + const ranked: Array<{ + item: Extract; + score: number; + tieBreaker: string; + }> = []; + + for (const item of items) { + const score = scoreSlashCommandItem(item, normalizedQuery); + if (score === null) { + continue; + } + + insertRankedSearchResult( + ranked, + { + item, + score, + tieBreaker: + item.type === "slash-command" + ? `0\u0000${item.command}` + : `1\u0000${item.command.name}\u0000${item.provider}`, + }, + Number.POSITIVE_INFINITY, + ); + } + + return ranked.map((entry) => entry.item); +} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index d269fafbbf..0a019f6f33 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -4,9 +4,6 @@ import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, - measureComposerFooterOverflowPx, - resolveComposerFooterContentWidth, - shouldForceCompactComposerFooterForFit, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, } from "./composerFooterLayout"; @@ -56,99 +53,3 @@ describe("shouldUseCompactComposerPrimaryActions", () => { ).toBe(false); }); }); - -describe("measureComposerFooterOverflowPx", () => { - it("returns the overflow amount when content exceeds the footer width", () => { - expect( - measureComposerFooterOverflowPx({ - footerContentWidth: 500, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(28); - }); - - it("returns zero when content fits", () => { - expect( - measureComposerFooterOverflowPx({ - footerContentWidth: 500, - leadingContentWidth: 320, - actionsWidth: 160, - }), - ).toBe(0); - }); -}); - -describe("shouldForceCompactComposerFooterForFit", () => { - it("stays expanded when content widths fit within the footer", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 320, - actionsWidth: 160, - }), - ).toBe(false); - }); - - it("stays expanded when minor overflow can be recovered by compacting primary actions", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(false); - }); - - it("forces footer compact mode when action compaction would not recover enough space", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 420, - actionsWidth: 220, - }), - ).toBe(true); - }); - - it("ignores incomplete measurements", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: null, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(false); - }); -}); - -describe("resolveComposerFooterContentWidth", () => { - it("subtracts horizontal padding from the measured footer width", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: 500, - paddingLeft: 10, - paddingRight: 10, - }), - ).toBe(480); - }); - - it("clamps negative widths to zero", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: 10, - paddingLeft: 8, - paddingRight: 8, - }), - ).toBe(0); - }); - - it("returns null when measurements are incomplete", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: null, - paddingLeft: 8, - paddingRight: 8, - }), - ).toBeNull(); - }); -}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index b4a7fe3d60..ae5fd56669 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -2,8 +2,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX = COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; -const COMPOSER_FOOTER_CONTENT_GAP_PX = 8; -const COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX = 120; export function shouldUseCompactComposerFooter( width: number | null, @@ -24,46 +22,3 @@ export function shouldUseCompactComposerPrimaryActions( } return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX; } - -export function measureComposerFooterOverflowPx(input: { - footerContentWidth: number | null; - leadingContentWidth: number | null; - actionsWidth: number | null; -}): number | null { - const footerContentWidth = input.footerContentWidth; - const leadingContentWidth = input.leadingContentWidth; - const actionsWidth = input.actionsWidth; - if (footerContentWidth === null || leadingContentWidth === null || actionsWidth === null) { - return null; - } - return Math.max( - 0, - leadingContentWidth + actionsWidth + COMPOSER_FOOTER_CONTENT_GAP_PX - footerContentWidth, - ); -} - -export function shouldForceCompactComposerFooterForFit(input: { - footerContentWidth: number | null; - leadingContentWidth: number | null; - actionsWidth: number | null; -}): boolean { - const overflowPx = measureComposerFooterOverflowPx(input); - if (overflowPx === null) { - return false; - } - return overflowPx > COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX; -} - -export function resolveComposerFooterContentWidth(input: { - footerWidth: number | null; - paddingLeft: number | null; - paddingRight: number | null; -}): number | null { - const footerWidth = input.footerWidth; - const paddingLeft = input.paddingLeft; - const paddingRight = input.paddingRight; - if (footerWidth === null || paddingLeft === null || paddingRight === null) { - return null; - } - return Math.max(0, footerWidth - paddingLeft - paddingRight); -} diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index 273f4204e6..bf869ee31d 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -5,5 +5,8 @@ export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-8 export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight"; +export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = + "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 84bde53048..454ecdfe0e 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -17,6 +17,7 @@ import { const baseState: DesktopUpdateState = { enabled: true, status: "idle", + channel: "latest", currentVersion: "1.0.0", hostArch: "x64", appArch: "x64", diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx new file mode 100644 index 0000000000..ab31fe7e17 --- /dev/null +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -0,0 +1,1432 @@ +import { PlusIcon, QrCodeIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { + type AuthClientSession, + type AuthPairingLink, + type DesktopServerExposureState, + type EnvironmentId, +} from "@t3tools/contracts"; +import { DateTime } from "effect"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { cn } from "../../lib/utils"; +import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { Input } from "../ui/input"; +import { + Dialog, + DialogFooter, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { QRCodeSvg } from "../ui/qr-code"; +import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { setPairingTokenOnUrl } from "../../pairingUrl"; +import { + createServerPairingCredential, + fetchSessionState, + revokeOtherServerClientSessions, + revokeServerClientSession, + revokeServerPairingLink, + isLoopbackHostname, + type ServerClientSessionRecord, + type ServerPairingLinkRecord, +} from "~/environments/primary"; +import type { WsRpcClient } from "~/rpc/wsRpcClient"; +import { + type SavedEnvironmentRecord, + type SavedEnvironmentRuntimeState, + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, + addSavedEnvironment, + getPrimaryEnvironmentConnection, + reconnectSavedEnvironment, + removeSavedEnvironment, +} from "~/environments/runtime"; + +const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +function formatAccessTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return accessTimestampFormatter.format(parsed); +} + +type ConnectionStatusDotProps = { + tooltipText?: string | null; + dotClassName: string; + pingClassName?: string | null; +}; + +function ConnectionStatusDot({ + tooltipText, + dotClassName, + pingClassName, +}: ConnectionStatusDotProps) { + const dotContent = ( + <> + {pingClassName ? ( + + ) : null} + + + ); + + if (!tooltipText) { + return ( + + {dotContent} + + ); + } + + const dot = ( + + ); + + return ( + + + + {tooltipText} + + + ); +} + +function getSavedBackendStatusTooltip( + runtime: SavedEnvironmentRuntimeState | null, + record: SavedEnvironmentRecord, + nowMs: number, +) { + const connectionState = runtime?.connectionState ?? "disconnected"; + + if (connectionState === "connected") { + const connectedAt = runtime?.connectedAt ?? record.lastConnectedAt; + return connectedAt ? `Connected for ${formatElapsedDurationLabel(connectedAt, nowMs)}` : null; + } + + if (connectionState === "connecting") { + return null; + } + + if (connectionState === "error") { + return runtime?.lastError ?? "An unknown connection error occurred."; + } + + return record.lastConnectedAt + ? `Last connected at ${formatAccessTimestamp(record.lastConnectedAt)}` + : "Not connected yet."; +} + +/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ +const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; + +const ITEM_ROW_INNER_CLASSNAME = + "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; + +function sortDesktopPairingLinks(links: ReadonlyArray) { + return [...links].toSorted( + (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ); +} + +function sortDesktopClientSessions(sessions: ReadonlyArray) { + return [...sessions].toSorted((left, right) => { + if (left.current !== right.current) { + return left.current ? -1 : 1; + } + if (left.connected !== right.connected) { + return left.connected ? -1 : 1; + } + return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime(); + }); +} + +function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { + return { + ...pairingLink, + createdAt: DateTime.formatIso(pairingLink.createdAt), + expiresAt: DateTime.formatIso(pairingLink.expiresAt), + }; +} + +function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { + return { + ...clientSession, + issuedAt: DateTime.formatIso(clientSession.issuedAt), + expiresAt: DateTime.formatIso(clientSession.expiresAt), + lastConnectedAt: + clientSession.lastConnectedAt === null + ? null + : DateTime.formatIso(clientSession.lastConnectedAt), + }; +} + +function upsertDesktopPairingLink( + current: ReadonlyArray, + next: ServerPairingLinkRecord, +) { + const existingIndex = current.findIndex((pairingLink) => pairingLink.id === next.id); + if (existingIndex === -1) { + return sortDesktopPairingLinks([...current, next]); + } + const updated = [...current]; + updated[existingIndex] = next; + return sortDesktopPairingLinks(updated); +} + +function removeDesktopPairingLink(current: ReadonlyArray, id: string) { + return current.filter((pairingLink) => pairingLink.id !== id); +} + +function upsertDesktopClientSession( + current: ReadonlyArray, + next: ServerClientSessionRecord, +) { + const existingIndex = current.findIndex( + (clientSession) => clientSession.sessionId === next.sessionId, + ); + if (existingIndex === -1) { + return sortDesktopClientSessions([...current, next]); + } + const updated = [...current]; + updated[existingIndex] = next; + return sortDesktopClientSessions(updated); +} + +function removeDesktopClientSession( + current: ReadonlyArray, + sessionId: ServerClientSessionRecord["sessionId"], +) { + return current.filter((clientSession) => clientSession.sessionId !== sessionId); +} + +function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { + const url = new URL(endpointUrl); + url.pathname = "/pair"; + return setPairingTokenOnUrl(url, credential).toString(); +} + +function resolveCurrentOriginPairingUrl(credential: string): string { + const url = new URL("/pair", window.location.href); + return setPairingTokenOnUrl(url, credential).toString(); +} + +type PairingLinkListRowProps = { + pairingLink: ServerPairingLinkRecord; + endpointUrl: string | null | undefined; + revokingPairingLinkId: string | null; + onRevoke: (id: string) => void; +}; + +const PairingLinkListRow = memo(function PairingLinkListRow({ + pairingLink, + endpointUrl, + revokingPairingLinkId, + onRevoke, +}: PairingLinkListRowProps) { + const nowMs = useRelativeTimeTick(1_000); + const expiresAtMs = useMemo( + () => new Date(pairingLink.expiresAt).getTime(), + [pairingLink.expiresAt], + ); + const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); + + const currentOriginPairingUrl = useMemo( + () => resolveCurrentOriginPairingUrl(pairingLink.credential), + [pairingLink.credential], + ); + const shareablePairingUrl = + endpointUrl != null && endpointUrl !== "" + ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) + : isLoopbackHostname(window.location.hostname) + ? null + : currentOriginPairingUrl; + const copyValue = shareablePairingUrl ?? pairingLink.credential; + const canCopyToClipboard = + typeof window !== "undefined" && + window.isSecureContext && + navigator.clipboard?.writeText != null; + + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => { + toastManager.add({ + type: "success", + title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", + description: shareablePairingUrl + ? "Open it in the client you want to pair to this environment." + : "Paste it into another client with this backend's reachable host.", + }); + }, + onError: (error) => { + setIsRevealDialogOpen(true); + toastManager.add({ + type: "error", + title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", + description: canCopyToClipboard ? error.message : "Showing the full value instead.", + }); + }, + }); + + const handleCopy = useCallback(() => { + copyToClipboard(copyValue, undefined); + }, [copyToClipboard, copyValue]); + + const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); + + const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; + const primaryLabel = pairingLink.label ?? `${roleLabel} link`; + + if (expiresAtMs <= nowMs) { + return null; + } + + return ( +
    +
    +
    +
    + +

    {primaryLabel}

    + + {shareablePairingUrl ? ( + <> + + } + > + + + + + + + ) : null} + +
    +

    + {[roleLabel, formatExpiresInLabel(pairingLink.expiresAt, nowMs)].join(" · ")} +

    + {shareablePairingUrl === null ? ( +

    + Copy the token and pair from another client using this backend's reachable host. +

    + ) : null} +
    +
    + + {canCopyToClipboard ? ( + + ) : ( + }> + {shareablePairingUrl ? "Show link" : "Show token"} + + )} + + + {shareablePairingUrl ? "Pairing link" : "Pairing token"} + + {shareablePairingUrl + ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." + : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} + + + +