diff --git a/.agents/skills/ios-debugger-agent/LICENSE b/.agents/skills/ios-debugger-agent/LICENSE new file mode 100644 index 00000000000..0d193abe04f --- /dev/null +++ b/.agents/skills/ios-debugger-agent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md new file mode 100644 index 00000000000..7afa579383d --- /dev/null +++ b/.agents/skills/ios-debugger-agent/SKILL.md @@ -0,0 +1,64 @@ +--- +name: ios-debugger-agent +description: Build, launch, inspect, and drive iOS apps with the repository-configured XcodeBuildMCP server. Use on macOS for iOS Simulator builds, focused native test runs, semantic UI automation, screenshots, logs, or debugging, including T3 Code Mobile verification. +--- + +# iOS Debugger Agent + +Use the repository-configured `xcodebuildmcp` tools instead of requiring a globally installed Codex plugin. Prefer MCP tools over raw `xcodebuild`, `xcrun`, or `simctl` when the client exposes them. + +## Confirm availability + +This workflow requires macOS 14.5 or newer, Xcode 16 or newer, and Node.js 18 or newer. The repository pins XcodeBuildMCP in both `.mcp.json` for Claude Code and `.codex/config.toml` for Codex. Project MCP servers may require one-time trust or approval followed by a new session. + +If the tools are missing: + +1. Confirm the repository is trusted and its project MCP server was approved. +2. Restart or recreate the agent session after approving configuration. +3. Run `npx --yes xcodebuildmcp@2.6.2 doctor` when the server starts but simulator or UI-automation tools are unavailable. Follow its actionable Xcode or AXe setup guidance. +4. Fall back to the pinned XcodeBuildMCP CLI or native Apple CLIs only when the current agent client cannot expose project MCP tools. + +Do not ask contributors to install the OpenAI `build-ios-apps` plugin globally. + +## Establish one simulator context + +1. Call `session_show_defaults` before discovery, build, launch, or UI work. +2. Call `list_sims` and select one explicit simulator UDID. Prefer a simulator that is already booted; boot an installed simulator when verification requires it, but do not create or download runtimes without user authorization. +3. Call `session_set_defaults` with the project or workspace, scheme, Debug configuration, simulator ID, and bundle identifier when known. +4. Keep every subsequent build, launch, screenshot, log capture, and UI action pinned to that same UDID. + +Avoid generic Mac window automation for switching among Simulator windows. Explicit device identity is more reliable. + +## Choose build or launch + +- Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed. +- Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely. +- Use `launch_app_sim` when a compatible app is already installed and no native rebuild is needed. +- To reuse an existing build artifact, use `get_sim_app_path` or `get_app_bundle_id`, install it with `install_app_sim` when necessary, and then launch it. +- Do not run a build-only action immediately before `build_run_sim` unless the task requires both artifacts. + +After launch, call `snapshot_ui` or `screenshot` before interacting. An open Simulator window alone is not evidence that the intended app launched. + +## Drive the UI semantically + +1. Call `snapshot_ui` to obtain the current accessibility hierarchy and element references. +2. Use only current `elementRef` values whose snapshot entries list the intended action. XcodeBuildMCP `2.6.2` does not accept coordinates for `tap`; when the app exposes no actionable reference, prefer a registered deep link or another app-supported route and otherwise report the accessibility blocker. +3. Refresh with `snapshot_ui` after navigation or layout changes. Element references are snapshot-specific. +4. Use `wait_for_ui` for asynchronous transitions when available rather than fixed sleeps. +5. Capture a final `screenshot` for the state that proves the affected flow. + +Use `gesture` or scoped swipe actions when needed. If a gesture is unreliable, return to a known route or relaunch rather than switching to generic desktop automation. + +## Capture logs and debug + +- Use `start_sim_log_cap` and `stop_sim_log_cap` with the exact bundle identifier for focused runtime logs. +- Use debugger tools only when the task requires runtime diagnosis; attach to the selected simulator and app rather than an ambiguous process. +- Summarize relevant errors instead of returning unbounded logs. + +## Clean up + +Stop only log captures, debugger sessions, apps, or simulators started for the current test. Leave pre-existing simulators and unrelated sessions alone. + +## Upstream + +Adapted from OpenAI's [`build-ios-apps`](https://github.com/openai/plugins/tree/main/plugins/build-ios-apps) plugin version `0.1.2` (`ios-debugger-agent`, MIT) and aligned with XcodeBuildMCP `2.6.2` tool names. diff --git a/.agents/skills/ios-debugger-agent/agents/openai.yaml b/.agents/skills/ios-debugger-agent/agents/openai.yaml new file mode 100644 index 00000000000..094bae6620b --- /dev/null +++ b/.agents/skills/ios-debugger-agent/agents/openai.yaml @@ -0,0 +1,9 @@ +interface: + display_name: "iOS Debugger Agent" + short_description: "Build and drive iOS Simulator apps" + default_prompt: "Use $ios-debugger-agent to build, launch, and inspect the current iOS app on Simulator." +dependencies: + tools: + - type: "mcp" + value: "xcodebuildmcp" + description: "Repository-configured Xcode build, simulator, logging, debugging, and semantic UI tools" diff --git a/.agents/skills/ios-simulator-browser/LICENSE b/.agents/skills/ios-simulator-browser/LICENSE new file mode 100644 index 00000000000..0d193abe04f --- /dev/null +++ b/.agents/skills/ios-simulator-browser/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/ios-simulator-browser/SKILL.md b/.agents/skills/ios-simulator-browser/SKILL.md new file mode 100644 index 00000000000..74b97530799 --- /dev/null +++ b/.agents/skills/ios-simulator-browser/SKILL.md @@ -0,0 +1,51 @@ +--- +name: ios-simulator-browser +description: Stream an explicit iOS Simulator through pinned serve-sim into the T3 Code in-app browser or another agent browser. Use on Apple Silicon macOS when the user should watch simulator verification live or when browser-visible simulator evidence is needed. +--- + +# iOS Simulator Browser + +Use serve-sim as the shared visual feed for an iOS Simulator. Use `ios-debugger-agent` and XcodeBuildMCP semantic UI tools to drive the app; do not treat browser-canvas coordinates as a substitute for missing app accessibility. + +## Confirm availability + +serve-sim `0.1.45` requires Apple Silicon macOS, Xcode command-line tools, and Node.js 20 or newer. If the host is unsupported, continue with XcodeBuildMCP screenshots and report that live streaming was unavailable. + +When running inside T3 Code, use its product-native browser MCP to open the stream. Other agent hosts may use their own browser or preview surface. + +Keep serve-sim on its default `127.0.0.1` binding. Do not expose its preview to a LAN or tunnel unless the user explicitly requests that access and the network is trusted; the preview includes a token-gated shell-execution route. + +## Start one owned stream + +1. Obtain the exact simulator UDID from the iOS build or launch workflow. +2. Check whether an existing serve-sim stream for that UDID belongs to another task. Reuse it only when explicitly shared; never kill another task's stream. +3. Otherwise, clear only a stale stream for that UDID and start the pinned version with scoped cleanup: + + ```bash + SIMULATOR_ID= + cleanup_serve_sim() { + npx --yes serve-sim@0.1.45 --kill "$SIMULATOR_ID" >/dev/null 2>&1 || true + } + trap cleanup_serve_sim EXIT INT TERM HUP + cleanup_serve_sim + npx --yes serve-sim@0.1.45 "$SIMULATOR_ID" + ``` + +4. Keep the terminal alive and open the exact local URL printed by serve-sim in the agent's browser. +5. Verify that a live simulator frame renders. A loaded wrapper page is not sufficient evidence. + +## Observe while driving semantically + +- Let the user watch the serve-sim stream while XcodeBuildMCP performs `snapshot_ui`, semantic taps, typing, gestures, and screenshots. +- Keep the browser and Xcode tooling pinned to the same simulator UDID. +- Do not switch to generic desktop automation or browser-canvas clicking merely because the stream is visible. + +If the in-app browser explicitly reports that previews are unavailable, do not install unrelated browser automation. Continue through XcodeBuildMCP, capture a simulator screenshot, report the unavailable live stream, and clean up the owned serve-sim process. + +## Finish + +Stop the long-running terminal and wait for its cleanup trap to finish. If it disappeared without cleanup, run `npx --yes serve-sim@0.1.45 --kill ` for that exact simulator. Never run an unscoped `--kill`. + +## Upstream + +Adapted from OpenAI's [`build-ios-apps`](https://github.com/openai/plugins/tree/main/plugins/build-ios-apps) plugin version `0.1.2` (`ios-simulator-browser`, MIT). It invokes serve-sim `0.1.45` under its Apache-2.0 license without vendoring the package. diff --git a/.agents/skills/ios-simulator-browser/agents/openai.yaml b/.agents/skills/ios-simulator-browser/agents/openai.yaml new file mode 100644 index 00000000000..9e8cdf7a16b --- /dev/null +++ b/.agents/skills/ios-simulator-browser/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "iOS Simulator Browser" + short_description: "Stream iOS Simulator in the browser" + default_prompt: "Use $ios-simulator-browser to stream the selected iOS Simulator into the T3 Code in-app browser." diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index c573807d615..c3dc1c103d4 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -5,6 +5,8 @@ description: Launch and test the T3 Code web app in isolated development environ # Test T3 App +Use this skill for the web client. For iOS Simulator, Android Emulator, or physical-device testing against an isolated T3 backend, use the sibling [`test-t3-mobile`](../test-t3-mobile/SKILL.md) skill. + ## Start an isolated web environment 1. Run commands from the repository root. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md new file mode 100644 index 00000000000..f3e3dcfd8ce --- /dev/null +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -0,0 +1,188 @@ +--- +name: test-t3-mobile +description: Launch and test T3 Code Mobile on an iOS Simulator or Android Emulator against disposable local T3 environments, including Metro and dev-client reuse, native rebuild decisions, per-client pairing, seeded projects, semantic UI control, screenshots, and iOS serve-sim streaming. Use after mobile UI or native changes, when reproducing phone or tablet behavior, pairing an emulator to isolated state, or verifying mobile behavior on macOS, Linux, or Windows. +--- + +# Test T3 Mobile + +Run one focused, end-to-end mobile verification pass against disposable T3 state. Use the sibling [`test-t3-app`](../test-t3-app/SKILL.md) skill as the detailed reference for pairing-token semantics and SQLite fixtures. + +Command examples use POSIX shell syntax. On Windows, use PowerShell equivalents: set variables with `$env:NAME = "value"`, use an explicit temporary directory from `[System.IO.Path]::GetTempPath()`, and run multiline examples on one line or with PowerShell backticks. Use `$env:ANDROID_HOME\platform-tools\adb.exe` when `adb` is not already on `PATH`. + +## Select a viable platform + +Inspect the host and the affected code before launching processes: + +- On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. +- On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable. +- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK, emulator, or dev-client prerequisite rather than claiming verification. + +Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target. + +## Choose the lightest valid launch path + +- For JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle. +- For native source, native dependencies, entitlements, config plugins, or generated project changes, rebuild the affected platform. +- Use `vp run ios:dev` or `vp run android:dev` only when an Expo clean prebuild is actually required; both commands regenerate the native project. +- If the user requested no native rebuild and no compatible app is installed, reuse an existing compatible `.app` or `.apk` artifact when available. Otherwise report the missing dev client instead of silently rebuilding. + +The development identity on both platforms is: + +- App: `T3 Code Dev` +- Bundle/package identifier: `com.t3tools.t3code.dev` +- URL scheme: `t3code-dev` + +Bundle or package presence proves the correct variant, not native compatibility. Reuse it only when the current changes did not alter its Expo SDK, native dependencies, config plugins, entitlements, generated project, or native source. + +## Start one disposable T3 environment + +Run backend commands from the repository root. Use the ignored, worktree-local `.t3` directory or create a fresh directory with the host OS's temporary-directory mechanism. An explicit base directory stores state in `/userdata`; never point testing at shared `~/.t3` state. + +Seed a small number of meaningful Git projects before starting the backend: + +```bash +node apps/server/src/bin.ts project add \ + --base-dir \ + --title +``` + +Running `project add` before the backend starts gives it exclusive offline database access. If a backend is already running, wait until it is ready so the CLI dispatches through the live server; never run offline mutations concurrently with the server. + +Use direct SQLite mutation only for disposable projection fixtures. Follow `test-t3-app` and stop the backend before writing. + +Start a headless backend after seeding: + +```bash +node apps/server/src/bin.ts serve \ + --host 127.0.0.1 \ + --port \ + --base-dir \ + --no-browser +``` + +Use these client origins: + +- iOS Simulator: `http://127.0.0.1:` +- Android Emulator: `http://10.0.2.2:` +- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin + +Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. + +## Start or reuse Metro safely + +Run Metro from `apps/mobile`. + +1. Inspect any process on the intended Metro port and its `/status` response. Reuse it only when it is healthy, belongs to this worktree, and matches `APP_VARIANT=development`, `--dev-client`, and scheme `t3code-dev`. +2. Never kill another worktree's Metro. Use a free explicit port when necessary. +3. Run `vp run dev:client` on the standard port. For another port, retain the complete development identity: + + ```bash + APP_VARIANT=development vp exec expo start \ + --dev-client \ + --scheme t3code-dev \ + --clear \ + --lan \ + --port + ``` + + In PowerShell, set `$env:APP_VARIANT = "development"` first and then run the `vp exec expo start ...` command without the leading assignment. + +4. Open the exact development-client URL for the selected device and confirm the loaded bundle belongs to this worktree and Metro port. + +### iOS launch + +Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session defaults: + +- Workspace: `/apps/mobile/ios/T3CodeDev.xcworkspace` +- Scheme: `T3CodeDev` +- Configuration: `Debug` +- Simulator ID: the selected UDID +- Bundle ID: `com.t3tools.t3code.dev` + +Check the installed client with: + +```bash +xcrun simctl get_app_container com.t3tools.t3code.dev app +xcrun simctl openurl +``` + +Accept the iOS confirmation prompt and dismiss the developer menu when it obscures the app. + +### Android launch + +Select one running emulator serial from `adb devices` and check the installed client: + +```bash +adb -s shell pm path com.t3tools.t3code.dev +adb -s reverse tcp: tcp: +adb -s shell am start -W \ + -a android.intent.action.VIEW \ + -d '' \ + com.t3tools.t3code.dev +``` + +Do not start, stop, erase, or reconfigure an emulator owned by another task. Track and later stop only processes owned by this test. + +## Pair each client once + +Issue a fresh credential against the running backend's exact base directory: + +```bash +T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ + --base-dir \ + --base-url \ + --ttl 15m \ + --label agent-mobile- +``` + +In PowerShell, set `$env:T3CODE_PORT = ""` first and run the `node ... auth pairing create` command without the leading assignment. + +If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates: + +```bash +xcrun simctl openurl 't3code-dev://connections/new' +adb -s shell am start -W \ + -a android.intent.action.VIEW \ + -d 't3code-dev://connections/new' \ + com.t3tools.t3code.dev +``` + +Run only the command for the selected platform. + +In T3 Code Dev, open Add Environment and enter the complete `` and newly printed `Token`. Verify the expected seeded projects appear before exercising the affected flow. + +Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. + +## Drive and observe the affected flow + +### iOS + +Use `snapshot_ui` and current element references from XcodeBuildMCP for taps and typing. Stream the same UDID through `ios-simulator-browser` so the user can watch in T3 Code when the host supports it. Use the stream as a visual feed rather than a reason to switch to fragile browser coordinates. + +### Android + +Prefer semantic Android automation exposed by the current agent host. Otherwise inspect the current hierarchy with `adb shell uiautomator dump`, target stable resource IDs, content descriptions, text, or bounds, and use scoped `adb shell input` actions. Refresh the hierarchy after navigation. Capture the final state with `adb exec-out screencap -p`. + +Android does not use serve-sim. Use a browser-compatible Android mirror when the host already provides one; otherwise return focused emulator screenshots as evidence rather than installing unrelated streaming infrastructure during verification. + +## Verify and clean up + +Exercise only the affected flow on one representative device unless the change specifically concerns platform, OS version, or screen size. Before finishing: + +1. Confirm the app connected to the intended disposable environment instead of merely rendering an empty disconnected state. +2. Capture the relevant final state. +3. Remove the disposable environment from T3 Code Dev. +4. Remove any `adb reverse` rule created for this test with `adb -s reverse --remove tcp:`. +5. Stop only the serve-sim, Metro, backend, emulator, and log processes started by this test. +6. Remove only base directories and temporary Git repositories deliberately created for this test. Preserve them when they contain useful reproduction evidence. + +Keep local verification focused. Do not turn this workflow into a full repository test run. + +## Troubleshoot predictable failures + +- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. +- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. +- **A second client cannot pair:** pairing tokens are single-use; issue another token. +- **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. +- **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. +- **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. diff --git a/.agents/skills/test-t3-mobile/agents/openai.yaml b/.agents/skills/test-t3-mobile/agents/openai.yaml new file mode 100644 index 00000000000..e9518ce9e04 --- /dev/null +++ b/.agents/skills/test-t3-mobile/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Test T3 Mobile" + short_description: "Test T3 Code on iOS or Android" + default_prompt: "Use $test-t3-mobile to launch T3 Code against an isolated backend and verify the affected flow on an available simulator or emulator." diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000000..a6369d240fc --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,9 @@ +[mcp_servers.xcodebuildmcp] +enabled = true +required = false +command = "npx" +args = ["--yes", "xcodebuildmcp@2.6.2", "mcp"] +startup_timeout_sec = 20.0 + +[mcp_servers.xcodebuildmcp.env] +XCODEBUILDMCP_ENABLED_WORKFLOWS = "simulator,ui-automation,debugging,logging" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f4ded5167c..c3827f59ddf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -890,6 +890,7 @@ jobs: env: T3CODE_CLERK_PUBLISHABLE_KEY: "" T3CODE_CLERK_JWT_TEMPLATE: "" + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "" T3CODE_RELAY_URL: "" VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} @@ -959,6 +960,7 @@ jobs: --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ + --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000..213a64995fa --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "xcodebuildmcp": { + "command": "npx", + "args": ["--yes", "xcodebuildmcp@2.6.2", "mcp"], + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "simulator,ui-automation,debugging,logging" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md index a3e489404c6..d891ef1a6fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,10 @@ - Backend changes must include and run focused tests for the changed behavior. - Run targeted formatting, lint, and type checks for the affected scope when available. - Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. CI is responsible for the full verification suite. -- After frontend feature development or any user-visible frontend behavior change, the primary agent must use the `test-t3-app` skill once after integrating the work. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser. - - Subagents must not independently launch dev servers or repeat integrated browser verification unless their delegated task explicitly requires it. +- After frontend feature development or any user-visible frontend behavior change, the primary agent must run one integrated verification pass for each affected client surface after integrating the work: + - Web: use the `test-t3-app` skill. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser. + - Mobile: use the `test-t3-mobile` skill. Connect one representative iOS Simulator or Android Emulator available on the host to one isolated environment and verify the affected flow. On compatible macOS hosts, prefer iOS for cross-platform changes and stream it through serve-sim in the T3 Code in-app browser or another available agent browser; use Android when it is the affected or viable platform. + - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it. - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete. ## Fork Notes diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index c5264332b66..f8e05915718 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -73,8 +73,14 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { + function* (): Effect.fn.Return< + void, + never, + DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow + > { const shutdown = yield* DesktopShutdown.DesktopShutdown; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.flushMainWindowBounds; yield* shutdown.request; yield* shutdown.awaitComplete; }, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5e6a3f5164d..fa0811d5df7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -76,6 +76,7 @@ function makePoolLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 1a107c5c856..dcfee93778d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index e258bb2dfc5..743fd6a1fce 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -15,6 +15,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly clearStorageData: ReturnType; readonly getUserAgent: ReturnType; readonly setPermissionRequestHandler: ReturnType; + readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; } >(), @@ -40,6 +41,7 @@ describe("BrowserSession", () => { clearStorageData: vi.fn(() => Promise.resolve()), getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), }; sessions.set(partition, browserSession); @@ -61,6 +63,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const requestAllows = (permission: string): boolean => { + let granted: boolean | undefined; + requestHandler(null, permission, (value: boolean) => { + granted = value; + }); + assert.isDefined(granted); + return granted; + }; + + for (const permission of [ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", + ]) { + assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); + assert.isTrue( + checkHandler(null, permission) as boolean, + `check handler should allow ${permission}`, + ); + } + + // `clipboard-write` is not a real Electron permission — the async write API + // uses `clipboard-sanitized-write` — so the stale name must not be granted, + // and unrelated permissions stay denied. + for (const permission of ["clipboard-write", "midi"]) { + assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`); + assert.isFalse( + checkHandler(null, permission) as boolean, + `check handler should deny ${permission}`, + ); + } + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves partition scope and the platform failure chain", () => { const nativeCause = new Error("native digest failed"); const platformCause = PlatformError.systemError({ diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index afa8dafe976..aa0b0743e93 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -11,6 +11,20 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; +// Permissions granted to preview web content. `clipboard-sanitized-write` is the +// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT +// `clipboard-write`, which is not a valid Electron permission name. Async +// clipboard writes are gated by the permission *check* handler (not only the +// request handler), so both handlers must allow it; otherwise built-in "Copy" +// buttons — e.g. the Next.js / Vercel error overlay — fail with +// `Failed to execute 'writeText' on 'Clipboard': Write permission denied`. +const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", +]); + export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( "BrowserSessionPartitionDerivationError", { @@ -120,9 +134,11 @@ export const make = Effect.gen(function* BrowserSessionMake() { .replace(/\s*t3code\/[\d.]+/, ""); browserSession.setUserAgent(userAgent); browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; - callback(allowed.includes(permission)); + callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }); + browserSession.setPermissionCheckHandler((_webContents, permission) => + ALLOWED_PREVIEW_PERMISSIONS.has(permission), + ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 70b26798266..3878b0e36ad 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + mainWindowBounds: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + ), + ), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -91,6 +102,8 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -116,6 +129,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -215,10 +230,13 @@ describe("DesktopSettings", () => { "serverExposureMode": "network-accessible", "tailscaleServeEnabled": true, "tailscaleServePort": 8443, + "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 }, }\n`, ); assert.deepEqual(yield* settings.load, { + mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -232,6 +250,24 @@ describe("DesktopSettings", () => { ), ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowMaximized: true, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); + assert.equal(loaded.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -239,12 +275,15 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); }), @@ -261,6 +300,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -286,6 +327,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -310,6 +353,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6a26bf5a6e2..466c9a9b5f8 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly mainWindowBounds: DesktopWindowBounds | null; + readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -48,8 +50,25 @@ export interface DesktopSettingsChange { } export const DEFAULT_TAILSCALE_SERVE_PORT = 443; +const MIN_MAIN_WINDOW_SIZE = { + width: 840, + height: 620, +} as const; +export const DesktopWindowBoundsSchema = Schema.Struct({ + x: Schema.Int, + y: Schema.Int, + width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), + height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), +}); +export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; +export const DEFAULT_MAIN_WINDOW_SIZE = { + width: 1100, + height: 780, +} as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslOnly: false, }; +const DesktopWindowBoundsDocument = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + const DesktopSettingsDocument = Schema.Struct({ + mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -81,6 +109,8 @@ type Mutable = { -readonly [K in keyof T]: T[K] }; const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); +const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); +const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setMainWindowBounds: ( + bounds: DesktopWindowBounds, + isMaximized: boolean, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { + return Option.getOrNull(decodeDesktopWindowBounds(value)); +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, ): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; const updateChannelConfiguredByUser = @@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + mainWindowBounds, + mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -197,6 +238,12 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.mainWindowBounds !== null) { + document.mainWindowBounds = settings.mainWindowBounds; + } + if (settings.mainWindowMaximized) { + document.mainWindowMaximized = true; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } @@ -237,6 +284,22 @@ function setServerExposureMode( }; } +function setMainWindowBounds( + settings: DesktopSettings, + bounds: DesktopWindowBounds, + isMaximized: boolean, +): DesktopSettings { + return settings.mainWindowBounds !== null && + desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && + settings.mainWindowMaximized === isMaximized + ? settings + : { + ...settings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; +} + function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, @@ -431,6 +494,18 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), + setMainWindowBounds: (bounds, isMaximized) => + persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( + Effect.withSpan("desktop.settings.setMainWindowBounds", { + attributes: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), + setMainWindowBounds: (bounds, isMaximized) => + update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5ae92bbee96..32224c7a5ca 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -158,6 +158,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ba77292fdc8..168846466ed 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 492ef1ad577..587da8d4431 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,12 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -18,12 +23,20 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, + screen: { + getAllDisplays: vi.fn(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }, + ]), + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -66,15 +79,21 @@ function makeFakeBrowserWindow() { const window = { close: vi.fn(), focus: vi.fn(), + getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), isDestroyed: vi.fn(() => false), isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), + maximize: vi.fn(), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); }), - once: vi.fn(), + once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), restore: vi.fn(), setBackgroundColor: vi.fn(), setAutoHideCursor: vi.fn(), @@ -86,7 +105,14 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, + getBounds: window.getBounds, + getNormalBounds: window.getNormalBounds, + isDestroyed: window.isDestroyed, + isFullScreen: window.isFullScreen, + isMaximized: window.isMaximized, + isMinimized: window.isMinimized, loadURL: window.loadURL, + maximize: window.maximize, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -144,13 +170,57 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ), ); +const desktopWindowBoundsEquivalence = Schema.toEquivalence( + DesktopAppSettings.DesktopWindowBoundsSchema, +); + function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly desktopSettings?: DesktopAppSettings.DesktopSettings; + readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowMaximizedUpdates?: boolean[]; + readonly beforeMainWindowBoundsUpdate?: ( + bounds: DesktopAppSettings.DesktopWindowBounds, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; }) { + let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => desktopSettings), + load: Effect.sync(() => desktopSettings), + setMainWindowBounds: (bounds, isMaximized) => + Effect.gen(function* () { + if (input.beforeMainWindowBoundsUpdate) { + yield* input.beforeMainWindowBoundsUpdate(bounds); + } + const changed = + desktopSettings.mainWindowBounds === null || + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowMaximized !== isMaximized; + if (changed) { + desktopSettings = { + ...desktopSettings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; + input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowMaximizedUpdates?.push(isMaximized); + } + return { settings: desktopSettings, changed }; + }), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: (options) => Effect.sync(() => { @@ -175,6 +245,7 @@ function makeTestLayer(input: { Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + desktopAppSettingsLayer, desktopServerExposureLayer, DesktopState.layer, electronMenuLayer, @@ -272,6 +343,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + DesktopAppSettings.layerTest(), desktopServerExposureLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { @@ -294,6 +366,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("restores bounds only when the window fits within a connected display", () => { + const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; + const displays = [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 2560, height: 1440 }, + ]; + + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays), + persistedBounds, + ); + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), + DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, + ); + }); + it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ @@ -335,6 +424,10 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); @@ -343,6 +436,427 @@ describe("DesktopWindow", () => { }), ); + it.effect("uses the persisted main window bounds when opening the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.width, 1320); + assert.equal(createdWindowOptions[0]?.height, 880); + assert.equal(createdWindowOptions[0]?.x, 120); + assert.equal(createdWindowOptions[0]?.y, 80); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("restores the persisted maximized state", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowMaximized: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); + if (!readyToShow) { + return yield* Effect.die("window ready-to-show listener was not registered"); + } + readyToShow(); + assert.equal(fakeWindow.maximize.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("debounces move and resize bounds updates", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!move || !resize) { + return yield* Effect.die("window bounds listeners were not registered"); + } + + fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(250); + + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); + resize(); + yield* TestClock.adjust(499); + assert.deepEqual(mainWindowBoundsUpdates, []); + + yield* TestClock.adjust(1); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state for a maximized window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state from the native maximize event", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const maximize = fakeWindow.windowListeners.get("maximize"); + if (!maximize) { + return yield* Effect.die("window maximize listener was not registered"); + } + + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + maximize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not persist bounds that fail the domain schema", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, []); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("preserves unrestorable bounds until the user changes the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + const move = fakeWindow.windowListeners.get("move"); + if (!close || !move) { + return yield* Effect.die("window lifecycle listeners were not registered"); + } + + close(); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.getBounds.mockReturnValue({ x: 80, y: 60, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 80, y: 60, width: 1280, height: 840 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when fullscreen before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isFullScreen.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when minimized before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isMinimized.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("logs display lookup failures before falling back to the default size", () => + Effect.gen(function* () { + const displayLookupFailure = new Error("screen API unavailable"); + vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { + throw displayLookupFailure; + }); + const logRecords: Array<{ + readonly message: unknown; + readonly annotations: Readonly>; + }> = []; + const logger = Logger.make(({ fiber, message }) => { + logRecords.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + }).pipe( + Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), + ); + + const warning = logRecords.find( + (record) => + Array.isArray(record.message) && + record.message[0] === "failed to read connected displays; using defaults", + ); + assert.isDefined(warning); + assert.strictEqual(warning.annotations.cause, displayLookupFailure); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); + }), + ); + + it.effect("persists the current main window bounds before the window closes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const writeStarted = yield* Deferred.make(); + const allowWrite = yield* Deferred.make(); + const flushCompleted = yield* Deferred.make(); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + beforeMainWindowBoundsUpdate: () => + Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowWrite)), + Effect.asVoid, + ), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Deferred.await(writeStarted); + fakeWindow.isDestroyed.mockReturnValue(true); + + const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( + Effect.andThen(Deferred.succeed(flushCompleted, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + assert.isFalse(yield* Deferred.isDone(flushCompleted)); + + yield* Deferred.succeed(allowWrite, undefined); + yield* Fiber.join(flushFiber); + assert.isTrue(yield* Deferred.isDone(flushCompleted)); + + assert.deepEqual(mainWindowBoundsUpdates, [ + { + x: 240, + y: 160, + width: 1410, + height: 930, + }, + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index c808c77e51c..db4b698434d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -17,11 +17,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED @@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets + | DesktopAppSettings.DesktopAppSettings | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -75,6 +78,7 @@ export class DesktopWindow extends Context.Service< // window so a "macOS dock click" while the backend is down doesn't // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; + readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -99,6 +103,45 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +type DisplayBounds = Pick; + +function windowFitsWithinDisplay( + windowBounds: DesktopAppSettings.DesktopWindowBounds, + displayBounds: DisplayBounds, +): boolean { + return ( + windowBounds.x >= displayBounds.x && + windowBounds.y >= displayBounds.y && + windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && + windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + ); +} + +function windowBoundsEqual( + left: DesktopAppSettings.DesktopWindowBounds, + right: DesktopAppSettings.DesktopWindowBounds, +): boolean { + return ( + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height + ); +} + +export function resolveInitialMainWindowBounds( + persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, + displays: readonly DisplayBounds[], +): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { + if ( + persistedBounds !== null && + displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) + ) { + return persistedBounds; + } + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; +} + // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. @@ -202,6 +245,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -214,6 +258,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); @@ -250,9 +295,31 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const persistedSettings = yield* desktopSettings.get; + const persistedBounds = persistedSettings.mainWindowBounds; + const displayBoundsResult = yield* Effect.sync(() => { + try { + return { + _tag: "Success" as const, + bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), + }; + } catch (cause) { + return { _tag: "Failure" as const, cause }; + } + }); + const displayBounds = + displayBoundsResult._tag === "Success" + ? displayBoundsResult.bounds + : yield* logWindowWarning("failed to read connected displays; using defaults", { + cause: displayBoundsResult.cause, + }).pipe(Effect.as([])); + const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; + if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { + yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); + } const window = yield* electronWindow.create({ - width: 1100, - height: 780, + ...initialBounds, minWidth: 840, minHeight: 620, show: false, @@ -274,6 +341,92 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + let boundsPersistFiber: Fiber.Fiber | undefined; + let pendingBoundsPersistFiber: Fiber.Fiber | undefined; + let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; + const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { + if (window.isDestroyed()) { + return null; + } + const bounds = + window.isFullScreen() || window.isMaximized() || window.isMinimized() + ? window.getNormalBounds() + : window.getBounds(); + return DesktopAppSettings.normalizeMainWindowBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }); + }; + const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; + const persistCurrentBounds = (): Fiber.Fiber | undefined => { + if (!boundsPersistenceEnabled) { + return pendingBoundsPersistFiber; + } + const bounds = readPersistableBounds(); + if (bounds === null) { + return pendingBoundsPersistFiber; + } + pendingBoundsPersistFiber = runFork( + desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), + ), + ); + return pendingBoundsPersistFiber; + }; + const scheduleBoundsPersist = () => { + if (!boundsPersistenceEnabled) { + const currentBounds = readPersistableBounds(); + if ( + currentBounds === null || + (fallbackWindowBounds !== null && + windowBoundsEqual(currentBounds, fallbackWindowBounds) && + window.isMaximized() === fallbackWindowMaximized) + ) { + return; + } + } + boundsPersistenceEnabled = true; + if (boundsPersistFiber !== undefined) { + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + } + boundsPersistFiber = runFork( + Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + boundsPersistFiber = undefined; + void persistCurrentBounds(); + }), + ), + ), + ); + }; + const clearBoundsPersist = () => { + if (boundsPersistFiber === undefined) { + return; + } + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; + const flushBoundsPersist = Effect.sync(() => { + clearBoundsPersist(); + return persistCurrentBounds(); + }).pipe( + Effect.flatMap((fiber) => + fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), + ), + ); + flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -364,6 +517,13 @@ export const make = Effect.gen(function* () { event.preventDefault(); window.setTitle(environment.displayName); }); + window.on("resize", scheduleBoundsPersist); + window.on("move", scheduleBoundsPersist); + window.on("maximize", scheduleBoundsPersist); + window.on("unmaximize", scheduleBoundsPersist); + window.on("close", () => { + runFork(flushBoundsPersist); + }); if (environment.platform === "darwin") { window.on("enter-full-screen", () => { @@ -472,6 +632,9 @@ export const make = Effect.gen(function* () { bindFirstRevealTrigger(revealSubscribers, () => { // Reveal the real window, then close the connecting splash (if any) so the // two don't overlap and there's no blank gap between them. + if (persistedSettings.mainWindowMaximized) { + window.maximize(); + } void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); }); @@ -482,6 +645,7 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); + clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); }); @@ -598,6 +762,9 @@ export const make = Effect.gen(function* () { handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), ), + flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( + Effect.withSpan("desktop.window.flushMainWindowBounds"), + ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* focusedMainWindow; diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index db244d7c726..4cf787d9ce5 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; -import { renderCompactBrandTitle } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -60,6 +59,7 @@ import { EMPTY_INCOMING_SHARE_PRESENTATION_STATE, transitionIncomingSharePresentation, } from "./features/sharing/incoming-share-presentation"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -79,19 +79,24 @@ type AppScreenOptions = NativeStackNavigationOptions & { // Shared header presets. Screens only override genuinely dynamic values (titles, // subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. // -// GLASS: transparent header over the screen's primary scroll view, with the iOS 26 -// scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet). +// GLASS: transparent header over the screen's primary scroll view on supported +// iOS versions. Pre-glass iOS gets the same solid material as internal-scroll +// surfaces so content is laid out below the bar instead of underlapping it. const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED + ? { backgroundColor: "transparent" } + : SHEET_BACKGROUND_COLOR !== undefined + ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, - headerTransparent: Platform.OS === "ios", - scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined, - unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, + headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, + unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally @@ -378,8 +383,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - headerTitle: renderCompactBrandTitle, - title: "T3 Code", + title: "Threads", }, }), Thread: createNativeStackScreen({ diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx deleted file mode 100644 index 2ecf34aa40c..00000000000 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { View } from "react-native"; - -import { AppText as Text } from "./AppText"; -import { T3Wordmark } from "./T3Wordmark"; -import { useThemeColor } from "../lib/useThemeColor"; - -/** - * Compact brand lockup sized for native navigation bars. - */ -export function CompactBrandTitle() { - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); - - return ( - - - - Code - - - - Alpha - - - - ); -} - -export function renderCompactBrandTitle() { - return ; -} diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index d58ee338973..f88e3287445 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -58,6 +58,9 @@ export function ConnectionSheetButton(props: { return ( item.node.path} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined + NATIVE_LIQUID_GLASS_SUPPORTED + ? { top: headerInset, left: 0, right: 0, bottom: 0 } + : undefined } keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index cd9467c774e..49cf06d85ec 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native"; import { useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -87,9 +86,7 @@ export function HomeRouteScreen() { > <> {/* Restore the compact title in case the split branch blanked it. */} - + @@ -399,11 +400,20 @@ export function HomeScreen(props: HomeScreenProps) { onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} variant="plain" /> - {emptyState.loading ? ( + {emptyState.loading && !shouldShowConnectionStatus ? ( ) : null} + {shouldShowConnectionStatus && Platform.OS === "ios" ? ( + + + + ) : null} {connectionStatus} @@ -460,8 +470,8 @@ export function HomeScreen(props: HomeScreenProps) { ListHeaderComponent={listHeader} ListEmptyComponent={listEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} + contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts index 767f0e3dd3f..8c3c873cc9e 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts @@ -67,4 +67,21 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); }); + + it("shows shell catch-up while cached threads remain visible", () => { + const state = workspaceState({ hasPendingShellSnapshot: true }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads..."); + }); + + it("distinguishes initial shell loading from cached catch-up", () => { + const state = workspaceState({ + hasLoadedShellSnapshot: false, + hasPendingShellSnapshot: true, + }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); + }); }); diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx index 8acdacfbbb3..1e986ad1a50 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx @@ -12,7 +12,10 @@ export function WorkspaceConnectionStatus(props: { readonly variant?: "floating" | "sidebar"; }) { const iconColor = useThemeColor("--color-icon-muted"); - const isReconnecting = props.state.connectingEnvironments.length > 0; + const isSynchronizing = + props.state.networkStatus !== "offline" && + props.state.connectionError === null && + (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); const variant = props.variant ?? "floating"; return ( @@ -37,7 +40,7 @@ export function WorkspaceConnectionStatus(props: { : undefined } > - {isReconnecting ? ( + {isSynchronizing ? ( ) : ( diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 7d2c6d840d4..d8eed4383b1 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -5,6 +5,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool state.networkStatus === "offline" || state.connectionError !== null || state.hasConnectingEnvironment || + state.hasPendingShellSnapshot || (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) ); } @@ -18,5 +19,8 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { return `Reconnecting ${state.connectingEnvironments.length} environments`; } if (state.connectionError !== null) return state.connectionError; + if (state.hasPendingShellSnapshot) { + return state.hasLoadedShellSnapshot ? "Syncing threads..." : "Loading threads..."; + } return "Not connected"; } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31ad5660983..32527b0b719 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -28,6 +28,7 @@ import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { PendingApproval, PendingUserInput, @@ -197,7 +198,13 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { entering={FadeInDown.duration(200)} exiting={FadeOut.duration(140)} > - + diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6e0e243aad6..f7ee4eaa74f 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -17,6 +17,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -571,8 +572,10 @@ function ThreadNavigationSidebarPane( itemsAreEqual={homeListItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} - automaticallyAdjustsScrollIndicatorInsets - contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} + contentInsetAdjustmentBehavior={ + NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never" + } contentContainerStyle={[ styles.threadListContent, { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 31456211645..23d38c0d56d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -23,6 +23,7 @@ import { } from "../../components/AndroidScreenHeader"; import { LoadingScreen } from "../../components/LoadingScreen"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { connectionTone } from "../connection/connectionTone"; import { @@ -278,7 +279,7 @@ function ThreadRouteContent( ); /* ─── Native header theming ──────────────────────────────────────── */ - const usesNativeHeaderGlass = Platform.OS === "ios"; + const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED; const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index a94c033fa4d..f0e3f89c07d 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,7 +11,7 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; -import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -34,13 +34,12 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: { backgroundColor: "transparent" }, - headerTitle: renderCompactBrandTitle, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, - headerTransparent: true, - scrollEdgeEffects: SCROLL_EDGE_EFFECTS, - title: "T3 Code", - unstable_navigationItemStyle: "editor", + headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, + title: "Threads", + unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; const SidebarStack = createNativeStackNavigator(); diff --git a/apps/mobile/src/lib/native-glass-capability.test.ts b/apps/mobile/src/lib/native-glass-capability.test.ts new file mode 100644 index 00000000000..43f865c77c3 --- /dev/null +++ b/apps/mobile/src/lib/native-glass-capability.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { supportsNativeLiquidGlass } from "./native-glass-capability"; + +describe("supportsNativeLiquidGlass", () => { + it("uses native liquid glass when iOS reports the capability", () => { + expect(supportsNativeLiquidGlass("ios", true)).toBe(true); + }); + + it("keeps pre-glass iOS on the solid fallback", () => { + expect(supportsNativeLiquidGlass("ios", false)).toBe(false); + }); + + it("does not enable iOS liquid-glass layout behavior on other platforms", () => { + expect(supportsNativeLiquidGlass("android", true)).toBe(false); + expect(supportsNativeLiquidGlass("web", true)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/native-glass-capability.ts b/apps/mobile/src/lib/native-glass-capability.ts new file mode 100644 index 00000000000..61c8547b507 --- /dev/null +++ b/apps/mobile/src/lib/native-glass-capability.ts @@ -0,0 +1,6 @@ +export function supportsNativeLiquidGlass( + platform: string, + nativeCapabilityAvailable: boolean, +): boolean { + return platform === "ios" && nativeCapabilityAvailable; +} diff --git a/apps/mobile/src/native/native-glass.ts b/apps/mobile/src/native/native-glass.ts new file mode 100644 index 00000000000..40b28076d36 --- /dev/null +++ b/apps/mobile/src/native/native-glass.ts @@ -0,0 +1,9 @@ +import { isLiquidGlassSupported } from "@callstack/liquid-glass"; +import { Platform } from "react-native"; + +import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; + +export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( + Platform.OS, + isLiquidGlassSupported, +); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 4f654ee886c..0e702ebc11b 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -6,11 +6,17 @@ import * as NodePath from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentOrchestrationHttpApi, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; @@ -23,6 +29,7 @@ import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; @@ -462,6 +469,63 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("force removes projects that still contain threads", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-test-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-workspace-"), + ); + + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const project = afterAdd.projects.find( + (candidate) => candidate.workspaceRoot === workspaceRoot && candidate.deletedAt === null, + ); + assert.isTrue(project !== undefined); + + const config = yield* makeCliTestServerConfig(baseDir); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-cli-force-remove-thread"), + threadId: ThreadId.make("thread-cli-force-remove"), + projectId: project!.id, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + + yield* runCliWithRuntime([ + "project", + "remove", + project!.id, + "--force", + "--base-dir", + baseDir, + ]); + const afterRemove = yield* readPersistedSnapshot(baseDir); + assert.isTrue( + (afterRemove.projects.find((candidate) => candidate.id === project!.id)?.deletedAt ?? + null) !== null, + ); + assert.isTrue( + (afterRemove.threads.find((thread) => thread.id === "thread-cli-force-remove")?.deletedAt ?? + null) !== null, + ); + }), + ); + it.effect("routes project commands through a running server when runtime state is present", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 710d39c4c29..25733a5e35b 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -493,6 +493,10 @@ const projectRemoveCommand = Command.make("remove", { project: Argument.string("project").pipe( Argument.withDescription("Project id or workspace root to remove."), ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Delete the project and all of its threads."), + Flag.withDefault(false), + ), }).pipe( Command.withDescription("Remove a project."), Command.withHandler((flags) => @@ -515,6 +519,7 @@ const projectRemoveCommand = Command.make("remove", { type: "project.delete", commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, + force: flags.force, }); return `Removed project ${project.id} (${project.title}).`; }), diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 1aac2bb0d41..cd0036aece0 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); + expect(second.capabilities.connectionProbe).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b5fbd8e1088..1c0d34ea5bc 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -135,6 +135,7 @@ export const make = Effect.gen(function* () { serverVersion: packageJson.version, capabilities: { repositoryIdentity: true, + connectionProbe: true, }, }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index fd60b475b76..93dfc0614bb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -219,6 +219,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + expect(await runtime.runPromise(engine.latestSequence)).toBe(7); const result = await runtime.runPromise( engine.dispatch({ type: "thread.meta.update", @@ -229,6 +230,7 @@ describe("OrchestrationEngine", () => { ); expect(result.sequence).toBe(8); + expect(await runtime.runPromise(engine.latestSequence)).toBe(8); expect(fullSnapshotReadCount).toBe(0); await runtime.dispose(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 9598d1c3ef5..19184915ac7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -329,6 +329,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { get streamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"] { return Stream.fromPubSub(eventPubSub); }, + // The command read model's snapshotSequence tracks the latest committed + // event sequence (updated on the worker fiber). A plain property read is a + // consistent, committed value — reassignment of `commandReadModel` is + // atomic on the single-threaded event loop. + latestSequence: Effect.sync(() => commandReadModel.snapshotSequence), } satisfies OrchestrationEngineShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts new file mode 100644 index 00000000000..05370781c0d --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -0,0 +1,33 @@ +import { + EventId, + ProviderDriverKind, + RuntimeRequestId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts"; + +describe("runtimeEventToActivities approval details", () => { + it("preserves complete multiline command details", () => { + const detail = `bun run release -- ${"long-argument ".repeat(20)}\nsecond line`; + const event = { + type: "request.opened", + eventId: EventId.make("evt-request-opened"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-07-18T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + requestId: RuntimeRequestId.make("approval-1"), + payload: { + requestType: "command_execution_approval", + detail, + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + + expect(activity?.kind).toBe("approval.requested"); + expect((activity?.payload as Record | undefined)?.detail).toBe(detail); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba388949..20611e1ee75 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -448,6 +448,51 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBeNull(); }); + it("clears active turn when provider session becomes ready", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-session-ready"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-session-ready"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-session-ready", + 10_000, + ); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-ready-with-active-turn"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + payload: { + state: "ready", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "ready" && + entry.session?.activeTurnId === null && + entry.session?.lastError === null, + 10_000, + ); + expect(thread.session?.status).toBe("ready"); + expect(thread.session?.activeTurnId).toBeNull(); + expect(thread.session?.lastError).toBeNull(); + }); + it("does not clear active turn when session/thread started arrives mid-turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -466,6 +511,7 @@ describe("ProviderRuntimeIngestion", () => { (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-midturn-lifecycle", + 10_000, ); harness.emit({ @@ -502,6 +548,7 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + 10_000, ); }); @@ -2947,6 +2994,196 @@ describe("ProviderRuntimeIngestion", () => { ).toBe("# Plan title"); }); + it("titles task activities with the task description, including on completion", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-named-task-started"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + description: "Typecheck mobile app", + taskType: "local_bash", + }, + }); + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-named-task-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + description: "Typecheck mobile app", + summary: "Running tsc across the mobile workspace.", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-named-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + status: "completed", + summary: "Typecheck finished without errors.", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", + ), + ); + + const progress = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress", + ); + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", + ); + + const progressPayload = + progress?.payload && typeof progress.payload === "object" + ? (progress.payload as Record) + : undefined; + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(progress?.summary).toBe("Typecheck mobile app"); + expect(progressPayload?.title).toBe("Typecheck mobile app"); + expect(completed?.summary).toBe("Task completed"); + expect(completedPayload?.title).toBe("Typecheck mobile app"); + expect(completedPayload?.summary).toBe("Typecheck finished without errors."); + expect(completedPayload?.detail).toBe("Typecheck finished without errors."); + }); + + it("titles task completion from task.started when no progress event carried the name", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-fast-task-started"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-fast-task"), + payload: { + taskId: "fast-task-1", + description: "wait for codex review to finish", + taskType: "local_bash", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-fast-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-fast-task"), + payload: { + taskId: "fast-task-1", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", + ), + ); + + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", + ); + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(completedPayload?.title).toBe("wait for codex review to finish"); + }); + + it("titles task completion from persisted activities after the description cache is swept", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-swept-task-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-swept-task"), + payload: { + taskId: "swept-task-1", + description: "Watch round-3 CI and bots", + summary: "Polling CI checks.", + }, + }); + + await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress", + ), + ); + + // session.exited sweeps the in-memory description cache; the completion + // that follows must recover the name from persisted activities. + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-swept-task-session-exited"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: {}, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-swept-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-swept-task"), + payload: { + taskId: "swept-task-1", + status: "completed", + summary: "CI is green.", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + ), + ); + + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + ); + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); + }); + it("projects structured user input request and resolution as thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 9d111e03072..a198d4b16fa 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -42,6 +42,42 @@ import { import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; +const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; + +// Fallback when the in-memory description cache no longer has the task name +// (server restart, session-exit sweep, TTL/capacity eviction): earlier +// task.started/task.progress activities for the task are persisted with it. +function findTaskTitleInActivities( + activities: ReadonlyArray | undefined, + taskId: string, +): string | undefined { + if (!activities) { + return undefined; + } + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity || (activity.kind !== "task.started" && activity.kind !== "task.progress")) { + continue; + } + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as { taskId?: unknown; title?: unknown; detail?: unknown }) + : undefined; + if (payload?.taskId !== taskId) { + continue; + } + const title = + typeof payload.title === "string" + ? payload.title + : activity.kind === "task.started" && typeof payload.detail === "string" + ? payload.detail + : undefined; + if (title && title.trim().length > 0) { + return title; + } + } + return undefined; +} interface AssistantSegmentState { baseKey: string; @@ -59,6 +95,8 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const GOAL_ACTIVITY_STATE_BY_THREAD_CACHE_CAPACITY = 10_000; const GOAL_ACTIVITY_STATE_BY_THREAD_TTL = Duration.minutes(120); +const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; +const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; @@ -306,6 +344,12 @@ function orchestrationSessionStatusFromRuntimeState( } } +function sessionStatusAllowsActiveTurn( + status: ReturnType, +): boolean { + return status === "starting" || status === "running"; +} + function requestKindFromCanonicalRequestType( requestType: string | undefined, ): "command" | "file-read" | "file-change" | undefined { @@ -323,9 +367,9 @@ function requestKindFromCanonicalRequestType( } } -function runtimeEventToActivities( +export function runtimeEventToActivities( event: ProviderRuntimeEvent, - context?: { readonly previousGoal?: GoalActivityState | null }, + context?: { readonly previousGoal?: GoalActivityState | null; readonly taskTitle?: string }, ): ReadonlyArray { const maybeSequence = (() => { const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; @@ -357,7 +401,7 @@ function runtimeEventToActivities( requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, - ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.detail ? { detail: event.payload.detail } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -535,9 +579,15 @@ function runtimeEventToActivities( createdAt: event.createdAt, tone: "info", kind: "task.progress", - summary: "Reasoning update", + summary: + event.payload.description.trim().length > 0 + ? truncateDetail(event.payload.description, 120) + : "Reasoning update", payload: { taskId: event.payload.taskId, + ...(event.payload.description.trim().length > 0 + ? { title: truncateDetail(event.payload.description, 120) } + : {}), detail: truncateDetail(event.payload.summary ?? event.payload.description), ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), @@ -565,7 +615,15 @@ function runtimeEventToActivities( payload: { taskId: event.payload.taskId, status: event.payload.status, - ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), + ...(context?.taskTitle ? { title: truncateDetail(context.taskTitle, 120) } : {}), + // summary + detail mirror task.progress: clients label the row from + // summary and keep detail for the preview/expanded body. + ...(event.payload.summary + ? { + summary: truncateDetail(event.payload.summary), + detail: truncateDetail(event.payload.summary), + } + : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), }, turnId: toTurnId(event.turnId) ?? null, @@ -800,6 +858,27 @@ const make = Effect.gen(function* () { lookup: () => Effect.die(new Error("goal activity state should be read through getOption")), }); + // Task names arrive on task.started/task.progress but not on task.completed, + // so remember them per task to title the completion activity. + const taskDescriptionByTaskKey = yield* Cache.make({ + capacity: TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY, + timeToLive: TASK_DESCRIPTION_BY_TASK_TTL, + lookup: () => Effect.succeed(""), + }); + + const rememberTaskDescription = (threadId: ThreadId, taskId: string, description: string) => + Cache.set(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId), description); + + // Entries are left in place after completion so replayed or duplicate + // terminal events stay titled; TTL, capacity, and the session-exit sweep + // bound the cache. + const lookupTaskDescription = (threadId: ThreadId, taskId: string) => + Cache.getOption(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId)).pipe( + Effect.map((description) => + Option.filter(description, (value) => value.length > 0).pipe(Option.getOrUndefined), + ), + ); + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery .getThreadDetailById(threadId) @@ -1224,6 +1303,7 @@ const make = Effect.gen(function* () { const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); + const taskDescriptionKeys = Array.from(yield* Cache.keys(taskDescriptionByTaskKey)); yield* Effect.forEach( turnKeys, (key) => @@ -1259,6 +1339,12 @@ const make = Effect.gen(function* () { : Effect.void, { concurrency: 1 }, ).pipe(Effect.asVoid); + yield* Effect.forEach( + taskDescriptionKeys, + (key) => + key.startsWith(prefix) ? Cache.invalidate(taskDescriptionByTaskKey, key) : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); }); const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( @@ -1415,12 +1501,6 @@ const make = Effect.gen(function* () { event.type === "turn.started" || event.type === "turn.completed" ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" - ? null - : activeTurnId; const status = (() => { switch (event.type) { case "session.state.changed": @@ -1440,6 +1520,14 @@ const make = Effect.gen(function* () { return activeTurnId !== null ? "running" : "ready"; } })(); + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" + ? null + : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn(status) + ? null + : activeTurnId; const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") @@ -1819,9 +1907,6 @@ const make = Effect.gen(function* () { yield* Cache.getOption(goalActivityStateByThreadId, thread.id), () => thread.goal, ); - const activities = runtimeEventToActivities(event, { - previousGoal: previousGoalForActivity, - }); if (event.type === "thread.goal.updated") { yield* Cache.set(goalActivityStateByThreadId, thread.id, { objective: event.payload.objective, @@ -1830,6 +1915,25 @@ const make = Effect.gen(function* () { } else if (event.type === "thread.goal.cleared") { yield* Cache.invalidate(goalActivityStateByThreadId, thread.id); } + if (event.type === "task.started" || event.type === "task.progress") { + const description = event.payload.description?.trim(); + if (description) { + yield* rememberTaskDescription(thread.id, event.payload.taskId, description); + } + } + let taskTitle: string | undefined; + if (event.type === "task.completed") { + taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); + if (!taskTitle) { + const threadDetail = yield* getLoadedThreadDetail(); + taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); + } + } + + const activities = runtimeEventToActivities(event, { + previousGoal: previousGoalForActivity, + ...(taskTitle ? { taskTitle } : {}), + }); yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index dc1b5fd3003..f8bcfd76ac0 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -56,6 +56,13 @@ export interface OrchestrationEngineShape { * This is a hot runtime stream (new events only), not a historical replay. */ readonly streamDomainEvents: Stream.Stream; + + /** + * The latest sequence reflected in the engine's authoritative command read + * model (0 if none). Used to gauge how far behind a resuming client is before + * choosing between an incremental replay and a fresh projected snapshot. + */ + readonly latestSequence: Effect.Effect; } /** diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts new file mode 100644 index 00000000000..020fc48a465 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; + +import { ClaudeExecutableFileCheck, resolveClaudeSdkExecutablePath } from "./ClaudeExecutable.ts"; + +const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm"; +const NPM_SHIM = `${NPM_DIR}\\claude.cmd`; +const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; +const NPM_PACKAGE_CLI = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`; + +function withWindowsResolution(input: { + readonly resolvedCommand: string | undefined; + readonly existingFiles?: ReadonlyArray; +}) { + const existing = new Set(input.existingFiles ?? []); + return (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), + Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), + ); +} + +describe("resolveClaudeSdkExecutablePath", () => { + it.effect("returns the configured path unchanged on non-Windows platforms", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(SpawnExecutableResolution, () => { + throw new Error("must not resolve on non-Windows platforms"); + }), + ), + ).toBe("claude"); + }), + ); + + it.effect("returns the resolved absolute path for native Windows executables", () => + Effect.gen(function* () { + const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe"; + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: nativeBinary }), + ), + ).toBe(nativeBinary); + }), + ); + + it.effect("follows an npm launcher shim to the packaged native binary", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_EXE, NPM_PACKAGE_CLI], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + + it.effect("follows .bat and .ps1 launcher shims the same way", () => + Effect.gen(function* () { + for (const shim of [`${NPM_DIR}\\claude.bat`, `${NPM_DIR}\\claude.ps1`]) { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: shim, + existingFiles: [NPM_PACKAGE_EXE], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + } + }), + ); + + it.effect("normalizes mixed-case shim extensions before matching", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: `${NPM_DIR}\\claude.CMD`, + existingFiles: [NPM_PACKAGE_EXE], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + + it.effect("falls back to cli.js when the package ships no native binary", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_CLI], + }), + ), + ).toBe(NPM_PACKAGE_CLI); + }), + ); + + it.effect("returns the configured path when a shim has no known package entry", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: NPM_SHIM }), + ), + ).toBe("claude"); + }), + ); + + it.effect("returns the configured path when command resolution finds nothing", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: undefined }), + ), + ).toBe("claude"); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts new file mode 100644 index 00000000000..febfdb26f9e --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -0,0 +1,90 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +/** + * Windows launcher-script extensions that Node cannot spawn without a shell + * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore + * cannot use as `pathToClaudeCodeExecutable`. + */ +const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ".ps1"]); + +/** + * Entry points of the npm `@anthropic-ai/claude-code` package relative to the + * global `node_modules` directory that sits next to the npm launcher shim. + * Newer package versions ship a native `bin/claude.exe`; older versions only + * ship `cli.js`, which the SDK runs with a JavaScript runtime. + */ +const NPM_PACKAGE_ENTRY_CANDIDATES = [ + ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], + ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], +] as const; + +export type ExecutableFileCheck = (filePath: string) => boolean; + +function isExistingFile(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** Injectable file-existence check so tests can run against a fake filesystem. */ +export const ClaudeExecutableFileCheck = Context.Reference( + "server/provider/Drivers/ClaudeExecutableFileCheck", + { + defaultValue: () => isExistingFile, + }, +); + +/** + * Resolves the configured Claude binary path into a value the Claude Agent + * SDK can spawn directly via `pathToClaudeCodeExecutable`. + * + * The SDK spawns the given path without a shell and without Windows PATH / + * PATHEXT resolution, so a bare command name like `claude` fails with + * "native binary not found" and an npm `claude.cmd` shim fails with + * `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can + * fall back to `shell: true`; the SDK offers no such escape hatch. + * + * On Windows this resolves the command against PATH/PATHEXT and, when the + * result is an npm launcher shim, follows it to the real package entry + * (`bin/claude.exe`, or `cli.js` for older package versions). On other + * platforms the configured value is returned unchanged. + */ +export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( + function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") { + return binaryPath; + } + + const resolveExecutable = yield* SpawnExecutableResolution; + const isFile = yield* ClaudeExecutableFileCheck; + const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath; + const extension = NodePath.win32.extname(resolved).toLowerCase(); + if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) { + return resolved; + } + + const shimDirectory = NodePath.win32.dirname(resolved); + for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) { + const candidate = NodePath.win32.join(shimDirectory, ...entrySegments); + if (isFile(candidate)) { + return candidate; + } + } + + yield* Effect.logWarning( + "Claude launcher shim resolved but no known package entry was found next to it; the Claude Agent SDK cannot spawn launcher scripts directly.", + { binaryPath, resolvedShimPath: resolved }, + ); + return binaryPath; + }, +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 29555353d34..26617056b25 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -70,6 +70,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { @@ -1345,6 +1346,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( + Effect.provideService(Path.Path, path), + ); + const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -3411,7 +3419,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - const claudeBinaryPath = claudeSettings.binaryPath; + const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af8f5d6704b..ff0d1992454 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,7 +2,6 @@ import { type ClaudeSettings, type ModelCapabilities, type ModelSelection, - ProviderDriverKind, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -37,13 +36,13 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); -const PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, @@ -479,9 +478,19 @@ function claudeAuthMetadata(input: { return undefined; } +function apiProviderAuthMetadata( + apiProvider: string | undefined, +): { readonly type: string; readonly label: string } | undefined { + return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; +} + // ── SDK capability probe ──────────────────────────────────────────── -const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the +// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning +// account info. The previous 8s budget expired mid-init, so the probe returned +// `undefined` and left the provider unverified and unselectable in the picker. +const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); @@ -492,6 +501,12 @@ type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + /** + * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth + * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) + * the subscription/token fields are absent and auth is external AWS creds. + */ + readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -588,6 +603,10 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const executablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -598,7 +617,7 @@ const probeClaudeCapabilities = ( })(), options: { persistSession: false, - pathToClaudeCodeExecutable: claudeSettings.binaryPath, + pathToClaudeCodeExecutable: executablePath, abortController: abort, settingSources: ["user", "project", "local"], allowedTools: [], @@ -613,12 +632,14 @@ const probeClaudeCapabilities = ( readonly email?: string; readonly subscriptionType?: string; readonly tokenSource?: string; + readonly apiProvider?: string; } | undefined; return { email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, + apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -668,7 +689,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -759,7 +779,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const models = providerModelsFromSettings( getBuiltInClaudeModelsForVersion(parsedVersion), - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -794,10 +813,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const authMetadata = claudeAuthMetadata({ - subscriptionType: capabilities.subscriptionType, - authMethod: capabilities.tokenSource, - }); + const authMetadata = + claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + }) ?? apiProviderAuthMetadata(capabilities.apiProvider); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -827,7 +847,6 @@ export const makePendingClaudeProvider = ( const checkedAt = yield* nowIso; const models = providerModelsFromSettings( BUILT_IN_MODELS, - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 0e60bb87072..41451076d20 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -290,6 +290,7 @@ validationLayer("CodexAdapterLive validation", (it) => { binaryPath: "codex", cwd: process.cwd(), environment: mergeProviderSessionEnvironment(undefined, undefined), + launchArgs: "", model: "gpt-5.3-codex", providerInstanceId: ProviderInstanceId.make("codex"), serviceTier: "priority", @@ -370,6 +371,69 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("passes configured launch args into the session runtime", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-launch-args"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable foo"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for the session runtime", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({ launchArgs: "--enable settings-feature" }); + return yield* makeCodexAdapter(codexConfig, { + environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --enable env-feature " }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-launch-args-env"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable env-feature"); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps codex model options for the adapter's bound custom instance id", () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const customRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 1ce0a1ea544..e72a5c71288 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -62,6 +62,7 @@ import { type CodexSessionRuntimeShape, } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -273,8 +274,14 @@ function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): stri } } -function itemDetail(item: CodexLifecycleItem): string | undefined { +function itemDetail(itemType: CanonicalItemType, item: CodexLifecycleItem): string | undefined { + const itemRecord = item as Record; + const action = itemRecord.action as Record | undefined; + const actionQueries = Array.isArray(action?.queries) ? action.queries : []; const candidates = [ + ...(itemType === "web_search" + ? [itemRecord.query, action?.query, ...actionQueries, action?.pattern, action?.url] + : []), "command" in item ? item.command : undefined, "title" in item ? item.title : undefined, "summary" in item ? item.summary : undefined, @@ -282,6 +289,7 @@ function itemDetail(item: CodexLifecycleItem): string | undefined { "path" in item ? item.path : undefined, "prompt" in item ? item.prompt : undefined, ]; + for (const candidate of candidates) { const trimmed = typeof candidate === "string" ? trimText(candidate) : undefined; if (!trimmed) continue; @@ -471,7 +479,7 @@ function mapItemLifecycle( return undefined; } - const detail = itemDetail(item); + const detail = itemDetail(itemType, item); const status = lifecycle === "item.started" ? "inProgress" @@ -885,7 +893,7 @@ function mapToRuntimeEvents( } const itemType = toCanonicalItemType(item.type); if (itemType === "plan") { - const detail = itemDetail(item); + const detail = itemDetail(itemType, item); if (!detail) { return []; } @@ -1440,6 +1448,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, environment, + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c..9306087a0bc 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -26,6 +26,7 @@ import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -289,6 +290,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -303,10 +305,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; - const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: environment, - extendEnv: true, - }); + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { + env: environment, + extendEnv: true, + }, + ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -465,6 +471,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu probe: (input: { readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -503,6 +510,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, + launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), customModels: codexSettings.customModels, environment: resolvedEnvironment, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 1527072dae7..119fa36303a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -13,6 +13,7 @@ import { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; +import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, hasConfiguredMcpServer, @@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => { }); }); +describe("codexSessionAppServerArgs", () => { + it("keeps the app-server subcommand when explicit args are provided", () => { + NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [ + "app-server", + "-c", + "model=gpt-5", + ]); + }); + + it("keeps launch args when explicit app-server args are provided", () => { + NodeAssert.deepStrictEqual( + codexSessionAppServerArgs( + ["-c", "mcp_servers.t3-code.url=http://127.0.0.1/mcp"], + "--strict-config --enable foo", + ), + [ + "app-server", + "--strict-config", + "--enable", + "foo", + "-c", + "mcp_servers.t3-code.url=http://127.0.0.1/mcp", + ], + ); + }); +}); + describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { NodeAssert.equal( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 9a953f24a43..6b908a55b69 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -37,6 +37,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); @@ -106,6 +107,7 @@ export interface CodexSessionRuntimeOptions { readonly providerInstanceId?: ProviderInstanceId; readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly environment?: NodeJS.ProcessEnv; readonly cwd: string; readonly runtimeMode: RuntimeMode; @@ -734,11 +736,11 @@ export const makeCodexSessionRuntime = ( ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; const extendEnv = options.environment === undefined; - const spawnCommand = yield* resolveSpawnCommand( - options.binaryPath, - ["app-server", ...(options.appServerArgs ?? [])], - { env, extendEnv }, - ); + const appServerArgs = codexSessionAppServerArgs(options.appServerArgs, options.launchArgs); + const spawnCommand = yield* resolveSpawnCommand(options.binaryPath, appServerArgs, { + env, + extendEnv, + }); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 063f2c1bf2b..fee4306c4c5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -8,7 +8,6 @@ import type { ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; -import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as Crypto from "effect/Crypto"; @@ -51,7 +50,6 @@ import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts" const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( CursorListAvailableModelsResponse, ); -const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_PRESENTATION = { displayName: "Cursor", badgeLabel: "Early Access", @@ -576,7 +574,7 @@ export const discoverCursorModelsViaAcp = ( export function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { - return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES); + return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); } /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ @@ -638,7 +636,6 @@ export function buildCursorProviderSnapshot(input: { checkedAt: input.checkedAt, models: providerModelsFromSettings( input.discoveredModels ?? [], - PROVIDER, input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 33f61ad97f6..934eecdb5ae 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -1,7 +1,6 @@ import { type GrokSettings, type ModelCapabilities, - ProviderDriverKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -38,7 +37,6 @@ const GROK_PRESENTATION = { showInteractionModeToggle: false, requiresNewThreadForModelChange: true, } as const; -const PROVIDER = ProviderDriverKind.make("grok"); const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); @@ -98,12 +96,7 @@ function grokModelsFromSettings( customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { - return providerModelsFromSettings( - builtInModels, - PROVIDER, - customModels ?? [], - EMPTY_CAPABILITIES, - ); + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } function buildGrokDiscoveredModelsFromSessionModelState( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index b227ff1ab66..1385ccbaabe 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -5,8 +5,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -31,6 +33,8 @@ import { } from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, + isOpenCodeNotFound, + isSameOpenCodeDirectory, makeOpenCodeAdapter, mergeOpenCodeAssistantText, } from "./OpenCodeAdapter.ts"; @@ -54,6 +58,7 @@ const runtimeMock = { state: { startCalls: [] as string[], sessionCreateUrls: [] as string[], + sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], closeCalls: [] as string[], @@ -63,10 +68,17 @@ const runtimeMock = { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + sessionGetIds: [] as string[], + missingSessionIds: new Set(), + transientErrorSessionIds: new Set(), + sessionDirectoryById: new Map(), + sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, + forkCalls: [] as Array<{ sessionID: string; directory?: string }>, }, reset() { this.state.startCalls.length = 0; this.state.sessionCreateUrls.length = 0; + this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; @@ -76,6 +88,12 @@ const runtimeMock = { this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.state.sessionGetIds.length = 0; + this.state.missingSessionIds.clear(); + this.state.transientErrorSessionIds.clear(); + this.state.sessionDirectoryById.clear(); + this.state.sessionUpdateCalls.length = 0; + this.state.forkCalls.length = 0; }, }; @@ -100,10 +118,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { connectToOpenCodeServer: ({ serverUrl }) => Effect.gen(function* () { const url = serverUrl ?? "http://127.0.0.1:4301"; - // Unconditionally register a scope finalizer for test observability — - // preserves the `closeCalls` / `closeError` probes that the existing - // suites rely on. Production code never attaches a finalizer to an - // external server (it simply returns `Effect.succeed(...)`). + // Always register a finalizer so the closeCalls/closeError probes fire; + // production attaches none for external servers. yield* Effect.addFinalizer(() => Effect.sync(() => { runtimeMock.state.closeCalls.push(url); @@ -122,13 +138,42 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => { + create: async (input: Record) => { runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.sessionCreateInputs.push(input); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); return { data: { id: `${baseUrl}/session` } }; }, + get: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionGetIds.push(sessionID); + // The real client is `throwOnError: true`: non-2xx rejects rather + // than resolving, so missing → 404 throw, transient → 500 throw. + if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { + throw new Error("opencode server error", { cause: { status: 500 } }); + } + if (runtimeMock.state.missingSessionIds.has(sessionID)) { + throw new Error(`Session not found: ${sessionID}`, { + cause: { status: 404, body: { name: "NotFoundError" } }, + }); + } + const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); + return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; + }, + update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { + runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); + return { data: { id: sessionID } }; + }, + fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { + // Fork clones history into a new session bound to the directory. + const forkedId = `${sessionID}_fork`; + runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); + if (directory) { + runtimeMock.state.sessionDirectoryById.set(forkedId, directory); + } + return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; + }, abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); }, @@ -176,6 +221,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { @@ -248,6 +301,256 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("returns a durable resume cursor for a freshly created session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-cursor"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + // Without a persisted cursor, a session is created and its id is + // surfaced as a resume cursor so the upper layer can persist it. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("resumes the persisted OpenCode session instead of creating a new one", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + }); + + // The adapter validates the persisted id with session.get and re-adopts + // it — no new session is minted (issue #3604). + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_persisted"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_persisted", + }); + // Resume re-asserts the permission ruleset for the current runtimeMode. + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_persisted"); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sends follow-up turns to the resumed session id", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume-turn"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + }); + + const result = yield* adapter.sendTurn({ + threadId, + input: "continue where we left off", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/sonnet", + ), + }); + + // The prompt targets the resumed id, and the turn re-surfaces the cursor. + NodeAssert.deepEqual( + (runtimeMock.state.promptCalls[0] as { sessionID: string }).sessionID, + "ses_persisted", + ); + NodeAssert.deepEqual(result.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_persisted", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("falls back to a fresh session when the persisted session is gone", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stale"); + runtimeMock.state.missingSessionIds.add("ses_stale"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, + }); + + // get probed the stale id, found nothing, then created a new session and + // emitted a fresh cursor rather than wedging the thread. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores a malformed or wrong-version resume cursor", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-badcursor"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 99, sessionId: "ses_persisted" }, + }); + + // A foreign/stale-shaped cursor is treated as "no resume": never probed, + // a fresh session is created. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces a non-not-found resume probe error instead of silently starting fresh", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-transient"); + // session.get returns a 500 (not a 404) for this id. + runtimeMock.state.transientErrorSessionIds.add("ses_transient"); + + const exit = yield* Effect.exit( + adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_transient" }, + }), + ); + + // A transient/transport/auth failure must propagate — NOT be masked as a + // brand-new empty session (the #3604 class of silent context loss). + NodeAssert.equal(Exit.isFailure(exit), true); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_transient"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + }), + ); + + it.effect("re-applies the current runtimeMode permissions when resuming", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-perms"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + // A different runtimeMode than the original create — resume must not + // leave the upstream session on stale permissions. + runtimeMode: "approval-required", + threadId, + resumeCursor: { schemaVersion: 1, sessionId: "ses_perms" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_perms"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_perms"); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "forks the resumed session into the requested directory instead of losing context", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-cwd"); + // The persisted session still exists but was created in another working dir + // (e.g. the thread moved from the project root into a git worktree). + runtimeMock.state.sessionDirectoryById.set("ses_otherdir", "/some/other/worktree"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_otherdir" }, + }); + + // A cwd change must not mint an empty session: the adapter forks the + // persisted session into the requested cwd, carrying history forward. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_otherdir"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.equal(runtimeMock.state.forkCalls.length, 1); + NodeAssert.equal(runtimeMock.state.forkCalls[0]?.sessionID, "ses_otherdir"); + NodeAssert.equal(typeof runtimeMock.state.forkCalls[0]?.directory, "string"); + // Permission ruleset re-asserted on the fork for the current runtimeMode. + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_otherdir_fork"); + // Durable cursor now points at the history-complete fork in the new directory. + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_otherdir_fork", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reuses the resumed session when the stored directory differs only lexically", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-samedir"); + // Same working tree, different spelling (trailing slash) — must reuse, + // not fork. + runtimeMock.state.sessionDirectoryById.set("ses_samedir", `${process.cwd()}/`); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_samedir" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_samedir"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_samedir", + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("fails sendTurn for missing sessions through the typed error channel", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -670,6 +973,88 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("classifies a confirmed not-found across the shapes the SDK/runtime can produce", () => + Effect.sync(() => { + // The real production shape: runOpenCodeSdk wraps the thrown Error + // (cause = { body, status }) under OpenCodeRuntimeError. + const wrappedError = new Error("Session not found: ses_x", { + cause: { body: { name: "NotFoundError" }, status: 404 }, + }); + NodeAssert.equal( + isOpenCodeNotFound({ + _tag: "OpenCodeRuntimeError", + operation: "session.get", + detail: "Session not found: ses_x", + cause: wrappedError, + }), + true, + ); + + // 404 expressed only via response.status (the bot's flagged shape). + NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 404 } } }), true); + // 404 via a bare numeric status / statusCode. + NodeAssert.equal(isOpenCodeNotFound(new Error("x", { cause: { status: 404 } })), true); + NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true); + // OpenCode NotFoundError body name with no status. + NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true); + + // NOT a miss: only structured signals count, never free text. A non-404 + // error whose message/detail merely contains "not found" must propagate, + // not be misread as a missing session and silently start fresh. + NodeAssert.equal( + isOpenCodeNotFound(new Error("upstream provider not found", { cause: { status: 500 } })), + false, + ); + NodeAssert.equal(isOpenCodeNotFound({ detail: "status=500 body={...not found...}" }), false); + // An explicit non-404 status seals its subtree: a 500 whose serialized + // body echoes a NotFoundError name — or that is itself named + // *NotFound* — is a real failure, never a miss. + NodeAssert.equal(isOpenCodeNotFound({ status: 500, body: { name: "NotFoundError" } }), false); + NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError", status: 500 }), false); + // A "NotFound"-flavored name that isn't OpenCode's exact `NotFoundError` + // is not a confirmed miss even without a sealing status. + NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError" }), false); + NodeAssert.equal(isOpenCodeNotFound({ cause: { name: "ProviderNotFoundError" } }), false); + NodeAssert.equal( + isOpenCodeNotFound( + new Error("x", { cause: { status: 502, body: { name: "NotFoundError" } } }), + ), + false, + ); + // Other transient/auth/network failures must propagate too. + NodeAssert.equal(isOpenCodeNotFound(new Error("boom", { cause: { status: 500 } })), false); + NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 401 } } }), false); + NodeAssert.equal(isOpenCodeNotFound(new Error("network error (no response)")), false); + NodeAssert.equal(isOpenCodeNotFound(undefined), false); + }), + ); + + it.effect("treats lexically or physically identical directories as the same", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); + + // Lexical-only differences (trailing slash, dot segments) short-circuit + // without touching the filesystem — the paths need not exist. + NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); + NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); + // Nonexistent paths degrade to the lexical comparison instead of failing. + NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); + + // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to + // the directory it points at, so the two spellings compare equal. + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); + const real = path.join(base, "real"); + const link = path.join(base, "link"); + yield* fileSystem.makeDirectory(real); + yield* fileSystem.symlink(real, link); + NodeAssert.equal(yield* sameDirectory(link, real), true); + NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); + }).pipe(Effect.scoped), + ); + it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); @@ -765,6 +1150,47 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-title-sync"); + runtimeMock.state.subscribedEvents = [ + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Investigate OpenCode title sync", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); + NodeAssert.equal("title" in (runtimeMock.state.sessionCreateInputs[0] ?? {}), false); + + const metadataUpdated = events.find((event) => event.type === "thread.metadata.updated"); + NodeAssert.ok(metadataUpdated); + if (metadataUpdated.type === "thread.metadata.updated") { + NodeAssert.equal(metadataUpdated.payload.name, "Investigate OpenCode title sync"); + } + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index e7c58d757e6..f029d2f6bd6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -17,6 +17,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -54,6 +56,114 @@ import * as Option from "effect/Option"; const PROVIDER = ProviderDriverKind.make("opencode"); +/** + * Version tag stamped into the OpenCode resume cursor. Bump if the cursor + * shape changes so stale-shaped cursors written by older builds are ignored + * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). + */ +const OPENCODE_RESUME_VERSION = 1 as const; + +/** + * Decode a persisted resume cursor into the upstream `ses_…` id. Anything + * that isn't a current-version cursor with a non-empty id means "no resume" + * rather than an error. Re-adopting the session id IS the resume mechanism — + * OpenCode scopes a conversation's history by session id. + */ +function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + if (record.schemaVersion !== OPENCODE_RESUME_VERSION) { + return undefined; + } + if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) { + return undefined; + } + return { sessionId: record.sessionId.trim() }; +} + +/** + * Whether an error definitively reports a missing session. Only a confirmed + * miss may silently start a fresh session; any other failure (the SDK client + * is `throwOnError: true`, so `session.get` rejects on every non-2xx) must + * propagate, or a transient blip resets a live thread to an empty one — the + * #3604 silent context loss. Decides on structured signals only, never free + * text: a numeric 404 or the exact `NotFoundError` name, found via a bounded walk + * over `cause`/`body`/`error`/`data`. An explicit non-404 status seals its + * subtree so a wrapped "NotFound" name can't reclassify a real failure. + * Exported for unit testing. + */ +export function isOpenCodeNotFound(cause: unknown): boolean { + const seen = new Set(); + const queue: Array = [cause]; + for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { + const node = queue.shift(); + if (node === null || typeof node !== "object" || seen.has(node)) { + continue; + } + seen.add(node); + const record = node as Record; + + const response = record.response; + const statuses = [ + record.status, + record.statusCode, + response !== null && typeof response === "object" + ? (response as { readonly status?: unknown }).status + : undefined, + ].filter((status): status is number => typeof status === "number"); + if (statuses.includes(404)) { + return true; + } + if (statuses.length > 0) { + continue; + } + + const name = record.name; + if (typeof name === "string" && name.toLowerCase() === "notfounderror") { + return true; + } + + for (const key of ["cause", "body", "error", "data"] as const) { + if (record[key] !== undefined) { + queue.push(record[key]); + } + } + } + return false; +} + +/** + * Whether two directory spellings name the same location. Raw string + * equality misreads a trailing slash, `.`/`..` segment, or symlinked cwd + * (macOS `/tmp` → `/private/tmp`) as a cwd change, needlessly forking the + * session on every resume. Lexically equal paths short-circuit; otherwise + * both sides go through `realPath`, each falling back to its lexical form + * on failure (deleted directory, external-server path) — so the probe can + * only widen matches, never split them. Takes the services as arguments so + * adapter methods stay service-free. Exported for unit testing. + */ +export function isSameOpenCodeDirectory( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + left: string, + right: string, +): Effect.Effect { + const lexicalLeft = path.resolve(left); + const lexicalRight = path.resolve(right); + if (lexicalLeft === lexicalRight) { + return Effect.succeed(true); + } + const canonicalize = (lexical: string) => + fileSystem.realPath(lexical).pipe(Effect.orElseSucceed(() => lexical)); + return Effect.zipWith( + canonicalize(lexicalLeft), + canonicalize(lexicalRight), + (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight, + ); +} + interface OpenCodeTurnSnapshot { readonly id: TurnId; readonly items: Array; @@ -66,6 +176,35 @@ type OpenCodeSubscribedEvent = ? TEvent : never; +function trimText(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined { + const properties = "properties" in event ? event.properties : undefined; + if (!properties || typeof properties !== "object") { + return undefined; + } + + const sessionID = (properties as { readonly sessionID?: unknown }).sessionID; + const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined; + if (sessionIDFromProperties) { + return sessionIDFromProperties; + } + + const info = (properties as { readonly info?: { readonly id?: unknown } }).info; + return info && typeof info.id === "string" ? info.id : undefined; +} + +function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined { + if (event.type !== "session.updated") { + return undefined; + } + + return trimText(event.properties.info.title); +} + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -431,6 +570,10 @@ export function makeOpenCodeAdapter( const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -644,8 +787,7 @@ export function makeOpenCodeAdapter( context: OpenCodeSessionContext, event: OpenCodeSubscribedEvent, ) { - const payloadSessionId = - "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined; + const payloadSessionId = openCodeEventSessionId(event); if (payloadSessionId !== context.openCodeSessionId) { return; } @@ -664,6 +806,26 @@ export function makeOpenCodeAdapter( }); switch (event.type) { + case "session.updated": { + const title = openCodeEventSessionTitle(event); + if (title) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + raw: event, + })), + type: "thread.metadata.updated", + payload: { + name: title, + metadata: { + sessionID: context.openCodeSessionId, + }, + }, + }); + } + break; + } + case "message.updated": { context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { @@ -1029,6 +1191,7 @@ export function makeOpenCodeAdapter( const serverUrl = openCodeSettings.serverUrl; const serverPassword = openCodeSettings.serverPassword; const directory = input.cwd ?? serverConfig.cwd; + const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; const existing = sessions.get(input.threadId); if (existing) { yield* stopOpenCodeContext(existing); @@ -1068,23 +1231,96 @@ export function makeOpenCodeAdapter( }), ); } - const openCodeSession = yield* runOpenCodeSdk("session.create", () => - client.session.create({ - title: `T3 Code ${input.threadId}`, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - if (!openCodeSession.data) { - return yield* new OpenCodeRuntimeError({ - operation: "session.create", - detail: "OpenCode session.create returned no session payload.", - }); - } + // Resume: re-adopt the session named by the durable cursor — + // OpenCode scopes history by session id. The probe recovers only + // a confirmed not-found (start fresh); transport/auth/server + // errors propagate instead of masking as a new empty session. + const resolved = yield* Effect.gen(function* () { + const adopted = resumeSessionId + ? yield* runOpenCodeSdk("session.get", () => + client.session.get({ sessionID: resumeSessionId }), + ).pipe( + Effect.map((response) => response.data), + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + ) + : undefined; + + // Reuse in place only when the session still matches the + // requested cwd; on a cwd change it is forked below instead. + const reusable = + adopted && + (!adopted.directory || (yield* sameDirectory(adopted.directory, directory))) + ? adopted + : undefined; + + if (reusable) { + // Resume skips `session.create`, so re-assert the ruleset — + // a runtime-mode change would otherwise leave the session on + // its original permissions. + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: reusable.id, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + return { openCodeSession: reusable, created: false }; + } + + // The session lives under a different cwd (e.g. the thread + // moved into a git worktree). Fork it into the requested + // directory instead of minting an empty one — the fork carries + // the full history, so the follow-up keeps its context (#3604). + if (adopted) { + yield* Effect.logInfo( + `OpenCode session '${adopted.id}' was created under a different working directory; forking into '${directory}' to preserve conversation history.`, + ); + const forkedSession = yield* runOpenCodeSdk("session.fork", () => + client.session.fork({ sessionID: adopted.id, directory }), + ); + const forked = forkedSession.data; + if (!forked) { + return yield* new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "OpenCode session.fork returned no session payload.", + }); + } + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: forked.id, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + return { openCodeSession: forked, created: true }; + } + + if (resumeSessionId) { + yield* Effect.logWarning( + `OpenCode session '${resumeSessionId}' no longer exists; starting a fresh session.`, + ); + } + const createdSession = yield* runOpenCodeSdk("session.create", () => + client.session.create({ + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + if (!createdSession.data) { + return yield* new OpenCodeRuntimeError({ + operation: "session.create", + detail: "OpenCode session.create returned no session payload.", + }); + } + return { openCodeSession: createdSession.data, created: true }; + }); + return { sessionScope, server, client, - openCodeSession: openCodeSession.data, + openCodeSession: resolved.openCodeSession, + created: resolved.created, }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); @@ -1099,13 +1335,16 @@ export function makeOpenCodeAdapter( // and already inserted a session while we were awaiting async work. const raceWinner = sessions.get(input.threadId); if (raceWinner) { - // Another call won the race – clean up the session we just created - // (including the remote SDK session) and return the existing one. - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, - }), - ).pipe(Effect.ignore); + // Another call won the race — clean up. Only abort the remote + // session if we created it here; a resumed one is shared upstream + // state the winner is now using. + if (started.created) { + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSession.id, + }), + ).pipe(Effect.ignore); + } yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -1119,6 +1358,13 @@ export function makeOpenCodeAdapter( cwd: directory, ...(input.modelSelection ? { model: input.modelSelection.model } : {}), threadId: input.threadId, + // ProviderService persists this cursor and feeds it back into + // `startSession` after the in-memory session is lost (reaper / + // restart), so follow-ups continue the same conversation (#3604). + resumeCursor: { + schemaVersion: OPENCODE_RESUME_VERSION, + sessionId: started.openCodeSession.id, + }, createdAt, updatedAt: createdAt, }; @@ -1284,6 +1530,11 @@ export function makeOpenCodeAdapter( return { threadId: input.threadId, turnId, + // Re-surface the durable cursor on every turn so the persisted binding + // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), }; }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b0e785512dc..05160517bfe 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -95,6 +95,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + loadInventoryFromCli: () => + runtimeMock.state.inventoryError + ? Effect.succeed({ + providerList: { all: [], default: {}, connected: [] as string[] }, + agents: [], + } as OpenCodeInventory) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; beforeEach(() => { @@ -197,11 +204,22 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("closes the local OpenCode server scope after provider refresh", () => + it.effect("does not spawn a local server for health check (uses CLI instead)", () => Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.closeCalls, 0); + }), + ); + + it.effect("degrades gracefully on CLI failure for local installs", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("opencode models failed"); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.models.length, 0); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc..21014e33f08 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,5 +1,4 @@ import { - ProviderDriverKind, type ModelCapabilities, type OpenCodeSettings, type ServerProviderModel, @@ -25,7 +24,6 @@ import { } from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; -const PROVIDER = ProviderDriverKind.make("opencode"); const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, @@ -259,7 +257,6 @@ export const makePendingOpenCodeProvider = ( const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = providerModelsFromSettings( [], - PROVIDER, openCodeSettings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); @@ -319,12 +316,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: failure.installed, version, @@ -340,12 +332,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: false, version: null, @@ -391,12 +378,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: true, version, @@ -409,26 +391,32 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } const inventoryExit = yield* Effect.exit( - Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ + (isExternalServer + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + environment: resolvedEnvironment, + }); + return yield* openCodeRuntime.loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }), + ); + }), + ) + : openCodeRuntime.loadInventoryFromCli({ binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(isExternalServer && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }).pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), + }) + ).pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), ), ); @@ -438,7 +426,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const models = providerModelsFromSettings( flattenOpenCodeModels(inventoryExit.value), - PROVIDER, customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 73390450efa..384de852f9b 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -61,6 +61,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ binaryPath: "codex", homePath: "", shadowHomePath: "", + launchArgs: "", customModels: [], ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5456a90bdf5..159d853121c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -55,6 +55,7 @@ const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTING const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); +const decodeCodexSettings = Schema.decodeSync(CodexSettings); const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ enabled: false, }); @@ -100,6 +101,7 @@ type TestClaudeCapabilities = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -109,6 +111,7 @@ function claudeCapabilities(overrides: Partial = {}) { email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiProvider: undefined, slashCommands: [], ...overrides, }); @@ -346,6 +349,21 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("passes configured launch args to the Codex provider probe", () => + Effect.gen(function* () { + let observedLaunchArgs: string | undefined; + const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + + const status = yield* checkCodexProviderStatus(settings, (input) => { + observedLaunchArgs = input.launchArgs; + return Effect.succeed(makeCodexProbeSnapshot()); + }); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -1492,6 +1510,30 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => + Effect.gen(function* () { + // Bedrock authenticates via external AWS credentials, so the SDK init + // reports only `apiProvider` with no subscription or token. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ apiProvider: "bedrock" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "bedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.test.ts b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts new file mode 100644 index 00000000000..115ac28eaf9 --- /dev/null +++ b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts @@ -0,0 +1,59 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { + codexAppServerArgs, + codexExecLaunchArgs, + resolveCodexLaunchArgs, +} from "./codexLaunchArgs.ts"; + +describe("resolveCodexLaunchArgs", () => { + it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), + "--enable foo", + ); + }); + + it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), + "--strict-config", + ); + }); + + it("ignores whitespace-only environment values", () => { + NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); + }); +}); + +describe("codexAppServerArgs", () => { + it("returns the app-server command for empty launch args", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); + }); + + it("appends parsed launch args after app-server", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ + "app-server", + "--strict-config", + "--enable", + "foo", + ]); + }); +}); + +describe("codexExecLaunchArgs", () => { + it("keeps shared codex flags and omits app-server-only flags", () => { + NodeAssert.deepStrictEqual( + codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), + ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"], + ); + }); + + it("does not pair value-taking flags with adjacent flags", () => { + NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ + "--strict-config", + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts new file mode 100644 index 00000000000..771a4f0b6ed --- /dev/null +++ b/apps/server/src/provider/Layers/codexLaunchArgs.ts @@ -0,0 +1,48 @@ +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; + +export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; + +export const resolveCodexLaunchArgs = ( + launchArgs?: string, + environment: NodeJS.ProcessEnv = process.env, +) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; + +export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => + tokenizeCliArgs(launchArgs); + +export const codexAppServerArgs = (launchArgs?: string) => [ + "app-server", + ...codexLaunchArgv(launchArgs), +]; + +export const codexExecLaunchArgs = (launchArgs?: string) => { + const args = codexLaunchArgv(launchArgs); + const execArgs: Array = []; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === undefined) continue; + + if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) { + execArgs.push(arg); + } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") { + const value = args[index + 1]; + if (value !== undefined && !value.startsWith("-")) { + execArgs.push(arg, value); + index++; + } + } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) { + execArgs.push(arg); + } + } + + return execArgs; +}; + +export const codexSessionAppServerArgs = ( + appServerArgs: ReadonlyArray | undefined, + launchArgs: string | undefined, +) => { + const launchAppServerArgs = codexAppServerArgs(launchArgs); + return appServerArgs ? [...launchAppServerArgs, ...appServerArgs] : launchAppServerArgs; +}; diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts new file mode 100644 index 00000000000..6208f04507e --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -0,0 +1,229 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { parseModelsCliOutput, parseAgentListCliOutput } from "./opencodeRuntime.ts"; + +describe("parseModelsCliOutput", () => { + it("parses a single model from a single provider", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ + id: "claude-sonnet-4-5", + providerID: "anthropic", + name: "Claude Sonnet 4.5", + capabilities: { temperature: true, reasoning: true, toolcall: true }, + cost: { input: 3, output: 15 }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.equal(result.connected.length, 1); + NodeAssert.equal(result.connected[0], "anthropic"); + + const provider = result.providers.get("anthropic")!; + NodeAssert.ok(provider); + NodeAssert.equal(provider.id, "anthropic"); + NodeAssert.equal(provider.name, "anthropic"); + NodeAssert.equal(Object.keys(provider.models).length, 1); + + const model = provider.models["claude-sonnet-4-5"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "claude-sonnet-4-5"); + NodeAssert.equal(model.providerID, "anthropic"); + NodeAssert.equal(model.name, "Claude Sonnet 4.5"); + }); + + it("parses multiple models from multiple providers", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + "openai/gpt-4o", + JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 2); + NodeAssert.equal(result.connected.length, 2); + NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); + NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); + NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); + }); + + it("handles empty input", () => { + const result = parseModelsCliOutput(""); + NodeAssert.equal(result.providers.size, 0); + NodeAssert.equal(result.connected.length, 0); + }); + + it("skips unparseable JSON blocks", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + "this is not valid json {{{", + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + const provider = result.providers.get("anthropic")!; + NodeAssert.equal(Object.keys(provider.models).length, 1); + NodeAssert.ok(provider.models["claude-haiku-4-5"]); + }); + + it("handles Windows-style CRLF line endings", () => { + const stdout = + "anthropic/claude-sonnet-4-5\r\n" + + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + + "\r\n"; + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); + }); + + it("handles model JSON with variants and nested fields", () => { + const stdout = [ + "opencode/gpt-5.4", + JSON.stringify({ + id: "gpt-5.4", + providerID: "opencode", + name: "GPT-5.4", + family: "gpt", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, input: 160000, output: 32000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + variants: { none: {}, low: {}, medium: {}, high: {} }, + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + const model = result.providers.get("opencode")!.models["gpt-5.4"]!; + NodeAssert.ok(model); + NodeAssert.ok(model.capabilities); + NodeAssert.equal(model.capabilities!.reasoning, true); + NodeAssert.ok(model.variants); + NodeAssert.equal(model.variants!["medium"] !== undefined, true); + }); +}); + +describe("parseAgentListCliOutput", () => { + it("parses a single agent", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[0]!.permission.length, 1); + }); + + it("parses multiple agents", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "plan (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 3); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[1]!.name, "explore"); + NodeAssert.equal(result[1]!.mode, "subagent"); + NodeAssert.equal(result[2]!.name, "plan"); + NodeAssert.equal(result[2]!.mode, "primary"); + }); + + it("handles empty input", () => { + const result = parseAgentListCliOutput(""); + NodeAssert.equal(result.length, 0); + }); + + it("skips agents with unparseable permission JSON", () => { + const stdout = [ + "build (primary)", + " not valid json {", + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "explore"); + }); + + it("handles real-world permission blocks with nested paths", () => { + const permissions = [ + { permission: "*", action: "allow", pattern: "*" }, + { + permission: "external_directory", + pattern: "C:\\Users\\test\\.local\\*", + action: "allow", + }, + { permission: "read", pattern: "*.env", action: "ask" }, + ]; + const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.permission.length, 3); + NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); + NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); + }); + + it("handles agent names with spaces", () => { + const stdout = [ + "code reviewer (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "my custom agent (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 2); + NodeAssert.equal(result[0]!.name, "code reviewer"); + NodeAssert.equal(result[0]!.mode, "subagent"); + NodeAssert.equal(result[1]!.name, "my custom agent"); + NodeAssert.equal(result[1]!.mode, "primary"); + }); + + it("marks known hidden agents", () => { + const stdout = [ + "compaction (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result[0]!.hidden, true); + NodeAssert.equal(result[1]!.hidden, false); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 46d0b3019bd..b853662b037 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -5,6 +5,7 @@ import { createOpencodeClient, type Agent, type FilePartInput, + type Model, type OpencodeClient, type PermissionRuleset, type ProviderListResponse, @@ -147,6 +148,10 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadInventoryFromCli: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; } function parseServerUrlFromOutput(output: string): string | null { @@ -160,6 +165,113 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } +const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; +const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; + +// Agents that are always hidden in OpenCode but the CLI "agent list" command +// does not expose the hidden flag. Keep in sync with OpenCode agent +// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). +const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); + +/** @internal */ +export function parseModelsCliOutput(stdout: string): { + readonly providers: ReadonlyMap< + string, + { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + >; + readonly connected: ReadonlyArray; +} { + const providers = new Map< + string, + { id: string; name: string; models: { [key: string]: Model } } + >(); + const lines = stdout.split("\n"); + let currentSlug: string | null = null; + const jsonLines: Array = []; + + const flushModel = () => { + if (currentSlug !== null && jsonLines.length > 0) { + const jsonStr = jsonLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const model = JSON.parse(jsonStr) as Model; + const separator = currentSlug.indexOf("/"); + if (separator > 0) { + const providerID = currentSlug.slice(0, separator); + const modelID = currentSlug.slice(separator + 1); + let provider = providers.get(providerID); + if (!provider) { + provider = { id: providerID, name: providerID, models: {} }; + providers.set(providerID, provider); + } + provider.models[modelID] = model; + } + } catch { + // Skip unparseable model JSON + } + } + } + currentSlug = null; + jsonLines.length = 0; + }; + + for (const line of lines) { + const slugMatch = SLUG_LINE_RE.exec(line); + if (slugMatch) { + flushModel(); + currentSlug = slugMatch[1]!; + } else if (currentSlug !== null) { + jsonLines.push(line); + } + } + flushModel(); + + return { providers, connected: [...providers.keys()] }; +} + +/** @internal */ +export function parseAgentListCliOutput(stdout: string): ReadonlyArray { + const agents: Array = []; + const lines = stdout.split("\n"); + let currentHeader: { name: string; mode: string } | null = null; + const blockLines: Array = []; + + const flushAgent = () => { + if (currentHeader !== null) { + const jsonStr = blockLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const permission = JSON.parse(jsonStr); + agents.push({ + name: currentHeader.name, + mode: currentHeader.mode as Agent["mode"], + hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), + permission, + options: {}, + }); + } catch { + // Skip unparseable agent + } + } + } + currentHeader = null; + blockLines.length = 0; + }; + + for (const line of lines) { + const match = AGENT_HEADER_RE.exec(line); + if (match) { + flushAgent(); + currentHeader = { name: match[1]!, mode: match[2]! }; + } else if (currentHeader !== null) { + blockLines.push(line); + } + } + flushAgent(); + + return agents; +} + export function parseOpenCodeModelSlug( slug: string | null | undefined, ): ParsedOpenCodeModelSlug | null { @@ -542,12 +654,76 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map(([providerList, agents]) => ({ providerList, agents })), ); + const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => + Effect.gen(function* () { + const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); + + const runModelsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["models", "--verbose"], + ...env, + }).pipe(Effect.exit); + const runAgentsCli = () => + runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe( + Effect.exit, + ); + + // First attempt — run both in parallel + let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], { + concurrency: "unbounded", + }); + + // Retry once after 1s on transient failures (e.g. SQLite "database is locked") + const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; + const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; + if (needsModelsRetry || needsAgentsRetry) { + yield* Effect.sleep("1 second"); + const [m2, a2] = yield* Effect.all( + [ + needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), + needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), + ], + { concurrency: "unbounded" }, + ); + modelsResult = m2; + agentsResult = a2; + } + + // Degrade gracefully on failure — return empty inventory (warning status, not error) + let connected: string[] = []; + let allProviders: ProviderListResponse["all"] = []; + if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + connected = [...parsed.connected]; + allProviders = [...parsed.providers.values()].map((p) => ({ + id: p.id, + name: p.name, + source: "config" as const, + env: [], + options: {}, + models: p.models, + })); + } + + let agents: ReadonlyArray = []; + if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { + agents = parseAgentListCliOutput(agentsResult.value.stdout); + } + + return { + providerList: { all: allProviders, default: {}, connected }, + agents, + }; + }); + return { startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadInventoryFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index abe138fdfb9..01157278066 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import type { ModelCapabilities } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; @@ -38,7 +38,6 @@ describe("providerModelsFromSettings", () => { it("applies the provided capabilities to custom models", () => { const models = providerModelsFromSettings( [], - ProviderDriverKind.make("opencode"), ["openai/gpt-5"], OPENCODE_CUSTOM_MODEL_CAPABILITIES, ); @@ -52,6 +51,25 @@ describe("providerModelsFromSettings", () => { }, ]); }); + + it("preserves a custom slug that collides with a provider alias", () => { + const capabilities = createModelCapabilities({ optionDescriptors: [] }); + const models = providerModelsFromSettings( + [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities, + }, + ], + [" opus "], + capabilities, + ); + + expect(models.map((model) => model.slug)).toEqual(["claude-opus-4-8", "opus"]); + expect(models[1]?.isCustom).toBe(true); + }); }); describe("ProviderCommandNotFoundError", () => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index dfe31ffdc44..e741a7a2c1d 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -13,7 +13,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeCustomModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -140,7 +140,6 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, - provider: ProviderDriverKind, customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { @@ -149,7 +148,7 @@ export function providerModelsFromSettings( const customEntries: ServerProviderModel[] = []; for (const candidate of customModels) { - const normalized = normalizeModelSlug(candidate, provider); + const normalized = normalizeCustomModelSlug(candidate); if (!normalized || seen.has(normalized)) { continue; } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index ff4d448619c..5f9dbe25bb2 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -473,6 +473,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape; const snapshotQuery = { @@ -663,6 +664,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape), Layer.succeed(ProjectionSnapshotQuery, { getShellSnapshot: () => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 79aa2cef87a..ac1de5c406d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -47,6 +47,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; @@ -681,6 +682,7 @@ const buildAppUnderTest = (options?: { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 0 }), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), ...options?.layers?.orchestrationEngine, }), ), @@ -3721,6 +3723,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); + assert.equal(response.shellResumeCompletionMarker, true); + assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5612,6 +5616,112 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("marks an empty shell catch-up replay as synchronized when requested", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const firstItem = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.runHead), + ), + ); + + assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("marks a socket thread snapshot as synchronized when requested", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual(items[1], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("buffers shell events published while the fallback snapshot loads", () => + Effect.gen(function* () { + const liveEvents = yield* PubSub.unbounded(); + const deletedEvent = { + sequence: 2, + eventId: EventId.make("event-shell-thread-deleted"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.deleted", + payload: { + threadId: defaultThreadId, + deletedAt: "2026-01-01T00:00:01.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.gen(function* () { + yield* PubSub.publish(liveEvents, deletedEvent); + return { + snapshotSequence: 1, + projects: [], + threads: [makeDefaultOrchestrationThreadShell()], + updatedAt: "2026-01-01T00:00:00.000Z", + }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + requestCompletionMarker: true, + }).pipe(Stream.take(3), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "thread-removed"); + assert.deepEqual(items[2], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("buffers thread events published while the initial snapshot loads", () => Effect.gen(function* () { const thread = makeDefaultOrchestrationReadModel().threads[0]!; @@ -5670,6 +5780,473 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("subscribeShell sends a fresh snapshot instead of replaying a large gap", () => + Effect.gen(function* () { + let readEventsCalls = 0; + const snapshotThreadId = ThreadId.make("thread-from-snapshot"); + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + // Head is far ahead of the client's afterSequence (gap > 1000). + latestSequence: Effect.succeed(100_000), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return { + sequence: 1, + eventId: EventId.make("event-should-not-be-read"), + aggregateKind: "thread", + aggregateId: snapshotThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + } satisfies OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 100_000, + projects: [], + threads: [makeDefaultOrchestrationThreadShell({ id: snapshotThreadId })], + updatedAt: now, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 5, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(items); + // Large gap => fresh snapshot, and the unbounded replay is never started. + assert.equal(first?.kind, "snapshot"); + if (first?.kind === "snapshot") { + assert.equal(first.snapshot.threads[0]?.id, snapshotThreadId); + } + assert.equal(second?.kind, "synchronized"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell replaces a cursor ahead of the authoritative head", () => + Effect.gen(function* () { + let readEventsCalls = 0; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(5), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return {} as OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 5, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const first = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 10 }).pipe( + Stream.runHead, + ), + ), + ); + + assert.equal(Option.getOrThrow(first).kind, "snapshot"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalesces a per-thread burst without stalling other threads", () => + Effect.gen(function* () { + const busyThreadId = ThreadId.make("thread-busy"); + const newThreadId = ThreadId.make("thread-new"); + const now = "2026-01-01T00:00:00.000Z"; + const shellFetches: Array = []; + let replayLimit: number | undefined; + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(50), + // A burst of message-sent deltas for the busy thread, plus one + // thread.created for a different thread, all within one batch. + readEvents: (_afterSequence, limit) => { + replayLimit = limit; + return Stream.fromIterable([ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(3), Stream.runCollect), + ), + ); + + const collected = Array.from(items); + const upsertedIds = collected.flatMap((item) => + item.kind === "thread-upserted" ? [item.thread.id] : [], + ); + // Both threads surface, and the busy thread's 20-event burst collapses to + // a single shell refetch (not 20). The new thread is not stuck behind it. + assert.include(upsertedIds, busyThreadId); + assert.include(upsertedIds, newThreadId); + assert.equal(collected[2]?.kind, "synchronized"); + assert.equal(shellFetches.filter((id) => id === busyThreadId).length, 1); + assert.equal(replayLimit, 50); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalesces live bursts after the synchronization marker", () => + Effect.gen(function* () { + const busyThreadId = ThreadId.make("thread-live-busy"); + const newThreadId = ThreadId.make("thread-live-new"); + const now = "2026-01-01T00:00:00.000Z"; + const liveEvents = yield* PubSub.unbounded(); + const synchronized = yield* Deferred.make(); + const shellFetches: Array = []; + const observedLiveThreadIds = new Set(); + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-live-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-live-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + Effect.gen(function* () { + const itemsFiber = yield* withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + requestCompletionMarker: true, + }).pipe( + Stream.tap((item) => + item.kind === "synchronized" + ? Deferred.succeed(synchronized, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.takeUntil((item) => { + if (item.kind === "thread-upserted") { + observedLiveThreadIds.add(item.thread.id); + } + return ( + observedLiveThreadIds.has(busyThreadId) && observedLiveThreadIds.has(newThreadId) + ); + }), + Stream.runCollect, + ), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(synchronized); + for (const event of [ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]) { + yield* PubSub.publish(liveEvents, event); + } + + return yield* Fiber.join(itemsFiber); + }), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "synchronized"); + const liveUpsertedIds = Array.from(items) + .slice(2) + .flatMap((item) => (item.kind === "thread-upserted" ? [item.thread.id] : [])); + assert.include(liveUpsertedIds, busyThreadId); + assert.include(liveUpsertedIds, newThreadId); + assert.isBelow(shellFetches.filter((id) => id === busyThreadId).length, 20); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("subscribeShell coalescing still emits a removal for a deleted thread", () => + Effect.gen(function* () { + const goneThreadId = ThreadId.make("thread-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeThreadEvent = ( + sequence: number, + type: "thread.deleted" | "thread.message-sent", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: goneThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: type === "thread.deleted" ? { threadId: goneThreadId, deletedAt: now } : {}, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(2), + // A thread.deleted followed, within the same coalescing window, by a + // later refetchable event for the same thread. The later event wins + // coalescing; its shell refetch returns none (the row is gone), which + // must still surface a removal rather than be swallowed. + readEvents: () => + Stream.fromIterable([ + makeThreadEvent(1, "thread.deleted"), + makeThreadEvent(2, "thread.message-sent"), + ]), + }, + projectionSnapshotQuery: { + getThreadShellById: () => Effect.succeed(Option.none()), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-removed"); + assert.equal(first?.kind === "thread-removed" ? first.threadId : null, goneThreadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell retries a transient shell projection refetch failure", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-transient-refetch"); + const now = "2026-01-01T00:00:00.000Z"; + let attempts = 0; + + const event: OrchestrationEvent = { + sequence: 1, + eventId: EventId.make("event-transient-refetch"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(1), + readEvents: () => Stream.make(event), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.suspend(() => { + attempts += 1; + return attempts === 1 + ? Effect.fail( + new PersistenceSqlError({ + operation: "test.shell-refetch", + detail: "transient failure", + }), + ) + : Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })), + ); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-upserted"); + assert.equal(first?.kind === "thread-upserted" ? first.thread.id : null, threadId); + assert.equal(attempts, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalescing still removes a project after a trailing update", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeProjectEvent = ( + sequence: number, + type: "project.deleted" | "project.meta-updated", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-project-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: + type === "project.deleted" + ? { projectId, deletedAt: now } + : { projectId, title: "Still deleted", updatedAt: now }, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(2), + readEvents: () => + Stream.fromIterable([ + makeProjectEvent(1, "project.deleted"), + makeProjectEvent(2, "project.meta-updated"), + ]), + }, + projectionSnapshotQuery: { + getProjectShellById: () => Effect.succeed(Option.none()), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "project-removed"); + assert.equal(first?.kind === "project-removed" ? first.projectId : null, projectId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("enriches replayed project events with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 1a331bc717f..b8102bda9ad 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -168,6 +168,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -211,6 +212,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -260,6 +262,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provideService(Crypto.Crypto, { ...crypto, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18de..487ae9b45b8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -179,6 +179,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "/Users/julius/.codex", shadowHomePath: "", + launchArgs: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { @@ -420,6 +421,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "", shadowHomePath: "", + launchArgs: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 24054a95870..657118fff51 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -35,6 +35,8 @@ function makeFakeCodexBinary( requireServiceTier?: string; requireReasoningEffort?: string; forbidReasoningEffort?: boolean; + requireArg?: string; + forbidArg?: string; stdinMustContain?: string; stdinMustNotContain?: string; }, @@ -50,6 +52,7 @@ function makeFakeCodexBinary( codexPath, [ "#!/bin/sh", + 'original_args="$*"', 'output_path=""', 'seen_image="0"', 'seen_service_tier=""', @@ -87,6 +90,22 @@ function makeFakeCodexBinary( " shift", "done", 'stdin_content="$(cat)"', + ...(input.requireArg !== undefined + ? [ + `case " $original_args " in *" ${input.requireArg} "*) ;; *)`, + ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`, + ` exit 8`, + "esac", + ] + : []), + ...(input.forbidArg !== undefined + ? [ + `case " $original_args " in *" ${input.forbidArg} "*)`, + ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`, + ` exit 9`, + "esac", + ] + : []), ...(input.requireImage ? [ 'if [ "$seen_image" != "1" ]; then', @@ -166,8 +185,12 @@ function withFakeCodexEnv( requireServiceTier?: string; requireReasoningEffort?: string; forbidReasoningEffort?: boolean; + requireArg?: string; + forbidArg?: string; stdinMustContain?: string; stdinMustNotContain?: string; + launchArgs?: string; + environment?: NodeJS.ProcessEnv; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -175,8 +198,8 @@ function withFakeCodexEnv( const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); const codexPath = yield* makeFakeCodexBinary(tempDir, input); - const config = decodeCodexSettings({ binaryPath: codexPath }); - const textGeneration = yield* makeCodexTextGeneration(config); + const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs }); + const textGeneration = yield* makeCodexTextGeneration(config, input.environment); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -237,6 +260,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); + it.effect("passes exec-safe launch args into codex exec", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + launchArgs: "--strict-config --listen off", + requireArg: "--strict-config", + forbidArg: "--listen", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ), + ); + + it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for codex exec over settings", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + launchArgs: "--enable settings-feature", + environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --listen off " }, + requireArg: "--strict-config", + forbidArg: "settings-feature", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ), + ); + it.effect("defaults git text generation codex effort to low", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0e68994fd3d..6a5c0df43c7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -14,6 +14,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; +import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; import { TextGenerationError } from "@t3tools/contracts"; import * as TextGeneration from "./TextGeneration.ts"; import { @@ -174,6 +175,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; @@ -182,6 +184,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func codexConfig.binaryPath || "codex", [ "exec", + ...codexExecLaunchArgs(launchArgs), "--ephemeral", "--skip-git-repo-check", "-s", diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 558a8663b64..1fcf9bc4c73 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -107,6 +107,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const DEFAULT_TEST_MODEL_SELECTION = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 32dcb8a13d6..08b1770a0a2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -36,6 +36,7 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + type ProjectId, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -276,6 +277,13 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< const PROVIDER_STATUS_DEBOUNCE_MS = 200; +// When a resuming client's cursor is more than this many events behind the +// current head, skip the per-event catch-up replay and send a fresh shell +// snapshot instead. Replaying each intervening event costs a shell refetch; +// past this gap a single O(active-threads) snapshot is cheaper and bounded. +// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). +const SHELL_RESUME_MAX_GAP = 1_000; + const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], @@ -284,6 +292,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], + [WS_METHODS.serverProbe, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -620,16 +629,7 @@ const makeWsRpcLayer = ( switch (event.type) { case "project.created": case "project.meta-updated": - return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( - Effect.map((project) => - Option.map(project, (nextProject) => ({ - kind: "project-upserted" as const, - sequence: event.sequence, - project: nextProject, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return projectUpsertOrRemove(event.payload.projectId, event.sequence); case "project.deleted": return Effect.succeed( Option.some({ @@ -648,35 +648,192 @@ const makeWsRpcLayer = ( }), ); case "thread.unarchived": - return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return threadUpsertOrRemove(event.payload.threadId, event.sequence); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); } - return projectionSnapshotQuery - .getThreadShellById(ThreadId.make(event.aggregateId)) - .pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); } }; + // Coalescing makes each projection read represent every event for that + // aggregate in the current window. Retry a typed persistence failure once + // so a brief read failure cannot strand the shell at its previous state. + // If both attempts fail, log and drop the stream item; treating an error as + // a missing row would incorrectly remove a still-active aggregate. + const retryShellProjectionRead = ( + aggregateKind: "project" | "thread", + aggregateId: string, + read: Effect.Effect, + ): Effect.Effect, never, never> => + read.pipe( + Effect.retry({ times: 1 }), + Effect.map(Option.some), + Effect.tapError((error) => + Effect.logWarning("orchestration shell projection refetch failed", { + aggregateKind, + aggregateId, + error, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + + const projectUpsertOrRemove = ( + projectId: ProjectId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "project", + projectId, + projectionSnapshotQuery.getProjectShellById(projectId), + ).pipe( + Effect.map( + Option.flatMap((project) => + Option.match(project, { + onNone: () => + Option.some({ + kind: "project-removed" as const, + sequence, + projectId, + }), + onSome: (nextProject) => + Option.some({ + kind: "project-upserted" as const, + sequence, + project: nextProject, + }), + }), + ), + ), + ); + + // Refetch a thread's shell and emit an upsert if it is still active, or a + // `thread-removed` if the projection has no active row for it. Emitting a + // removal on a `none` (rather than dropping the event) is what keeps + // coalescing correct: when a burst collapses a `thread.deleted`/`archived` + // into a later refetchable event for the same thread, the refetch returns + // `none` for the now-inactive row and this still tells the sidebar to drop + // it. A `thread-removed` the client does not have is a harmless no-op. The + // projection commits in the same transaction before the event publishes, + // so a `none` reliably means the thread is deleted or archived, not + // not-yet-persisted. + const threadUpsertOrRemove = ( + threadId: ThreadId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "thread", + threadId, + projectionSnapshotQuery.getThreadShellById(threadId), + ).pipe( + Effect.map( + Option.flatMap((thread) => + Option.match(thread, { + onNone: () => + Option.some({ + kind: "thread-removed" as const, + sequence, + threadId, + }), + onSome: (nextThread) => + Option.some({ + kind: "thread-upserted" as const, + sequence, + thread: nextThread, + }), + }), + ), + ), + ); + + // Turn a batch of domain events into shell stream items, coalescing by + // aggregate first. `toShellStreamEvent` re-reads the *current* projected + // shell for an aggregate, so within a batch only the latest event per + // aggregate matters: a burst of streaming `thread.message-sent` deltas for + // one thread collapses into a single shell refetch, and an unrelated + // `thread.created` in the same batch is never stuck behind those DB reads. + // + // Input events arrive in ascending sequence; we keep the last (highest + // sequence) event per aggregate, then re-sort ascending before emitting so + // the client — which applies shell items strictly by increasing sequence + // and drops any `sequence <= snapshotSequence` — never skips a coalesced + // item. The refetch runs with bounded concurrency (order-preserving). + const SHELL_REFETCH_CONCURRENCY = 8; + const coalesceShellEvents = ( + events: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + if (events.length === 0) { + return []; + } + const latestByAggregate = new Map(); + for (const event of events) { + latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event); + } + const survivors = Array.from(latestByAggregate.values()).sort( + (left, right) => left.sequence - right.sequence, + ); + const shellEvents = yield* Effect.forEach(survivors, toShellStreamEvent, { + concurrency: SHELL_REFETCH_CONCURRENCY, + }); + return shellEvents.flatMap((option) => (Option.isSome(option) ? [option.value] : [])); + }); + + // Small time/size window over which to coalesce shell events. The window + // bounds the worst-case added latency for a brand-new thread to appear in + // the sidebar (imperceptible), while collapsing high-frequency streaming + // traffic so it can't serialize the shell stream behind per-event DB reads. + const SHELL_COALESCE_WINDOW = Duration.millis(50); + const SHELL_COALESCE_MAX_CHUNK = 512; + const coalesceShellStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellEvents), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + + type ShellLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + + // A completion marker is queued alongside raw live events so it cannot + // overtake an event still waiting in the coalescing window. Split each + // batch at markers and coalesce only the event segments on either side. + const coalesceShellLiveInputs = ( + inputs: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + const output: Array = []; + let pendingEvents: Array = []; + + for (const input of inputs) { + if (input.kind === "event") { + pendingEvents.push(input.event); + continue; + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + pendingEvents = []; + output.push({ kind: "synchronized" }); + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + return output; + }); + + const coalesceShellLiveStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellLiveInputs), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -935,6 +1092,8 @@ const makeWsRpcLayer = ( otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, settings, + shellResumeCompletionMarker: true, + threadResumeCompletionMarker: true, }; }); @@ -1065,51 +1224,29 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.gen(function* () { - const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + // Coalesce the live shell stream per aggregate over a small window + // so bursts of high-frequency events (streaming message deltas, + // activity appends) collapse into a single shell refetch and never + // serialize a brand-new thread's `thread.created` behind hundreds + // of per-event DB reads. See coalesceShellStream. + // Attach live delivery into a scope-bound buffer BEFORE loading any + // snapshot or draining catch-up, otherwise an event published while + // the snapshot query is in flight is lost (it is past the snapshot's + // sequence but the live subscription is not attached yet). Every + // path below emits from this same buffered live tail. Overlapping + // events are deduped by sequence on the client. + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + orchestrationEngine.streamDomainEvents.pipe( + Stream.runForEach((event) => + Queue.offer(liveBuffer, { kind: "event" as const, event }), + ), ), + { startImmediately: true }, ); + const bufferedLiveStream = coalesceShellLiveStream(Stream.fromQueue(liveBuffer)); - // When the client already holds a shell snapshot (cached, or loaded - // over HTTP) it passes that snapshot's sequence, and we resume by - // replaying shell events after it instead of re-sending the whole - // projects/threads list over the socket. As in the thread path, the - // live subscription is attached (into a scope-bound buffer) before - // draining the catch-up replay so no event published during the - // replay window is lost; overlapping events are deduped by sequence - // on the client. The full range is read (not the store's default - // page limit) since the shell filter runs after reading. - if (input.afterSequence !== undefined) { - const afterSequence = input.afterSequence; - return Stream.unwrap( - Effect.gen(function* () { - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const catchUpStream = orchestrationEngine - .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) - .pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, - ), - Stream.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to replay orchestration shell events", - cause, - }), - ), - ); - return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); - }), - ); - } - - const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + const loadSnapshot = projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => Effect.logError("orchestration shell snapshot load failed", { cause }), ), @@ -1122,12 +1259,70 @@ const makeWsRpcLayer = ( ), ); + // Offer the completion marker into the same queue as live events. + // Anything buffered while snapshot/replay work was in flight is + // therefore delivered before the client is told it is synchronized. + const synchronizedThenLive = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }).pipe( + Effect.andThen(Queue.takeAll(liveBuffer)), + Effect.flatMap(coalesceShellLiveInputs), + ), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), + bufferedLiveStream, + ) + : bufferedLiveStream; + + // When the client already holds a shell snapshot (cached, or loaded + // over HTTP) it passes that snapshot's sequence, and we resume by + // replaying shell events after it instead of re-sending the whole + // projects/threads list over the socket. If the client is too far + // behind, we fall back to a fresh snapshot instead of an unbounded + // replay (see below). + if (input.afterSequence !== undefined) { + const afterSequence = input.afterSequence; + const headSequence = yield* orchestrationEngine.latestSequence; + const replayGap = headSequence - afterSequence; + // Gap too large: replaying every intervening event (each a shell + // refetch) is far more expensive than a single O(active-threads) + // snapshot. A cursor ahead of this engine's authoritative state + // is also invalid, so reset it with a snapshot. Send the snapshot + // followed by the buffered live tail, exactly as the + // no-afterSequence path does. + if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { + const snapshot = yield* loadSnapshot; + return Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + synchronizedThenLive, + ); + } + const catchUpStream = coalesceShellStream( + // Replay only through the head captured above. Newer events + // are already covered by the live subscription, so this bound + // cannot chase a moving event-store head or grow the live + // buffer indefinitely while waiting for an empty page. + orchestrationEngine.readEvents(afterSequence, replayGap), + ).pipe( + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to replay orchestration shell events", + cause, + }), + ), + ); + return Stream.concat(catchUpStream, synchronizedThenLive); + } + + const snapshot = yield* loadSnapshot; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot, }), - liveStream, + synchronizedThenLive, ); }), { "rpc.aggregate": "orchestration" }, @@ -1206,7 +1401,16 @@ const makeWsRpcLayer = ( }), ), ); - return Stream.concat(catchUpStream, bufferedLiveStream); + const afterCatchUp = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + bufferedLiveStream, + ) + : bufferedLiveStream; + return Stream.concat(catchUpStream, afterCatchUp); } const snapshot = yield* projectionSnapshotQuery @@ -1228,16 +1432,29 @@ const makeWsRpcLayer = ( }); } + const afterSnapshot = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + bufferedLiveStream, + ) + : bufferedLiveStream; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot: snapshot.value, }), - bufferedLiveStream, + afterSnapshot, ); }), { "rpc.aggregate": "orchestration" }, ), + [WS_METHODS.serverProbe]: (_input) => + observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 673b093e333..701af3a79fc 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -9,7 +9,15 @@ import { usePreparedConnection } from "~/state/session"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { readonly _tag: "Success"; readonly url: string }; + +export function useAssetUrlState( + environmentId: EnvironmentId, + resource: AssetResource, +): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( assetEnvironment.createUrl({ @@ -17,10 +25,22 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc input: { resource }, }), ); + if (result._tag === "Failure") { + return { _tag: "Failure" }; + } if (preparedConnection._tag === "None" || result._tag !== "Success") { + return { _tag: "Loading" }; + } + const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; +} + +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const result = useAssetUrlState(environmentId, resource); + if (result._tag !== "Success") { return null; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return result.url; } export function useAssetUrls( diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 587ec65cbc9..6c692dc3de8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,13 +1,22 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; +import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { + resolveInitialThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_MIN_WIDTH, + THREAD_SIDEBAR_WIDTH_STORAGE_KEY, +} from "./threadSidebarWidth"; import { Sidebar, SidebarProvider, @@ -18,11 +27,20 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; -const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; -const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +function readInitialThreadSidebarWidth(): number { + try { + return resolveInitialThreadSidebarWidth( + getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), + window.innerWidth, + ); + } catch (error) { + console.error("Could not read persisted thread sidebar width.", error); + return resolveInitialThreadSidebarWidth(null, window.innerWidth); + } +} + function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); @@ -33,6 +51,12 @@ function SidebarControl() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-keybinding-capture]") + ) { + return; + } if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; event.preventDefault(); @@ -40,8 +64,9 @@ function SidebarControl() { toggleSidebar(); }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + // Capture before focused editors consume commands such as Mod+B for rich-text formatting. + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); }, [keybindings, toggleSidebar]); return ( @@ -75,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false; }); - const macosWindowControlsStyle = - isMacosDesktop && !isWindowFullscreen - ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) - : undefined; + const sidebarProviderStyle = { + "--sidebar-width": `${sidebarWidth}px`, + ...(isMacosDesktop && !isWindowFullscreen + ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } + : {}), + } as CSSProperties; useEffect(() => { if (!isMacosDesktop) return; @@ -124,16 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate, pathname]); return ( - + + shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, }} > diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e6aa2b5dc42..e0dcca93b5f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -149,6 +149,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { + buildProjectScript, commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, @@ -2730,13 +2731,7 @@ function ChatViewContent(props: ChatViewProps) { input.name, activeProject.scripts.map((script) => script.id), ); - const nextScript: ProjectScript = { - id: nextId, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; + const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => @@ -2770,13 +2765,7 @@ function ChatViewContent(props: ChatViewProps) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } - const updatedScript: ProjectScript = { - ...existingScript, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; + const updatedScript = buildProjectScript(existingScript.id, input); const nextScripts = activeProject.scripts.map((script) => script.id === scriptId ? updatedScript diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 585d46150a8..169126788ae 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -28,7 +28,10 @@ import { KEY_ENTER_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, + BLUR_COMMAND, + FOCUS_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -185,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -323,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -394,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -1167,6 +1170,80 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +/** + * Chips render as non-editable decorators, so the browser never paints the + * native text selection over them; without help, a selection spanning chips + * is only visible in the slivers between them. Mirror the selection onto the + * chips with a data attribute the stylesheet turns into a highlight overlay. + */ +function ComposerChipSelectionPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + let selectedKeys = new Set(); + // Lexical keeps the range selection on blur without emitting an update, + // so focus is tracked separately; while blurred the native highlight is + // gone and the mirrored one has to go with it. + let hasFocus = editor.getRootElement() === document.activeElement; + + const applyKeys = (nextKeys: Set) => { + for (const key of selectedKeys) { + if (!nextKeys.has(key)) { + editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); + } + } + for (const key of nextKeys) { + editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); + } + selectedKeys = nextKeys; + }; + + const readSelectedKeys = () => { + const nextKeys = new Set(); + editor.getEditorState().read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + for (const node of selection.getNodes()) { + if (node instanceof DecoratorNode) { + nextKeys.add(node.getKey()); + } + } + } + }); + return nextKeys; + }; + + const unregisterUpdate = editor.registerUpdateListener(() => { + applyKeys(hasFocus ? readSelectedKeys() : new Set()); + }); + const unregisterFocus = editor.registerCommand( + FOCUS_COMMAND, + () => { + hasFocus = true; + applyKeys(readSelectedKeys()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + const unregisterBlur = editor.registerCommand( + BLUR_COMMAND, + () => { + hasFocus = false; + applyKeys(new Set()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + return () => { + unregisterUpdate(); + unregisterFocus(); + unregisterBlur(); + }; + }, [editor]); + + return null; +} + function ComposerInlineTokenPastePlugin() { const [editor] = useLexicalComposerContext(); @@ -1701,6 +1778,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 7125040a2b5..76e502fe0ef 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -38,6 +40,73 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("archiveSelectedThreadEntries", () => { + const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; + const success = { _tag: "Success" } as const; + const failure = { _tag: "Failure" } as const; + + it("records every entry after full success", async () => { + const outcome = await archiveSelectedThreadEntries({ + entries, + archive: async (_entry, onArchived) => { + onArchived(); + return success; + }, + }); + + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [], + }); + }); + + it("stops at a mutation failure and retains prior successes", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + if (entry.threadKey === "two") return failure; + onArchived(); + return success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(2); + expect(outcome).toEqual({ + archivedThreadKeys: ["one"], + mutationFailure: failure, + followupFailures: [], + }); + }); + + it("continues after a post-archive failure", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + onArchived(); + return entry.threadKey === "two" ? failure : success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(3); + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [failure], + }); + }); +}); + +describe("buildMultiSelectThreadContextMenuItems", () => { + it("offers bulk archive with the selected count", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }), + ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false }); + }); + + it("disables bulk archive when a selected thread is running", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }), + ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true }); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c719211844e..c119081726e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -36,6 +37,55 @@ type ScopedSidebarThread = ThreadSortInput & { export type ThreadTraversalDirection = "previous" | "next"; +export async function archiveSelectedThreadEntries< + TEntry extends { readonly threadKey: string }, + TResult extends { readonly _tag: "Success" | "Failure" }, +>(input: { + entries: readonly TEntry[]; + archive: (entry: TEntry, onArchived: () => void) => Promise; +}): Promise<{ + archivedThreadKeys: readonly string[]; + mutationFailure: Extract | null; + followupFailures: readonly Extract[]; +}> { + const archivedThreadKeys: string[] = []; + const followupFailures: Extract[] = []; + + for (const entry of input.entries) { + let didArchive = false; + const result = await input.archive(entry, () => { + didArchive = true; + }); + if (didArchive || result._tag === "Success") { + archivedThreadKeys.push(entry.threadKey); + } + if (result._tag === "Success") continue; + const failure = result as Extract; + if (didArchive) { + followupFailures.push(failure); + continue; + } + return { archivedThreadKeys, mutationFailure: failure, followupFailures }; + } + + return { archivedThreadKeys, mutationFailure: null, followupFailures }; +} + +export function buildMultiSelectThreadContextMenuItems(input: { + count: number; + hasRunningThread: boolean; +}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { + return [ + { id: "mark-unread", label: `Mark unread (${input.count})` }, + { + id: "archive", + label: `Archive (${input.count})`, + disabled: input.hasRunningThread, + }, + { id: "delete", label: `Delete (${input.count})`, destructive: true }, + ]; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d19171d5a61..3a4dde16ba3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -194,6 +194,8 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, getVisibleThreadsForProject, resolveAdjacentThreadId, @@ -1808,24 +1810,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; if (threadKeys.length === 0) return; const count = threadKeys.length; + const selectedThreadEntries = threadKeys.flatMap((threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + const thread = threadRef ? readThreadShell(threadRef) : null; + return threadRef && thread ? [{ threadKey, threadRef, thread }] : []; + }); + const hasRunningThread = selectedThreadEntries.some( + ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, + ); const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], + buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), position, ); if (clicked === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + for (const { threadKey, thread } of selectedThreadEntries) { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); } clearSelection(); return; } + if (clicked === "archive") { + if (appSettingsConfirmThreadArchive) { + const confirmed = await api.dialogs.confirm( + `Archive ${count} thread${count === 1 ? "" : "s"}?`, + ); + if (!confirmed) return; + } + + const archiveOutcome = await archiveSelectedThreadEntries({ + entries: selectedThreadEntries, + archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), + }); + for (const failure of archiveOutcome.followupFailures) { + if (isAtomCommandInterrupted(failure)) continue; + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread archived, but navigation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + if (archiveOutcome.mutationFailure) { + removeFromSelection(archiveOutcome.archivedThreadKeys); + if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) { + const error = squashAtomCommandFailure(archiveOutcome.mutationFailure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + removeFromSelection(threadKeys); + return; + } + if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { @@ -1839,10 +1886,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const deletedThreadKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + for (const { threadRef } of selectedThreadEntries) { + const result = await deleteThread(threadRef, { deletedThreadKeys, }); if (result._tag === "Failure") { @@ -1862,7 +1907,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec removeFromSelection(threadKeys); }, [ + appSettingsConfirmThreadArchive, appSettingsConfirmThreadDelete, + archiveThread, clearSelection, deleteThread, markThreadUnread, @@ -2864,12 +2911,12 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ backdropVariant && "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!", )} /> - + ); }); -function SidebarBrand({ stageLabel, onBackdrop }: { stageLabel: string; onBackdrop: boolean }) { +function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { return ( Code - - {stageLabel} - ); } diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index d3d86186c03..6f0953a9773 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -201,9 +201,9 @@ function DevBlueprintArt() { gradientUnits="userSpaceOnUse" spreadMethod="reflect" > - + - + { + it("keeps its compact header sticky while preserving singular labels", () => { + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('class="sticky top-0 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + expect(markup).not.toContain("1 changed files"); + }); +}); describe("ChangedFilesTree", () => { it.each([ diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 3ea0e6315fd..6fc1462c5e6 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -6,11 +6,19 @@ import { summarizeTurnDiffStats, type TurnDiffTreeNode, } from "../../lib/turnDiffTree"; -import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; +import { + ChevronsDownUpIcon, + ChevronsUpDownIcon, + ChevronRightIcon, + FileDiffIcon, + FolderIcon, + FolderClosedIcon, +} from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); return ( -
-
-

