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..5c2d2c8381 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 504952e3aa..ac01ee8793 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: preflight: name: Preflight runs-on: ubuntu-24.04 + timeout-minutes: 10 outputs: version: ${{ steps.release_meta.outputs.version }} tag: ${{ steps.release_meta.outputs.tag }} @@ -81,6 +82,7 @@ jobs: name: Build ${{ matrix.label }} needs: preflight runs-on: ${{ matrix.runner }} + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -227,6 +229,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 @@ -260,6 +263,7 @@ jobs: name: Publish GitHub Release needs: [preflight, build, publish_cli] runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 @@ -307,6 +311,7 @@ jobs: name: Finalize release 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..776d11b803 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,7 +9,8 @@ "bun.lock", "*.tsbuildinfo", "**/routeTree.gen.ts", - "apps/web/public/mockServiceWorker.js" + "apps/web/public/mockServiceWorker.js", + "apps/web/src/lib/vendor/qrcodegen.ts" ], "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/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md 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/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts new file mode 100644 index 0000000000..fd6180b5da --- /dev/null +++ b/apps/desktop/src/backendReadiness.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BackendReadinessAbortedError, + isBackendReadinessAborted, + waitForHttpReady, +} from "./backendReadiness"; + +describe("waitForHttpReady", () => { + it("returns once the backend reports a successful session endpoint", 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); + }); + + 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); + }); +}); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts new file mode 100644 index 0000000000..cd5a3c023e --- /dev/null +++ b/apps/desktop/src/backendReadiness.ts @@ -0,0 +1,103 @@ +export interface WaitForHttpReadyOptions { + readonly timeoutMs?: number; + readonly intervalMs?: number; + readonly requestTimeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly signal?: AbortSignal; +} + +const DEFAULT_TIMEOUT_MS = 10_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 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(`${baseUrl}/api/auth/session`, { + redirect: "manual", + signal: requestController.signal, + }); + if (response.ok) { + 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/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts new file mode 100644 index 0000000000..e687bf544e --- /dev/null +++ b/apps/desktop/src/desktopSettings.test.ts @@ -0,0 +1,64 @@ +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, + setDesktopServerExposurePreference, + 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())).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("persists and reloads the configured server exposure mode", () => { + const settingsPath = makeSettingsPath(); + + writeDesktopSettings(settingsPath, { + serverExposureMode: "network-accessible", + }); + + expect(readDesktopSettings(settingsPath)).toEqual({ + serverExposureMode: "network-accessible", + }); + }); + + it("preserves the requested network-accessible preference across temporary fallback", () => { + expect( + setDesktopServerExposurePreference( + { + serverExposureMode: "local-only", + }, + "network-accessible", + ), + ).toEqual({ + serverExposureMode: "network-accessible", + }); + }); + + it("falls back to defaults when the settings file is malformed", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync(settingsPath, "{not-json", "utf8"); + + expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); +}); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts new file mode 100644 index 0000000000..80ef229ea2 --- /dev/null +++ b/apps/desktop/src/desktopSettings.ts @@ -0,0 +1,51 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; +import type { DesktopServerExposureMode } from "@t3tools/contracts"; + +export interface DesktopSettings { + readonly serverExposureMode: DesktopServerExposureMode; +} + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + serverExposureMode: "local-only", +}; + +export function setDesktopServerExposurePreference( + settings: DesktopSettings, + requestedMode: DesktopServerExposureMode, +): DesktopSettings { + return settings.serverExposureMode === requestedMode + ? settings + : { + ...settings, + serverExposureMode: requestedMode, + }; +} + +export function readDesktopSettings(settingsPath: string): DesktopSettings { + try { + if (!FS.existsSync(settingsPath)) { + return DEFAULT_DESKTOP_SETTINGS; + } + + const raw = FS.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as { + readonly serverExposureMode?: unknown; + }; + + return { + serverExposureMode: + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + }; + } catch { + return DEFAULT_DESKTOP_SETTINGS; + } +} + +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..96ca4bd89e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,6 +7,7 @@ import * as Path from "node:path"; import { app, BrowserWindow, + clipboard, dialog, ipcMain, Menu, @@ -19,6 +20,8 @@ import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { DesktopTheme, + DesktopServerExposureMode, + DesktopServerExposureState, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, @@ -29,7 +32,15 @@ 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_SETTINGS, + readDesktopSettings, + setDesktopServerExposurePreference, + writeDesktopSettings, +} from "./desktopSettings"; +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { resolveDesktopServerExposure } from "./serverExposure"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { @@ -59,9 +70,12 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; 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_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +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 DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -82,6 +96,7 @@ 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; +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -91,8 +106,13 @@ 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 restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -102,6 +122,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); +let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -140,17 +162,120 @@ 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 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 +323,27 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +async function waitForBackendHttpReady(baseUrl: string): Promise { + cancelBackendReadinessWait(); + const controller = new AbortController(); + backendReadinessAbortController = controller; + + try { + await waitForHttpReady(baseUrl, { + signal: controller.signal, + }); + } finally { + if (backendReadinessAbortController === controller) { + backendReadinessAbortController = null; + } + } +} + +function cancelBackendReadinessWait(): void { + backendReadinessAbortController?.abort(); + backendReadinessAbortController = null; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -542,10 +688,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()) { @@ -766,6 +909,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; @@ -1035,7 +1198,8 @@ function startBackend(): void { noBrowser: true, port: backendPort, t3Home: BASE_DIR, - authToken: backendAuthToken, + host: backendBindHost, + desktopBootstrapToken: backendBootstrapToken, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), @@ -1094,6 +1258,7 @@ function startBackend(): void { } function stopBackend(): void { + cancelBackendReadinessWait(); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1115,6 +1280,7 @@ function stopBackend(): void { } async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { + cancelBackendReadinessWait(); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1167,9 +1333,36 @@ 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_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_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); @@ -1337,14 +1530,19 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function getInitialWindowBackgroundColor(): string { + return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, height: 780, minWidth: 840, minHeight: 620, - show: false, + show: isDevelopment, autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, titleBarStyle: "hiddenInset", @@ -1375,6 +1573,14 @@ 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" }, + ); + } + menuTemplate.push( { role: "cut", enabled: params.editFlags.canCut }, { role: "copy", enabled: params.editFlags.canCopy }, @@ -1401,15 +1607,20 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); - window.once("ready-to-show", () => { - window.show(); - }); + if (!isDevelopment) { + window.once("ready-to-show", () => { + revealWindow(window); + }); + } if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); + void window.loadURL(resolveDesktopDevServerUrl()); window.webContents.openDevTools({ mode: "detach" }); + setImmediate(() => { + revealWindow(window); + }); } else { - void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); + void window.loadURL(resolveDesktopWindowUrl()); } window.on("closed", () => { @@ -1421,6 +1632,14 @@ function createWindow(): BrowserWindow { return window; } +function resolveDesktopWindowUrl(): string { + if (backendHttpUrl) { + return backendHttpUrl; + } + + return `${DESKTOP_SCHEME}://app`; +} + // Override Electron's userData path before the `ready` event so that // Chromium session data uses a filesystem-friendly directory name. // Must be called synchronously at the top level — before `app.whenReady()`. @@ -1430,21 +1649,72 @@ 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 Effect.service(NetService).pipe( + Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)), + Effect.provide(NetService.layer), + Effect.runPromise, + )); + writeDesktopLogHeader( + configuredBackendPort === undefined + ? `reserved backend port via NetService port=${backendPort}` + : `using configured backend port port=${backendPort}`, ); - 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}`); + 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(`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"); + + 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; + } + + await waitForBackendHttpReady(backendHttpUrl); + writeDesktopLogHeader("bootstrap backend ready"); mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); } @@ -1454,6 +1724,7 @@ app.on("before-quit", () => { updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); + cancelBackendReadinessWait(); stopBackend(); restoreStdIoCapture?.(); }); @@ -1467,13 +1738,19 @@ app registerDesktopProtocol(); configureAutoUpdater(); void bootstrap().catch((error) => { + if (isBackendReadinessAborted(error) && isQuitting) { + return; + } handleFatalStartupError("bootstrap", error); }); app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createWindow(); + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; + if (existingWindow) { + revealWindow(existingWindow); + return; } + mainWindow = createWindow(); }); }) .catch((error) => { @@ -1492,6 +1769,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..60392f7dba 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -12,13 +12,20 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; 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_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +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; + getLocalEnvironmentBootstrap: () => { + const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; }, + getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), + setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), 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/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 87c81f08c8..5239cf103f 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), 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..fa7191110a --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -0,0 +1,385 @@ +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 { 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.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..640cc030f8 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.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 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"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "desktop", + }), + ), + ), + ); + + 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"]); + }).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..eaddf968f3 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -0,0 +1,57 @@ +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 { SESSION_COOKIE_NAME } from "../utils.ts"; + +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +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 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: SESSION_COOKIE_NAME, + }; + + 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..a106d15fd5 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -0,0 +1,151 @@ +import * as Crypto from "node:crypto"; + +import { Effect, FileSystem, Layer, Path } 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 isMissingSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => + cause instanceof PlatformError.PlatformError && cause.reason._tag === "NotFound"; + + const isAlreadyExistsSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => + cause instanceof PlatformError.PlatformError && cause.reason._tag === "AlreadyExists"; + + const get: ServerSecretStoreShape["get"] = (name) => + fileSystem.readFile(resolveSecretPath(name)).pipe( + Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.catch((cause) => + isMissingSecretFileError(cause) + ? 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) => + isAlreadyExistsSecretFileError(error.cause) + ? 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) => + isMissingSecretFileError(cause) + ? 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..e8d034aff2 --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -0,0 +1,493 @@ +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 { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; +import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; +import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { SESSION_COOKIE_NAME } from "../utils.ts"; +import { + SessionCredentialError, + SessionCredentialService, + type IssuedSession, + type SessionCredentialChange, + type SessionCredentialServiceShape, + type VerifiedSession, +} from "../Services/SessionCredentialService.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + 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; + +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 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 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.makeUnsafe(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* Effect.try({ + try: () => + Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), + catch: (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* Effect.try({ + try: () => + Schema.decodeUnknownSync(WebSocketClaims)( + JSON.parse(base64UrlDecodeUtf8(encodedPayload)), + ), + catch: (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: SESSION_COOKIE_NAME, + 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..cbaaf98fd3 --- /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, ServiceMap } 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 ServiceMap.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..4f5cf4fdab --- /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, ServiceMap } 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 ServiceMap.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..0e38679b85 --- /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, ServiceMap } 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 ServiceMap.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..43dae6ca69 --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuthPolicy.ts @@ -0,0 +1,11 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerAuthPolicyShape { + readonly getDescriptor: () => Effect.Effect; +} + +export class ServerAuthPolicy extends ServiceMap.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..376527aea3 --- /dev/null +++ b/apps/server/src/auth/Services/ServerSecretStore.ts @@ -0,0 +1,22 @@ +import { Data, ServiceMap } 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 ServiceMap.Service< + ServerSecretStore, + ServerSecretStoreShape +>()("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..591504766e --- /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, ServiceMap } 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 ServiceMap.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..e87c66c6b9 --- /dev/null +++ b/apps/server/src/auth/utils.ts @@ -0,0 +1,121 @@ +import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Crypto from "node:crypto"; + +export const SESSION_COOKIE_NAME = "t3_session"; + +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/cli-config.test.ts b/apps/server/src/cli-config.test.ts index ef2f9f55d8..6dd48c5d25 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,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "env-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -106,7 +104,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 +122,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,7 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -166,7 +162,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 +178,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 +212,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "bootstrap-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -242,7 +236,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 +278,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 +292,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 +329,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -371,7 +362,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 +392,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: resolved.staticDir, devUrl: undefined, noBrowser: true, - authToken: undefined, + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index fbbe26e6cf..4840fecc9a 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -1,28 +1,41 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + 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 CliError from "effect/unstable/cli/CliError"; +import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli } from "./cli.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))); + 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("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 +47,87 @@ 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")); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 9ece02a0d3..01fd300542 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,6 +1,20 @@ import { NetService } from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect"; +import { AuthSessionId } from "@t3tools/contracts"; +import { + Config, + Console, + Duration, + Effect, + FileSystem, + LogLevel, + Option, + Path, + References, + Schema, + SchemaIssue, + SchemaTransformation, +} from "effect"; import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; import { @@ -15,6 +29,14 @@ import { 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"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); @@ -25,7 +47,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 +80,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 +134,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,12 +156,16 @@ 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; } +interface CliAuthLocationFlags { + readonly baseDir: Option.Option; + readonly devUrl: Option.Option; +} + const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(Option.filter(flag, Boolean), () => envValue); @@ -248,13 +265,9 @@ export const resolveServerConfig = ( () => mode === "desktop", ), ); - const authToken = Option.getOrUndefined( - resolveOptionPrecedence( - flags.authToken, - Option.fromUndefinedOr(env.authToken), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.authToken), - ), + const desktopBootstrapToken = Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.desktopBootstrapToken), ), ); const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -327,7 +340,7 @@ export const resolveServerConfig = ( staticDir, devUrl, noBrowser, - authToken, + desktopBootstrapToken, autoBootstrapProjectFromCwd, logWebSocketEvents, }; @@ -335,6 +348,110 @@ export const resolveServerConfig = ( return config; }); +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, + 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(AuthControlPlaneRuntimeLive), + Effect.provideService(ServerConfig, config), + Effect.provideService(References.MinimumLogLevel, minimumLogLevel), + ); + }); + const commandFlags = { mode: modeFlag, port: portFlag, @@ -348,13 +465,216 @@ 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 = { + baseDir: baseDirFlag, + devUrl: devUrlFlag, +} as const; + +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 startCommand = Command.make("start", commandFlags).pipe( Command.withDescription("Run the T3 Code server."), Command.withHandler((flags) => Effect.gen(function* () { @@ -365,4 +685,14 @@ const rootCommand = Command.make("t3", commandFlags).pipe( ), ); -export const cli = rootCommand; +export const cli = Command.make("t3", commandFlags).pipe( + Command.withDescription("Run the T3 Code server."), + 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)); + }), + ), + Command.withSubcommands([startCommand, authCommand]), +); 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.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..bea28168a6 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -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", - }; } /** diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9ceea4c13c..14c34b8336 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -30,6 +30,8 @@ export interface ServerDerivedPaths { readonly providerEventLogPath: string; readonly terminalLogsDir: string; readonly anonymousIdPath: string; + readonly environmentIdPath: string; + readonly secretsDir: string; } /** @@ -54,7 +56,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; - readonly authToken: string | undefined; + readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; } @@ -83,6 +85,8 @@ 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"), + secretsDir: join(stateDir, "secrets"), }; }); @@ -145,7 +149,7 @@ 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, + } 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..58102378c6 --- /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.makeUnsafe(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..a5f77c5093 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -0,0 +1,104 @@ +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(() => + runProcess(command, args, { + allowNonZeroExit: true, + }), + ).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..9cf432ca72 --- /dev/null +++ b/apps/server/src/environment/Services/ServerEnvironment.ts @@ -0,0 +1,13 @@ +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerEnvironmentShape { + readonly getEnvironmentId: Effect.Effect; + readonly getDescriptor: Effect.Effect; +} + +export class ServerEnvironment extends ServiceMap.Service< + ServerEnvironment, + ServerEnvironmentShape +>()("t3/environment/Services/ServerEnvironment") {} diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5e4416d8b9..5ff2714b61 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -949,11 +949,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; diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 1178a4b67e..911a601955 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1177,9 +1177,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, @@ -1312,6 +1310,17 @@ 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.ignoreCause({ log: true })); + return yield* readStatusDetailsLocal(cwd); + }); + const status: GitCoreShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ @@ -2000,12 +2009,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 +2081,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 +2128,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { execute, status, statusDetails, + statusDetailsLocal, prepareCommitContext, commit, pushCurrentBranch, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 005bdb5bc6..38cbd13014 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -854,7 +854,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 +962,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 +1685,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..d5e7eca217 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -8,9 +8,13 @@ import { GitCommandError, GitRunStackedActionResult, GitStackedAction, + type GitStatusLocalResult, + type GitStatusRemoteResult, ModelSelection, } from "@t3tools/contracts"; import { + detectGitHostingProviderFromRemoteUrl, + mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -695,26 +699,55 @@ 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({ + capacity: STATUS_RESULT_CACHE_CAPACITY, + lookup: readLocalStatus, + 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 +758,38 @@ 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({ capacity: STATUS_RESULT_CACHE_CAPACITY, - lookup: readStatus, + lookup: readRemoteStatus, 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, @@ -1311,9 +1353,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 +1555,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 +1759,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 +1774,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/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d7a28d1763..015efa8bbd 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -7,10 +7,12 @@ * @module GitCore */ import { ServiceMap } from "effect"; -import type { Effect, Scope } 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. diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 86842257b4..0e04ceedcb 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -14,6 +14,8 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, + GitStatusLocalResult, + GitStatusRemoteResult, GitStatusInput, GitStatusResult, } 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. */ diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..b898b03f56 --- /dev/null +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -0,0 +1,27 @@ +import { ServiceMap } 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 ServiceMap.Service< + GitStatusBroadcaster, + GitStatusBroadcasterShape +>()("t3/git/Services/GitStatusBroadcaster") {} 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..ed40389429 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( @@ -193,11 +231,14 @@ export const staticAndDevRouteLayer = HttpRouter.add( } 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 +246,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..1d0ab812f2 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -322,7 +322,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 +334,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", }), ); } diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 76b14c8597..59b0239c96 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", @@ -43,6 +45,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, "darwin", + { PATH: "" }, ); assert.deepEqual(vscodeLaunch, { command: "code", @@ -70,6 +73,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 +96,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 +106,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", @@ -119,6 +125,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const vscodeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, "darwin", + { PATH: "" }, ); assert.deepEqual(vscodeLineAndColumn, { command: "code", @@ -146,6 +153,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 +163,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 +190,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 +236,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 +246,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", @@ -321,4 +364,29 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { assert.deepEqual(editors, ["trae", "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..ef50d3a5b8 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -75,6 +75,18 @@ function resolveCommandEditorArgs( } } +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 +210,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); } } @@ -236,6 +256,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,9 +268,11 @@ 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, + command, args: resolveCommandEditorArgs(editorDef, input.cwd), }; } diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 72adb175f9..2e1d078f81 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"; @@ -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), @@ -424,6 +447,28 @@ describe("CheckpointReactor", () => { ).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.makeUnsafe("evt-turn-completed-refresh-local-status"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("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(); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 03abebaf3a..3ed4dacd2b 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 = @@ -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; @@ -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..77b12e86ae 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"; @@ -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), @@ -623,6 +625,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), @@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); @@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1850745469..6835f79d01 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, @@ -1846,6 +1847,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(), { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..0a9d90107f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -4,6 +4,7 @@ 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"; @@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); const projectionSnapshotLayer = it.layer( - OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), ); projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { @@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c695674..b0f883f940 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -38,6 +38,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, @@ -163,6 +164,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, @@ -436,269 +439,283 @@ 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", - ), - ), - ), - listThreadRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreads:query", - "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", - ), + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", ), ), - listThreadMessageRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", - ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", ), ), - listThreadProposedPlanRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", - ), + ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", ), ), - listThreadActivityRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", - ), + ), + listThreadProposedPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", ), ), - listThreadSessionRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", - ), + ), + listThreadActivityRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", ), ), - listCheckpointRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", - ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", ), ), - listLatestTurnRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", - ), + ), + listCheckpointRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", ), ), - listProjectionStateRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", - ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", ), ), - ]); - - 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; - - 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 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 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 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 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(), - }; - - return yield* decodeReadModel(snapshot).pipe( + ), + listProjectionStateRows(undefined).pipe( Effect.mapError( - toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + 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(); + + 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 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 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 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, + }); + } + + 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, + 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; @@ -732,19 +749,24 @@ 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), + ), + ), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index fe6cb9caf5..f41a596ceb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -28,6 +28,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.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"; @@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderCommandReactorLive.pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 85f4d966e3..30f43365d9 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"; @@ -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( 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/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d18..8c9fe4d9fd 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,9 @@ 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"; /** * Migration loader with all migrations defined inline. @@ -63,6 +66,9 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionSnapshotLookupIndexes", Migration0019], + [20, "AuthAccessManagement", Migration0020], + [21, "AuthSessionClientMetadata", Migration0021], + [22, "AuthSessionLastConnectedAt", Migration0022], ] 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/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts new file mode 100644 index 0000000000..76c14a6586 --- /dev/null +++ b/apps/server/src/persistence/Services/AuthPairingLinks.ts @@ -0,0 +1,76 @@ +import { Option, Schema, ServiceMap } 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 ServiceMap.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..a4410fa379 --- /dev/null +++ b/apps/server/src/persistence/Services/AuthSessions.ts @@ -0,0 +1,93 @@ +import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } 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 ServiceMap.Service< + AuthSessionRepository, + AuthSessionRepositoryShape +>()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} 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..4e33f5c162 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -0,0 +1,147 @@ +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({ + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), + 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/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..2847cbca11 --- /dev/null +++ b/apps/server/src/project/Services/RepositoryIdentityResolver.ts @@ -0,0 +1,12 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface RepositoryIdentityResolverShape { + readonly resolve: (cwd: string) => Effect.Effect; +} + +export class RepositoryIdentityResolver extends ServiceMap.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..fdc6b5d789 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"; @@ -2496,57 +2497,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(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9f2eeb014e..94f8ba0c90 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2693,7 +2693,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); - const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; + 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 } : {}), @@ -2881,8 +2885,7 @@ 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), }); } diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 761b795fe5..9feec28637 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -27,6 +27,14 @@ 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 BUILT_IN_MODELS: ReadonlyArray = [ { @@ -87,13 +95,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ 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 ); } @@ -450,7 +453,12 @@ 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 models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); if (!claudeSettings.enabled) { return buildServerProvider({ diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index b5eb873e85..db91e8da0d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -723,6 +723,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { description: "Allow workspace writes only", }, ], + multiSelect: true, }, ], }, @@ -749,6 +750,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..957ae1b2bc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -382,6 +382,7 @@ function toUserInputQuestions(payload: Record | undefined) { header, question: prompt, options, + multiSelect: question.multiSelect === true, }; }) .filter( @@ -392,6 +393,7 @@ function toUserInputQuestions(payload: Record | undefined) { header: string; question: string; options: Array<{ label: string; description: string }>; + multiSelect: boolean; } => question !== undefined, ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 667bdf048b..3509fa9257 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -49,6 +49,19 @@ 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 +172,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 ); } @@ -339,7 +347,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({ diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e1243c4bd0..4c80d78e20 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + ModelCapabilities, ServerProvider, ServerProviderAuth, ServerProviderModel, @@ -102,6 +103,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 +119,7 @@ export function providerModelsFromSettings( slug: normalized, name: normalized, isCustom: true, - capabilities: null, + capabilities: customModelCapabilities, }); } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7a23058fc7..4e683637e7 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,16 @@ 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, + Path, + Stream, +} from "effect"; import { FetchHttpClient, HttpBody, @@ -32,6 +43,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 +56,7 @@ 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 { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -56,6 +69,7 @@ import { 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 +87,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 defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", } as const; - +const testEnvironmentDescriptor = { + environmentId: EnvironmentId.makeUnsafe("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 +178,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( @@ -260,6 +301,8 @@ const buildAppUnderTest = (options?: { browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; + serverEnvironment?: Partial; + repositoryIdentityResolver?: Partial; }; }) => Effect.gen(function* () { @@ -279,7 +322,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 +331,27 @@ const buildAppUnderTest = (options?: { staticDir: undefined, devUrl, noBrowser: true, - authToken: undefined, + desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const gitManagerLayer = Layer.mock(GitManager)({ + ...options?.layers?.gitManager, + }); + const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); - const appLayer = HttpRouter.serve(makeRoutesLayer, { + 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 +384,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 }), @@ -383,6 +431,9 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + ); + + const appLayer = servedRoutesLayer.pipe( Layer.provide( Layer.mock(BrowserTraceCollector)({ record: () => Effect.void, @@ -405,6 +456,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 +479,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 +520,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,11 +534,130 @@ const getHttpServerUrl = (pathname = "") => return `http://127.0.0.1:${address.port}${pathname}`; }); -const getWsServerUrl = (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; - return `ws://127.0.0.1:${address.port}${pathname}`; + 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) => { @@ -466,11 +683,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { config: { devUrl: new URL("http://127.0.0.1:5173") }, }); - const url = yield* getHttpServerUrl("/foo/bar"); + 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/"); + assert.equal( + response.headers.get("location"), + "http://127.0.0.1:5173/foo/bar?token=test-token", + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -492,6 +712,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); @@ -512,6 +737,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); @@ -519,6 +749,597 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).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.equal(body.auth.sessionCookieName, "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.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const first = yield* bootstrapBrowserSession(); + const second = yield* bootstrapBrowserSession(); + + 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( + "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-", + }); + + yield* buildAppUnderTest(); + + 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("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: bootstrapResponse, cookie } = yield* bootstrapBrowserSession(); + + 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.environment.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(response.auth.policy, "desktop-managed-local"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "rejects websocket rpc handshake when a session token is only provided via query string", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?token=${encodeURIComponent(sessionToken)}`; + + const error = yield* Effect.flip( + Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), + ); + + assert.equal(error._tag, "RpcClientError"); + assertInclude(String(error), "401"); + }).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", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -535,7 +1356,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 +1383,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 +1524,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 +1598,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 +1642,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 +1690,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 +1736,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 +1746,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,38 +1764,6 @@ 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; @@ -1058,6 +1868,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 +1878,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({ @@ -1260,6 +2071,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 +2203,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 +2212,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]({ @@ -1467,52 +2299,383 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCheckout]({ - cwd: "/tmp/repo", - branch: "main", - }), - ), - ); + 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( + 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), + }, + }, + }); + + 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(); @@ -1633,6 +2796,73 @@ 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.makeUnsafe("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"); @@ -2135,6 +3365,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events only once before streaming them to subscribers", () => + Effect.gen(function* () { + let resolveCalls = 0; + 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" as const, + owner: "t3tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + readEvents: () => + Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-06T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Replayed Project", + updatedAt: "2026-04-06T00:00:00.000Z", + }, + } satisfies Extract), + streamDomainEvents: Stream.empty, + }, + repositoryIdentityResolver: { + resolve: () => { + resolveCalls += 1; + return Effect.succeed(repositoryIdentity); + }, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(resolveCalls, 1); + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("enriches subscribed project meta updates with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "upstream", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + streamDomainEvents: Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Renamed Project", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration.getSnapshot errors", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..c1581b9382 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"; @@ -30,6 +32,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers 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 +46,27 @@ 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"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -161,15 +180,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 +204,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 +222,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 +237,23 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( ); export const makeRoutesLayer = Layer.mergeAll( + authBearerBootstrapRouteLayer, + authBootstrapRouteLayer, + authClientsRevokeOthersRouteLayer, + authClientsRevokeRouteLayer, + authClientsRouteLayer, + authPairingLinksRevokeRouteLayer, + authPairingLinksRouteLayer, + authPairingCredentialRouteLayer, + authSessionRouteLayer, + authWebSocketTokenRouteLayer, attachmentsRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, + serverEnvironmentRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, -); +).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 1cd8c25c03..cfa5c553a9 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.makeUnsafe("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/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fc06d77566..c3159cc9d8 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,14 +1,23 @@ +import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Option, Ref } from "effect"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { + getAutoBootstrapDefaultModelSelection, launchStartupHeartbeat, makeCommandGate, 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* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7c9231ac93..fd43c6b359 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, @@ -27,7 +28,9 @@ 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"; +import { ServerAuth } from "./auth/Services/ServerAuth"; const isWildcardHost = (host: string | undefined): boolean => host === "0.0.0.0" || host === "::" || host === "[::]"; @@ -148,6 +151,11 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( Effect.asVoid, ); +export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, +}); + const autoBootstrapWelcome = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; @@ -169,10 +177,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { const createdAt = new Date().toISOString(); nextProjectId = ProjectId.makeUnsafe(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()), @@ -184,10 +189,8 @@ 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 = @@ -228,28 +231,39 @@ const autoBootstrapWelcome = Effect.gen(function* () { } 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 }), @@ -262,6 +276,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { 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(); @@ -308,7 +323,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: preparing welcome payload"); const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); + const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: publishing welcome event", { + environmentId: environment.environmentId, cwd: welcome.cwd, projectName: welcome.projectName, bootstrapProjectId: welcome.bootstrapProjectId, @@ -319,7 +336,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "welcome", - payload: welcome, + payload: { + environment, + ...welcome, + }, }), ); }).pipe( @@ -354,14 +374,23 @@ 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); + 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/ws.ts b/apps/server/src/ws.ts index 33a0518611..8d4f946691 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, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; import { + type AuthAccessStreamEvent, + AuthSessionId, CommandId, EventId, type OrchestrationCommand, @@ -20,13 +22,14 @@ import { 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 +49,896 @@ 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 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.makeUnsafe(`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.makeUnsafe(crypto.randomUUID()), + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); - 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 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 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 instanceof Error + ? error.message + : "Unknown setup activity dispatch failure.", + }, + ), + ), + ); + }; - 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, + }); } - 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, - }; - }); - - 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, - }), - ), - ), - { "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, - }), - ), - ); - } - return result; - }).pipe( + 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) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: "Failed to dispatch orchestration command", + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + }; + + 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.getSnapshot]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getSnapshot, + projectionSnapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot", 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.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.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.getTurnDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getTurnDiff, + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), + ), ), + { "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 }), - ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, - }), + [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" }, - ), - [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), + [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.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(), - }); + Effect.flatMap(enrichOrchestrationEvents), + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), + ), + { "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.flatMap(enrichOrchestrationEvents), + Effect.catch(() => Effect.succeed([] as Array)), + ); + const replayStream = Stream.fromIterable(replayEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(enrichProjectEvent), + ); + const source = Stream.merge(replayStream, liveStream); + 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; + return source.pipe( + Stream.mapEffect((event) => + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { + return [[], { nextSequence, pendingBySequence }]; } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, + 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 }]; + }, + ), ), - ), - Stream.flatMap((events) => Stream.fromIterable(events)), - ); + Stream.flatMap((events) => Stream.fromIterable(events)), + ); + }), + { "rpc.aggregate": "orchestration" }, + ), + [WS_METHODS.serverGetConfig]: (_input) => + observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { + "rpc.aggregate": "server", }), - { "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.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", }), - { "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}`, + [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, + }), + ), + ), + { "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": "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.shellOpenInEditor]: (input) => + observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.subscribeGitStatus]: (input) => + observeRpcStream( + WS_METHODS.subscribeGitStatus, + gitStatusBroadcaster.streamStatus(input), + { + "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) => + [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)), + ), + }), + ), + ), + { "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 - .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), - }), - ), + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, ), - { "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), + [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).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": "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, - }, - })), - ); - 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 }, - })), - ); - - return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), - Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), - ); + [WS_METHODS.gitInit]: (input) => + observeRpcEffect( + WS_METHODS.gitInit, + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.terminalOpen]: (input) => + observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { + "rpc.aggregate": "terminal", }), - { "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); + [WS_METHODS.terminalWrite]: (input) => + observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { + "rpc.aggregate": "terminal", }), - { "rpc.aggregate": "server" }, - ), - }); - }), -); + [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" }, + ), + [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 }, + })), + ); + const settingsUpdates = serverSettings.streamChanges.pipe( + Stream.map((settings) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings }, + })), + ); + + 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" }, + ), + [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..d127743705 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@formkit/auto-animate": "^0.9.0", "@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", diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts new file mode 100644 index 0000000000..f86d7c5a79 --- /dev/null +++ b/apps/web/src/authBootstrap.test.ts @@ -0,0 +1,501 @@ +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("revalidates the server session state after a previous authenticated result", 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: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + 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/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 2f66e063ad..6137b77c52 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,14 +1,21 @@ -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, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); +const remoteEnvironmentId = EnvironmentId.makeUnsafe("environment-remote"); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -76,6 +83,80 @@ 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("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..54ec4370f4 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,22 +1,68 @@ -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 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..10e6023a41 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,168 +1,101 @@ -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 { 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; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; + availableEnvironments?: readonly EnvironmentOption[]; + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } -export default function BranchToolbar({ +export function BranchToolbar({ + environmentId, threadId, + draftId, onEnvModeChange, 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) => 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, + hasServerThread: serverThread !== undefined, draftThreadEnvMode: draftThread?.envMode, }); + const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); - 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, - activeWorktreePath, - hasServerThread, - setThreadBranchAction, - setDraftThreadContext, - threadId, - effectiveEnvMode, - ], - ); + const showEnvironmentPicker = + availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; - if (!activeThreadId || !activeProject) return null; + 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..32c80f6542 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,6 @@ -import type { GitBranch } from "@t3tools/contracts"; -import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -14,19 +15,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"; @@ -43,13 +45,10 @@ 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; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -60,7 +59,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 +73,125 @@ function getBranchTriggerLabel(input: { } export function BranchToolbarBranchSelector({ - activeProjectCwd, - activeThreadBranch, - activeWorktreePath, - branchCwd, - effectiveEnvMode, + environmentId, + threadId, + draftId, envLocked, - onSetThreadBranch, 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) => 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 = 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 = 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) { + 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, + 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 +201,9 @@ export function BranchToolbarBranchSelector({ isPending: isBranchesSearchPending, } = useInfiniteQuery( gitBranchSearchInfiniteQueryOptions({ + environmentId, cwd: branchCwd, query: deferredTrimmedBranchQuery, - enabled: isBranchMenuOpen, }), ); const branches = useMemo( @@ -185,20 +278,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 +307,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 +322,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 +384,12 @@ export function BranchToolbarBranchSelector({ ) { return; } - onSetThreadBranch(currentGitBranch, null); - }, [ - activeThreadBranch, - activeWorktreePath, - currentGitBranch, - effectiveEnvMode, - onSetThreadBranch, - ]); + setThreadBranch(currentGitBranch, null); + }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + // --------------------------------------------------------------------------- + // Combobox / virtualizer plumbing + // --------------------------------------------------------------------------- const handleOpenChange = useCallback( (open: boolean) => { setIsBranchMenuOpen(open); @@ -316,10 +398,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); @@ -448,7 +530,7 @@ export function BranchToolbarBranchSelector({ style={style} onClick={() => createBranch(trimmedBranchQuery)} > - Create new branch "{trimmedBranchQuery}" + Create new branch "{trimmedBranchQuery}" ); } @@ -456,7 +538,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 diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx new file mode 100644 index 0000000000..39bf50359d --- /dev/null +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -0,0 +1,97 @@ +import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; +import { memo, useMemo } from "react"; + +import { + resolveCurrentWorkspaceLabel, + resolveEnvModeLabel, + 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 ? ( + <> + + {resolveCurrentWorkspaceLabel(activeWorktreePath)} + + ) : ( + <> + + {resolveCurrentWorkspaceLabel(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.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a1..11926cd95c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,7 +22,7 @@ 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 { readLocalApi } from "../localApi"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - const api = readNativeApi(); + const api = readLocalApi(); if (api) { void openInPreferredEditor(api, targetPath); } else { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 84302386dc..98a0e0f7bb 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4,6 +4,7 @@ import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, + EnvironmentId, type MessageId, type OrchestrationEvent, type OrchestrationReadModel, @@ -16,6 +17,12 @@ import { OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -23,27 +30,43 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore, DraftId } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, removeInlineTerminalContextPlaceholder, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; -import { __resetNativeApiForTests } from "../nativeApi"; +import { __resetLocalApiForTests } from "../localApi"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; -import { useStore } from "../store"; +import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +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-browser-test" as ThreadId; -const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); +const THREAD_KEY = scopedThreadKey(THREAD_REF); +const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; +const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); -const ATTACHMENT_SVG = ""; +const ATTACHMENT_SVG = ""; interface TestFixture { snapshot: OrchestrationReadModel; @@ -93,9 +116,9 @@ const TEXT_VIEWPORT_MATRIX = [ { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, ] as const satisfies readonly ViewportSpec[]; interface UserRowMeasurement { @@ -119,6 +142,19 @@ function isoAt(offsetSeconds: number): string { function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + 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: [], @@ -224,6 +260,7 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, + previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -303,6 +340,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { snapshot, serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + 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, @@ -385,6 +429,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest }; } +function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.makeUnsafe(`event-thread-session-set-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: NOW_ISO, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.session-set", + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: `turn-${threadId}` as TurnId, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + }; +} + function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); } @@ -397,20 +468,83 @@ async function waitForWsClient(): Promise { (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, ), ).toBe(true); + expect( + wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( + true, + ); }, { timeout: 8_000, interval: 16 }, ); } -async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { +function threadRefFor(threadId: ThreadId) { + return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); +} + +function threadKeyFor(threadId: ThreadId): string { + return scopedThreadKey(threadRefFor(threadId)); +} + +function composerDraftFor(target: string) { + const { draftsByThreadKey } = useComposerDraftStore.getState(); + return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; +} + +function draftIdFromPath(pathname: string) { + const segments = pathname.split("/"); + const draftId = segments[segments.length - 1]; + if (!draftId) { + throw new Error(`Expected thread path, received "${pathname}".`); + } + return DraftId.makeUnsafe(draftId); +} + +function draftThreadIdFor(draftId: ReturnType): ThreadId { + const draftSession = useComposerDraftStore.getState().getDraftSession(draftId); + if (!draftSession) { + throw new Error(`Expected draft session for "${draftId}".`); + } + return draftSession.threadId; +} + +function serverThreadPath(threadId: ThreadId): string { + return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`; +} + +async function waitForAppBootstrap(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise { await waitForWsClient(); fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); sendOrchestrationDomainEvent( createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), ); +} + +async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { + sendOrchestrationDomainEvent( + createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), + ); +} + +async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { + await materializePromotedDraftThreadViaDomainEvent(threadId); + await startPromotedServerThreadViaDomainEvent(threadId); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( + undefined, + ); }, { timeout: 8_000, interval: 16 }, ); @@ -441,9 +575,12 @@ function withProjectScripts( function setDraftThreadWithoutWorktree(): void { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -452,8 +589,8 @@ function setDraftThreadWithoutWorktree(): void { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); } @@ -651,24 +788,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { ], }; } - 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: [], @@ -709,6 +828,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { @@ -836,6 +956,16 @@ async function waitForButtonContainingText(text: string): Promise { + return waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="select-item"]')).find((item) => + item.textContent?.includes(text), + ) ?? null, + `Unable to find select item containing "${text}".`, + ); +} + async function expectComposerActionsContained(): Promise { const footer = await waitForElement( () => document.querySelector('[data-chat-composer-footer="true"]'), @@ -866,7 +996,7 @@ async function expectComposerActionsContained(): Promise { } async function waitForInteractionModeButton( - expectedLabel: "Chat" | "Plan", + expectedLabel: "Build" | "Plan", ): Promise { return waitForElement( () => @@ -1040,14 +1170,21 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${THREAD_ID}`], + initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), ); - const screen = await render(, { - container: host, - }); + const screen = await render( + + + , + { + container: host, + }, + ); + await waitForWsClient(); + await waitForAppBootstrap(); await waitForLayout(); const cleanup = async () => { @@ -1138,28 +1275,32 @@ describe("ChatView timeline estimator parity (full app)", () => { return []; }, }); - __resetNativeApiForTests(); + await __resetLocalApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); useStore.setState({ - projects: [], - threads: [], - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, + }); + useUiStateStore.setState({ + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, }); useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -1203,6 +1344,35 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ); + it("re-expands the bootstrap project using its scoped key", async () => { + useUiStateStore.setState({ + projectExpandedById: { + [PROJECT_KEY]: false, + }, + projectOrder: [PROJECT_KEY], + threadLastVisitedAtById: {}, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, + targetText: "bootstrap project expand", + }), + }); + + try { + await vi.waitFor( + () => { + expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { const userText = "x".repeat(3_200); const targetMessageId = "msg-user-target-resize" as MessageId; @@ -1299,7 +1469,7 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, - targetAttachmentCount: 3, + targetAttachmentCount: 2, }), }); @@ -1313,7 +1483,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { role: "user", text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], }, { timelineWidthPx: timelineWidthMeasuredPx }, ); @@ -1400,8 +1570,8 @@ describe("ChatView timeline estimator parity (full app)", () => { } useTerminalStateStore.setState({ - terminalStateByThreadId: { - [THREAD_ID]: { + terminalStateByThreadKey: { + [THREAD_KEY]: { terminalOpen: true, terminalHeight: 280, terminalIds: ["default"], @@ -1411,8 +1581,8 @@ describe("ChatView timeline estimator parity (full app)", () => { activeTerminalGroupId: "group-default", }, }, - terminalLaunchContextByThreadId: { - [THREAD_ID]: { + terminalLaunchContextByThreadKey: { + [THREAD_KEY]: { cwd: "/repo/project", worktreePath: null, }, @@ -1658,9 +1828,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from local draft threads at the project cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1669,8 +1842,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1734,9 +1907,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from worktree draft threads at the worktree cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1745,8 +1921,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1797,9 +1973,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1808,13 +1987,13 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, + viewport: WIDE_FOOTER_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ { id: "setup", @@ -1919,12 +2098,15 @@ describe("ChatView timeline estimator parity (full app)", () => { it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1933,8 +2115,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1960,7 +2142,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2019,12 +2201,15 @@ describe("ChatView timeline estimator parity (full app)", () => { it("shows the send state once bootstrap dispatch is in flight", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -2033,8 +2218,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -2063,7 +2248,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2096,7 +2281,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const initialModeButton = await waitForInteractionModeButton("Chat"); + const initialModeButton = await waitForInteractionModeButton("Build"); expect(initialModeButton.title).toContain("enter plan mode"); window.dispatchEvent( @@ -2109,7 +2294,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); await waitForLayout(); - expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode"); + expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); const composerEditor = await waitForComposerEditor(); composerEditor.focus(); @@ -2125,7 +2310,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( async () => { expect((await waitForInteractionModeButton("Plan")).title).toContain( - "return to normal chat mode", + "return to normal build mode", ); }, { timeout: 8_000, interval: 16 }, @@ -2142,7 +2327,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( async () => { - expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode"); + expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); }, { timeout: 8_000, interval: 16 }, ); @@ -2151,11 +2336,37 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows runtime mode descriptions in the desktop composer access select", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + }); + + try { + const runtimeModeSelect = await waitForButtonByText("Full access"); + runtimeModeSelect.click(); + + expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain( + "Ask before commands and file changes", + ); + + const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits"); + expect(autoAcceptItem.textContent).toContain("Auto-approve edits"); + expect((await waitForSelectItemContainingText("Full access")).textContent).toContain( + "Allow commands and edits without prompts", + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps removed terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-removed", terminalLabel: "Terminal 1", @@ -2182,21 +2393,21 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; + const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_ID, nextPrompt.prompt); - store.removeTerminalContext(THREAD_ID, "ctx-removed"); + store.setPrompt(THREAD_REF, nextPrompt.prompt); + store.removeTerminalContext(THREAD_REF, "ctx-removed"); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); expect(document.body.textContent).not.toContain(removedLabel); }, { timeout: 8_000, interval: 16 }, ); useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-added", terminalLabel: "Terminal 2", @@ -2208,7 +2419,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; + const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); expect(document.body.textContent).toContain(addedLabel); expect(document.body.textContent).not.toContain(removedLabel); @@ -2223,7 +2434,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("disables send when the composer only contains an expired terminal pill", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-only", terminalLabel: "Terminal 1", @@ -2259,7 +2470,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("warns when sending text while omitting expired terminal pills", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-send-warning", terminalLabel: "Terminal 1", @@ -2270,7 +2481,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); useComposerDraftStore .getState() - .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); + .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -2410,7 +2621,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps the new thread selected after clicking the new-thread button", async () => { + it("canonicalizes promoted draft threads to the server thread route", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -2432,27 +2643,81 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); // The composer editor should be present for the new draft thread. await waitForComposerEditor(); - // Simulate the steady-state promotion path: the server emits - // `thread.created`, the client materializes the thread incrementally, - // and the draft is cleared by live batch effects. + // `thread.created` should only mark the draft as promoting; it should + // not navigate away until the server thread has actual runtime state. + await materializePromotedDraftThreadViaDomainEvent(newThreadId); + expect(mounted.router.state.location.pathname).toBe(newThreadPath); + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + + // Once the server thread starts, the route should canonicalize. + await startPromotedServerThreadViaDomainEvent(newThreadId); + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( + undefined, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + // The route should switch to the canonical server thread path. + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(newThreadId), + "Promoted drafts should canonicalize to the server thread route.", + ); + + // The composer should remain usable after canonicalization, regardless of + // whether the promoted thread is still visibly empty or has already + // entered the running state. + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("canonicalizes stale promoted draft routes to the server thread route", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, + targetText: "draft hydration race test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); + await promoteDraftThreadViaDomainEvent(newThreadId); - // The route should still be on the new thread — not redirected away. + await mounted.router.navigate({ + to: "/draft/$draftId", + params: { draftId: newDraftId }, + }); + await waitForURL( mounted.router, - (path) => path === newThreadPath, - "New thread should remain selected after server thread promotion clears the draft.", + (path) => path === serverThreadPath(newThreadId), + "Stale promoted draft routes should canonicalize to the server thread path.", ); - // The empty thread view and composer should still be visible. - await expect - .element(page.getByText("Send a message to start the conversation.")) - .toBeInTheDocument(); await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); } finally { await mounted.cleanup(); @@ -2493,9 +2758,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2546,9 +2811,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new sticky claude draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { claudeAgent: { provider: "claudeAgent", @@ -2586,9 +2851,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + expect(composerDraftFor(newDraftId)).toBe(undefined); } finally { await mounted.cleanup(); } @@ -2628,9 +2893,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a sticky draft thread UUID.", ); - const threadId = threadPath.slice(1) as ThreadId; + const draftId = draftIdFromPath(threadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2643,7 +2908,7 @@ describe("ChatView timeline estimator parity (full app)", () => { activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection(threadId, { + useComposerDraftStore.getState().setModelSelection(draftId, { provider: "codex", model: "gpt-5.4", options: { @@ -2659,7 +2924,7 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => path === threadPath, "New-thread should reuse the existing project draft thread.", ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2766,9 +3031,21 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a promoted draft thread UUID.", ); - const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; + const promotedDraftId = draftIdFromPath(promotedThreadPath); + const promotedThreadId = draftThreadIdFor(promotedDraftId); await promoteDraftThreadViaDomainEvent(promotedThreadId); + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(promotedThreadId), + "Promoted drafts should canonicalize to the server thread route before a fresh draft is created.", + ); + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().getDraftThread(promotedDraftId)).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, @@ -2818,6 +3095,55 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("uses the active worktree path when saving a proposed plan to the workspace", async () => { + const snapshot = createSnapshotWithLongProposedPlan(); + const threads = snapshot.threads.slice(); + const targetThreadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); + const targetThread = targetThreadIndex >= 0 ? threads[targetThreadIndex] : undefined; + if (targetThread) { + threads[targetThreadIndex] = { + ...targetThread, + worktreePath: "/repo/worktrees/plan-thread", + }; + } + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + threads, + }, + }); + + try { + const planActionsButton = await waitForElement( + () => document.querySelector('button[aria-label="Plan actions"]'), + "Unable to find proposed plan actions button.", + ); + planActionsButton.click(); + + const saveToWorkspaceItem = await waitForElement( + () => + (Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find( + (item) => item.textContent?.trim() === "Save to workspace", + ) ?? null) as HTMLElement | null, + 'Unable to find "Save to workspace" menu item.', + ); + saveToWorkspaceItem.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain( + "Enter a path relative to /repo/worktrees/plan-thread.", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps pending-question footer actions inside the composer after a real resize", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, @@ -2838,6 +3164,59 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("submits pending user input after the final option selection resolves the draft answers", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInput(), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + const firstOption = await waitForButtonContainingText("Tight"); + firstOption.click(); + + const finalOption = await waitForButtonContainingText("Conservative"); + finalOption.click(); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.user-input.respond", + ) as + | { + _tag: string; + type?: string; + requestId?: string; + answers?: Record; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "thread.user-input.respond", + requestId: "req-browser-user-input", + answers: { + scope: "Tight", + risk: "Conservative", + }, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8d49bc07f2..ce82b3f77c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,8 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { useStore } from "../store"; +import { type EnvironmentState, useStore } from "../store"; +import { type Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -9,9 +11,12 @@ import { deriveComposerSendState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ @@ -169,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => { }); }); +describe("shouldWriteThreadErrorToCurrentServerThread", () => { + it("routes errors to the active server thread when route and target match", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); + + expect( + shouldWriteThreadErrorToCurrentServerThread({ + serverThread: { + environmentId: localEnvironmentId, + id: threadId, + }, + routeThreadRef, + targetThreadId: threadId, + }), + ).toBe(true); + }); + + it("does not route draft-thread errors into server-backed state", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + + expect( + shouldWriteThreadErrorToCurrentServerThread({ + serverThread: undefined, + routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), + targetThreadId: threadId, + }), + ).toBe(false); + }); +}); + const makeThread = (input?: { id?: ThreadId; latestTurn?: { @@ -178,8 +213,9 @@ const makeThread = (input?: { startedAt: string | null; completedAt: string | null; } | null; -}) => ({ +}): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -205,110 +241,197 @@ const makeThread = (input?: { activities: [], }); +function setStoreThreads(threads: ReadonlyArray>) { + const projectId = ProjectId.makeUnsafe("project-1"); + const environmentState: EnvironmentState = { + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + environmentId: localEnvironmentId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + createdAt: "2026-03-29T00:00:00.000Z", + updatedAt: "2026-03-29T00:00:00.000Z", + scripts: [], + }, + }, + threadIds: threads.map((thread) => thread.id), + threadIdsByProjectId: { + [projectId]: threads.map((thread) => thread.id), + }, + threadShellById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + id: thread.id, + environmentId: thread.environmentId, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + ]), + ), + threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), + threadTurnStateById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }, + ]), + ), + messageIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), + ), + messageByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.messages.map((message) => [message.id, message])), + ]), + ), + activityIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), + ), + activityByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), + ]), + ), + proposedPlanIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), + ), + proposedPlanByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), + ]), + ), + turnDiffIdsByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + thread.turnDiffSummaries.map((summary) => summary.turnId), + ]), + ), + turnDiffSummaryByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), + ]), + ), + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }; + useStore.setState({ + activeEnvironmentId: localEnvironmentId, + environmentStateById: { + [localEnvironmentId]: environmentState, + }, + }); +} + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - useStore.setState((state) => ({ - ...state, - projects: [], - threads: [], - bootstrapComplete: true, - })); + setStoreThreads([]); }); describe("waitForStartedServerThread", () => { it("resolves immediately when the thread is already started", async () => { const threadId = ThreadId.makeUnsafe("thread-started"); - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); - await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), + ).resolves.toBe(true); }); it("waits for the thread to start via subscription updates", async () => { const threadId = ThreadId.makeUnsafe("thread-wait"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(threadId, 500); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); await expect(promise).resolves.toBe(true); }); it("handles the thread starting between the initial read and subscription setup", async () => { const threadId = ThreadId.makeUnsafe("thread-race"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const originalSubscribe = useStore.subscribe.bind(useStore); let raced = false; vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { if (!raced) { raced = true; - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-race"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-race"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); } return originalSubscribe(listener); }); - await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), + ).resolves.toBe(true); }); it("returns false after the timeout when the thread never starts", async () => { vi.useFakeTimers(); const threadId = ThreadId.makeUnsafe("thread-timeout"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); - const promise = waitForStartedServerThread(threadId, 500); + setStoreThreads([makeThread({ id: threadId })]); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); await vi.advanceTimersByTimeAsync(500); @@ -338,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("does not clear local dispatch before server state changes", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -374,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when a new turn is already settled", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -419,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when the session changes without an observed running phase", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..ffcd0cb3f5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,9 +1,16 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ModelSelection, + type ScopedThreadRef, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { useStore } from "../store"; +import { selectThreadByRef, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -24,6 +31,7 @@ export function buildLocalDraftThread( ): Thread { return { id: threadId, + environmentId: draftThread.environmentId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", @@ -44,13 +52,32 @@ export function buildLocalDraftThread( }; } +export function shouldWriteThreadErrorToCurrentServerThread(input: { + serverThread: + | { + environmentId: EnvironmentId; + id: ThreadId; + } + | null + | undefined; + routeThreadRef: ScopedThreadRef; + targetThreadId: ThreadId; +}): boolean { + return Boolean( + input.serverThread && + input.targetThreadId === input.routeThreadRef.threadId && + input.serverThread.environmentId === input.routeThreadRef.environmentId && + input.serverThread.id === input.targetThreadId, + ); +} + export function reconcileMountedTerminalThreadIds(input: { - currentThreadIds: ReadonlyArray; - openThreadIds: ReadonlyArray; - activeThreadId: ThreadId | null; + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; -}): ThreadId[] { +}): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), @@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { } export async function waitForStartedServerThread( - threadId: ThreadId, + threadRef: ScopedThreadRef, timeoutMs = 1_000, ): Promise { - const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId); + const getThread = () => selectThreadByRef(useStore.getState(), threadRef); const thread = getThread(); if (threadHasStarted(thread)) { @@ -225,7 +252,7 @@ export async function waitForStartedServerThread( }; const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) { + if (!threadHasStarted(selectThreadByRef(state, threadRef))) { return; } finish(true); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f995bb4ce7..578c679798 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, + type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, @@ -12,6 +13,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ServerProvider, + type ScopedThreadRef, type ThreadId, type TurnId, type KeybindingCommand, @@ -20,16 +22,26 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useShallow } from "zustand/react/shallow"; +import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; +import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { clampCollapsedComposerCursor, @@ -61,10 +73,15 @@ import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { useStore } from "../store"; -import { useProjectById, useThreadById } from "../storeSelectors"; +import { + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, + useStore, +} from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -82,12 +99,10 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { LRUCache } from "../lib/lruCache"; - import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import BranchToolbar from "./BranchToolbar"; +import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -100,10 +115,13 @@ import { ListTodoIcon, LockIcon, LockOpenIcon, + type LucideIcon, + PenLineIcon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; @@ -114,9 +132,7 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { SidebarTrigger } from "./ui/sidebar"; -import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; +import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, getProviderModels, @@ -125,6 +141,12 @@ import { import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { deriveLogicalProjectKey } from "../logicalProject"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -132,6 +154,7 @@ import { useComposerDraftStore, useEffectiveComposerModelState, useComposerThreadDraft, + type DraftId, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, @@ -155,6 +178,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { NoActiveThreadState } from "./NoActiveThreadState"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; @@ -163,6 +187,7 @@ import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { getComposerProviderState, renderProviderTraitsMenuContent, @@ -188,6 +213,7 @@ import { reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldWriteThreadErrorToCurrentServerThread, threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; @@ -199,88 +225,128 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type ThreadPlanCatalogEntry = Pick; -const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; -const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; -const threadPlanCatalogCache = new LRUCache<{ - proposedPlans: Thread["proposedPlans"]; - entry: ThreadPlanCatalogEntry; -}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); - -function estimateThreadPlanCatalogEntrySize(thread: Thread): number { - return Math.max( - 64, - thread.id.length + - thread.proposedPlans.reduce( - (total, plan) => - total + - plan.id.length + - plan.planMarkdown.length + - plan.updatedAt.length + - (plan.turnId?.length ?? 0), - 0, - ), - ); -} - -function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { - const cached = threadPlanCatalogCache.get(thread.id); - if (cached && cached.proposedPlans === thread.proposedPlans) { - return cached.entry; - } +function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { + return useStore( + useMemo(() => { + let previousThreadIds: readonly ThreadId[] = []; + let previousResult: ThreadPlanCatalogEntry[] = []; + let previousEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + + return (state) => { + const sameThreadIds = + previousThreadIds.length === threadIds.length && + previousThreadIds.every((id, index) => id === threadIds[index]); + const nextEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + const nextResult: ThreadPlanCatalogEntry[] = []; + let changed = !sameThreadIds; + + for (const threadId of threadIds) { + let shell: object | undefined; + let proposedPlanIds: readonly string[] | undefined; + let proposedPlansById: Record | undefined; + + for (const environmentState of Object.values(state.environmentStateById)) { + const matchedShell = environmentState.threadShellById[threadId]; + if (!matchedShell) { + continue; + } + shell = matchedShell; + proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; + proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as + | Record + | undefined; + break; + } - const entry: ThreadPlanCatalogEntry = { - id: thread.id, - proposedPlans: thread.proposedPlans, - }; - threadPlanCatalogCache.set( - thread.id, - { - proposedPlans: thread.proposedPlans, - entry, - }, - estimateThreadPlanCatalogEntrySize(thread), - ); - return entry; -} + if (!shell) { + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === null && + previous.proposedPlanIds === undefined && + previous.proposedPlansById === undefined + ) { + nextEntries.set(threadId, previous); + continue; + } + changed = true; + nextEntries.set(threadId, { + shell: null, + proposedPlanIds: undefined, + proposedPlansById: undefined, + entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, + }); + continue; + } -function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - const selector = useMemo(() => { - let previousThreads: Array | null = null; - let previousEntries: ThreadPlanCatalogEntry[] = []; + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === shell && + previous.proposedPlanIds === proposedPlanIds && + previous.proposedPlansById === proposedPlansById + ) { + nextEntries.set(threadId, previous); + nextResult.push(previous.entry); + continue; + } - return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => { - const nextThreads = threadIds.map((threadId) => - state.threads.find((thread) => thread.id === threadId), - ); - const cachedThreads = previousThreads; - if ( - cachedThreads && - nextThreads.length === cachedThreads.length && - nextThreads.every((thread, index) => thread === cachedThreads[index]) - ) { - return previousEntries; - } + changed = true; + const proposedPlans = + proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById + ? proposedPlanIds.flatMap((planId) => { + const proposedPlan = proposedPlansById?.[planId]; + return proposedPlan ? [proposedPlan] : []; + }) + : EMPTY_PROPOSED_PLANS; + const entry = { id: threadId, proposedPlans }; + nextEntries.set(threadId, { + shell, + proposedPlanIds, + proposedPlansById, + entry, + }); + nextResult.push(entry); + } - previousThreads = nextThreads; - previousEntries = nextThreads.flatMap((thread) => - thread ? [toThreadPlanCatalogEntry(thread)] : [], - ); - return previousEntries; - }; - }, [threadIds]); + if (!changed && previousResult.length === nextResult.length) { + return previousResult; + } - return useStore(selector); + previousThreadIds = threadIds; + previousEntries = nextEntries; + previousResult = nextResult; + return nextResult; + }; + }, [threadIds]), + ); } function formatOutgoingPrompt(params: { @@ -328,9 +394,19 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); -interface ChatViewProps { - threadId: ThreadId; -} +type ChatViewProps = + | { + environmentId: EnvironmentId; + threadId: ThreadId; + routeKind: "server"; + draftId?: never; + } + | { + environmentId: EnvironmentId; + threadId: ThreadId; + routeKind: "draft"; + draftId: DraftId; + }; interface TerminalLaunchContext { threadId: ThreadId; @@ -338,6 +414,29 @@ interface TerminalLaunchContext { worktreePath: string | null; } +const runtimeModeConfig: Record< + RuntimeMode, + { label: string; description: string; icon: LucideIcon } +> = { + "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[]; + type PersistentTerminalLaunchContext = Pick; function useLocalDispatchState(input: { @@ -408,6 +507,7 @@ function useLocalDispatchState(input: { } interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; @@ -418,7 +518,8 @@ interface PersistentThreadTerminalDrawerProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; } -function PersistentThreadTerminalDrawer({ +const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ + threadRef, threadId, visible, launchContext, @@ -428,13 +529,16 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useThreadById(threadId); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, - ); - const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId); + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), ); const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); @@ -480,32 +584,32 @@ function PersistentThreadTerminalDrawer({ const setTerminalHeight = useCallback( (height: number) => { - storeSetTerminalHeight(threadId, height); + storeSetTerminalHeight(threadRef, height); }, - [storeSetTerminalHeight, threadId], + [storeSetTerminalHeight, threadRef], ); const splitTerminal = useCallback(() => { - storeSplitTerminal(threadId, `terminal-${randomUUID()}`); + storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadId]); + }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); const createNewTerminal = useCallback(() => { - storeNewTerminal(threadId, `terminal-${randomUUID()}`); + storeNewTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadId]); + }, [bumpFocusRequestId, storeNewTerminal, threadRef]); const activateTerminal = useCallback( (terminalId: string) => { - storeSetActiveTerminal(threadId, terminalId); + storeSetActiveTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeSetActiveTerminal, threadId], + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], ); const closeTerminal = useCallback( (terminalId: string) => { - const api = readNativeApi(); + const api = readEnvironmentApi(threadRef.environmentId); if (!api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => @@ -526,10 +630,10 @@ function PersistentThreadTerminalDrawer({ void fallbackExitWrite(); } - storeCloseTerminal(threadId, terminalId); + storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], + [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], ); const handleAddTerminalContext = useCallback( @@ -549,6 +653,7 @@ function PersistentThreadTerminalDrawer({ return (
); -} - -export default function ChatView({ threadId }: ChatViewProps) { - const serverThread = useThreadById(threadId); +}); + +export default function ChatView(props: ChatViewProps) { + const { environmentId, threadId, routeKind } = props; + const draftId = routeKind === "draft" ? props.draftId : null; + const routeThreadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const composerDraftTarget: ScopedThreadRef | DraftId = + routeKind === "server" ? routeThreadRef : props.draftId; + const serverThread = useStore( + useMemo( + () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), + ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadId], + const activeThreadLastVisitedAt = useUiStateStore((store) => + routeKind === "server" + ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))] + : undefined, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( @@ -592,7 +712,7 @@ export default function ChatView({ threadId }: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); - const composerDraft = useComposerThreadDraft(threadId); + const composerDraft = useComposerThreadDraft(composerDraftTarget); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -635,16 +755,20 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, + const getDraftSessionByLogicalProjectKey = useComposerDraftStore( + (store) => store.getDraftSessionByLogicalProjectKey, ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, ); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, + const draftThread = useComposerDraftStore((store) => + routeKind === "server" + ? store.getDraftSessionByRef(routeThreadRef) + : draftId + ? store.getDraftSession(draftId) + : null, ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -654,8 +778,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); - const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< - Record + const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< + Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -724,7 +848,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); + const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); @@ -733,67 +857,81 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); - const terminalState = useMemo( - () => selectThreadTerminalState(terminalStateByThreadId, threadId), - [terminalStateByThreadId, threadId], + const terminalState = useTerminalStateStore((state) => + selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), ); - const openTerminalThreadIds = useMemo( - () => - Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + const openTerminalThreadKeys = useTerminalStateStore( + useShallow((state) => + Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => + nextTerminalState.terminalOpen ? [nextThreadKey] : [], ), - [terminalStateByThreadId], + ), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const serverThreadKeys = useStore( + useShallow((state) => + selectThreadsAcrossEnvironments(state).map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + ); const storeServerTerminalLaunchContext = useTerminalStateStore( - (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, + (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, ); const storeClearTerminalLaunchContext = useTerminalStateStore( (s) => s.clearTerminalLaunchContext, ); - const threads = useStore((state) => state.threads); - const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); - const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); - const draftThreadIds = useMemo( - () => Object.keys(draftThreadsByThreadId) as ThreadId[], - [draftThreadsByThreadId], + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = useMemo( + () => + Object.values(draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + [draftThreadsByThreadKey], + ); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); + const mountedTerminalThreadRefs = useMemo( + () => + mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; + }), + [mountedTerminalThreadKeys], ); - const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { - setComposerDraftPrompt(threadId, nextPrompt); + setComposerDraftPrompt(composerDraftTarget, nextPrompt); }, - [setComposerDraftPrompt, threadId], + [composerDraftTarget, setComposerDraftPrompt], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); + addComposerDraftImage(composerDraftTarget, image); }, - [addComposerDraftImage, threadId], + [addComposerDraftImage, composerDraftTarget], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { - addComposerDraftImages(threadId, images); + addComposerDraftImages(composerDraftTarget, images); }, - [addComposerDraftImages, threadId], + [addComposerDraftImages, composerDraftTarget], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { - addComposerDraftTerminalContexts(threadId, contexts); + addComposerDraftTerminalContexts(composerDraftTarget, contexts); }, - [addComposerDraftTerminalContexts, threadId], + [addComposerDraftTerminalContexts, composerDraftTarget], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { - removeComposerDraftImage(threadId, imageId); + removeComposerDraftImage(composerDraftTarget, imageId); }, - [removeComposerDraftImage, threadId], + [composerDraftTarget, removeComposerDraftImage], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { @@ -806,7 +944,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); - removeComposerDraftTerminalContext(threadId, contextId); + removeComposerDraftTerminalContext(composerDraftTarget, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( @@ -815,11 +953,19 @@ export default function ChatView({ threadId }: ChatViewProps) { ), ); }, - [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], + [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt], ); - const fallbackDraftProject = useProjectById(draftThread?.projectId); - const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); + const fallbackDraftProjectRef = draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const fallbackDraftProject = useStore( + useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), + ); + const localDraftError = + routeKind === "server" && serverThread + ? null + : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); const localDraftThread = useMemo( () => draftThread @@ -835,20 +981,27 @@ export default function ChatView({ threadId }: ChatViewProps) { : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); - const activeThread = serverThread ?? localDraftThread; + const isServerThread = routeKind === "server" && serverThread !== undefined; + const activeThread = isServerThread ? serverThread : localDraftThread; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; - const isServerThread = serverThread !== undefined; + const runtimeModeOption = runtimeModeConfig[runtimeMode]; + const RuntimeModeIcon = runtimeModeOption.icon; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; - const existingOpenTerminalThreadIds = useMemo(() => { - const existingThreadIds = new Set([...serverThreadIds, ...draftThreadIds]); - return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); - }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); + const activeThreadRef = useMemo( + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), + [activeThread], + ); + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const existingOpenTerminalThreadKeys = useMemo(() => { + const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); + }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -868,12 +1021,12 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread?.activities], ); useEffect(() => { - setMountedTerminalThreadIds((currentThreadIds) => { + setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, - openThreadIds: existingOpenTerminalThreadIds, - activeThreadId, - activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -881,9 +1034,65 @@ export default function ChatView({ threadId }: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useProjectById(activeThread?.projectId); + const activeProjectRef = activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null; + const activeProject = useStore( + useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), + ); + + // Compute the list of environments this logical project spans, used to + // drive the environment picker in BranchToolbar. + const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const logicalProjectEnvironments = useMemo(() => { + if (!activeProject) return []; + const logicalKey = deriveLogicalProjectKey(activeProject); + const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); + const seen = new Set(); + const envs: Array<{ + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; + }> = []; + for (const p of memberProjects) { + if (seen.has(p.environmentId)) continue; + seen.add(p.environmentId); + const isPrimary = p.environmentId === primaryEnvironmentId; + const savedRecord = savedEnvironmentRegistry[p.environmentId]; + const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; + const label = resolveEnvironmentOptionLabel({ + isPrimary, + environmentId: p.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? null, + savedLabel: savedRecord?.label ?? null, + }); + envs.push({ + environmentId: p.environmentId, + projectId: p.id, + label, + isPrimary, + }); + } + // Sort: primary first, then alphabetical + envs.sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + return a.label.localeCompare(b.label); + }); + return envs; + }, [ + activeProject, + allProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const openPullRequestDialog = useCallback( (reference?: string) => { @@ -908,50 +1117,71 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeProject) { throw new Error("No active project is available for this pull request."); } - const storedDraftThread = getDraftThreadByProjectId(activeProject.id); - if (storedDraftThread) { - setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); - if (storedDraftThread.threadId !== threadId) { + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); + if (storedDraftSession) { + setDraftThreadContext(storedDraftSession.draftId, input); + setLogicalProjectDraftThreadId( + logicalProjectKey, + activeProjectRef, + storedDraftSession.draftId, + { + threadId: storedDraftSession.threadId, + ...input, + }, + ); + if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(storedDraftSession.draftId), }); } - return storedDraftThread.threadId; + return storedDraftSession.threadId; } - const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { - setDraftThreadContext(threadId, input); - setProjectDraftThreadId(activeProject.id, threadId, input); - return threadId; + const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; + if ( + !isServerThread && + activeDraftSession?.logicalProjectKey === logicalProjectKey && + draftId + ) { + setDraftThreadContext(draftId, input); + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { + threadId: activeDraftSession.threadId, + createdAt: activeDraftSession.createdAt, + runtimeMode: activeDraftSession.runtimeMode, + interactionMode: activeDraftSession.interactionMode, + ...input, + }); + return activeDraftSession.threadId; } - clearProjectDraftThreadId(activeProject.id); + const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); - setProjectDraftThreadId(activeProject.id, nextThreadId, { + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { + threadId: nextThreadId, createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(nextDraftId), }); return nextThreadId; }, [ activeProject, - clearProjectDraftThreadId, - getDraftThread, - getDraftThreadByProjectId, + draftId, + getDraftSession, + getDraftSessionByLogicalProjectKey, isServerThread, navigate, + routeKind, setDraftThreadContext, - setProjectDraftThreadId, - threadId, + setLogicalProjectDraftThreadId, ], ); @@ -975,12 +1205,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(serverThread.id); + markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, + serverThread?.environmentId, serverThread?.id, ]); @@ -992,7 +1223,17 @@ export default function ChatView({ threadId }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; - const serverConfig = useServerConfig(); + const primaryServerConfig = useServerConfig(); + const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => + activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, + ); + // Use the server config for the thread's environment. For the primary + // environment fall back to the global atom; for remote environments use + // the runtime state stored by the environment manager. + const serverConfig = + primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId + ? primaryServerConfig + : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, @@ -1000,7 +1241,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId, + threadRef: composerDraftTarget, providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, @@ -1203,11 +1444,28 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); + const clearAttachmentPreviewHandoff = useCallback( + (messageId: MessageId, previewUrls?: ReadonlyArray) => { + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + const currentPreviewUrls = + previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + setAttachmentPreviewHandoffByMessageId((existing) => { + if (!(messageId in existing)) { + return existing; + } + const next = { ...existing }; + delete next[messageId]; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + for (const previewUrl of currentPreviewUrls) { + revokeBlobPreviewUrl(previewUrl); + } + }, + [], + ); const clearAttachmentPreviewHandoffs = useCallback(() => { - for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { - window.clearTimeout(timeoutId); - } - attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; + attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { for (const previewUrl of previewUrls) { revokeBlobPreviewUrl(previewUrl); @@ -1241,29 +1499,89 @@ export default function ChatView({ threadId }: ChatViewProps) { attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); - - const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - if (typeof existingTimeout === "number") { - window.clearTimeout(existingTimeout); + }, []); + const serverMessages = activeThread?.messages; + useEffect(() => { + if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { + return; } - attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { - const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; - if (currentPreviewUrls) { - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } + + const cleanups: Array<() => void> = []; + + for (const [messageId, handoffPreviewUrls] of Object.entries( + attachmentPreviewHandoffByMessageId, + )) { + if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { + continue; } - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) return existing; - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; + + const serverMessage = serverMessages.find( + (message) => message.id === messageId && message.role === "user", + ); + if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { + continue; + } + + const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => + attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], + ); + if ( + serverPreviewUrls.length === 0 || + serverPreviewUrls.length !== handoffPreviewUrls.length || + serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) + ) { + continue; + } + + attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; + + let cancelled = false; + const imageInstances: HTMLImageElement[] = []; + + const preloadServerPreviews = Promise.all( + serverPreviewUrls.map( + (previewUrl) => + new Promise((resolve, reject) => { + const image = new Image(); + imageInstances.push(image); + const handleLoad = () => resolve(); + const handleError = () => + reject(new Error(`Failed to load server preview for ${messageId}.`)); + image.addEventListener("load", handleLoad, { once: true }); + image.addEventListener("error", handleError, { once: true }); + image.src = previewUrl; + }), + ), + ); + + void preloadServerPreviews + .then(() => { + if (cancelled) { + return; + } + clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); + }) + .catch(() => { + if (!cancelled) { + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + } + }); + + cleanups.push(() => { + cancelled = true; + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + for (const image of imageInstances) { + image.src = ""; + } }); - delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); - }, []); - const serverMessages = activeThread?.messages; + } + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; const serverMessagesWithPreviewHandoff = @@ -1399,10 +1717,12 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); + const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - const modelOptionsByProvider = useMemo( + const modelOptionsByProvider = useMemo< + Record> + >( () => ({ codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: @@ -1435,6 +1755,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ + environmentId, cwd: gitCwd, query: effectivePathQuery, enabled: isPathTrigger, @@ -1476,7 +1797,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "slash-command", command: "default", label: "/default", - description: "Switch this thread back to normal chat mode", + description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); @@ -1526,6 +1847,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; + const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalLaunchContext?.threadId === activeThreadId ? terminalLaunchContext @@ -1571,22 +1893,69 @@ export default function ChatView({ threadId }: ChatViewProps) { [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { + if (!isServerThread) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); - }, [diffOpen, navigate, threadId]); + }, [diffOpen, environmentId, isServerThread, navigate, threadId]); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); + + // Handle environment change for draft threads. When the user picks a + // different environment we update the draft context to point at the physical + // project in that environment while keeping the same logical project. + const onEnvironmentChange = useCallback( + (nextEnvironmentId: EnvironmentId) => { + if (envLocked || !draftId) return; + const target = logicalProjectEnvironments.find( + (env) => env.environmentId === nextEnvironmentId, + ); + if (!target) return; + setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(target.environmentId, target.projectId), + }); + }, + [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + ); + + // Auto-correct the draft model selection when the environment changes and + // the previously-selected provider/model is no longer available. This keeps + // the stored draft state consistent with the resolved picker values and + // prevents stale model references when the user sends a message. + const prevEnvironmentIdRef = useRef(activeThread?.environmentId); + useEffect(() => { + const currentEnvId = activeThread?.environmentId; + if (!currentEnvId || envLocked || prevEnvironmentIdRef.current === currentEnvId) { + prevEnvironmentIdRef.current = currentEnvId; + return; + } + prevEnvironmentIdRef.current = currentEnvId; + + // The resolved provider/model already account for the new environment's + // provider list. Persist that resolved selection into the draft. + if (activeThread) { + setComposerDraftModelSelection(scopeThreadRef(activeThread.environmentId, activeThread.id), { + provider: selectedProvider, + model: selectedModel, + }); + } + }, [activeThread, envLocked, selectedModel, selectedProvider, setComposerDraftModelSelection]); + const activeTerminalGroup = terminalState.terminalGroups.find( (group) => group.id === terminalState.activeTerminalGroupId, @@ -1601,21 +1970,27 @@ export default function ChatView({ threadId }: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); - if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { + const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({ + serverThread, + routeThreadRef, + targetThreadId, + }); + if (isCurrentServerThread) { setStoreThreadError(targetThreadId, nextError); return; } - setLocalDraftErrorsByThreadId((existing) => { - if ((existing[targetThreadId] ?? null) === nextError) { + const localDraftErrorKey = draftId ?? targetThreadId; + setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey] ?? null) === nextError) { return existing; } return { ...existing, - [targetThreadId]: nextError, + [localDraftErrorKey]: nextError, }; }); }, - [setStoreThreadError], + [draftId, routeThreadRef, serverThread, setStoreThreadError], ); const focusComposer = useCallback(() => { @@ -1646,7 +2021,7 @@ export default function ChatView({ threadId }: ChatViewProps) { insertion.cursor, ); const inserted = insertComposerDraftTerminalContext( - activeThread.id, + scopeThreadRef(activeThread.environmentId, activeThread.id), insertion.prompt, { id: randomUUID(), @@ -1670,30 +2045,30 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const setTerminalOpen = useCallback( (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); + if (!activeThreadRef) return; + storeSetTerminalOpen(activeThreadRef, open); }, - [activeThreadId, storeSetTerminalOpen], + [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; + if (!activeThreadRef || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; - storeSplitTerminal(activeThreadId, terminalId); + storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); + }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; const terminalId = `terminal-${randomUUID()}`; - storeNewTerminal(activeThreadId, terminalId); + storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal]); + }, [activeThreadRef, storeNewTerminal]); const closeTerminal = useCallback( (terminalId: string) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!activeThreadId || !api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => @@ -1716,10 +2091,18 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { void fallbackExitWrite(); } - storeCloseTerminal(activeThreadId, terminalId); + if (activeThreadRef) { + storeCloseTerminal(activeThreadRef, terminalId); + } setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], + [ + activeThreadId, + activeThreadRef, + environmentId, + storeCloseTerminal, + terminalState.terminalIds.length, + ], ); const runProjectScript = useCallback( async ( @@ -1732,7 +2115,7 @@ export default function ChatView({ threadId }: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { @@ -1759,10 +2142,13 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath: targetWorktreePath, }); setTerminalOpen(true); + if (!activeThreadRef) { + return; + } if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadId, targetTerminalId); + storeNewTerminal(activeThreadRef, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadId, targetTerminalId); + storeSetActiveTerminal(activeThreadRef, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); @@ -1809,12 +2195,14 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, activeThreadId, + activeThreadRef, gitCwd, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, + environmentId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1830,7 +2218,7 @@ export default function ChatView({ threadId }: ChatViewProps) { keybinding?: string | null; keybindingCommand: KeybindingCommand; }) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api) return; await api.orchestration.dispatchCommand({ @@ -1846,10 +2234,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }); if (isElectron && keybindingRule) { - await api.server.upsertKeybinding(keybindingRule); + const localApi = readLocalApi(); + if (!localApi) { + throw new Error("Local API unavailable."); + } + await localApi.server.upsertKeybinding(keybindingRule); } }, - [], + [environmentId], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { @@ -1953,9 +2345,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(threadId, mode); + setComposerDraftRuntimeMode(composerDraftTarget, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { runtimeMode: mode }); + setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } scheduleComposerFocus(); }, @@ -1963,18 +2355,18 @@ export default function ChatView({ threadId }: ChatViewProps) { isLocalDraftThread, runtimeMode, scheduleComposerFocus, + composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, - threadId, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; - setComposerDraftInteractionMode(threadId, mode); + setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { interactionMode: mode }); + setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); } scheduleComposerFocus(); }, @@ -1982,19 +2374,14 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode, isLocalDraftThread, scheduleComposerFocus, + composerDraftTarget, setComposerDraftInteractionMode, setDraftThreadContext, - threadId, ], ); const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const toggleRuntimeMode = useCallback(() => { - void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", - ); - }, [handleRuntimeModeChange, runtimeMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { @@ -2020,7 +2407,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!serverThread) { return; } - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api) { return; } @@ -2060,7 +2447,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } }, - [serverThread], + [environmentId, serverThread], ); // Auto-scroll on new messages @@ -2390,17 +2777,17 @@ export default function ChatView({ threadId }: ChatViewProps) { dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [resetLocalDispatch, threadId]); + }, [draftId, resetLocalDispatch, threadId]); useEffect(() => { let cancelled = false; void (async () => { if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(threadId); + clearComposerDraftPersistedAttachments(composerDraftTarget); return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( @@ -2431,7 +2818,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } // Stage attachments in persisted draft state first so persist middleware can write them. - syncComposerDraftPersistedAttachments(threadId, serialized); + syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); @@ -2445,17 +2832,18 @@ export default function ChatView({ threadId }: ChatViewProps) { if (cancelled) { return; } - syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); + syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); } })(); return () => { cancelled = true; }; }, [ + composerDraftTarget, clearComposerDraftPersistedAttachments, composerImages, + getComposerDraft, syncComposerDraftPersistedAttachments, - threadId, ]); const closeExpandedImage = useCallback(() => { @@ -2506,17 +2894,17 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => window.removeEventListener("keydown", onKeyDown); }, [closeExpandedImage, expandedImage, navigateExpandedImage]); - const activeWorktreePath = activeThread?.worktreePath; - const envMode: DraftThreadEnvMode = activeWorktreePath - ? "worktree" - : isLocalDraftThread - ? (draftThread?.envMode ?? "local") - : "local"; + const activeWorktreePath = activeThread?.worktreePath ?? null; + const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread: isServerThread, + draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, + }); useEffect(() => { if (!activeThreadId) { setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(threadId); + storeClearTerminalLaunchContext(routeThreadRef); return; } setTerminalLaunchContext((current) => { @@ -2524,7 +2912,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (current.threadId === activeThreadId) return current; return null; }); - }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); + }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { @@ -2542,12 +2930,20 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } return null; } return current; }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + }, [ + activeProjectCwd, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + storeClearTerminalLaunchContext, + ]); useEffect(() => { if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { @@ -2561,11 +2957,14 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === storeServerTerminalLaunchContext.cwd && (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } } }, [ activeProjectCwd, activeThreadId, + activeThreadRef, activeThreadWorktreePath, storeClearTerminalLaunchContext, storeServerTerminalLaunchContext, @@ -2575,11 +2974,16 @@ export default function ChatView({ threadId }: ChatViewProps) { if (terminalState.terminalOpen) { return; } - if (activeThreadId) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); } setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); + }, [ + activeThreadId, + activeThreadRef, + storeClearTerminalLaunchContext, + terminalState.terminalOpen, + ]); useEffect(() => { if (phase !== "running") return; @@ -2592,16 +2996,16 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [phase]); useEffect(() => { - if (!activeThreadId) return; - const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; + if (!activeThreadKey) return; + const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; const current = Boolean(terminalState.terminalOpen); if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); @@ -2610,8 +3014,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }; } - terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThreadId, focusComposer, terminalState.terminalOpen]); + terminalOpenByThreadRef.current[activeThreadKey] = current; + }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -2806,14 +3210,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const onRevertToTurnCount = useCallback( async (turnCount: number) => { - const api = readNativeApi(); - if (!api || !activeThread || isRevertingCheckpoint) return; + const api = readEnvironmentApi(environmentId); + const localApi = readLocalApi(); + if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } - const confirmed = await api.dialogs.confirm( + const confirmed = await localApi.dialogs.confirm( [ `Revert this thread to checkpoint ${turnCount}?`, "This will discard newer messages and turn diffs in this thread.", @@ -2842,12 +3247,20 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], + [ + activeThread, + environmentId, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + phase, + setThreadError, + ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); @@ -2870,7 +3283,7 @@ export default function ChatView({ threadId }: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2887,7 +3300,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2920,10 +3333,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { - setStoreThreadError( - threadIdForSend, - "Select a base branch before sending in New worktree mode.", - ); + setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -2990,7 +3400,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } promptRef.current = ""; - clearComposerDraftContent(threadIdForSend); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -3127,7 +3537,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; const onInterrupt = async () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", @@ -3139,7 +3549,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId) return; setRespondingRequestIds((existing) => @@ -3162,12 +3572,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, environmentId, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId) return; setRespondingUserInputRequestIds((existing) => @@ -3190,7 +3600,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, environmentId, setThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( @@ -3211,21 +3621,33 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: { - selectedOptionLabel: optionLabel, - customAnswer: "", + setPendingUserInputAnswersByRequestId((existing) => { + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? + activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return existing; + } + + return { + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[activePendingUserInput.requestId]?.[questionId], + optionLabel, + ), }, - }, - })); + }; + }); promptRef.current = ""; setComposerCursor(0); setComposerTrigger(null); }, - [activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -3292,7 +3714,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: string; interactionMode: "default" | "plan"; }) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || @@ -3347,7 +3769,10 @@ export default function ChatView({ threadId }: ChatViewProps) { // Keep the mode toggle and plan-follow-up banner in sync immediately // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + setComposerDraftInteractionMode( + scopeThreadRef(activeThread.environmentId, threadIdForSend), + nextInteractionMode, + ); await api.orchestration.dispatchCommand({ type: "thread.turn.start", @@ -3411,11 +3836,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, selectedModel, + environmentId, ], ); const onImplementPlanInNewThread = useCallback(async () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || @@ -3487,17 +3913,20 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .then(() => { - return waitForStartedServerThread(nextThreadId); + return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: activeThread.environmentId, + threadId: nextThreadId, + }, }); }) - .catch(async (err) => { + .catch(async (err: unknown) => { await api.orchestration .dispatchCommand({ type: "thread.delete", @@ -3529,6 +3958,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, selectedProviderModels, selectedModel, + environmentId, ]); const onProviderModelSelect = useCallback( @@ -3549,7 +3979,10 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: resolvedProvider, model: resolvedModel, }; - setComposerDraftModelSelection(activeThread.id, nextModelSelection); + setComposerDraftModelSelection( + scopeThreadRef(activeThread.environmentId, activeThread.id), + nextModelSelection, + ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); }, @@ -3581,7 +4014,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, - threadId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3590,7 +4024,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, - threadId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3600,11 +4035,20 @@ export default function ChatView({ threadId }: ChatViewProps) { const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { - setDraftThreadContext(threadId, { envMode: mode }); + setDraftThreadContext(composerDraftTarget, { + envMode: mode, + ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + }); } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [ + composerDraftTarget, + draftThread?.worktreePath, + isLocalDraftThread, + scheduleComposerFocus, + setDraftThreadContext, + ], ); const applyPromptReplacement = useCallback( @@ -3801,7 +4245,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( - threadId, + composerDraftTarget, syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } @@ -3816,8 +4260,8 @@ export default function ChatView({ threadId }: ChatViewProps) { composerTerminalContexts, onChangeActivePendingUserInputCustomAnswer, setPrompt, + composerDraftTarget, setComposerDraftTerminalContexts, - threadId, ], ); @@ -3870,9 +4314,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { + if (!isServerThread) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath @@ -3881,7 +4331,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, threadId], + [environmentId, isServerThread, navigate, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3893,28 +4343,7 @@ export default function ChatView({ threadId }: ChatViewProps) { // Empty state: no active thread if (!activeThread) { - return ( -
- {!isElectron && ( -
-
- - Threads -
-
- )} - {isElectron && ( -
- No active thread -
- )} -
-
-

Select a thread or create a new one to get started.

-
-
-
- ); + return ; } return ( @@ -3927,6 +4356,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} >
@@ -4028,7 +4459,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
@@ -4256,7 +4687,7 @@ export default function ChatView({ threadId }: ChatViewProps) { traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} - onToggleRuntimeMode={toggleRuntimeMode} + onRuntimeModeChange={handleRuntimeModeChange} /> ) : ( <> @@ -4283,13 +4714,13 @@ export default function ChatView({ threadId }: ChatViewProps) { onClick={toggleInteractionMode} title={ interactionMode === "plan" - ? "Plan mode — click to return to normal chat mode" + ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode" } > - {interactionMode === "plan" ? "Plan" : "Chat"} + {interactionMode === "plan" ? "Plan" : "Build"} @@ -4298,29 +4729,39 @@ export default function ChatView({ threadId }: ChatViewProps) { className="mx-0.5 hidden h-4 sm:block" /> - - + ); } @@ -177,10 +270,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 +293,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 +304,120 @@ 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(); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..d8ff73da78 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,19 @@ 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 { 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; } interface PendingDefaultBranchAction { @@ -92,6 +94,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 +209,21 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ gitCwd, activeThreadRef }: 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) => + 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 +251,49 @@ 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(activeThreadRef, { + branch, + worktreePath: activeDraftThread.worktreePath, + }); }, - [activeServerThread, activeThreadId, setThreadBranch], + [ + activeDraftThread, + activeServerThread, + activeThreadRef, + setDraftThreadContext, + setThreadBranch, + ], ); const syncThreadBranchAfterGitAction = useCallback( @@ -275,7 +308,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,19 +322,27 @@ 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; useEffect(() => { @@ -359,8 +403,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 +457,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 +646,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 +805,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 +880,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 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..68450256fe 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.makeUnsafe("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: [], @@ -146,6 +172,13 @@ function buildFixture(): TestFixture { 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, @@ -170,20 +203,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 +218,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 +259,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 +294,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 +363,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 () => { @@ -322,18 +430,17 @@ describe("Keybindings update toast", () => { 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..39aa4e2f72 --- /dev/null +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -0,0 +1,41 @@ +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..134ca2e6f3 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,7 @@ function stepStatusIcon(status: string): React.ReactNode { interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; + environmentId: EnvironmentId; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; @@ -62,6 +64,7 @@ interface PlanSidebarProps { const PlanSidebar = memo(function PlanSidebar({ activePlan, activeProposedPlan, + environmentId, markdownCwd, workspaceRoot, timestampFormat, @@ -87,7 +90,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 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ () => setIsSavingToWorkspace(false), () => setIsSavingToWorkspace(false), ); - }, [planMarkdown, workspaceRoot]); + }, [environmentId, planMarkdown, workspaceRoot]); return (
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..f9e5561a50 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,7 +20,7 @@ import { 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.makeUnsafe("environment-local"); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -625,6 +627,7 @@ function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -642,6 +645,7 @@ function makeProject(overrides: Partial = {}): Project { function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..6dbd5605c3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, + CloudIcon, FolderIcon, GitPullRequestIcon, PlusIcon, @@ -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, @@ -45,21 +33,39 @@ import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, + type EnvironmentId, 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 { + 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 { + selectProjectByRef, + selectProjectsAcrossEnvironments, + selectSidebarThreadsForProjectRef, + selectSidebarThreadsForProjectRefs, + selectSidebarThreadsAcrossEnvironments, + selectThreadByRef, + useStore, +} from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -70,12 +76,17 @@ 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 { useThreadActions } from "../hooks/useThreadActions"; +import { + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -97,7 +108,6 @@ import { SidebarFooter, SidebarGroup, SidebarHeader, - SidebarMenuAction, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -110,8 +120,6 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - getVisibleSidebarThreadIds, - getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -124,13 +132,19 @@ import { sortProjectsForSidebar, sortThreadsForSidebar, useThreadJumpHintVisibility, + ThreadStatusPill, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +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 +159,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 +237,7 @@ function ThreadStatusLabel({ status, compact = false, }: { - status: NonNullable>; + status: ThreadStatusPill; compact?: boolean; }) { if (compact) { @@ -245,55 +314,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 +433,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 +610,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 +623,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,36 +633,15 @@ 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} @@ -434,34 +661,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( - props.appSettingsConfirmThreadArchive ? ( + appSettingsConfirmThreadArchive ? (
@@ -495,14 +698,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,190 +711,1640 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { ) ) : null} - {props.showThreadJumpHints && props.jumpLabel ? ( - - {props.jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} + + {isRemoteThread && ( + + + } + > + + + {threadEnvironmentLabel} + + )} + {jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(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 SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { + return ( + + + + ); +}); + +function T3Wordmark() { + return ( + + + + ); +} + +type SortableProjectHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" +>; + +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} + + ))} + +
+
+
+ ); +} + +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 })}
  • ); } -export default function Sidebar() { - const projects = useStore((store) => store.projects); - const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); - const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); - const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( - useShallow((store) => ({ - projectExpandedById: store.projectExpandedById, - projectOrder: store.projectOrder, - threadLastVisitedAtById: store.threadLastVisitedAtById, - })), - ); - const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); - const toggleProject = useUiStateStore((store) => store.toggleProject); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, +const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + const wordmark = ( +
    + + + + + + Code + + + {APP_STAGE_LABEL} + + + } + /> + + Version {APP_VERSION} + + +
    ); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, + + 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"]; + shouldShowProjectPathEntry: boolean; + handleStartAddProject: () => void; + isElectron: boolean; + isPickingFolder: boolean; + isAddingProject: boolean; + handlePickFolder: () => Promise; + addProjectInputRef: React.RefObject; + addProjectError: string | null; + newCwd: string; + setNewCwd: React.Dispatch>; + setAddProjectError: React.Dispatch>; + handleAddProject: () => void; + setAddingProject: React.Dispatch>; + canAddProject: boolean; + 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; + 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, + shouldShowProjectPathEntry, + handleStartAddProject, + isElectron, + isPickingFolder, + isAddingProject, + handlePickFolder, + addProjectInputRef, + addProjectError, + newCwd, + setNewCwd, + setAddProjectError, + handleAddProject, + setAddingProject, + canAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + handleNewThread, + archiveThread, + deleteThread, + sortedProjects, + expandedThreadListsByProject, + activeRouteProjectKey, + routeThreadKey, + newThreadShortcutLabel, + threadJumpLabelByKey, + attachThreadListAutoAnimateRef, + expandThreadListForProject, + collapseThreadListForProject, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleAddProjectInputChange = useCallback( + (event: React.ChangeEvent) => { + setNewCwd(event.target.value); + setAddProjectError(null); + }, + [setAddProjectError, setNewCwd], + ); + const handleAddProjectInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") handleAddProject(); + if (event.key === "Escape") { + setAddingProject(false); + setAddProjectError(null); + } + }, + [handleAddProject, setAddProjectError, setAddingProject], + ); + const handleBrowseForFolderClick = useCallback(() => { + void handlePickFolder(); + }, [handlePickFolder]); + + return ( + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + +
    + + Projects + +
    + + + + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
    +
    + {shouldShowProjectPathEntry && ( +
    + {isElectron && ( + + )} +
    + + +
    + {addProjectError && ( +

    + {addProjectError} +

    + )} +
    + )} + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} + + {projectsLength === 0 && !shouldShowProjectPathEntry && ( +
    + No projects yet +
    + )} +
    +
    ); +}); + +export default function Sidebar() { + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); + 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 appSettings = useSettings(); + const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const { updateSettings } = useUpdateSettings(); - const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); + const { handleNewThread } = useHandleNewThread(); const { archiveThread, deleteThread } = useThreadActions(); - const routeThreadId = useParams({ + const routeThreadRef = useParams({ strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => resolveThreadRouteRef(params), }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const keybindings = useServerKeybindings(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); @@ -705,172 +2352,208 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); - const [renamingThreadId, setRenamingThreadId] = useState(null); - const [renamingTitle, setRenamingTitle] = useState(""); - const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState(null); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet + ReadonlySet >(() => new Set()); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const renamingCommittedRef = useRef(false); - const renamingInputRef = useRef(null); - const confirmArchiveButtonRefs = useRef(new Map()); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); - const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); - const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + 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) => project.id, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), }); }, [projectOrder, projects]); - const sidebarProjects = useMemo( - () => - orderedProjects.map((project) => ({ - ...project, - expanded: projectExpandedById[project.id] ?? true, - })), - [orderedProjects, projectExpandedById], - ); - const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]); - const projectCwdById = useMemo( - () => new Map(projects.map((project) => [project.id, project.cwd] as const)), - [projects], - ); - const routeTerminalOpen = routeThreadId - ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen - : false; - const sidebarShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: routeTerminalOpen, - }, - }), - [platform, routeTerminalOpen], - ); - const threadGitTargets = useMemo( - () => - sidebarThreads.map((thread) => ({ - threadId: thread.id, - branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, - })), - [projectCwdById, sidebarThreads], - ); - const threadGitStatusCwds = useMemo( - () => [ - ...new Set( - threadGitTargets - .filter((target) => target.branch !== null) - .map((target) => target.cwd) - .filter((cwd): cwd is string => cwd !== null), - ), - ], - [threadGitTargets], - ); - const threadGitStatusQueries = useQueries({ - queries: threadGitStatusCwds.map((cwd) => ({ - ...gitStatusQueryOptions(cwd), - staleTime: 30_000, - refetchInterval: 60_000, - })), - }); - const prByThreadId = useMemo(() => { - const statusByCwd = new Map(); - for (let index = 0; index < threadGitStatusCwds.length; index += 1) { - const cwd = threadGitStatusCwds[index]; - if (!cwd) continue; - const status = threadGitStatusQueries[index]?.data; - if (status) { - statusByCwd.set(cwd, status); + + // 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]); } } - const map = new Map(); - for (const target of threadGitTargets) { - const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; - const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); + 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 map; - }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - - const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); + return result; + }, [ + orderedProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); - const api = readNativeApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - }); - return; + 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; } - - void api.shell.openExternal(prUrl).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }); - }, []); - - const attemptArchiveThread = useCallback( - async (threadId: ThreadId) => { - try { - await archiveThread(threadId); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); + 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]); } - }, - [archiveThread], + } + 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 focusMostRecentThreadForProject = useCallback( - (projectId: ProjectId) => { + (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { + const physicalKey = scopedProjectKey( + scopeProjectRef(projectRef.environmentId, projectRef.projectId), + ); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const latestThread = sortThreadsForSidebar( - (threadIdsByProjectId[projectId] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), - appSettings.sidebarThreadSortOrder, + (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), + sidebarThreadSortOrder, )[0]; if (!latestThread) return; void navigate({ - to: "/$threadId", - params: { threadId: latestThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), }); }, - [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId], + [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], ); const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; - const api = readNativeApi(); + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; if (!api) return; setIsAddingProject(true); @@ -883,7 +2566,10 @@ export default function Sidebar() { const existing = projects.find((project) => project.cwd === cwd); if (existing) { - focusMostRecentThreadForProject(existing.id); + focusMostRecentThreadForProject({ + environmentId: existing.environmentId, + projectId: existing.id, + }); finishAddingProject(); return; } @@ -904,9 +2590,11 @@ export default function Sidebar() { }, createdAt, }); - await handleNewThread(projectId, { - envMode: appSettings.defaultThreadEnvMode, - }).catch(() => undefined); + if (activeEnvironmentId !== null) { + await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { + envMode: defaultThreadEnvMode, + }).catch(() => undefined); + } } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; @@ -926,374 +2614,60 @@ export default function Sidebar() { }, [ focusMostRecentThreadForProject, + activeEnvironmentId, handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - appSettings.defaultThreadEnvMode, - ], - ); - - const handleAddProject = () => { - void addProjectFromPath(newCwd); - }; - - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - - const handlePickFolder = async () => { - const api = readNativeApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } - if (pickedPath) { - await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); - } - setIsPickingFolder(false); - }; - - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; - - const cancelRename = useCallback(() => { - setRenamingThreadId(null); - renamingInputRef.current = null; - }, []); - - const commitRename = useCallback( - async (threadId: ThreadId, newTitle: string, originalTitle: string) => { - const finishRename = () => { - setRenamingThreadId((current) => { - if (current !== threadId) return current; - renamingInputRef.current = null; - return null; - }); - }; - - const trimmed = newTitle.trim(); - if (trimmed.length === 0) { - toastManager.add({ - type: "warning", - title: "Thread title cannot be empty", - }); - finishRename(); - return; - } - if (trimmed === originalTitle) { - finishRename(); - return; - } - const api = readNativeApi(); - if (!api) { - finishRename(); - return; - } - try { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId, - title: trimmed, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - finishRename(); - }, - [], - ); - - const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ - threadId: ThreadId; - }>({ - onCopy: (ctx) => { - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: ctx.threadId, - }); - }, - onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }, - }); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ - path: string; - }>({ - onCopy: (ctx) => { - toastManager.add({ - type: "success", - title: "Path copied", - description: ctx.path, - }); - }, - onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }, - }); - const handleThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { - const api = readNativeApi(); - if (!api) return; - const thread = sidebarThreadsById[threadId]; - if (!thread) return; - const threadWorkspacePath = - thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; - const clicked = await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy Path" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); - - if (clicked === "rename") { - setRenamingThreadId(threadId); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; - return; - } - - if (clicked === "mark-unread") { - markThreadUnread(threadId, thread.latestTurn?.completedAt); - return; - } - if (clicked === "copy-path") { - if (!threadWorkspacePath) { - toastManager.add({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }); - return; - } - copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); - return; - } - if (clicked === "copy-thread-id") { - copyThreadIdToClipboard(threadId, { threadId }); - return; - } - if (clicked !== "delete") return; - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) { - return; - } - } - await deleteThread(threadId); - }, - [ - appSettings.confirmThreadDelete, - copyPathToClipboard, - copyThreadIdToClipboard, - deleteThread, - markThreadUnread, - projectCwdById, - sidebarThreadsById, - ], - ); - - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readNativeApi(); - if (!api) return; - const ids = [...selectedThreadIds]; - if (ids.length === 0) return; - const count = ids.length; - - const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ); - - if (clicked === "mark-unread") { - for (const id of ids) { - const thread = sidebarThreadsById[id]; - markThreadUnread(id, thread?.latestTurn?.completedAt); - } - clearSelection(); - return; - } - - if (clicked !== "delete") return; - - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", - ].join("\n"), - ); - if (!confirmed) return; - } - - const deletedIds = new Set(ids); - for (const id of ids) { - await deleteThread(id, { deletedThreadIds: deletedIds }); - } - removeFromSelection(ids); - }, - [ - appSettings.confirmThreadDelete, - clearSelection, - deleteThread, - markThreadUnread, - removeFromSelection, - selectedThreadIds, - sidebarThreadsById, - ], - ); - - const handleThreadClick = useCallback( - (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => { - const isMac = isMacPlatform(navigator.platform); - const isModClick = isMac ? event.metaKey : event.ctrlKey; - const isShiftClick = event.shiftKey; - - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadId); - return; - } - - if (isShiftClick) { - event.preventDefault(); - rangeSelectTo(threadId, orderedProjectThreadIds); - return; - } - - // Plain click — clear selection, set anchor for future shift-clicks, and navigate - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(threadId); - void navigate({ - to: "/$threadId", - params: { threadId }, - }); - }, - [ - clearSelection, - navigate, - rangeSelectTo, - selectedThreadIds.size, - setSelectionAnchor, - toggleThreadSelection, - ], - ); - - const navigateToThread = useCallback( - (threadId: ThreadId) => { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(threadId); - void navigate({ - to: "/$threadId", - params: { threadId }, - }); - }, - [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], + isAddingProject, + projects, + shouldBrowseForProjectImmediately, + defaultThreadEnvMode, + ], ); - 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 handleAddProject = () => { + void addProjectFromPath(newCwd); + }; - 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 canAddProject = newCwd.trim().length > 0 && !isAddingProject; - 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 handlePickFolder = async () => { + const api = readLocalApi(); + if (!api || isPickingFolder) return; + setIsPickingFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder(); + } catch { + // Ignore picker failures and leave the current thread selection unchanged. + } + if (pickedPath) { + await addProjectFromPath(pickedPath); + } else if (!shouldBrowseForProjectImmediately) { + addProjectInputRef.current?.focus(); + } + setIsPickingFolder(false); + }; - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; + const handleStartAddProject = () => { + setAddProjectError(null); + if (shouldBrowseForProjectImmediately) { + void handlePickFolder(); + return; + } + setAddingProject((prev) => !prev); + }; - 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, - }); + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); } + setSelectionAnchor(scopedThreadKey(threadRef)); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); }, - [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - copyPathToClipboard, - getDraftThreadByProjectId, - projects, - threadIdsByProjectId, - ], + [clearSelection, navigate, setSelectionAnchor], ); const projectDnDSensors = useSensors( @@ -1312,30 +2686,30 @@ 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); + reorderProjects(activeProject.projectKey, overProject.projectKey); }, - [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 +2734,124 @@ 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], - }, - }); + sortedProjects.flatMap((project) => { const projectThreads = sortThreadsForSidebar( - (threadIdsByProjectId[project.id] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), - appSettings.sidebarThreadSortOrder, + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => resolveProjectThreadStatus(thread)), - ); - 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); - } - } - return mapping; - }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); - const orderedSidebarThreadIds = visibleSidebarThreadIds; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const [threadJumpLabelByKey, setThreadJumpLabelByKey] = + useState>(EMPTY_THREAD_JUMP_LABELS); + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { - const getShortcutContext = () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - }); - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + setThreadJumpLabelByKey((current) => { + if (!shouldShowHints) { + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; + } + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, platform, - context: getShortcutContext(), - }), - ); + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(shouldShowHints); if (event.defaultPrevented || event.repeat) { return; @@ -1509,22 +2859,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,26 +2887,45 @@ 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, { + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + setThreadJumpLabelByKey((current) => { + if (!shouldShowHints) { + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; + } + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, platform, - context: getShortcutContext(), - }), - ); + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(shouldShowHints); }; const onWindowBlur = () => { + setThreadJumpLabelByKey((current) => + current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, + ); updateThreadJumpHintsVisibility(false); }; @@ -1566,266 +2939,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 +2963,7 @@ export default function Sidebar() { return () => { window.removeEventListener("mousedown", onMouseDown); }; - }, [clearSelection, selectedThreadIds.size]); + }, [clearSelection, selectedThreadCount]); useEffect(() => { if (!isElectron) return; @@ -1880,10 +3008,6 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); - const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -1946,246 +3070,82 @@ 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..4066882f7b --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -0,0 +1,315 @@ +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", () => ({ + readLocalApi: readLocalApiMock, +})); + +import { TerminalViewport } from "./ThreadTerminalDrawer"; + +const THREAD_ID = ThreadId.makeUnsafe("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..c753508a06 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, @@ -31,7 +32,8 @@ import { 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 +76,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 +235,7 @@ export function shouldHandleTerminalSelectionMouseUp( } interface TerminalViewportProps { + threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; terminalLabel: string; @@ -222,7 +250,8 @@ interface TerminalViewportProps { drawerHeight: number; } -function TerminalViewport({ +export function TerminalViewport({ + threadRef, threadId, terminalId, terminalLabel, @@ -239,6 +268,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 +290,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 +301,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 +310,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 +370,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, ); @@ -416,7 +446,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 +456,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 +514,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 +603,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 +638,7 @@ function TerminalViewport({ writeTerminalSnapshot(activeTerminal, snapshot); const bufferedEntries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ); const replayEntries = selectTerminalEventEntriesAfterSnapshot( @@ -677,7 +707,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 +722,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 +744,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 +807,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + threadRef, threadId, cwd, worktreePath, @@ -1098,6 +1133,7 @@ export default function ThreadTerminalDrawer({ >
    -
    -
    -
    -
    - -
    -
    -
    -

    - {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)}
    -          
    -
    -
    -
    - ); -} - export function WebSocketConnectionCoordinator() { const status = useWsConnectionStatus(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -277,7 +120,7 @@ export function WebSocketConnectionCoordinator() { toastResetTimerRef.current = null; } lastForcedReconnectAtRef.current = Date.now(); - void getWsRpcClient() + void getPrimaryEnvironmentConnection() .reconnect() .catch((error) => { if (!showFailureToast) { @@ -527,18 +370,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/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f04c9879fa..9857ef22eb 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,9 +1,11 @@ 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 { DiffIcon, TerminalSquareIcon } from "lucide-react"; @@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; interface ChatHeaderProps { + activeThreadEnvironmentId: EnvironmentId; activeThreadId: ThreadId; activeThreadTitle: string; activeProjectName: string | undefined; @@ -39,6 +42,7 @@ interface ChatHeaderProps { } export const ChatHeader = memo(function ChatHeader({ + activeThreadEnvironmentId, activeThreadId, activeThreadTitle, activeProjectName, @@ -101,7 +105,12 @@ 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); @@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str , { container: host }, ); @@ -150,9 +158,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..84e0953440 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -20,7 +20,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; - onToggleRuntimeMode: () => void; + onRuntimeModeChange: (mode: RuntimeMode) => void; }) { return ( @@ -60,10 +60,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 ? ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c8cad7bf36..98aff58b0d 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,5 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useEffect, useEffectEvent, useRef } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, @@ -13,7 +13,7 @@ interface PendingUserInputPanelProps { respondingRequestIds: ApprovalRequestId[]; answers: Record; 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,8 @@ 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; + return; } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return; @@ -109,11 +110,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 +135,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 (
    diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..c644867aac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -42,6 +42,8 @@ beforeAll(() => { }); }); +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { onRevertUserMessage={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} markdownCwd={undefined} resolvedTheme="light" timestampFormat="locale" @@ -95,7 +98,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); - }); + }, 10_000); it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { onRevertUserMessage={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} markdownCwd={undefined} resolvedTheme="light" timestampFormat="locale" diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8cb8b89684..5100824328 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,4 @@ -import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; import { memo, useCallback, @@ -48,6 +48,7 @@ import { type MessagesTimelineRow, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -81,6 +82,7 @@ interface MessagesTimelineProps { onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; + activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; @@ -116,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, isRevertingCheckpoint, onImageExpand, + activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, @@ -385,7 +388,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {image.name} @@ -530,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
    @@ -792,6 +796,16 @@ function workEntryPreview( : `${firstPath} +${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 { if (workEntry.requestKind === "command") return TerminalIcon; if (workEntry.requestKind === "file-read") return EyeIcon; @@ -840,6 +854,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); const preview = workEntryPreview(workEntry); + 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,19 +868,46 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
    -

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

    +
    +

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

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

    +
    {hasChangedFiles && !previewIsChangedFiles && ( diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index 1e947a3c84..6a37c5b099 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { height: 1_100, }; const MARKDOWN_CWD = "/repo/project"; +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; interface RowMeasurement { actualHeightPx: number; @@ -31,7 +32,10 @@ interface RowMeasurement { interface VirtualizationScenario { name: string; targetRowId: string; - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; maxEstimateDeltaPx: number; } @@ -48,7 +52,10 @@ interface VirtualizerSnapshot { } function MessagesTimelineBrowserHarness( - props: Omit, "scrollContainer">, + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >, ) { const [scrollContainer, setScrollContainer] = useState(null); const [expandedWorkGroups, setExpandedWorkGroups] = useState>( @@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( > ; onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"]; -}): Omit, "scrollContainer"> { +}): Omit, "scrollContainer" | "activeThreadEnvironmentId"> { return { hasMessages: true, isWorking: false, @@ -481,7 +489,10 @@ async function waitForElement( async function measureTimelineRow(input: { host: HTMLElement; - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; targetRowId: string; }): Promise { const scrollContainer = await waitForElement( @@ -550,7 +561,10 @@ async function measureTimelineRow(input: { } async function mountMessagesTimeline(input: { - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; viewport?: { width: number; height: number }; }) { const viewport = input.viewport ?? DEFAULT_VIEWPORT; @@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { return { host, rerender: async ( - nextProps: Omit, "scrollContainer">, + nextProps: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >, ) => { await screen.rerender(); await waitForLayout(); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 703bfadaa3..d30fa9be04 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -13,10 +13,12 @@ import { 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 }> = [ @@ -37,12 +39,12 @@ 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 +110,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/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 74c22e6431..4dc7240c13 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 LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("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.makeUnsafe("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", @@ -120,10 +127,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 +142,7 @@ function ClaudeTraitsPickerHarness(props: { }); const handlePromptChange = useCallback( (nextPrompt: string) => { - setPrompt(CLAUDE_THREAD_ID, nextPrompt); + setPrompt(CLAUDE_THREAD_REF, nextPrompt); }, [setPrompt], ); @@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { = { - [CLAUDE_THREAD_ID]: { + const draftsByThreadKey: Record = { + [CLAUDE_THREAD_KEY]: { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], @@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { }, }; useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); const host = document.createElement("div"); document.body.append(host); @@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); @@ -369,10 +376,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 +398,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 +410,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/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/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."} + + + +