- {files.length} changed files +

+
+

+ + {files.length} changed file{files.length === 1 ? "" : "s"} + {hasNonZeroStat(summaryStat) && (

- - + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} + + + + onOpenTurnDiff(turnId, files[0]?.path)} + /> + } + > + + + View diff +
-
- -
+
); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 661b286a46d..c262eacba24 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,6 +44,10 @@ import { shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + dataTransferHasComposerMention, + makeComposerMentionDragHandlers, +} from "./composerMentionDrag"; import { type ComposerImageAttachment, type DraftId, @@ -1919,6 +1923,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerImages(files); focusComposer(); }; + + const insertComposerTextAtEnd = ( + text: string, + options?: { ensureLeadingBoundary?: boolean }, + ): boolean => { + if ( + text.length === 0 || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + (environmentUnavailable !== null && activePendingProgress === null) + ) { + return false; + } + const prompt = promptRef.current; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); + return applyPromptReplacement( + prompt.length, + prompt.length, + needsLeadingSpace ? ` ${text}` : text, + ); + }; + + // File-tree drags land as mentions. Handled in the capture phase so the + // editor never sees the drop; the load-bearing rules (native stop, "move" + // effect, no eager focus) live in makeComposerMentionDragHandlers. + const composerMentionDragHandlers = makeComposerMentionDragHandlers({ + insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }), + setDragActive: setIsDragOverComposer, + onInsertRejected: () => { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + }, + }); + + const onComposerMentionDragLeaveCapture = (event: React.DragEvent) => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) return; + event.stopPropagation(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setIsDragOverComposer(false); + }; + + // A cancelled drag (Escape) can end without a dragleave on the hovered + // target, which would leave the drop highlight stuck. dragend always fires + // on the in-page drag source and bubbles to window, so it is the reset of + // last resort while the highlight is up. + useEffect(() => { + if (!isDragOverComposer) return; + const onWindowDragEnd = () => { + dragDepthRef.current = 0; + setIsDragOverComposer(false); + }; + window.addEventListener("dragend", onWindowDragEnd); + return () => window.removeEventListener("dragend", onWindowDragEnd); + }, [isDragOverComposer]); const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); @@ -1982,26 +2047,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => { - if ( - text.length === 0 || - isConnecting || - isComposerApprovalState || - pendingUserInputs.length > 0 || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - ) { - return false; - } - const prompt = promptRef.current; - const needsLeadingSpace = - (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); - return applyPromptReplacement( - prompt.length, - prompt.length, - needsLeadingSpace ? ` ${text}` : text, - ); - }, + insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); }, @@ -2128,6 +2174,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onDragOver={onComposerDragOver} onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} + onDragEnterCapture={composerMentionDragHandlers.onDragEnter} + onDragOverCapture={composerMentionDragHandlers.onDragOver} + onDragLeaveCapture={onComposerMentionDragLeaveCapture} + onDropCapture={composerMentionDragHandlers.onDrop} >
({ + id, + variant: "warning", + icon: , + title: `${id} warning`, +}); + +describe("ComposerBannerStack", () => { + it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => { + const markup = renderToStaticMarkup( + , + ); + + const expandedItems = markup.match( + /
/, + ); + + expect(expandedItems?.[1]).toContain("grid-rows-[0fr]"); + expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]"); + expect(expandedItems?.[1]).toContain("z-20"); + expect(expandedItems?.[1]).not.toContain("absolute"); + expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning")); + expect(markup).toContain("invisible pointer-events-none"); + expect(markup).toContain("group-focus-within/banner-stack:visible"); + }); + + it("does not render an expandable region for a single banner", () => { + const markup = renderToStaticMarkup(); + + expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 1f82c452b05..d51c23a2f27 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -85,7 +85,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
@@ -119,30 +119,39 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
{hasStack ? (
- {stackedItems.map((item) => ( +
- requestDismiss(item)} - /> + {stackedItems.map((item) => ( +
+ requestDismiss(item)} + /> +
+ ))}
- ))} +
) : null}
diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx new file mode 100644 index 00000000000..fbaf81bc9fd --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx @@ -0,0 +1,28 @@ +import { ApprovalRequestId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; + +describe("ComposerPendingApprovalPanel", () => { + it("renders complete multiline command details without hover or truncation", () => { + const detail = `bun run release -- ${"long-argument ".repeat(20)}\nsecond line`; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-approval-detail="complete"'); + expect(markup).toContain('aria-label="Command"'); + expect(markup).toContain(detail); + expect(markup).not.toContain("truncate"); + expect(markup).not.toContain("line-clamp"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index 569fd108a4a..dd966bf5795 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -16,6 +16,12 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova : approval.requestKind === "file-read" ? "File-read approval requested" : "File-change approval requested"; + const detailLabel = + approval.requestKind === "command" + ? "Command" + : approval.requestKind === "file-read" + ? "File to read" + : "File change"; return (
@@ -26,6 +32,18 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova 1/{pendingCount} ) : null}
+ {approval.detail ? ( +
+

{detailLabel}

+
+            {approval.detail}
+          
+
+ ) : null}
); }); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 32d195d7b2e..6a88fb8da19 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -101,7 +101,7 @@ export function DraftHeroHeadline({ ); return ( -

+

{hasResolvedProject ? ( <>What should we build in {projectSelector}? ) : canChooseProject ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f990af4831b..2b1b5264158 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -64,6 +64,45 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number) return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } +export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; +export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; +export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; + +/** + * The minimap overlays the viewport's left edge while the content column is + * centered, so the side gutter between them shrinks under browser zoom or a + * narrow pane. A fixed-width hover strip would then sit on top of the message + * text and swallow its pointer events. Cap the strip's width so it never + * extends past the gutter into the content column; 0 disables the strip. + */ +export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number { + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { + return 0; + } + + const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); + const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); + return Math.max( + 0, + Math.min( + TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH, + Math.floor(sideGutter) - TIMELINE_MINIMAP_HIT_STRIP_LEFT, + ), + ); +} + +/** + * Once the preview is open, keep the full preview and the space leading to it + * interactive. The collapsed strip remains gutter-capped so it cannot block + * selecting message text. + */ +export function resolveTimelineMinimapInteractiveWidth( + collapsedWidth: number, + expanded: boolean, +): number | string { + return expanded ? TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH : collapsedWidth; +} + function computeElapsedMs(startIso: string, endIso: string): number | null { const start = Date.parse(startIso); const end = Date.parse(endIso); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6e0a232761e..148e3803398 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; @@ -219,12 +219,66 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("keeps assistant changed-files headers sticky below the thread header", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const assistantMessageId = MessageId.make("message-assistant-with-files"); + const turnId = TurnId.make("turn-with-files"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('class="sticky top-2 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + }); + it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); @@ -254,6 +308,28 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); + + // No usable gutter (zoomed in / narrow pane): the strip must go inert + // instead of overlaying the centered content column. + expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0); + // Partial gutter: strip shrinks to what fits between the viewport edge + // and the content column. + expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14); + // Full gutter: unchanged 40px-wide strip. + expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0); + + // The collapsed target stays narrow, but an open preview keeps its full + // 20rem width plus the 2rem offset from the minimap rail interactive. + expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0); + expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14); + expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40); + expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem"); + expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem"); + expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); it("anchors a sent attachment message using its measured height", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 18f68b409a0..1a26eef3a15 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,8 +44,11 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, CircleAlertIcon, EyeIcon, + FileDiffIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -74,7 +77,9 @@ import { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, type StableMessagesTimelineRowsState, type MessagesTimelineRow, @@ -325,6 +330,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -396,6 +402,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); }; const frame = requestAnimationFrame(measure); @@ -512,6 +519,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ items={minimapItems} bottomInset={contentInsetEndAdjustment} hasPersistentGutter={minimapHasPersistentGutter} + hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} onSelect={(item) => { onManualNavigation(); @@ -606,15 +614,21 @@ function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number return typeof height === "number" && Number.isFinite(height) ? height : null; } +function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { + return target instanceof Element && target.closest("[data-minimap-preview]") !== null; +} + function TimelineMinimap({ bottomInset, hasPersistentGutter, + hitStripWidth, items, stripMap, onSelect, }: { bottomInset: number; hasPersistentGutter: boolean; + hitStripWidth: number; items: ReadonlyArray; stripMap: Map; onSelect: (item: TimelineMinimapItem) => void; @@ -677,7 +691,7 @@ function TimelineMinimap({ return (